summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/components/Citations.astro15
-rw-r--r--src/components/Translations.astro133
-rw-r--r--src/components/licenses/WTFPL.astro53
-rw-r--r--src/components/organisms/Date.astro4
-rw-r--r--src/components/signature/Authors.astro275
-rw-r--r--src/components/templates/Authors.astro355
-rw-r--r--src/components/templates/CopyrightNotice.astro (renamed from src/components/CopyrightNotice.astro)21
-rw-r--r--src/components/templates/licenses/CC.astro (renamed from src/components/licenses/CC.astro)101
-rw-r--r--src/components/templates/licenses/WTFPL.astro70
-rw-r--r--src/components/templates/signature/Commit.astro (renamed from src/components/signature/Commit.astro)0
-rw-r--r--src/components/templates/signature/Downloads.astro (renamed from src/components/signature/Downloads.astro)0
-rw-r--r--src/components/templates/signature/Signature.astro (renamed from src/components/signature/Signature.astro)0
-rw-r--r--src/components/templates/signature/Summary.astro (renamed from src/components/signature/Summary.astro)4
-rw-r--r--src/content/entities.toml4
-rw-r--r--src/lib/collection/helpers.ts115
-rw-r--r--src/lib/collection/schemas.ts5
-rw-r--r--src/lib/collection/types.ts54
-rw-r--r--src/lib/git/log.ts162
-rw-r--r--src/lib/pgp/summary.ts216
-rw-r--r--src/lib/pgp/verify.ts38
-rw-r--r--src/pages/blog/read/[...slug].astro449
-rw-r--r--src/pages/blog/read/[slug].astro576
-rw-r--r--src/utils/anonymous.test.ts32
-rw-r--r--src/utils/anonymous.ts1
-rw-r--r--src/utils/datetime.ts18
-rw-r--r--src/utils/iterator.ts27
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 && (
- <>&lt;<a
- itemprop="email"
- rel="author external noreferrer"
- target="_blank"
- href={`mailto:${email}`}
- >{email}</a>&gt;</>
- )
- }</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
- ? (
- <>&lt;<a
- itemprop="email"
- rel="author external noreferrer"
- target="_blank"
- href={primary?.email && `mailto:${primary.email}`}
- >{primary?.email}</a>&gt;</>
- )
- : ""
- }
- {
- 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
+ ? (
+ <>&lt;<a
+ itemprop="email"
+ rel="author external noreferrer"
+ target="_blank"
+ href={primary?.email && `mailto:${primary.email}`}
+ >{primary?.email}</a>&gt;</>
+ )
+ : ""
+ }
+ </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 && (
+ <>&lt;<a
+ itemprop="email"
+ rel="author external noreferrer"
+ target="_blank"
+ href={`mailto:${email}`}
+ >{email}</a>&gt;</>
+ )
+ }</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;
+}