diff options
Diffstat (limited to 'src')
26 files changed, 1638 insertions, 1090 deletions
diff --git a/src/components/Citations.astro b/src/components/Citations.astro index ff42ad4..7ae6b26 100644 --- a/src/components/Citations.astro +++ b/src/components/Citations.astro @@ -1,9 +1,8 @@ --- -import type { CollectionEntry } from "astro:content"; -import { getEntries } from "astro:content"; +import type { BlogPosting } from "@lib/collection/types"; -type Props = { citations: CollectionEntry<"blog">["data"]["relatedPosts"] }; -const citations = await getEntries(Astro.props.citations ?? []); +type Props = { citations: BlogPosting[] }; +const { citations } = Astro.props; --- { citations.length > 0 && @@ -12,15 +11,15 @@ const citations = await getEntries(Astro.props.citations ?? []); <p>O autor recomenda ler também:</p> <ul> { - citations.map(({ collection, id, data }) => ( + citations.map(({ "@id": id, headline }) => ( <li itemprop="citation" itemscope itemtype="http://schema.org/BlogPosting" - itemid={Astro.url.href.replace(/[^\/]*\/?$/, id)} + itemid={id} > - <a href={`/${collection}/read/${id}`}> - <cite itemprop="headline">{data.title}</cite> + <a href={`/blog/read/${id}`}> + <cite itemprop="headline">{headline}</cite> </a> </li> )) diff --git a/src/components/Translations.astro b/src/components/Translations.astro index 6c2bf42..b78e5af 100644 --- a/src/components/Translations.astro +++ b/src/components/Translations.astro @@ -1,102 +1,115 @@ --- -import type { CollectionEntry } from "astro:content"; import { getFlagEmojiFromLocale, getLanguageNameFromLocale, } from "../utils/lang"; -import { getEntries } from "astro:content"; +import type { BlogPosting } from "@lib/collection/types"; +import { get } from "@utils/anonymous"; interface Props { - lang: string; - translations?: CollectionEntry<"blog">["data"]["translations"]; + id: string; + lang: Intl.LocalesArgument; + workTranslations: BlogPosting[]; + translationOfWork?: BlogPosting; } -const { lang } = Astro.props; +const { id, lang, workTranslations, translationOfWork } = Astro.props; -const translations = await getEntries(Astro.props.translations ?? []).then( - (translations) => - translations.sort((x, y) => x.data.lang.localeCompare(y.data.lang)), +const entries = [ + { "@type": "BlogPosting", "@id": id, inLanguage: lang }, + ...workTranslations, +]; +if (translationOfWork !== undefined) { + entries.push(translationOfWork); +} +const translations = entries.sort((x, y) => + x.inLanguage.localeCompare(y.inLanguage, lang) ); + +const list = new Intl.ListFormat(lang, { + type: "unit", + style: "narrow", +}); +const parts = list.formatToParts(translations.map(get("inLanguage"))); +let i = 0; --- -{ - /* TODO: What about <https://schema.org/translationOfWork> and <https://schema.org/translator>? */ -} +{/* TODO: <https://schema.org/translator> */} { translations.length > 0 && ( <aside> - <nav> - <p>Traduções:</p> - <ul class="translations"> + <nav class="mute small"> + <span>Traduções:{" "}</span> + <span class="translations" role="list"> { - translations.map(async ( - { data, collection, id }, - ) => { - const active = lang.localeCompare(data.lang) === 0; - return ( - <li - itemprop={active ? undefined : "workTranslation"} + parts.map( + ( + { type, value }: { + type: "element" | "literal"; + value: string; + }, + ) => { + switch (type) { + case "element": { + const { + "@id": identifier, + headline, + inLanguage, + } = translations[i++]; + const original = id === identifier; + const active = + lang.localeCompare(inLanguage, lang) === 0; + return ( + <span + role="listitem" + itemprop={active + ? undefined + : original + ? "translationOfWork" + : "workTranslation"} itemscope={!active} itemtype={active ? undefined : "http://schema.org/BlogPosting"} - itemid={active - ? undefined - : new URL(`${collection}/read/${id}`, Astro.site).href} + itemid={active ? undefined : identifier} > <a - href={`/${collection}/read/${id}`} + href={identifier} class:list={[{ active }]} rel={active ? undefined : "alternate"} - hreflang={active ? undefined : data.lang} + hreflang={active ? undefined : inLanguage} type="text/html" - title={data.title} - ><span class="emoji">{getFlagEmojiFromLocale(data.lang)}</span> - {getLanguageNameFromLocale(data.lang)} (<span + title={headline} + ><span class="emoji">{getFlagEmojiFromLocale(inLanguage)}</span> + {getLanguageNameFromLocale(inLanguage)} (<span itemprop="inLanguage" - >{data.lang}</span>)</a> - </li> + >{inLanguage}</span>)</a> + </span> ); - }) + } + case "literal": { + return <span>{value}</span>; + } + } + }, + ) } - </ul> + </span> </nav> </aside> ) } <style> - .translations { - list-style-type: none; - padding-inline-start: 0; - } - - .translations > li { - display: inline; + nav { + padding-block: calc(var(--size-2) * 1em); } - .translations > li > a > .emoji { - text-decoration: none; - font-family: var(--ff-icons); - } - - .translations > li > a.active { + a.active { font-weight: bolder; - text-decoration: underline; - color: var(--color-active); - } - - nav:has(.translations) { - display: flex; - gap: 1rem; - } - - nav:has(.translations) > * { - font-size: smaller; } - .translations > li:not(:first-child)::before { - content: "|"; - margin-inline: 0.5em; + a:hover { + color: var(--color-active); } @media print { diff --git a/src/components/licenses/WTFPL.astro b/src/components/licenses/WTFPL.astro deleted file mode 100644 index feab7ec..0000000 --- a/src/components/licenses/WTFPL.astro +++ /dev/null @@ -1,53 +0,0 @@ ---- -import type { Props as BaseProps } from "../CopyrightNotice.astro"; -interface Props extends BaseProps {} - -let { website, author, email, dateCreated } = Astro.props; ---- - -<footer itemprop="copyrightNotice"> - <p> - <small> - Copyright © <span itemprop="copyrightYear">{ - dateCreated.getFullYear() - }</span> - <span - itemprop="copyrightholder" - itemscope - itemtype="https://schema.org/Person" - >{ - website ? ( - <a - itemprop="url" - rel="author external noreferrer" - target="_blank" - href={website} - content={website} - ><span itemprop="name">{author}</span></a> - ) : author - } - { - email && ( - <><<a - itemprop="email" - rel="author external noreferrer" - target="_blank" - href={`mailto:${email}`} - >{email}</a>></> - ) - }</span> - </small> - </p> - <p> - <small> - This work is free. You can redistribute it and/or modify it under the - terms of the Do What The Fuck You Want To Public License, Version 2, as - published by Sam Hocevar. See <a - itemprop="license" - href="http://www.wtfpl.net/" - rel="license noreferrer" - target="_blank" - >http://www.wtfpl.net/</a> for more details. - </small> - </p> -</footer> diff --git a/src/components/organisms/Date.astro b/src/components/organisms/Date.astro index 960cfb7..3718664 100644 --- a/src/components/organisms/Date.astro +++ b/src/components/organisms/Date.astro @@ -3,9 +3,9 @@ import type { HTMLAttributes } from "astro/types"; interface Props { date: Date; - locales: Intl.LocalesArgument; + locales?: Intl.LocalesArgument; options: Intl.DateTimeFormatOptions; - itemprop: HTMLAttributes<"time">["itemprop"]; + itemprop?: HTMLAttributes<"time">["itemprop"]; } const { date, locales, options } = Astro.props; diff --git a/src/components/signature/Authors.astro b/src/components/signature/Authors.astro deleted file mode 100644 index 4e52d4e..0000000 --- a/src/components/signature/Authors.astro +++ /dev/null @@ -1,275 +0,0 @@ ---- -import { toPK } from "@lib/pgp"; -import { createKeyFromArmor } from "@lib/pgp/create"; -import type { Verification } from "@lib/pgp/verify"; -import { defined, get, instanciate } from "@utils/anonymous"; -import { z } from "astro:content"; -import type { EntityTypesEnum } from "src/consts"; -import qrcode from "yaqrcode"; -import type { getSigners } from "@lib/collection/helpers"; - -interface Props { - verifications: NonNullable<Verification["verifications"]>; - expectedSigners: Awaited<ReturnType<typeof getSigners>>; - commitSignerKey?: string; -} - -const { - verifications: verificationsPromise, - expectedSigners, - commitSignerKey, -} = Astro.props; - -const fingerprintToData = new Map< - string, - { websites: URL[]; role: z.infer<typeof EntityTypesEnum> } ->(); - -for (const { entity, role } of expectedSigners) { - const key = await createKeyFromArmor(entity.data.publickey.armor); - const fingerprint = key.getFingerprint(); - fingerprintToData.set(fingerprint, { - websites: entity.data.websites?.map(instanciate(URL)) ?? [], - role, - }); -} - -let verifications = await Promise.all( - verificationsPromise.map(async ({ key, keyID, userID, verified }) => { - return { - key: await key, - keyID, - userID: await userID, - verified: await verified.catch(() => false), - }; - }), -); - -const expectedKeys = await Promise.all( - expectedSigners.map(get("entity")).map(({ data }) => - createKeyFromArmor(data.publickey.armor) - ), -); - -const expectedFingerprints = new Set( - expectedKeys.map((key) => key.getFingerprint()), -); - -const verifiedFingerprints = new Set( - verifications.map((v) => v.key).filter(defined).map(toPK).map((key) => - key.getFingerprint() - ), -); - -if (!expectedFingerprints.isSubsetOf(verifiedFingerprints)) { - throw new Error( - `Missing signature from expected signers: ${[ - ...expectedFingerprints.difference(verifiedFingerprints).values(), - ]}`, - ); -} ---- - -<div> - <table> - <caption> - <strong>Assinaturas</strong> - <p> - Para verificar uma assinatura é necessário a <a href="#message" - >mensagem</a>, a <a href="#signature">assinatura digital</a> e as <em - >chaves públicas</em> dos assinantes. Esta tabela mostra algumas - informações sobre os assinantes e as suas chaves públicas. - </p> - </caption> - <colgroup> - <col /> - <col /> - </colgroup> - <colgroup> - <col /> - <col /> - <col /> - </colgroup> - <thead> - <tr> - <th scope="col">Assinante</th> - <th scope="col">Função</th> - <th scope="col">Fingerprint</th> - <th scope="col">Válido</th> - <th scope="col">Commiter</th> - </tr> - </thead> - <tbody> - { - verifications.map(({ userID, key, keyID, verified }) => { - const fingerprint = key - ? toPK(key).getFingerprint() - : undefined; - const info = fingerprint - ? fingerprintToData.get(fingerprint) - : undefined; - const primary = userID?.[0]; - let role = ""; - switch (info?.role) { - case "author": { - role = "Autor"; - break; - } - case "co-author": { - role = "Co-autor"; - break; - } - case "translator": { - role = "Tradutor"; - break; - } - } - return ( - <tr> - <th scope="row"> - <address - itemprop="author" - itemscope - itemtype="https://schema.org/Person" - > - { - primary?.name - ? info?.websites[0] ? ( - <a - itemprop="url" - rel="author external noreferrer" - target="_blank" - href={info.websites[0]} - ><span itemprop="name">{primary.name}</span></a> - ) : <span itemprop="name">{primary.name}</span> - : primary?.email - ? ( - <><<a - itemprop="email" - rel="author external noreferrer" - target="_blank" - href={primary?.email && `mailto:${primary.email}`} - >{primary?.email}</a>></> - ) - : "" - } - { - primary && ( - <> - <button - popovertarget={`user-id-${fingerprint}`} - class="emoji" - > - ➕ - </button> - <section - class="user-id" - popover - id={`user-id-${fingerprint}`} - > - { - userID && ( - <><p><code>UserID</code>s</p><ul> - {userID.map((x) => <li>{x.userID}</li>)} - </ul></> - ) - } - { - info?.websites && ( - <><p>Websites</p><ul> - { - info.websites.map(( - x, - ) => <li><a href={x}>{x}</a></li>) - } - </ul></> - ) - } - </section> - </> - ) - } - </address> - </th> - <td>{role}</td> - <td> - <><span title={fingerprint?.replace(/(....)/g, "$1 ")}>{ - key - ? "0x" + toPK(key).getKeyID().toHex() - : "0x" + keyID.toHex() - }</span> - { - key && false && ( - <img - src={qrcode(toPK(key).armor(), { - typeNumber: 40, - errorCorrectLevel: "L", - })} - /> - ) - } - { - key && - ( - <button popovertarget={`armor-${fingerprint}`}> - Armor - </button> - <section class="armor" popover id={`armor-${fingerprint}`}> - <pre><code>{toPK(key).armor()}</code></pre> - </section> - ) - } - </> - </td> - <td>{verified ? "✅" : "❌"}</td> - <td> - { - commitSignerKey && - key?.getFingerprint().toUpperCase()?.endsWith( - commitSignerKey.toUpperCase(), - ) && "✅" - } - </td> - </tr> - ); - }) - } - </tbody> - </table> -</div> - -<style> - div { - overflow-x: auto; - } - - table { - table-layout: fixed; - border-collapse: collapse; - border: 3px solid; - margin-inline: auto; - max-width: 90svw; - } - - th, - td { - padding: 1rem; - text-align: center; - } - - tbody tr:nth-child(odd) { - background-color: #e7e7e7; - } - - section[popover] { - text-align: initial; - } - section[popover].armor { - max-height: calc(200dvh / 3); - } - @media (prefers-color-scheme: dark) { - tbody tr:nth-child(odd) { - background-color: #181818; - } - } -</style> diff --git a/src/components/templates/Authors.astro b/src/components/templates/Authors.astro new file mode 100644 index 0000000..61ca026 --- /dev/null +++ b/src/components/templates/Authors.astro @@ -0,0 +1,355 @@ +--- +import { type RevocationReason, toPK } from "@lib/pgp"; +import type { Verification } from "@lib/pgp/verify"; +import { defined, get } from "@utils/anonymous"; +import type { getSigners } from "@lib/collection/helpers"; +import type { PublicKey, UserIDPacket } from "openpgp"; +import { + createVerificationSummary, + VerificationResult, +} from "@lib/pgp/summary"; +import Date from "@components/organisms/Date.astro"; + +interface Props { + verifications: NonNullable<Verification["verifications"]>; + expectedSigners: Map< + string, + { + signer: Awaited<ReturnType<typeof getSigners>>[number]; + users: UserIDPacket[]; + key: PublicKey; + } + >; + commitSignerKey?: string; +} + +const { + verifications: verificationsPromise, + expectedSigners, + commitSignerKey, +} = Astro.props; + +let verifications = await Promise.all( + verificationsPromise.map(async (verification) => { + const { key, keyID, userID, verified } = verification; + return { + key: await key, + keyID, + userID: await userID, + verified: await verified.catch(() => false), + summary: await createVerificationSummary(verification), + }; + }), +); + +const expectedKeys = Array.from(expectedSigners.values()).map(get("key")); + +const expectedFingerprints = new Set( + expectedKeys.map((key) => key.getFingerprint()), +); + +const verifiedFingerprints = new Set( + verifications.map((v) => v.key).filter(defined).map(toPK).map((key) => + key.getFingerprint() + ), +); + +if (!expectedFingerprints.isSubsetOf(verifiedFingerprints)) { + throw new Error( + `Missing signature from expected signers: ${[ + ...expectedFingerprints.difference(verifiedFingerprints).values(), + ]}`, + ); +} +--- + +<div> + <table> + <caption> + <strong>Assinaturas</strong> + <p> + Para verificar uma assinatura é necessário a <a href="#message" + >mensagem</a>, a <a href="#signature">assinatura digital</a> e as <em + >chaves públicas</em> dos assinantes. Esta tabela mostra algumas + informações sobre os assinantes, as suas chaves públicas e as suas + assinaturas. + </p> + </caption> + <colgroup> + <col /> + <col /> + </colgroup> + <colgroup> + <col /> + <col /> + <col /> + </colgroup> + <colgroup> + <col /> + </colgroup> + <thead> + <tr> + <th scope="col">Assinante</th> + <th scope="col">Função</th> + <th scope="col">Fingerprint</th> + <th scope="col">Válido</th> + <th scope="col">Commiter</th> + <th scope="col">Mais Informações</th> + </tr> + </thead> + <tbody> + { + verifications.map( + ({ userID, key, keyID, verified, summary }) => { + const fingerprint = key + ? toPK(key).getFingerprint() + : undefined; + const info = fingerprint + ? expectedSigners.get(fingerprint) + : undefined; + const primary = userID?.[0]; + const signer = info?.signer; + let role = ""; + switch (signer?.role) { + case "author": { + role = "Autor"; + break; + } + case "co-author": { + role = "Co-autor"; + break; + } + case "translator": { + role = "Tradutor"; + break; + } + } + const { + reasons, + created, + expired, + revoked, + revocationReason, + trusted, + } = (summary[1].get(keyID.toHex()) ?? []).reduce( + (acc, x) => { + if (!("key" in x || "keyID" in x)) { + return acc; + } + + switch (x.result) { + case VerificationResult.MISSING_KEY: + acc.reasons.push(x.reason); + acc.created = x.created ?? undefined; + break; + case VerificationResult.UNTRUSTED_KEY: + acc.created = x.created ?? undefined; + acc.trusted &&= false; + break; + case VerificationResult.TRUSTED_KEY: + acc.created = x.created ?? undefined; + acc.trusted = true; + break; + case VerificationResult + .EXPIRATION_AFTER_SIGNATURE: + acc.created = x.created ?? undefined; + acc.expired = x.expired; + break; + case VerificationResult + .EXPIRATION_BEFORE_SIGNATURE: + acc.created = x.created ?? undefined; + acc.expired = x.expired; + break; + case VerificationResult + .REVOCATION_AFTER_SIGNATURE: + acc.created = x.created ?? undefined; + acc.revoked = x.revoked; + acc.revocationReason = x.revocationReason; + break; + case VerificationResult + .REVOCATION_BEFORE_SIGNATURE: + acc.created = x.created ?? undefined; + acc.revoked = x.revoked; + acc.revocationReason = x.revocationReason; + break; + case VerificationResult.KEY_DOES_NOT_SIGN: + break; + } + + return acc; + }, + { + reasons: [], + created: undefined, + expired: undefined, + revoked: undefined, + revocationReason: undefined, + trusted: false, + } as { + reasons: Error[]; + created?: Date; + expired?: Date; + revoked?: Date; + revocationReason?: RevocationReason; + trusted: boolean; + }, + ); + return ( + <tr> + <th scope="row"> + <address + itemprop="author" + itemscope + itemtype="https://schema.org/Person" + > + { + primary?.name + ? signer?.entity?.data?.websites[0] + ? ( + <a + itemprop="url" + rel="author external noreferrer" + target="_blank" + href={signer?.entity?.data?.websites[0]} + ><span itemprop="name">{primary.name}</span></a> + ) + : <span itemprop="name">{primary.name}</span> + : primary?.email + ? ( + <><<a + itemprop="email" + rel="author external noreferrer" + target="_blank" + href={primary?.email && `mailto:${primary.email}`} + >{primary?.email}</a>></> + ) + : "" + } + </address> + {signer !== undefined && <><hr /><a href="#">Ver perfil</a></>} + </th> + <td>{role}</td> + <td> + <samp title={fingerprint?.replace(/(....)/g, "$1 ")}>{ + key + ? "0x" + toPK(key).getKeyID().toHex() + : "0x" + keyID.toHex() + }</samp> + </td> + <td>{verified ? "✅" : "❌"}</td> + <td> + { + commitSignerKey && + key?.getFingerprint().toUpperCase()?.endsWith( + commitSignerKey.toUpperCase(), + ) && "✅" + } + </td> + <td> + { + key && ( + <> + <button + type="button" + class="emoji" + popovertarget={`info-${fingerprint}`} + > + ➕ + </button> + <section popover id={`info-${fingerprint}`}> + <p>Erros</p> + { + summary[0].map((x) => { + if (!("reason" in x)) { + return undefined; + } + + let reason = x.reason; + + return <pre>{reason.message}</pre>; + }) + } + <p><strong>Informações</strong></p> + <dl class="divider"> + { + reasons.map(({ message }) => ( + <> <dt>Erro</dt> <dd>{message}</dd></> + )) + } + { + created && ( + <> + <dt>Data da assinatura</dt> + <dd><Date date={created} options={{}} /></dd> + </> + ) + } + { + expired && ( + <> + <dt>Data de expiração</dt> + <dd><Date date={expired} options={{}} /></dd> + </> + ) + } + { + revoked && ( + <> + <dt>Data de revogação</dt> + <dd><Date date={revoked} options={{}} /></dd> + </> + ) + } + { + revocationReason && ( + <> + <dt>Razão para a revogação</dt> + <dd> + {revocationReason.flag}: {revocationReason.msg} + </dd> + </> + ) + } + <dt>Chave confiável</dt> + <dd>{trusted ? "✅" : "❌"}</dd> + </dl> + </section></> + ) + } + </td> + </tr> + ); + }, + ) + } + </tbody> + </table> +</div> + +<style> + div { + overflow-x: auto; + } + + table { + table-layout: fixed; + border-collapse: collapse; + border: 3px solid; + margin-inline: auto; + max-width: 90svw; + } + + th, + td { + padding: 1rem; + text-align: center; + } + + tbody tr:nth-child(odd) { + background-color: oklch(from var(--color-light) l c h / calc(alpha * 0.25)); + } + + section[popover] { + text-align: initial; + } +</style> diff --git a/src/components/CopyrightNotice.astro b/src/components/templates/CopyrightNotice.astro index 8a5ebc6..8335293 100644 --- a/src/components/CopyrightNotice.astro +++ b/src/components/templates/CopyrightNotice.astro @@ -5,25 +5,26 @@ import { } from "@lib/collection/schemas"; import CC from "./licenses/CC.astro"; import WTFPL from "./licenses/WTFPL.astro"; +import type { Person } from "@lib/collection/types"; export interface Props { title: string; - author: string; - email?: string; - website?: string; - dateCreated: Date; + holders: Person[]; + years: number[]; license?: typeof LICENSES[number]; } let { license = "public domain" } = Astro.props; let Notice = undefined; -if (license === "WTFPL") { - Notice = WTFPL; -} else if ( - CREATIVE_COMMONS_LICENSES.some((x) => license.localeCompare(x) === 0) -) { - Notice = CC; +if (license !== undefined) { + if (license === "WTFPL") { + Notice = WTFPL; + } else if ( + CREATIVE_COMMONS_LICENSES.some((x) => license.localeCompare(x) === 0) + ) { + Notice = CC; + } } --- diff --git a/src/components/licenses/CC.astro b/src/components/templates/licenses/CC.astro index 61f9114..2aea423 100644 --- a/src/components/licenses/CC.astro +++ b/src/components/templates/licenses/CC.astro @@ -1,8 +1,13 @@ --- -import type { Props as BaseProps } from "../CopyRightNotice.astro"; -interface Props extends BaseProps {} +import type { ComponentProps } from "astro/types"; +import type CopyrightNotice from "@components/templates/CopyrightNotice.astro"; +import { listYearsWithRanges } from "@utils/datetime"; +interface Props extends ComponentProps<typeof CopyrightNotice> {} + +let { license, title, holders, years } = Astro.props; + +if (typeof license !== "string") throw new Error(); -let { title, website, author, dateCreated, license } = Astro.props; const publicdomain = license === "CC0"; const sa = /SA/.test(license); const nd = /ND/.test(license); @@ -10,6 +15,9 @@ const nc = /NC/.test(license); const licenseURL = `https://creativecommons.org/licenses/${ license.slice(3).toLowerCase() }/4.0/`; + +const firstYear = Math.min(...years); +const lastYears = years.sort((a, b) => a - b).slice(1); --- <footer itemprop="copyrightNotice"> @@ -17,21 +25,31 @@ const licenseURL = `https://creativecommons.org/licenses/${ publicdomain ? ( <p> <small> - <a href={Astro.url}>{title}</a> by <span - itemprop="copyrightholder" - itemscope - itemtype="https://schema.org/Person" - >{ - website ? ( - <a - itemprop="url" - rel="author external noreferrer" - target="_blank" - href={website} - content={website} - ><span itemprop="name">{author}</span></a> - ) : author - }</span> is marked <a + <a href={Astro.url}>{title}</a> by { + holders.map(({ name, url }) => { + if (name === undefined) return undefined; + + const website = url?.[0]; + + return ( + <span + itemprop="copyrightHolder" + itemscope + itemtype="https://schema.org/Person" + >{ + website !== undefined ? ( + <a + itemprop="url" + rel="author external noreferrer" + target="_blank" + href={website} + content={website} + ><span itemprop="name">{name}</span></a> + ) : <span itemprop="name">{name}</span> + }</span> + ); + }) + } is marked <a itemprop="license" rel="license noreferrer" target="_blank" @@ -54,22 +72,37 @@ const licenseURL = `https://creativecommons.org/licenses/${ <p> <small> <a href={Astro.url}>{title}</a> © <span itemprop="copyrightYear">{ - dateCreated.getFullYear() - }</span> by <span - itemprop="copyrightholder" - itemscope - itemtype="https://schema.org/Person" - >{ - website ? ( - <a - itemprop="url" - href={website} - target="_blank" - rel="author external noreferrer" - content={website} - ><span itemprop="name">{author}</span></a> - ) : author - }</span> is licensed under <a + firstYear + }</span>, { + listYearsWithRanges(lastYears, { + list: { type: "unit", style: "narrow" }, + locale: "en", + }).replace(/^\d+/, "") + } by { + holders.map(({ name, url }) => { + if (name === undefined) return undefined; + + const website = url?.[0]; + + return ( + <span + itemprop="copyrightHolder" + itemscope + itemtype="https://schema.org/Person" + >{ + website !== undefined ? ( + <a + itemprop="url" + rel="author external noreferrer" + target="_blank" + href={website} + content={website} + ><span itemprop="name">{name}</span></a> + ) : <span itemprop="name">{name}</span> + }</span> + ); + }) + } is licensed under <a itemprop="license" rel="license noreferrer" target="_blank" diff --git a/src/components/templates/licenses/WTFPL.astro b/src/components/templates/licenses/WTFPL.astro new file mode 100644 index 0000000..d3546c7 --- /dev/null +++ b/src/components/templates/licenses/WTFPL.astro @@ -0,0 +1,70 @@ +--- +import { listYearsWithRanges } from "@utils/datetime"; +import type { Props as BaseProps } from "../CopyrightNotice.astro"; +interface Props extends BaseProps {} + +const { years, holders } = Astro.props; +const firstYear = Math.min(...years); +const lastYears = years.sort((a, b) => a - b).slice(1); +--- + +<footer itemprop="copyrightNotice"> + <p> + <small> + Copyright © <span itemprop="copyrightYear">{firstYear}</span>, { + listYearsWithRanges(lastYears, { + list: { type: "unit", style: "narrow" }, + locale: "en", + }).replace(/^\d+/, "") + } + + { + holders.map(({ name, url, email }) => { + if (name === undefined) return undefined; + + const website = url?.[0]; + + return ( + <span + itemprop="copyrightHolder" + itemscope + itemtype="https://schema.org/Person" + >{ + website !== undefined ? ( + <a + itemprop="url" + rel="author external noreferrer" + target="_blank" + href={website} + content={website} + ><span itemprop="name">{name}</span></a> + ) : <span itemprop="name">{name}</span> + } + { + email !== undefined && ( + <><<a + itemprop="email" + rel="author external noreferrer" + target="_blank" + href={`mailto:${email}`} + >{email}</a>></> + ) + }</span> + ); + }) + } + </small> + </p> + <p> + <small> + This work is free. You can redistribute it and/or modify it under the + terms of the Do What The Fuck You Want To Public License, Version 2, as + published by Sam Hocevar. See <a + itemprop="license" + href="http://www.wtfpl.net/" + rel="license noreferrer" + target="_blank" + >http://www.wtfpl.net/</a> for more details. + </small> + </p> +</footer> diff --git a/src/components/signature/Commit.astro b/src/components/templates/signature/Commit.astro index 328a8f9..328a8f9 100644 --- a/src/components/signature/Commit.astro +++ b/src/components/templates/signature/Commit.astro diff --git a/src/components/signature/Downloads.astro b/src/components/templates/signature/Downloads.astro index 3497b37..3497b37 100644 --- a/src/components/signature/Downloads.astro +++ b/src/components/templates/signature/Downloads.astro diff --git a/src/components/signature/Signature.astro b/src/components/templates/signature/Signature.astro index 57e9902..57e9902 100644 --- a/src/components/signature/Signature.astro +++ b/src/components/templates/signature/Signature.astro diff --git a/src/components/signature/Summary.astro b/src/components/templates/signature/Summary.astro index f25c1d1..3469a0f 100644 --- a/src/components/signature/Summary.astro +++ b/src/components/templates/signature/Summary.astro @@ -1,6 +1,6 @@ --- import { - createVerificationSummary, + createVerificationsSummary, logLevel, type Summary, VerificationResult, @@ -11,7 +11,7 @@ import type { NonEmptyArray } from "@utils/iterator"; interface Props extends Verification {} -let [errors, keys] = await createVerificationSummary(Astro.props); +let [errors, keys] = await createVerificationsSummary(Astro.props); const failed = errors.filter((summary) => "reason" in summary); if (failed.length > 0) { diff --git a/src/content/entities.toml b/src/content/entities.toml index a05749a..351ec07 100644 --- a/src/content/entities.toml +++ b/src/content/entities.toml @@ -1,6 +1,6 @@ # [[entities]] # id = '' -# website = [''] +# websites = [''] # [entities.publickey] # armor = ''' # -----BEGIN PGP PUBLIC KEY BLOCK----- @@ -10,7 +10,7 @@ [[entities]] id = 'cravodeabril' -website = ['https://cravodeabril.pt'] +websites = ['https://cravodeabril.pt'] [entities.publickey] armor = ''' -----BEGIN PGP PUBLIC KEY BLOCK----- diff --git a/src/lib/collection/helpers.ts b/src/lib/collection/helpers.ts index f65e7c0..4dfafff 100644 --- a/src/lib/collection/helpers.ts +++ b/src/lib/collection/helpers.ts @@ -2,18 +2,26 @@ import type { CollectionEntry } from "astro:content"; import { Blog, Entity, + ENTITY_TYPES, + type EntityTypesEnum, type Entry, + type LICENSES, type MicroEntry, + Original, type OriginalEntry, + Translation, type TranslationEntry, } from "./schemas.ts"; -import { getEntries, type z } from "astro:content"; -import { defined, get, identity } from "../../utils/anonymous.ts"; +import type { z } from "astro:content"; +import { defined, get, identity, transform } from "../../utils/anonymous.ts"; import { createKeyFromArmor } from "../pgp/create.ts"; import { getUserIDsFromKey } from "../pgp/user.ts"; import type { UserIDPacket } from "openpgp"; +import readingTime from "reading-time"; import { getCollection } from "astro:content"; import { getEntry } from "astro:content"; +import { getEntries } from "astro:content"; +import { listYearsWithRanges } from "../../utils/datetime.ts"; export function getLastUpdate({ data }: CollectionEntry<"blog">): Date { return data.dateUpdated ?? data.dateCreated; @@ -36,20 +44,28 @@ export const sortFirstUpdated = ( b: CollectionEntry<"blog">, ): number => sortLastUpdated(b, a); +export function getSignersIDs( + { data }: CollectionEntry<"blog">, +): Record<z.infer<typeof EntityTypesEnum>, string[]> { + const { signers } = Blog.parse(data); + const id = ({ entity }: typeof signers[number]) => entity.id; + return Object.fromEntries( + ENTITY_TYPES.map((x) => [x, signers.filter((s) => s.role === x).map(id)]), + ) as ReturnType<typeof getSignersIDs>; +} + export async function getSigners( { data }: CollectionEntry<"blog">, ): Promise<{ - id: string; entity: CollectionEntry<"entity">; - role: z.infer<typeof Blog>["signers"][number]["role"] | undefined; + role?: z.infer<typeof Blog>["signers"][number]["role"]; }[]> { const post = Blog.parse(data); - return await getEntries(post.signers.map(get("entity"))).then((x) => - x.map((x) => ({ - id: x.id, - entity: x, - role: post.signers?.find((y) => y.entity.id === x.id)?.role, - })).filter(({ role }) => defined(role)) + return await Promise.all( + post.signers.map(async ({ entity, role }) => ({ + entity: await getEntry(entity), + role, + })), ); } @@ -76,13 +92,13 @@ export async function getFirstUserID( const signers = await getSigners(blog); const userIDs = await Promise.all( signers.filter(({ role }) => role === "author").map( - async ({ id, entity }) => { + async ({ entity }) => { const { publickey, websites } = Entity.parse(entity.data); const website = websites?.[0]; const key = await createKeyFromArmor(publickey.armor); const users = getUserIDsFromKey(undefined, key); return users.map((user) => { - return { ...user, entity: id, website }; + return { ...user, entity: entity.id, website }; })?.[0]; }, ), @@ -118,3 +134,78 @@ export async function getTranslationOriginal( } return await getEntry(translation.data.translationOf); } + +export function licenseNotice( + license: typeof LICENSES[number], + { title, holders, years }: { + title: string; + holders: { name: string; email?: string }[]; + years: number[]; + }, + locale?: Intl.LocalesArgument, +): string | undefined { + const list = new Intl.ListFormat(locale, { + style: "narrow", + type: "unit", + }); + switch (license) { + case "CC0": + return `${title} by ${ + list.format(holders.map(get("name"))) + } is marked CC0 1.0 Universal. To view a copy of this mark, visit https://creativecommons.org/publicdomain/zero/1.0/`; + case "CC-BY": + case "CC-BY-SA": + case "CC-BY-ND": + case "CC-BY-NC": + case "CC-BY-NC-SA": + case "CC-BY-NC-ND": + return `${title} © ${ + listYearsWithRanges(years, { + locale, + list: { type: "unit", style: "narrow" }, + }) + } by ${ + list.format(holders.map(get("name"))) + } is licensed under Creative Commons ${ + license.slice(3).replace("BY", "Attribution").replace( + "SA", + "ShareAlike", + ).replace("ND", "NoDerivatives").replace("NC", "NonCommercial") + } 4.0 International. To view a copy of this license, visit https://creativecommons.org/licenses/${ + license.slice(3).toLowerCase() + }/4.0/`; + case "WTFPL": + return `Copyright (C) ${ + listYearsWithRanges(years, { + locale, + list: { type: "unit", style: "narrow" }, + }) + } ${ + list.format(holders.map(({ name, email }) => + name + (email !== undefined ? ` ${email}` : "") + )) + }`; + case "public domain": + undefined; + } +} +export function licenseURL(license: typeof LICENSES[number]): URL | undefined { + switch (license) { + case "CC0": + case "CC-BY": + case "CC-BY-SA": + case "CC-BY-ND": + case "CC-BY-NC": + case "CC-BY-NC-SA": + case "CC-BY-NC-ND": + return new URL( + `https://creativecommons.org/licenses/${ + license.slice(3).toLowerCase() + }/4.0/`, + ); + case "WTFPL": + return new URL("http://www.wtfpl.net/"); + case "public domain": + return undefined; + } +} diff --git a/src/lib/collection/schemas.ts b/src/lib/collection/schemas.ts index f8a021d..720b9f2 100644 --- a/src/lib/collection/schemas.ts +++ b/src/lib/collection/schemas.ts @@ -119,6 +119,11 @@ export const Blog = z.discriminatedUnion("kind", [ ({ dateCreated, dateUpdated }) => dateUpdated === undefined || dateCreated.getTime() <= dateUpdated.getTime(), { message: "Update before creation" }, +).refine( + ({ signers, license }) => + !(signers.filter(({ role }) => role === "author").length <= 0 && + license !== undefined), + { message: "License cannot be defined if there are no signers." }, ); export type OriginalEntry = CollectionEntry<"blog"> & { diff --git a/src/lib/collection/types.ts b/src/lib/collection/types.ts new file mode 100644 index 0000000..67f1110 --- /dev/null +++ b/src/lib/collection/types.ts @@ -0,0 +1,54 @@ +export type Person = { + "@type": "Person"; + "@id"?: string; + email?: string; + knows?: Person[]; + knowsLanguage?: string[]; + nationality?: { + "@type": "Country"; + }; + description?: string; + name?: string; + url?: string[]; +}; + +export type BlogPosting = { + "@type": "BlogPosting"; + "@id": string; + url: string; + headline: string; + name: string; + alternativeHeadline?: string; + inLanguage: string; + abstract?: string; + description?: string; + author?: Person; + contributor?: Person[]; + translator?: Person[]; + dateCreated: string; + dateModified?: string; + datePublished?: string; + keywords?: string[]; + wordCount?: number; + timeRequired?: string; + articleBody?: string; + text?: string; + copyrightHolder?: Person[]; + copyrightNotice?: string; + copyrightYear?: number; + creativeWorkStatus?: "Published"; + encodingFormat?: "text/html"; + isAccessibleForFree?: true; + license: string | undefined; + citation?: BlogPosting[]; + mentions?: BlogPosting[]; + translationOfWork?: BlogPosting; + workTranslations?: BlogPosting[]; + isBasedOn?: BlogPosting; + locationCreated?: { + "@type": "Place"; + name: string; + }; + publisher?: Person; + version?: string | number; +}; diff --git a/src/lib/git/log.ts b/src/lib/git/log.ts index bcf6888..4545f17 100644 --- a/src/lib/git/log.ts +++ b/src/lib/git/log.ts @@ -1,4 +1,4 @@ -import { defined } from "../../utils/anonymous.ts"; +import { defined, get } from "../../utils/anonymous.ts"; import { type MaybeIterable, surelyIterable } from "../../utils/iterator.ts"; import { gitDir } from "./index.ts"; import type { Commit, CommitFile } from "./types.ts"; @@ -23,72 +23,86 @@ export async function getLastCommitForOneOfFiles( sources: MaybeIterable<URL>, ): Promise<Commit | undefined> { const files = surelyIterable(sources); - const gitLog = new Deno.Command("git", { - args: [ - "log", - "-1", - `--pretty=format:${format.map((x) => `%${x}`).join("%n")}`, - "--", - // deno-lint-ignore no-undef - ...Iterator.from(files).map((x) => x.pathname), - ], - }); + const gitLogs = (await Promise.all( + Iterator.from(files).map(async ({ pathname }) => { + const gitLog = new Deno.Command("git", { + args: [ + "log", + "--follow", + "-1", + `--pretty=format:${format.map((x) => `%${x}`).join("%n")}`, + "--", + pathname, + ], + }); + const { stdout } = await gitLog.output(); + const result = new TextDecoder().decode(stdout).trim(); - const { stdout } = await gitLog.output(); - const result = new TextDecoder().decode(stdout).trim(); + if (result.length <= 0) { + return undefined; + } - if (result.length <= 0) { - return undefined; - } + const [ + hash, + abbrHash, + authorDate, + authorName, + authorEmail, + committerDate, + committerName, + committerEmail, + // signatureValidation, + signer, + key, + keyFingerPrint, + ...rawLines + ] = result.split("\n"); - const [ - hash, - abbrHash, - authorDate, - authorName, - authorEmail, - committerDate, - committerName, - committerEmail, - // signatureValidation, - signer, - key, - keyFingerPrint, - ...rawLines - ] = result.split("\n"); - - const raw = rawLines.join("\n").trim(); - - const commit: Commit = { - // deno-lint-ignore no-undef - files: await fileStatusFromCommit(hash, Iterator.from(files)), - hash: { long: hash, short: abbrHash }, - author: { - date: new Date(authorDate), - name: authorName, - email: authorEmail, - }, - committer: { - date: new Date(committerDate), - name: committerName, - email: committerEmail, - }, - }; - - if (raw.length > 0) { - commit.signature = { - type: raw.startsWith("gpgsm:") - ? "x509" - : raw.startsWith("gpg:") - ? "gpg" - : "ssh", - signer, - key: { long: keyFingerPrint, short: key }, - rawMessage: raw, - }; - } + const raw = rawLines.join("\n").trim(); + + const commit: Commit = { + // deno-lint-ignore no-undef + files: await fileStatusFromCommit(hash, Iterator.from(files)), + hash: { long: hash, short: abbrHash }, + author: { + date: new Date(authorDate), + name: authorName, + email: authorEmail, + }, + committer: { + date: new Date(committerDate), + name: committerName, + email: committerEmail, + }, + }; + + if (raw.length > 0) { + commit.signature = { + type: raw.startsWith("gpgsm:") + ? "x509" + : raw.startsWith("gpg:") + ? "gpg" + : "ssh", + signer, + key: { long: keyFingerPrint, short: key }, + rawMessage: raw, + }; + } + + return commit; + }), + )).filter(defined); + + const last = gitLogs.sort(({ committer: a }, { committer: b }) => + b.date.getTime() - a.date.getTime() + )?.[0]; + + if (last === undefined) return undefined; - return commit; + const final = gitLogs.filter(({ hash }) => hash.long === last.hash.long); + + last.files = final.flatMap(get("files")); + return last; } async function fileStatusFromCommit( @@ -132,3 +146,25 @@ async function fileStatusFromCommit( return undefined; }).filter(defined); } + +export async function fileCreationCommitDate( + file: URL, +): Promise<Date | undefined> { + const gitDiffTree = new Deno.Command("git", { + args: [ + "log", + "--follow", + "--diff-filter=A", + "--format=%cI", + "--", + file.pathname, + ], + }); + + const { stdout } = await gitDiffTree.output(); + try { + return new Date(new TextDecoder().decode(stdout).trim()); + } catch { + return undefined; + } +} diff --git a/src/lib/pgp/summary.ts b/src/lib/pgp/summary.ts index 5c8a81c..bcd9bc8 100644 --- a/src/lib/pgp/summary.ts +++ b/src/lib/pgp/summary.ts @@ -57,7 +57,7 @@ export type Summary = { result: VerificationResult.MISSING_KEY; reason: Error; keyID: string; - created: Date; + created: Date | null; } | { result: | VerificationResult.SIGNATURE_CORRUPTED @@ -67,11 +67,11 @@ export type Summary = { } | { result: VerificationResult.TRUSTED_KEY; key: PublicKey | Subkey; - created: Date; + created: Date | null; } | { result: VerificationResult.UNTRUSTED_KEY; key: PublicKey | Subkey; - created: Date; + created: Date | null; } | { result: VerificationResult.EXPIRATION_AFTER_SIGNATURE; key: PublicKey | Subkey; @@ -99,7 +99,7 @@ export type Summary = { key: PublicKey | Subkey; }; -export async function createVerificationSummary( +export async function createVerificationsSummary( { dataCorrupted, verifications, signature }: Verification, ): Promise<[NonEmptyArray<Summary>, Map<string, NonEmptyArray<Summary>>]> { if (signature === undefined) { @@ -116,107 +116,7 @@ export async function createVerificationSummary( const summaries = await Promise.all< Promise<[Summary[], Map<string, Summary[]>]>[] - >( - (verifications ?? []).map( - async ({ signatureCorrupted, verified, packet, key }) => { - const errors: Summary[] = []; - const keys: Map<string, Summary[]> = new Map(); - - try { - await verified; - } catch (e) { - if (e instanceof Error) { - if ( - e.message.startsWith("Could not find signing key with key ID") - ) { - const keyID = e.message.slice(e.message.lastIndexOf(" ")); - const key = keys.get(keyID) ?? []; - key.push({ - result: VerificationResult.MISSING_KEY, - keyID, - reason: e, - }); - keys.set(keyID, key); - } else { - errors.push({ - result: VerificationResult.SIGNATURE_COULD_NOT_BE_CHECKED, - reason: e, - }); - } - } else { - throw e; - } - } - - const corrupted = await signatureCorrupted; - if (corrupted[0]) { - errors.push({ - result: VerificationResult.SIGNATURE_CORRUPTED, - reason: corrupted[1], - }); - } - - const sig = await packet; - const keyID = sig.issuerKeyID; - - sig.created; - - const keyAwaited = await key; - - if (keyAwaited === undefined) { - const key = keys.get(keyID.toHex()) ?? []; - key.push({ - result: VerificationResult.MISSING_KEY, - keyID: keyID.toHex(), - reason: new Error( - `Could not find signing key with key ID ${keyID.toHex()}`, - ), - }); - keys.set(keyID.toHex(), key); - - return [errors, keys] as [Summary[], Map<string, Summary[]>]; - } - - const keySummaries = keys.get(keyAwaited.getKeyID().toHex()) ?? []; - const expired = await isKeyExpired(keyAwaited); - - if (expired !== null && sig.created !== null) { - keySummaries.push({ - result: expired <= sig.created - ? VerificationResult.EXPIRATION_BEFORE_SIGNATURE - : VerificationResult.EXPIRATION_AFTER_SIGNATURE, - key: keyAwaited, - date: expired, - }); - } - - const revoked = isKeyRevoked(keyAwaited); - if (revoked?.date !== undefined && sig.created !== null) { - keySummaries.push({ - result: revoked?.date <= sig.created - ? VerificationResult.REVOCATION_BEFORE_SIGNATURE - : VerificationResult.REVOCATION_AFTER_SIGNATURE, - key: keyAwaited, - date: revoked.date, - revocationReason: revoked.reason, - }); - } - - const trust = sig.trustAmount ?? await keyTrust(keyAwaited as Key); - - keySummaries.push({ - result: trust > 0 - ? VerificationResult.TRUSTED_KEY - : VerificationResult.UNTRUSTED_KEY, - key: keyAwaited, - }); - - keys.set(keyAwaited.getKeyID().toHex(), keySummaries); - - return [errors, keys] as [Summary[], Map<string, Summary[]>]; - }, - ), - ); + >((verifications ?? []).map(createVerificationSummary)); const errors = summaries.flatMap(([x]) => x); const keys = new Map(summaries.flatMap(([, x]) => x.entries().toArray())); @@ -230,3 +130,109 @@ export async function createVerificationSummary( throw new Error("unreachable"); } + +export const createVerificationSummary = async ( + { signatureCorrupted, verified, packet, key }: NonNullable< + Verification["verifications"] + >[number], +): Promise<[Summary[], Map<string, Summary[]>]> => { + const errors: Summary[] = []; + const keys: Map<string, Summary[]> = new Map(); + + const sig = await packet; + + try { + await verified; + } catch (e) { + if (e instanceof Error) { + if ( + e.message.startsWith("Could not find signing key with key ID") + ) { + const keyID = e.message.slice(e.message.lastIndexOf(" ")); + const key = keys.get(keyID) ?? []; + key.push({ + result: VerificationResult.MISSING_KEY, + keyID, + reason: e, + created: sig.created, + }); + keys.set(keyID, key); + } else { + errors.push({ + result: VerificationResult.SIGNATURE_COULD_NOT_BE_CHECKED, + reason: e, + }); + } + } else { + throw e; + } + } + + const corrupted = await signatureCorrupted; + if (corrupted[0]) { + errors.push({ + result: VerificationResult.SIGNATURE_CORRUPTED, + reason: corrupted[1], + }); + } + + const keyID = sig.issuerKeyID; + + const keyAwaited = await key; + + if (keyAwaited === undefined) { + const key = keys.get(keyID.toHex()) ?? []; + key.push({ + result: VerificationResult.MISSING_KEY, + keyID: keyID.toHex(), + reason: new Error( + `Could not find signing key with key ID ${keyID.toHex()}`, + ), + created: sig.created, + }); + keys.set(keyID.toHex(), key); + + return [errors, keys] as [Summary[], Map<string, Summary[]>]; + } + + const keySummaries = keys.get(keyAwaited.getKeyID().toHex()) ?? []; + const expired = await isKeyExpired(keyAwaited); + + if (expired !== null && sig.created !== null) { + keySummaries.push({ + result: expired <= sig.created + ? VerificationResult.EXPIRATION_BEFORE_SIGNATURE + : VerificationResult.EXPIRATION_AFTER_SIGNATURE, + key: keyAwaited, + created: sig.created, + expired, + }); + } + + const revoked = isKeyRevoked(keyAwaited); + if (revoked?.date !== undefined && sig.created !== null) { + keySummaries.push({ + result: revoked?.date <= sig.created + ? VerificationResult.REVOCATION_BEFORE_SIGNATURE + : VerificationResult.REVOCATION_AFTER_SIGNATURE, + key: keyAwaited, + created: sig.created, + revoked: revoked.date, + revocationReason: revoked.reason, + }); + } + + const trust = sig.trustAmount ?? await keyTrust(keyAwaited as Key); + + keySummaries.push({ + result: trust > 0 + ? VerificationResult.TRUSTED_KEY + : VerificationResult.UNTRUSTED_KEY, + key: keyAwaited, + created: sig.created, + }); + + keys.set(keyAwaited.getKeyID().toHex(), keySummaries); + + return [errors, keys] as [Summary[], Map<string, Summary[]>]; +}; diff --git a/src/lib/pgp/verify.ts b/src/lib/pgp/verify.ts index 026b6df..1003147 100644 --- a/src/lib/pgp/verify.ts +++ b/src/lib/pgp/verify.ts @@ -24,6 +24,7 @@ import type { Commit } from "../git/types.ts"; import { findMapAsync, type MaybeIterable } from "../../utils/iterator.ts"; import { getUserIDsFromKey } from "./user.ts"; import { env } from "../environment.ts"; +import { toPK } from "./index.ts"; type DataURL = [URL, URL?]; type Corrupted = [false] | [true, Error]; @@ -195,18 +196,21 @@ export class SignatureVerifier { } } - addKey(key: MaybeIterable<PublicKey>): void { + addKey(key: MaybeIterable<PublicKey>): Iterable<PublicKey> { if (key instanceof PublicKey) { this.keys.push(key); + return [key]; } else { this.keys.push(...key); + return key; } } async addKeysFromDir( key: string | URL, rules: KeyDiscoveryRules = DEFAULT_KEY_DISCOVERY_RULES, - ): Promise<void> { + ): Promise<Iterable<PublicKey>> { + const keys: PublicKey[] = []; for await ( const i of createKeysFromDir(key, rules, { encoder: this.#encoder, @@ -214,39 +218,43 @@ export class SignatureVerifier { }) ) { this.keys.push(i); + keys.push(i); } + return keys; } async addKeyFromFile( key: string | URL, type: KeyFileFormat, - ): Promise<void> { + ): Promise<PublicKey> { switch (type) { case armored: { - this.keys.push(await createKeyFromFile(key, type, this.#decoder)); - break; + const k = await createKeyFromFile(key, type, this.#decoder); + this.keys.push(k); + return k; } case binary: { - this.keys.push(await createKeyFromFile(key, type, this.#encoder)); - break; + const k = await createKeyFromFile(key, type, this.#encoder); + this.keys.push(k); + return k; } } } async addKeyFromArmor( key: string | Uint8Array, - ): Promise<void> { - this.keys.push( - await createKeyFromArmor(key, this.#decoder).then((x) => x.toPublic()), - ); + ): Promise<PublicKey> { + const k = await createKeyFromArmor(key, this.#decoder).then(toPK); + this.keys.push(k); + return k; } async addKeyFromBinary( key: string | Uint8Array, - ): Promise<void> { - this.keys.push( - await createKeyFromBinary(key, this.#encoder).then((x) => x.toPublic()), - ); + ): Promise<PublicKey> { + const k = await createKeyFromBinary(key, this.#encoder).then(toPK); + this.keys.push(k); + return k; } public static async instance(): Promise<SignatureVerifier> { diff --git a/src/pages/blog/read/[...slug].astro b/src/pages/blog/read/[...slug].astro deleted file mode 100644 index 71d0929..0000000 --- a/src/pages/blog/read/[...slug].astro +++ /dev/null @@ -1,449 +0,0 @@ ---- -import { type CollectionEntry, getCollection } from "astro:content"; -import { render } from "astro:content"; -import Translations from "@components/Translations.astro"; -import { toIso8601Full } from "@utils/datetime"; -import ReadingTime from "@components/ReadingTime.astro"; -import Keywords from "@components/Keywords.astro"; -import Citations from "@components/Citations.astro"; -import Signature from "@components/signature/Signature.astro"; -import CopyrightNotice from "@components/CopyrightNotice.astro"; -import { verifier as verifierPrototype } from "@lib/pgp/verify"; -import { getSigners, isTranslation } from "@lib/collection/helpers"; -import { get } from "@utils/anonymous"; -import Authors from "@components/signature/Authors.astro"; -import { getEntry } from "astro:content"; -import Base from "@layouts/Base.astro"; -import readingTime from "reading-time"; -import type { - Entry, - MicroEntry, - OriginalEntry, -} from "@lib/collection/schemas"; - -export async function getStaticPaths() { - const posts = await getCollection("blog"); - return posts.map((post) => ({ - params: { slug: post.id }, - props: post, - })); -} - -type Props = CollectionEntry<"blog">; - -const post = Astro.props; - -let original: OriginalEntry | MicroEntry; -if (isTranslation(post)) { - original = await getEntry(post.data.translationOf) as - | OriginalEntry - | MicroEntry; - - if (!original) { - throw new Error(`Original post not found for ${post.id}`); - } - - const originalAuthor = (original.data.signers ?? []).filter( - (s) => s.role === "author", - ).map((s) => s.entity.id)?.[0]; - const originalCoAuthors = new Set( - (original.data.signers ?? []).filter( - (s) => s.role === "co-author", - ).map((s) => s.entity.id), - ); - const translationAuthor = (post.data.signers ?? []).filter( - (s) => s.role === "author", - ).map((s) => s.entity.id)?.[0]; - const translationCoAuthors = new Set( - (post.data.signers ?? []).filter( - (s) => s.role === "co-author", - ).map((s) => s.entity.id), - ); - - if ( - (translationAuthor !== undefined && - translationAuthor !== originalAuthor) || - !translationCoAuthors.isSubsetOf(originalCoAuthors) - ) { - throw new Error( - `Post ${post.id} has mismatched (co-)authors from original post ${original.id}`, - ); - } - - const translators = (post.data.signers ?? []).filter( - (s) => s.role === "translator", - ).map((s) => s.entity.id); - - for (const translator of translators) { - if ( - originalAuthor === translator || originalCoAuthors.has(translator) - ) { - throw new Error( - `Translator ${translator} in ${post.id} is already a (co-)author in original post`, - ); - } - } -} else { - original = post; - if (post.data.signers?.some((x) => x.role === "translator")) { - throw new Error( - `Post ${post.id} is not a translation but has translators defined`, - ); - } -} - -// Add own post as a translation -const translationsSet = new Set( - (await getCollection( - "blog", - (x) => - (x.data.kind === "translation") && x.data.translationOf.id === - (post.data.kind === "translation" - ? post.data.translationOf.id - : post.id), - ) ?? []).map(({ id }) => id), -); - -translationsSet.add(post.id); -const translations = [...translationsSet.values()].map((id) => ({ - collection: post.collection, - id, -})); - -const signers = await getSigners(post); - -const verifier = await verifierPrototype.then((x) => x.clone()); - -// Add signers public keys to keyring -for (const { data } of signers.map(get("entity"))) { - if (data.publickey.armor !== undefined) { - verifier.addKeyFromArmor(data.publickey.armor); - } -} - -const verification = post.filePath !== undefined - ? await verifier.verify([ - new URL(`file://${Deno.cwd()}/${post.filePath}`), - ]) - : undefined; - -const { Content } = await render(post); - -const { lang } = post.data; - -const commit = await verification?.commit; - -const reading = post.body ? readingTime(post.body, {}) : undefined; -const minutes = reading === undefined - ? undefined - : Math.ceil(reading.minutes); -const estimative = reading === undefined - ? undefined - : new Intl.DurationFormat(lang, { - style: "long", - }).format({ minutes }); -const duration = minutes === undefined - ? undefined - : `PT${Math.floor(minutes / 60) > 0 ? Math.floor(minutes / 60) + "H" : ""}${ - minutes % 60 > 0 ? minutes % 60 + "M" : "" - }`; - -const getOrUndefined = (k: string) => - k in post.data ? post.data[k as keyof typeof post.data] : undefined; -const author = { - "@type": "Person", -} as const; -const contributor = post.data.signers.filter(({ role }) => - role === "co-author" -).map(() => { - return { - "@type": "Person", - } as const; -}); -const translator = post.data.signers.filter(({ role }) => - role === "translator" -).map(() => { - return { - "@type": "Person", - } as const; -}); -const JSONLD = { - "@context": "https://schema.org", - "@type": "BlogPosting", - "@id": Astro.url.href, - articleBody: post.rendered?.html ?? post.body, - abstract: getOrUndefined("description"), - alternativeHeadline: getOrUndefined("subtitle"), - author, - citation: [].map(() => { - return { - "@type": "CreativeWork", - }; - }), - contributor, - copyrightHolder: [author, ...contributor, ...translator], - // copyrightNotice: post.data.license, // WORKAROUND - copyrightYear: post.data.dateCreated.getFullYear(), - creativeWorkStatus: "Published", - dateCreated: post.data.dateCreated.toISOString(), - dateModified: "dateUpdated" in post.data - ? post.data.dateUpdated?.toISOString() - : undefined, - // datePublished: undefined, // from git commit commit date - encodingFormat: "text/html", - headline: post.data.title, - inLanguage: post.data.lang, - isAccessibleForFree: true, - isBasedOn: isTranslation(post) - ? { - "@type": "BlogPosting", - "@id": new URL(`blog/read/${post.data.translationOf}`, Astro.site).href, - } - : undefined, - keywords: original.data.keywords, - license: post.data.license, // WORKAROUND - locationCreated: { - "@type": "Place", - // XXX: getOrUndefined("locationCreated"), - }, - mentions: [].map(() => { - return { - "@type": "Thing", - }; - }), - // publication: { - // "@type": "PublicationEvent", - // }, // from git commit - // publisher: { - // "@type": "Person", - // }, // from git commit - text: post.rendered?.html ?? post.body, - timeRequired: post.body !== undefined ? duration : undefined, - translationOf: isTranslation(post) - ? { - "@type": "BlogPosting", - "@id": new URL(`blog/read/${post.data.translationOf}`, Astro.site).href, - } - : undefined, - translator, - // version: undefined // TODO - wordCount: reading?.words, - workTranslations: translations.filter(({ id }) => id !== post.id).map(( - { id }, - ) => ({ - "@type": "BlogPosting", - "@id": new URL(`blog/read/${id}`, Astro.site).href, - })), - description: getOrUndefined("description"), - name: post.data.title, - url: Astro.url.href, -} as const; ---- - -<Base - title={post.data.title} - description={"description" in post.data ? post.data.description : post.data.title} -> - <main> - <article - itemscope - itemtype="http://schema.org/BlogPosting" - itemid={Astro.url.href} - > - <Translations {translations} {lang} /> - <hgroup> - <h1 itemprop="headline">{post.data.title}</h1> - { - "subtitle" in post.data && ( - <p itemprop="alternativeHeadline" class="subtitle"> - {post.data.subtitle} - </p> - ) - } - </hgroup> - { - "description" in post.data && post.data.description && - ( - <section itemprop="abstract"> - <h2>Resumo</h2> - { - post.data.description.split(new RegExp("\\s{2,}")) - .map(( - x, - ) => <p>{x}</p>) - } - </section> - ) - } - {verification && <Signature {lang} {verification} />} - <footer> - { - verification?.verifications && - ( - <Authors - verifications={verification.verifications} - expectedSigners={signers} - commitSignerKey={commit?.signature?.signer} - /> - ) - } - <dl> - <dt>Data de criação</dt> - <dd> - <time - itemprop="dateCreated" - datetime={toIso8601Full(post.data.dateCreated)} - >{ - new Intl.DateTimeFormat([lang], {}).format( - post.data.dateCreated, - ) - }</time> - </dd> - { - post.data.dateUpdated && ( - <dt>Última atualização</dt><dd> - <time - itemprop="dateModified" - datetime={toIso8601Full(post.data.dateUpdated)} - >{ - new Intl.DateTimeFormat([lang], {}).format( - post.data.dateUpdated, - ) - }</time> - </dd> - ) - } - { - "locationCreated" in post.data && - post.data.locationCreated && ( - <dt - itemprop="locationCreated" - itemscope - itemtype="https://schema.org/Place" - > - Local de criação - </dt><dd> - <span itemprop="name">{post.data.locationCreated}</span> - </dd> - ) - } - </dl> - <ReadingTime body={post.body} {lang} /> - </footer> - <hr /> - <div itemprop="articleBody text"><Content /></div> - <hr /> - { - "keywords" in original.data && ( - <Keywords keywords={original.data.keywords} /> - ) - } - { - "relatedPosts" in original.data && ( - <Citations citations={original.data.relatedPosts} /> - ) - } - <CopyrightNotice - author={signers[0]?.entity.data.websites?.[0] ?? "Anonymous"} - website={signers[0]?.entity.data.websites?.[0]} - email={signers[0]?.entity.data.websites?.[0]} - title={post.data.title} - dateCreated={post.data.dateCreated} - license={post.data.license} - /> - </article> - </main> -</Base> - -<script - type="application/ld+json" - is:inline - set:html={JSON.stringify(JSONLD)} -/> - -<script type="module" is:inline> - hashchange(); - - window.addEventListener("hashchange", hashchange); - - document.addEventListener( - "click", - function (event) { - if ( - event.target && - event.target instanceof HTMLAnchorElement && - event.target.href === location.href && - location.hash.length > 1 - ) { - requestIdleCallback(function () { - if (!event.defaultPrevented) { - hashchange(); - } - }); - } - }, - false, - ); - - function hashchange() { - let hash; - - try { - hash = decodeURIComponent(location.hash.slice(1)).toLowerCase(); - } catch (e) { - return; - } - - const name = "user-content-" + hash; - const target = document.getElementById(name) || - document.getElementsByName(name)[0]; - - if (target) { - requestIdleCallback(function () { - target.scrollIntoView(); - }); - } - } -</script> - -<style is:inline> - section[data-footnotes].footnotes { - word-wrap: break-word; - } -</style> - -<style> - hgroup { - text-align: center; - } - - .subtitle { - font-weight: lighter; - } - - [itemprop~="articleBody"] { - line-height: 1.4; - font-size: 1.2em; - text-align: justify; - - & h1, - & h2, - & h3 { - line-height: 1.2; - } - } - - [itemprop="abstract"] { - margin-inline: 1em; - padding-block: 1em; - font-style: italic; - } - - @media print { - body { - font-size: 1rem; - font-family: var(--ff-serif); - line-height: 1.62; - } - } -</style> diff --git a/src/pages/blog/read/[slug].astro b/src/pages/blog/read/[slug].astro new file mode 100644 index 0000000..263b31d --- /dev/null +++ b/src/pages/blog/read/[slug].astro @@ -0,0 +1,576 @@ +--- +import { type CollectionEntry, getCollection } from "astro:content"; +import { render } from "astro:content"; +import Translations from "@components/Translations.astro"; +import KeywordsList from "@components/organisms/KeywordsList.astro"; +import Citations from "@components/Citations.astro"; +import Signature from "@components/templates/signature/Signature.astro"; +import CopyrightNotice from "@components/templates/CopyrightNotice.astro"; +import { verifier as verifierPrototype } from "@lib/pgp/verify"; +import { + fromPosts, + getSigners, + getSignersIDs, + isTranslation, + licenseNotice, + licenseURL, +} from "@lib/collection/helpers"; +import { defined, get, transform } from "@utils/anonymous"; +import Authors from "@components/templates/Authors.astro"; +import Base from "@layouts/Base.astro"; +import type { + GetStaticPaths, + InferGetStaticParamsType, + InferGetStaticPropsType, +} from "astro"; +import DateTime from "@components/organisms/Date.astro"; +import { getUserIDsFromKey } from "@lib/pgp/user"; +import type { PublicKey, UserIDPacket } from "openpgp"; +import type { BlogPosting, Person } from "@lib/collection/types"; +import { + type MicroEntry, + Original, + type OriginalEntry, + Translation, +} from "@lib/collection/schemas"; +import { getEntry } from "astro:content"; +import { getEntries } from "astro:content"; +import readingTime from "reading-time"; +import { fileCreationCommitDate } from "@lib/git/log"; + +export const getStaticPaths = (async (): Promise< + { + params: { slug: string }; + props: CollectionEntry<"blog">; + }[] +> => { + const posts = await getCollection("blog"); + return posts.map((post) => ({ + params: { slug: post.id }, + props: post, + })); +}) satisfies GetStaticPaths; + +type Params = InferGetStaticParamsType<typeof getStaticPaths>; +type Props = InferGetStaticPropsType<typeof getStaticPaths>; + +let post: Props | undefined = Astro.props; + +const verifier = await verifierPrototype.then((x) => x.clone()); + +const signers: Map< + string, + { + signer: Awaited<ReturnType<typeof getSigners>>[number]; + users: UserIDPacket[]; + key: PublicKey; + } +> = new Map(); +// Add signers public keys to keyring +for (const signer of await getSigners(post)) { + const { data } = signer.entity; + const key = await verifier.addKeyFromArmor(data.publickey.armor); + signers.set(key.getFingerprint(), { + signer, + users: getUserIDsFromKey(undefined, key), + key, + }); +} + +const createPerson = ( + { signer, users }: typeof signers extends Map<any, infer V> ? V : never, +): Person | undefined => ({ + "@type": "Person", + "@id": signer.entity.id, // TODO: URL + name: users.find(({ name }) => name.length > 0)?.name, + url: signer.entity.data.websites, + email: users.find(({ email }) => email.length > 0)?.email, +}); + +const signersValues = Array.from(signers.values()); +const author: Person | undefined = transform( + signersValues.find(({ signer }) => signer.role === "author"), + (x) => x !== undefined ? createPerson(x) : undefined, +); +const coauthors: Person[] = signersValues.filter(({ signer }) => + signer.role === "co-author" +).map(createPerson).filter(defined); +const translators: Person[] = signersValues.filter(({ signer }) => + signer.role === "translator" +).map(createPerson).filter(defined); + +const { id, data, rendered, body, filePath } = post; + +const path = new URL(`file://${Deno.cwd()}/${filePath}`); +const verification = post.filePath !== undefined + ? await verifier.verify([path]) + : undefined; + +const commit = await verification?.commit; + +const { title, lang, dateCreated, dateUpdated, license } = data; + +let original: OriginalEntry | MicroEntry; +try { + const { translationOf } = Translation.parse(post); + const maybeOriginal = await getEntry(translationOf) as + | OriginalEntry + | MicroEntry + | undefined; + + if (maybeOriginal === undefined) { + throw new Error(`Original post not found for ${id}`); + } + + original = maybeOriginal; + + const { author: [originalAuthors], "co-author": originalCoauthors } = + getSignersIDs(original); + const originalAuthor = originalAuthors?.[0]; + + if ( + (author !== undefined && + author["@id"] !== originalAuthor) || + !new Set(coauthors).isSubsetOf(new Set(originalCoauthors)) + ) { + throw new Error( + `Post ${id} has mismatched (co-)authors from original post ${original.id}`, + ); + } + + for (const { "@id": t } of translators) { + if ( + originalAuthor === t || originalCoauthors.includes(t) + ) { + throw new Error( + `Translator ${t} in ${id} is already a (co-)author in original post`, + ); + } + } +} catch { + original = post as OriginalEntry | MicroEntry; + if (signersValues.some(({ signer }) => signer.role === "translator")) { + throw new Error( + `Post ${id} is not a translation but has translators defined`, + ); + } +} + +const translationsSet = await fromPosts( + isTranslation, + (x) => + new Set( + x.filter(({ data }) => data.translationOf.id === original.id).map( + get("id"), + ), + ), +); +translationsSet.add(original.id); + +const translations = await getEntries( + Array.from(translationsSet).map((id) => ({ + collection: original.collection, + id, + })), +); + +const reading = body ? readingTime(body, {}) : undefined; +const minutes = reading === undefined + ? undefined + : Math.ceil(reading.minutes); +const estimative = minutes === undefined + ? undefined + : new Intl.DurationFormat(lang, { + style: "long", + }).format({ hours: Math.floor(minutes / 60), minutes: minutes % 60 }); +const duration = minutes === undefined + ? undefined + : `PT${Math.floor(minutes / 60) > 0 ? Math.floor(minutes / 60) + "H" : ""}${ + minutes % 60 > 0 ? minutes % 60 + "M" : "" + }`; + +const linkedData: BlogPosting & { "@context": "https://schema.org" } = { + "@context": "https://schema.org", + "@type": "BlogPosting", + "@id": Astro.url.href, + url: Astro.url.href, + headline: title, + name: title, + abstract: "description" in data ? data.description : undefined, + alternativeHeadline: "subtitle" in data ? data.subtitle : undefined, + inLanguage: lang, + workTranslations: translations.filter((post) => + post.id !== id && post.id !== original.id + ).map(({ id, data }) => + ({ + "@type": "BlogPosting", + "@id": new URL(`blog/read/${id}`, Astro.site).href, + url: new URL(`blog/read/${id}`, Astro.site).href, + headline: data.title, + name: data.title, + inLanguage: data.lang, + dateCreated: data.dateCreated.toISOString(), + license: licenseURL(data.license)?.href, + translator: data.signers.filter(({ role }) => role === "translator") + .map(( + { entity }, + ): Person => ({ + "@type": "Person", + "@id": entity.id, + })), + }) as BlogPosting + ), + translationOfWork: original.id !== post.id + ? { + "@type": "BlogPosting", + "@id": new URL(`blog/read/${original.id}`, Astro.site).href, + url: new URL(`blog/read/${original.id}`, Astro.site).href, + headline: original.data.title, + name: original.data.title, + inLanguage: original.data.lang as string, + dateCreated: original.data.dateCreated.toISOString(), + license: licenseURL(original.data.license)?.href, + } as BlogPosting + : undefined, + // TODO: version + author, + contributor: coauthors, + translator: translators, + dateCreated: dateCreated.toISOString(), + dateModified: dateUpdated?.toISOString(), + datePublished: await fileCreationCommitDate(path).then((date) => + date?.toISOString() + ), + timeRequired: duration, + wordCount: reading?.words, + articleBody: rendered?.html ?? body, + text: rendered?.html ?? body, + keywords: original.data.keywords, + citation: await transform( + Original.safeParse(original.data).data, + async (o) => { + if (o === undefined) return o; + const related = await getEntries(o.relatedPosts); + return related.map(({ data }): BlogPosting => ({ + "@type": "BlogPosting", + "@id": new URL(`blog/read/${id}`, Astro.site).href, + url: new URL(`blog/read/${id}`, Astro.site).href, + headline: data.title, + name: data.title, + inLanguage: data.lang, + dateCreated: data.dateCreated.toISOString(), + license: licenseURL(data.license)?.href ?? undefined, + })); + }, + ), // TODO: citation V.S. mentions + mentions: await transform( + Original.safeParse(original.data).data, + async (o) => { + if (o === undefined) return o; + const related = await getEntries(o.relatedPosts); + return related.map(({ data }): BlogPosting => ({ + "@type": "BlogPosting", + "@id": new URL(`blog/read/${id}`, Astro.site).href, + url: new URL(`blog/read/${id}`, Astro.site).href, + headline: data.title, + name: data.title, + inLanguage: data.lang, + dateCreated: data.dateCreated.toISOString(), + license: licenseURL(data.license)?.href ?? undefined, + })); + }, + ), // TODO: citation V.S. mentions + copyrightHolder: [author, ...coauthors, ...translators].filter(defined), + copyrightNotice: licenseNotice(license, { + title, + holders: signersValues.map(({ users }) => { + const user = users?.[0]; + if (user === undefined) return undefined; + + const { name, email } = user; + + return (name.length > 0 && email.length > 0) + ? { name, email } + : undefined; + }).filter(defined), + years: new Array( // TODO: get years where there were commits + (dateUpdated?.getFullYear() ?? dateCreated.getFullYear()) - + dateCreated.getFullYear() + 1, + ).fill(dateCreated.getFullYear()).map((x, i) => x + i), + }, lang), + copyrightYear: dateCreated.getFullYear(), + creativeWorkStatus: "Published", + encodingFormat: "text/html", + isAccessibleForFree: true, + license: licenseURL(license)?.href ?? undefined, + publisher: transform(commit?.committer, (commiter) => { + if (commiter === undefined) return undefined; + + const { name, email } = commiter; + + return { + "@type": "Person", + name, + email, + }; + }), +}; + +const { Content } = await render(post); + +post = undefined; +--- + +<Base + title={linkedData.headline} + description={linkedData.abstract ?? linkedData.headline} +> + <main + itemprop="mainContentOfPage" + itemscope + itemtype="https://schema.org/WebPageElement" + > + <article + itemscope + itemtype="http://schema.org/BlogPosting" + itemid={Astro.url.href} + > + <Translations + id={linkedData["@id"]} + lang={linkedData.inLanguage} + workTranslations={linkedData.workTranslations ?? []} + translationOfWork={linkedData.translationOfWork} + /> + <hgroup> + <h1 itemprop="headline">{linkedData.headline}</h1> + { + linkedData.alternativeHeadline && ( + <p itemprop="alternativeHeadline" class="subtitle"> + {linkedData.alternativeHeadline} + </p> + ) + } + </hgroup> + { + linkedData.abstract && + ( + <section itemprop="abstract"> + <h2>Resumo</h2> + { + linkedData.abstract.split(new RegExp("\\s{2,}")) + .map(( + x, + ) => <p>{x}</p>) + } + </section> + ) + } + {verification && <Signature {lang} {verification} />} + <footer> + { + verification?.verifications && + ( + <Authors + verifications={verification.verifications} + expectedSigners={signers} + commitSignerKey={commit?.signature?.key.long} + /> + ) + } + <dl> + <dt>Data de criação</dt> + <dd> + <DateTime + date={new Date(linkedData.dateCreated)} + locales={linkedData.inLanguage} + options={{ + weekday: "long", + year: "numeric", + month: "long", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + timeZoneName: "long", + }} + itemprop="dateCreated" + /> + </dd> + { + linkedData.dateModified && ( + <dt>Última atualização</dt> + <dd> + <DateTime + date={new Date(linkedData.dateModified)} + locales={linkedData.inLanguage} + options={{ + weekday: "long", + year: "numeric", + month: "long", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + timeZoneName: "long", + }} + itemprop="dateModified" + /> + </dd> + ) + } + { + linkedData.locationCreated && ( + <div + itemprop="locationCreated" + itemscope + itemtype="https://schema.org/Place" + > + <dt>Local de criação</dt> + <dd itemprop="name">{linkedData.locationCreated.name}</dd> + </div> + ) + } + { + linkedData.wordCount && linkedData.timeRequired && + ( + <> + <dt>Tempo de leitura estimado</dt> + <dd> + <data + itemprop="timeRequired" + value={linkedData.timeRequired} + >~ {estimative}</data> + <data itemprop="wordCount" value={linkedData.wordCount} + >(<bdi>palavras</bdi>: {linkedData.wordCount})</data> + </dd> + </> + ) + } + </dl> + </footer> + <div itemprop="articleBody text"><Content /></div> + { + linkedData.keywords !== undefined && + linkedData.keywords.length > 0 && ( + <div id="keywords"> + <KeywordsList keywords={linkedData.keywords} /> + </div> + ) + } + { + linkedData.citation !== undefined && ( + <Citations citations={linkedData.citation} /> + ) + } + <CopyrightNotice + title={linkedData.headline} + holders={linkedData.copyrightHolder ?? [{ "@type": "Person" }]} + years={new Array( // TODO: get years where there were commits + (dateUpdated?.getFullYear() ?? dateCreated.getFullYear()) - + dateCreated.getFullYear() + 1, + ).fill(dateCreated.getFullYear()).map((x, i) => x + i)} + {license} + /> + </article> + </main> +</Base> + +<script + type="application/ld+json" + is:inline + set:html={JSON.stringify(linkedData)} +/> + +<script type="module" is:inline> + hashchange(); + + window.addEventListener("hashchange", hashchange); + + document.addEventListener( + "click", + function (event) { + if ( + event.target && + event.target instanceof HTMLAnchorElement && + event.target.href === location.href && + location.hash.length > 1 + ) { + requestIdleCallback(function () { + if (!event.defaultPrevented) { + hashchange(); + } + }); + } + }, + false, + ); + + function hashchange() { + let hash; + + try { + hash = decodeURIComponent(location.hash.slice(1)).toLowerCase(); + } catch (e) { + return; + } + + const name = "user-content-" + hash; + const target = document.getElementById(name) || + document.getElementsByName(name)[0]; + + if (target) { + requestIdleCallback(function () { + target.scrollIntoView(); + }); + } + } +</script> + +<style is:inline> + section[data-footnotes].footnotes { + word-wrap: break-word; + } +</style> + +<style> + hgroup { + text-align: center; + } + + .subtitle { + font-weight: lighter; + } + + #keywords { + display: flex; + margin-inline: auto; + margin-block: calc(var(--size-4) * 1em); + } + + [itemprop~="articleBody"] { + line-height: 1.4; + font-size: 1.2em; + text-align: justify; + + & h1, + & h2, + & h3 { + line-height: 1.2; + } + + border-block: 1px solid var(--color-dark); + padding-block: calc(var(--size-4) * 1em); + } + + [itemprop="abstract"] { + margin-inline: 1em; + padding-block: 1em; + font-style: italic; + } + + @media print { + body { + font-size: 1rem; + font-family: var(--ff-serif); + line-height: 1.62; + } + } +</style> diff --git a/src/utils/anonymous.test.ts b/src/utils/anonymous.test.ts index 2da613f..45896bf 100644 --- a/src/utils/anonymous.test.ts +++ b/src/utils/anonymous.test.ts @@ -9,6 +9,7 @@ import { identity, instanciate, pass, + transform, } from "./anonymous.ts"; import { assertSpyCalls, spy } from "@std/testing/mock"; import { FALSE, TRUE } from "../../tests/fixtures/test_data.ts"; @@ -128,3 +129,34 @@ describe("extremeBy", () => { assertEquals(extremeBy(data, "min"), Infinity); }); }); + +describe("transform", () => { + it("applies the function to the input", () => { + const result = transform(5, (x) => x * 2); + assertEquals(result, 10); + }); + + it("works with strings", () => { + const result = transform("hello", (x) => x.toUpperCase()); + assertEquals(result, "HELLO"); + }); + + it("works with objects", () => { + const input = { a: 1, b: 2 }; + const result = transform(input, ({ a, b }) => a + b); + assertEquals(result, 3); + }); + + it("returns the correct type", () => { + const TRUE = true; + const FALSE = false; + const result = transform(TRUE, (x) => !x); + assertEquals(result, FALSE); + }); + + it("works with identity function", () => { + const input = [1, 2, 3]; + const result = transform(input, identity); + assertEquals(result, input); + }); +}); diff --git a/src/utils/anonymous.ts b/src/utils/anonymous.ts index ddd28bd..58e5a0a 100644 --- a/src/utils/anonymous.ts +++ b/src/utils/anonymous.ts @@ -23,3 +23,4 @@ export const pass = <T>(fn: (x: T) => void): (x: T) => T => (x: T): T => { export const equal = <T>(x: T): (y: T) => boolean => (y: T): boolean => x === y; export const extremeBy = (arr: number[], mode: "max" | "min"): number => Math[mode](...arr); +export const transform = <T, U>(x: T, y: (x: T) => U): U => y(x); diff --git a/src/utils/datetime.ts b/src/utils/datetime.ts index c32fde0..be2ce08 100644 --- a/src/utils/datetime.ts +++ b/src/utils/datetime.ts @@ -1,3 +1,5 @@ +import { createRanges } from "./iterator.ts"; + export function toIso8601Full(date: Date): string { const yearN = date.getFullYear(); const isNegativeYear = yearN <= 0; @@ -66,3 +68,19 @@ export function getRelativeTimeUnit( if (Math.abs(minutes) >= 1) return [Math.round(minutes), "minute"]; return [Math.round(seconds), "second"]; } + +export function listDates(dates: Date[], { date, locale, list }: { + date: Intl.DateTimeFormatOptions; + locale: Intl.LocalesArgument; + list: Intl.ListFormatOptions; +}): string { + const formatter = new Intl.DateTimeFormat(locale, date); + return new Intl.ListFormat(locale, list).format(dates.map(formatter.format)); +} + +export function listYearsWithRanges(years: number[], { locale, list }: { + locale: Intl.LocalesArgument; + list: Intl.ListFormatOptions; +}): string { + return new Intl.ListFormat(locale, list).format(createRanges(years)); +} diff --git a/src/utils/iterator.ts b/src/utils/iterator.ts index fa58fc9..43437b6 100644 --- a/src/utils/iterator.ts +++ b/src/utils/iterator.ts @@ -50,3 +50,30 @@ export async function findMapAsync<T, R>( return await tryNext(0); } + +export function createRanges(nums: Iterable<number>): string[] { + const ns = new Set(nums).values().toArray().sort(( + a, + b, + ) => a - b); + + const result = []; + let start = ns[0]; + let end = ns[0]; + + for (let i = 1; i <= ns.length; i++) { + if (ns[i] === end + 1) { + end = ns[i]; + } else { + if (start === end) { + result.push(`${start}`); + } else { + result.push(`${start}-${end}`); + } + start = ns[i]; + end = ns[i]; + } + } + + return result; +} |