summaryrefslogtreecommitdiff
path: root/src/components
diff options
context:
space:
mode:
Diffstat (limited to 'src/components')
-rw-r--r--src/components/BaseHead.astro79
-rw-r--r--src/components/BlogCard.astro38
-rw-r--r--src/components/Citations.astro39
-rw-r--r--src/components/Commit.astro49
-rw-r--r--src/components/CopyrightNotice.astro66
-rw-r--r--src/components/DateSelector.astro141
-rw-r--r--src/components/Footer.astro62
-rw-r--r--src/components/Header.astro41
-rw-r--r--src/components/HeaderLink.astro18
-rw-r--r--src/components/Keywords.astro52
-rw-r--r--src/components/ReadingTime.astro26
-rw-r--r--src/components/SignaturesTableRows.astro51
-rw-r--r--src/components/Translations.astro107
-rw-r--r--src/components/licenses/CC.astro120
-rw-r--r--src/components/licenses/WTFPL.astro53
-rw-r--r--src/components/signature/Authors.astro281
-rw-r--r--src/components/signature/Commit.astro87
-rw-r--r--src/components/signature/Downloads.astro63
-rw-r--r--src/components/signature/Signature.astro44
-rw-r--r--src/components/signature/Summary.astro279
20 files changed, 1696 insertions, 0 deletions
diff --git a/src/components/BaseHead.astro b/src/components/BaseHead.astro
new file mode 100644
index 0000000..5ac0410
--- /dev/null
+++ b/src/components/BaseHead.astro
@@ -0,0 +1,79 @@
+---
+// Import the global.css file here so that it is included on
+// all pages through the use of the <BaseHead /> component.
+import "../styles/global.css";
+import { SITE_AUTHOR, SITE_DESCRIPTION, SITE_TITLE } from "../consts";
+import { ClientRouter } from "astro:transitions";
+
+export interface Props {
+ title: string;
+ description?: string;
+ image?: string;
+ keywords?: string[];
+}
+
+const canonicalURL = new URL(Astro.url.pathname, Astro.site);
+
+const { title, description = SITE_DESCRIPTION, image, keywords = [] } =
+ Astro.props;
+// const socialImage = image ?? Astro.site.href + 'assets/social.png'
+---
+
+<!-- Global Metadata -->
+<meta charset="utf-8" />
+<meta name="viewport" content="width=device-width,initial-scale=1" />
+
+<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
+<link rel="sitemap" href="/sitemap-index.xml" />
+<link
+ rel="alternate"
+ type="application/rss+xml"
+ title={SITE_TITLE}
+ href={new URL("rss.xml", Astro.site)}
+/>
+<meta name="generator" content={Astro.generator} />
+
+<!-- Canonical URL -->
+<link rel="canonical" href={canonicalURL} />
+
+<!-- Primary Meta Tags -->
+<title>{title}</title>
+<meta name="title" content={title} />
+<meta name="description" content={description} />
+<meta name="author" content={SITE_AUTHOR} />
+{keywords.length > 0 && <meta name="keywords" content={keywords.join(",")} />}
+<meta name="theme-color" content="#a50026" />
+<meta
+ name="theme-color"
+ content="#f46d43"
+ media="(prefers-color-scheme: dark)"
+/>
+
+<!-- Open Graph / Facebook -->
+<meta property="og:type" content="website" />
+<meta property="og:url" content={Astro.url} />
+<meta property="og:title" content={title} />
+<meta property="og:description" content={description} />
+{image && <meta property="og:image" content={new URL(image, Astro.url)} />}
+
+<!-- Twitter -->
+<meta property="twitter:card" content="summary_large_image" />
+<meta property="twitter:url" content={Astro.url} />
+<meta property="twitter:title" content={title} />
+<meta property="twitter:description" content={description} />
+{image && <meta property="twitter:image" content={new URL(image, Astro.url)} />}
+
+<ClientRouter />
+
+<script is:inline>
+ const root = document.documentElement;
+ const theme = localStorage.getItem("theme");
+ if (
+ theme === "dark" ||
+ (!theme && window.matchMedia("(prefers-color-scheme: dark)").matches)
+ ) {
+ root.classList.add("theme-dark");
+ } else {
+ root.classList.remove("theme-dark");
+ }
+</script>
diff --git a/src/components/BlogCard.astro b/src/components/BlogCard.astro
new file mode 100644
index 0000000..7ab42d7
--- /dev/null
+++ b/src/components/BlogCard.astro
@@ -0,0 +1,38 @@
+---
+import type { CollectionEntry } from "astro:content";
+
+interface Props extends CollectionEntry<"blog"> {}
+
+const { id, data } = Astro.props;
+const { title, description, dateCreated, lang } = data;
+
+const href = `/blog/read/${id}`;
+---
+
+<article>
+ <h2>
+ <a {href}>{title}</a>
+ </h2>
+ <p>{description}</p>
+ <footer>
+ <span><time datetime={(dateCreated as Date).toISOString()}>{
+ new Intl.DateTimeFormat(lang, {
+ weekday: "long",
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ timeZoneName: "long",
+ }).format(dateCreated)
+ }</time></span>
+ </footer>
+</article>
+
+<style>
+ article {
+ border-block-end: 1px solid #181818;
+ padding-block-end: 1rem;
+ margin-block: 0.5rem;
+ }
+</style>
diff --git a/src/components/Citations.astro b/src/components/Citations.astro
new file mode 100644
index 0000000..cc82eda
--- /dev/null
+++ b/src/components/Citations.astro
@@ -0,0 +1,39 @@
+---
+import type { CollectionEntry } from "astro:content";
+import { getEntries } from "astro:content";
+
+type Props = { citations: CollectionEntry<"blog">["data"]["relatedPosts"] };
+const citations = await getEntries(Astro.props.citations ?? []);
+---
+{
+ citations.length > 0 &&
+ (
+ <aside>
+ <p>O autor recomenda ler também:</p>
+ <ul>
+ {
+ citations.map(({ collection, id, data }) => (
+ <li
+ itemprop="citation"
+ itemscope
+ itemtype="http://schema.org/BlogPosting"
+ itemid={Astro.url.href.replace(/[^\/]*\/?$/, id)}
+ >
+ <a href={`/${collection}/read/${id}`}>
+ <cite itemprop="headline">{data.title}</cite>
+ </a>
+ </li>
+ ))
+ }
+ </ul>
+ </aside>
+ )
+}
+
+<style>
+ @media print {
+ aside {
+ display: none;
+ }
+ }
+</style>
diff --git a/src/components/Commit.astro b/src/components/Commit.astro
new file mode 100644
index 0000000..3ee284a
--- /dev/null
+++ b/src/components/Commit.astro
@@ -0,0 +1,49 @@
+---
+import type { Commit } from "@lib/git/types";
+import { gitDir } from "@lib/git";
+
+type Props = Commit;
+
+const { hash, files, author, signature } = Astro.props;
+
+const git = await gitDir;
+---
+<p>Git commit info:</p>
+<dl>
+ <dt>Hash</dt>
+ <dd>{hash}</dd>
+ <dt>Files</dt>
+ {files.map((file) => <dd>{file.pathname.replace(git, "")}</dd>)}
+ <dt>Author</dt>
+ <dd>{author.name} &lt;{author.email}&gt;</dd>
+ {
+ signature && (
+ <dt>Commit Signature</dt>
+ <dd>
+ <dl>
+ <dt>Type</dt>
+ <dd>{signature.type}</dd>
+ <dt>Signer</dt>
+ <dd>{signature.signerName}</dd>
+ <dt>Key fingerprint</dt>
+ <dd>{signature.keyFingerPrint}</dd>
+ </dl>
+ </dd>
+ )
+ }
+</dl>
+
+<style>
+ dl {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ }
+
+ dl > dt, dd {
+ display: inline-block;
+ }
+
+ dt::after {
+ content: ": ";
+ }
+</style>
diff --git a/src/components/CopyrightNotice.astro b/src/components/CopyrightNotice.astro
new file mode 100644
index 0000000..2aa72ad
--- /dev/null
+++ b/src/components/CopyrightNotice.astro
@@ -0,0 +1,66 @@
+---
+import CC from "./licenses/CC.astro";
+import WTFPL from "./licenses/WTFPL.astro";
+import { CREATIVE_COMMONS_LICENSES, LICENSES } from "../consts.ts";
+
+export interface Props {
+ title: string;
+ author: string;
+ email?: string;
+ website?: string;
+ dateCreated: Date;
+ 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;
+}
+---
+
+{Notice && <div lang="en"><Notice {...Astro.props} /></div>}
+
+{
+ /*
+https://spdx.org/licenses/WTFPL.html
+https://spdx.org/licenses/GFDL-1.3-or-later.html
+https://spdx.org/licenses/FSFAP.html
+https://artlibre.org/licence/lal/en/
+https://harmful.cat-v.org/software/
+
+IPL-1.0
+IPA
+Intel
+HPND
+EUPL-1.2
+EUPL-1.1
+EUDatagrid
+EPL-2.0
+EPL-1.0
+EFL-2.0
+ECL-2.0
+CPL-1.0
+CPAL-1.0
+CDDL-1.0
+BSL-1.0
+BSD-3-Clause
+BSD-2-Clause
+Artistic-2.0
+APSL-2.0
+Apache-2.0
+Apache-1.1
+AGPL-3.0-or-later
+AGPL-3.0-only
+AFL-3.0
+AFL-2.1
+AFL-2.0
+AFL-1.2
+AFL-1.1
+ */
+}
diff --git a/src/components/DateSelector.astro b/src/components/DateSelector.astro
new file mode 100644
index 0000000..324bc41
--- /dev/null
+++ b/src/components/DateSelector.astro
@@ -0,0 +1,141 @@
+---
+interface Props {
+ date: Date;
+ years: number[];
+ months: number[];
+ days?: number[];
+}
+
+const { date, years, months, days } = Astro.props;
+
+const y = date.getFullYear();
+const m = date.getMonth() + 1;
+const d = date.getDate();
+let yI = 0;
+let mI = 0;
+let dI = 0;
+
+const list = new Intl.ListFormat("pt-PT", { type: "unit", style: "narrow" });
+
+const pad = (n: number) => String(n).padStart(2, "0");
+---
+<nav>
+ <span role="list">
+ Anos:{" "}
+ {
+ list.formatToParts(years.map((y) =>
+ new Intl.DateTimeFormat("pt-PT", { year: "2-digit" }).format(
+ new Date(
+ Date.UTC(
+ y,
+ 0,
+ 1,
+ date.getTimezoneOffset() / 60,
+ date.getTimezoneOffset() % 60,
+ ),
+ ),
+ )
+ )).map(({ type, value }: { type: string; value: string }) => {
+ switch (type) {
+ case "element": {
+ const year = years[yI++];
+ return (
+ <span role="listitem"><a
+ class:list={[{ active: year === y }]}
+ href={`/blog/${year}`}
+ >{value}</a></span>
+ );
+ }
+ case "literal": {
+ return (
+ <span>{value}</span>
+ );
+ }
+ }
+ })
+ }
+ </span>
+ <br />
+ <span role="list">
+ Meses:{" "}
+ {
+ list.formatToParts(months.map((m) =>
+ new Intl.DateTimeFormat("pt-PT", { month: "short" }).format(
+ new Date(
+ Date.UTC(
+ y,
+ m - 1,
+ 1,
+ date.getTimezoneOffset() / 60,
+ date.getTimezoneOffset() % 60,
+ ),
+ ),
+ )
+ )).map(({ type, value }: { type: string; value: string }) => {
+ switch (type) {
+ case "element": {
+ const month = months[mI++];
+ return (
+ <span role="listitem"><a
+ class:list={[{ active: month === m }]}
+ href={`/blog/${y}/${pad(month)}`}
+ >{value}</a></span>
+ );
+ }
+ case "literal": {
+ return (
+ <span>{value}</span>
+ );
+ }
+ }
+ })
+ }
+ </span>
+ {
+ days &&
+ (
+ <><br /><span role="list">
+ Dias:{" "}
+ {
+ list.formatToParts(days.map((d) => {
+ return new Intl.DateTimeFormat("pt-PT", { day: "numeric" })
+ .format(
+ new Date(
+ Date.UTC(
+ y,
+ m - 1,
+ d,
+ date.getTimezoneOffset() / 60,
+ date.getTimezoneOffset() % 60,
+ ),
+ ),
+ );
+ })).map(({ type, value }: { type: string; value: string }) => {
+ switch (type) {
+ case "element": {
+ const day = days[dI++];
+ return (
+ <span role="listitem"><a
+ class:list={[{ active: day === d }]}
+ href={`/blog/${y}/${pad(m)}/${pad(d)}`}
+ >{value}</a></span>
+ );
+ }
+ case "literal": {
+ return (
+ <span>{value}</span>
+ );
+ }
+ }
+ })
+ }
+ </span></>
+ )
+ }
+</nav>
+
+<style>
+ a.active {
+ font-weight: bolder;
+ }
+</style>
diff --git a/src/components/Footer.astro b/src/components/Footer.astro
new file mode 100644
index 0000000..11c62c4
--- /dev/null
+++ b/src/components/Footer.astro
@@ -0,0 +1,62 @@
+---
+
+---
+
+<footer>
+ <address>
+ Sítio web de <a href={Astro.site} target="_blank" rel="author"
+ >João Augusto Costa Branco Marado Torres</a>
+ </address>
+ <section id="copying">
+ <h2>Licença de <span lang="en">Software</span></h2>
+ <div lang="en">
+ <p>
+ <small>
+ &lt;<a href="/" hreflang="pt-PT">cravodeabril.pt</a>&gt; Copyright
+ &copy; 2025 João Augusto Costa Branco Marado Torres
+ </small>
+ </p>
+ <p>
+ <small>
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the <a
+ href="https://www.gnu.org/licenses/agpl-3.0.html"
+ target="_blank"
+ rel="external license"
+ >GNU Affero General Public License</a> as published by the Free
+ Software Foundation, either version 3 of the License, or (at your
+ option) any later version.
+ </small>
+ </p>
+ <p>
+ <small>
+ This program is distributed in the hope that it will be useful, but
+ WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ Affero General Public License for more details.
+ </small>
+ </p>
+ <p>
+ <small>
+ You should have received a copy of the GNU Affero General Public
+ License along with this program. If not, see <a
+ href="https://www.gnu.org/licenses/"
+ target="_blank"
+ rel="external"
+ >https://www.gnu.org/licenses</a>
+ </small>
+ </p>
+ </div>
+ </section>
+ <nav>
+ <ul>
+ <li><a>Código de Conduta</a></li>
+ <li><a>Declaração de Exoneração de Responsabilidade</a></li>
+ <li><a>Aviso sobre cookies</a></li>
+ <li><a>Declaração de acessibilidade</a></li>
+ <li><a>Apoio</a></li>
+ <li><a>Contacto</a></li>
+ <li><a>Código fonte</a></li>
+ </ul>
+ </nav>
+</footer>
diff --git a/src/components/Header.astro b/src/components/Header.astro
new file mode 100644
index 0000000..874a496
--- /dev/null
+++ b/src/components/Header.astro
@@ -0,0 +1,41 @@
+---
+import HeaderLink from "./HeaderLink.astro";
+---
+
+<header>
+ <h1>&lt;<a href="/">cravodeabril.pt</a>&gt;</h1>
+ <search>
+ <form
+ action="https://www.google.com/search"
+ target="_blank"
+ rel="external noreferrer search"
+ role="search"
+ autocomplete="on"
+ name="search"
+ >
+ <p>
+ <label>Barra de pesquisa: <input
+ name="q"
+ type="search"
+ placeholder={`site:${Astro.site} consulta de pesquisa`}
+ value={`site:${Astro.site} `}
+ required
+ title={`"site:${Astro.site} " é usado para que os resultados da pesquisa fiquem restritos a este website`}
+ pattern={`site:${Astro.site} .+`}
+ size={`site:${Astro.site} .+`.length}
+ /></label>
+ </p>
+ <p><button type="submit">Pesquisar</button></p>
+ <p>
+ <small>Esta pesquisa é efectuada pelo Google e utiliza software
+ proprietário.</small>
+ </p>
+ </form>
+ </search>
+ <nav>
+ <ul>
+ <li><HeaderLink href="/blog">Publicações</HeaderLink></li>
+ <li><HeaderLink href="/blog/keywords">Palavras-Chave</HeaderLink></li>
+ </ul>
+ </nav>
+</header>
diff --git a/src/components/HeaderLink.astro b/src/components/HeaderLink.astro
new file mode 100644
index 0000000..8c01f92
--- /dev/null
+++ b/src/components/HeaderLink.astro
@@ -0,0 +1,18 @@
+---
+import type { HTMLAttributes } from "astro/types";
+
+type Props = HTMLAttributes<"a">;
+
+const { href, class: className, ...props } = Astro.props;
+const pathname = Astro.url.pathname;
+const isActive = href === pathname;
+---
+
+<a {href} class:list={[className, { current: isActive }]} {...props}>
+ <slot />
+</a>
+<style>
+ a.current {
+ font-weight: bolder;
+ }
+</style>
diff --git a/src/components/Keywords.astro b/src/components/Keywords.astro
new file mode 100644
index 0000000..1800d5a
--- /dev/null
+++ b/src/components/Keywords.astro
@@ -0,0 +1,52 @@
+---
+import type { CollectionEntry } from "astro:content";
+
+interface Props {
+ keywords: CollectionEntry<"blog">["data"]["keywords"];
+}
+
+const { keywords } = Astro.props;
+---
+<aside>
+ <ul>
+ {
+ keywords.map((x) => (
+ <li>
+ <a rel="tag" itemprop="keywords" href={`/blog/keywords/${x}`}><b>{
+ x
+ }</b></a>
+ </li>
+ ))
+ }
+ </ul>
+</aside>
+
+<style>
+ ul {
+ list-style-type: none;
+ padding-inline-start: 0;
+ max-width: 40ch;
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: center;
+ gap: 1em;
+ margin-inline: auto;
+ }
+
+ ul > li {
+ font-size: smaller;
+ display: inline-block;
+ }
+
+ ul > li::before {
+ content: "#";
+ color: var(--color-active);
+ font-weight: bolder;
+ }
+
+ @media print {
+ aside {
+ display: none;
+ }
+ }
+</style>
diff --git a/src/components/ReadingTime.astro b/src/components/ReadingTime.astro
new file mode 100644
index 0000000..2c8c676
--- /dev/null
+++ b/src/components/ReadingTime.astro
@@ -0,0 +1,26 @@
+---
+import type { CollectionEntry } from "astro:content";
+import { default as readingTime } from "reading-time";
+
+type Props = {
+ body: CollectionEntry<"blog">["body"];
+ lang: CollectionEntry<"blog">["data"]["lang"];
+};
+
+const { body, lang } = Astro.props;
+
+const reading = readingTime(body ?? "", {});
+const minutes = Math.ceil(reading.minutes);
+const estimative = new Intl.DurationFormat(lang, {
+ style: "long",
+}).format({ minutes });
+const duration = `PT${
+ Math.floor(minutes / 60) > 0 ? Math.floor(minutes / 60) + "H" : ""
+}${minutes % 60 > 0 ? minutes % 60 + "M" : ""}`;
+---
+<p>
+ <data itemprop="timeRequired" value={duration}><bdi>Tempo de leitura
+ estimado</bdi>: ~ {estimative}</data>
+ <data itemprop="wordCount" value={reading.words}
+ >(<bdi>palavras</bdi>: {reading.words})</data>
+</p>
diff --git a/src/components/SignaturesTableRows.astro b/src/components/SignaturesTableRows.astro
new file mode 100644
index 0000000..eafd4de
--- /dev/null
+++ b/src/components/SignaturesTableRows.astro
@@ -0,0 +1,51 @@
+---
+import { type Summary, VerificationResult } from "@lib/pgp/summary";
+import { PublicKey } from "openpgp";
+
+type Props = { summary: Summary; rowspan?: number };
+
+const { summary, rowspan } = Astro.props;
+const [type, _, info] = summary;
+
+let name: string = "";
+let email: string = "";
+let fingerprint: string = "";
+let trust: number | undefined = NaN;
+let commiter: boolean | undefined = undefined;
+let revoked: boolean | undefined = undefined;
+let keyType: "primary" | "sub" | "" = "";
+
+switch (type) {
+ case VerificationResult.MISSING_KEY:
+ fingerprint = typeof info.keyID === "string"
+ ? info.keyID
+ : info.keyID.toHex();
+ break;
+ case VerificationResult.TRUSTED_KEY:
+ const match = info.userID[0].match(/^(.*?)\s*(?:\((.*?)\))?\s*<(.+?)>$/);
+
+ if (match) {
+ name = match[1];
+ email = match[3];
+ }
+
+ fingerprint = info.key.getFingerprint();
+ trust = info.trust;
+ keyType = info.key instanceof PublicKey ? "primary" : "sub";
+ break;
+}
+
+const names = name.split(/\s/);
+const firstName = names[0];
+const lastName = names.length > 1 ? ` ${names[names.length - 1]}` : "";
+---
+<td {rowspan}><span title={name}>{firstName}{lastName}</span></td>
+<td {rowspan}>{email}</td>
+<td {rowspan}>
+ <span title={fingerprint.replace(/(....)/g, "$1 ").trim()}>
+ {`0x${fingerprint.slice(-8)}`}
+ </span>
+</td>
+<td {rowspan}>{trust}</td>
+<td {rowspan}>{commiter}</td>
+<td {rowspan}>{revoked}</td>
diff --git a/src/components/Translations.astro b/src/components/Translations.astro
new file mode 100644
index 0000000..b0164bb
--- /dev/null
+++ b/src/components/Translations.astro
@@ -0,0 +1,107 @@
+---
+import type { CollectionEntry } from "astro:content";
+import {
+ getFlagEmojiFromLocale,
+ getLanguageNameFromLocale,
+} from "../utils/lang";
+import { getEntries } from "astro:content";
+
+interface Props {
+ lang: string;
+ translations?: CollectionEntry<"blog">["data"]["translations"];
+}
+
+const { lang } = Astro.props;
+
+const translations = await getEntries(Astro.props.translations ?? []).then(
+ (translations) =>
+ translations.sort((x, y) => x.data.lang.localeCompare(y.data.lang)),
+);
+---
+
+{
+ /* TODO: What about <https://schema.org/translationOfWork> and <https://schema.org/translator>? */
+}
+
+{
+ translations.length > 0 && (
+ <aside>
+ <nav>
+ <p>Traduções:</p>
+ <ul class="translations">
+ {
+ translations.map(async (
+ { data, collection, id },
+ ) => {
+ const active = lang.localeCompare(data.lang) === 0;
+ return (
+ <li
+ itemprop={active ? undefined : "workTranslation"}
+ itemscope={!active}
+ itemtype={active ? undefined : "http://schema.org/BlogPosting"}
+ itemid={active
+ ? undefined
+ : new URL(`${collection}/read/${id}`, Astro.site).href}
+ >
+ <a
+ href={`/${collection}/read/${id}`}
+ class:list={[{ active }]}
+ rel={active ? undefined : "alternate"}
+ hreflang={active ? undefined : data.lang}
+ type="text/html"
+ title={data.title}
+ ><span class="emoji">{getFlagEmojiFromLocale(data.lang)}</span>
+ {getLanguageNameFromLocale(data.lang)} (<span
+ itemprop="inLanguage"
+ >{data.lang}</span>)</a>
+ </li>
+ );
+ })
+ }
+ </ul>
+ </nav>
+ </aside>
+ )
+}
+
+<style>
+ .translations {
+ list-style-type: none;
+ padding-inline-start: 0;
+ }
+
+ .translations > li {
+ display: inline;
+ }
+
+ .translations > li > a > .emoji {
+ text-decoration: none;
+ font-family: var(--ff-icons);
+ }
+
+ .translations > li > 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;
+ }
+
+ @media print {
+ aside {
+ display: none;
+ }
+ }
+</style>
diff --git a/src/components/licenses/CC.astro b/src/components/licenses/CC.astro
new file mode 100644
index 0000000..61f9114
--- /dev/null
+++ b/src/components/licenses/CC.astro
@@ -0,0 +1,120 @@
+---
+import type { Props as BaseProps } from "../CopyRightNotice.astro";
+interface Props extends BaseProps {}
+
+let { title, website, author, dateCreated, license } = Astro.props;
+const publicdomain = license === "CC0";
+const sa = /SA/.test(license);
+const nd = /ND/.test(license);
+const nc = /NC/.test(license);
+const licenseURL = `https://creativecommons.org/licenses/${
+ license.slice(3).toLowerCase()
+}/4.0/`;
+---
+
+<footer itemprop="copyrightNotice">
+ {
+ 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
+ itemprop="license"
+ rel="license noreferrer"
+ target="_blank"
+ href="https://creativecommons.org/publicdomain/zero/1.0/"
+ content="https://creativecommons.org/publicdomain/zero/1.0/"
+ >CC0 1.0</a>
+ <img
+ alt=""
+ src="https://mirrors.creativecommons.org/presskit/icons/cc.svg"
+ style="max-width: 1em; max-height: 1em; margin-left: 0.2em"
+ >
+ <img
+ alt=""
+ src="https://mirrors.creativecommons.org/presskit/icons/zero.svg"
+ style="max-width: 1em; max-height: 1em; margin-left: 0.2em"
+ >
+ </small>
+ </p>
+ ) : (
+ <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
+ itemprop="license"
+ rel="license noreferrer"
+ target="_blank"
+ href={licenseURL}
+ content={licenseURL}
+ >{license.replace("CC-", "CC ")} 4.0</a>
+ <img
+ alt=""
+ src="https://mirrors.creativecommons.org/presskit/icons/cc.svg"
+ style="max-width: 1em; max-height: 1em; margin-left: 0.2em"
+ >
+ <img
+ alt=""
+ src="https://mirrors.creativecommons.org/presskit/icons/by.svg"
+ style="max-width: 1em; max-height: 1em; margin-left: 0.2em"
+ >
+ {
+ nc && (
+ <img
+ alt=""
+ src="https://mirrors.creativecommons.org/presskit/icons/nc.svg"
+ style="max-width: 1em; max-height: 1em; margin-left: 0.2em"
+ >
+ )
+ }
+ {
+ sa && (
+ <>{" "}<img
+ alt=""
+ src="https://mirrors.creativecommons.org/presskit/icons/sa.svg"
+ style="max-width: 1em; max-height: 1em; margin-left: 0.2em"
+ ></>
+ )
+ }
+ {
+ nd && (
+ <>{" "}<img
+ alt=""
+ src="https://mirrors.creativecommons.org/presskit/icons/nd.svg"
+ style="max-width: 1em; max-height: 1em; margin-left: 0.2em"
+ ></>
+ )
+ }
+ </small>
+ </p>
+ )
+ }
+</footer>
diff --git a/src/components/licenses/WTFPL.astro b/src/components/licenses/WTFPL.astro
new file mode 100644
index 0000000..feab7ec
--- /dev/null
+++ b/src/components/licenses/WTFPL.astro
@@ -0,0 +1,53 @@
+---
+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/signature/Authors.astro b/src/components/signature/Authors.astro
new file mode 100644
index 0000000..43a2b36
--- /dev/null
+++ b/src/components/signature/Authors.astro
@@ -0,0 +1,281 @@
+---
+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 { type CollectionEntry, z } from "astro:content";
+import type { EntityTypesEnum } from "src/consts";
+import qrcode from "yaqrcode";
+
+interface Props {
+ verifications: NonNullable<Verification["verifications"]>;
+ expectedSigners: {
+ entity: CollectionEntry<"entity">;
+ role: z.infer<typeof EntityTypesEnum>;
+ }[];
+ 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/signature/Commit.astro b/src/components/signature/Commit.astro
new file mode 100644
index 0000000..9cc997a
--- /dev/null
+++ b/src/components/signature/Commit.astro
@@ -0,0 +1,87 @@
+---
+import { gitDir } from "@lib/git";
+import type { Commit } from "@lib/git/types";
+import { toIso8601Full } from "@utils/datetime";
+
+type Props = { commit: Commit; lang: string };
+
+const dir = await gitDir();
+const { hash, files, author, committer, signature } =
+ Astro.props.commit;
+
+const formatter = new Intl.DateTimeFormat([Astro.props.lang], {
+ dateStyle: "short",
+ timeStyle: "short",
+});
+---
+
+<section>
+ <details>
+ <summary>
+ Informações sobre o último commit que modificou ficheiros relacionados a
+ este blog post:
+ </summary>
+ <dl class="divider">
+ <dt>Hash</dt>
+ <dd><samp title={hash.long}>0x{hash.short.toUpperCase()}</samp></dd>
+ <dt>Ficheiros modificados</dt>
+ {
+ files.length > 0
+ ? files.map((file) => (
+ <dd><samp>{file.path.pathname.replace(dir.pathname, "")}</samp></dd>
+ ))
+ : <dd>Nenhum ficheiro modificado</dd>
+ }
+ <dt>
+ Autor (<time datetime={toIso8601Full(author.date)}>{
+ formatter.format(author.date)
+ }</time>)
+ </dt>
+ <dd>
+ {author.name} &lt;<a href={`mailto:${author.email}`}>{
+ author.email
+ }</a>&gt;
+ </dd>
+ <dt>
+ Commiter (<time datetime={toIso8601Full(committer.date)}>{
+ formatter.format(committer.date)
+ }</time>)
+ </dt>
+ <dd>
+ {committer.name} &lt;<a href={`mailto:${committer.email}`}>{
+ committer.email
+ }</a>&gt;
+ </dd>
+ {
+ signature &&
+ (
+ <dt>Assinatura do commit</dt>
+ <dd>
+ <dl>
+ <dt>Tipo</dt>
+ <dd><samp>{signature.type}</samp></dd>
+ <dt>Assinante</dt>
+ <dd>{signature.signer}</dd>
+ <dt>Fingerprint da chave</dt>
+ <dd><samp>0x{signature.key.short}</samp></dd>
+ </dl>
+ </dd>
+ )
+ }
+ </dl>
+ </details>
+</section>
+
+<style>
+ section {
+ font-size: smaller;
+ }
+
+ dl {
+ margin-block: 0;
+ }
+
+ details {
+ padding-block: 1rem;
+ }
+</style>
diff --git a/src/components/signature/Downloads.astro b/src/components/signature/Downloads.astro
new file mode 100644
index 0000000..ac8215f
--- /dev/null
+++ b/src/components/signature/Downloads.astro
@@ -0,0 +1,63 @@
+---
+import { gitDir } from "@lib/git";
+import { get } from "@utils/anonymous";
+
+interface Props {
+ lang: string;
+}
+
+const { lang } = Astro.props;
+
+let source = new URL(
+ `${Astro.url.href.replace("read/", "").replace(/\/$/, "")}.md`,
+);
+
+const dir = await gitDir();
+
+const format: Intl.NumberFormatOptions = {
+ notation: "compact",
+ style: "unit",
+ unit: "byte",
+ unitDisplay: "narrow",
+};
+
+const formatter = new Intl.NumberFormat(lang, format);
+
+const sourceSize = formatter.format(
+ await Deno.stat(
+ new URL("public" + source.pathname, dir),
+ ).then(get("size")),
+);
+const sig = await Deno.stat(
+ new URL("public" + source.pathname + ".sig", dir),
+).then(get("size")).catch(() => undefined);
+const sigSize = formatter.format(sig);
+---
+
+<section>
+ <p>Ficheiros para descarregar:</p>
+ <dl>
+ <dt>Blog post</dt>
+ <dd>
+ <a
+ id="message"
+ href={source}
+ download
+ type="text/markdown; charset=utf-8"
+ ><samp>text/markdown</samp>, <samp>{sourceSize}</samp></a>
+ </dd>
+ {
+ sig && (
+ <dt>Assinatura digital</dt>
+ <dd>
+ <a
+ id="signature"
+ href={`${source}.sig`}
+ download
+ type="application/pgp-signature"
+ ><samp>application/pgp-signature</samp>, <samp>{sigSize}</samp></a>
+ </dd>
+ )
+ }
+ </dl>
+</section>
diff --git a/src/components/signature/Signature.astro b/src/components/signature/Signature.astro
new file mode 100644
index 0000000..57e9902
--- /dev/null
+++ b/src/components/signature/Signature.astro
@@ -0,0 +1,44 @@
+---
+import type { Verification } from "@lib/pgp/verify";
+import Summary from "./Summary.astro";
+import Downloads from "./Downloads.astro";
+import Commit from "./Commit.astro";
+
+interface Props {
+ verification: Verification;
+ lang: string;
+}
+
+const { verification, lang } = Astro.props;
+const commit = await verification.commit;
+---
+
+<aside id="signatures">
+ <p><strong>Verificação da assinatura digital</strong></p>
+ <Summary {...verification} />
+ <Downloads {lang} />
+ {commit && <Commit {commit} {lang} />}
+</aside>
+
+<style is:global>
+ #signatures > section > p:first-child {
+ font-weight: bolder;
+ }
+</style>
+<style>
+ #signatures {
+ margin-inline: 1.5rem;
+ margin-block-end: 1.5rem;
+ box-shadow: 0 0 calc(1em) #e7e7e7;
+ border-radius: calc(1rem / 3);
+ padding: 1rem;
+ }
+
+ #signatures > p:first-child {
+ font-size: larger;
+
+ & > strong {
+ font-weight: bolder;
+ }
+ }
+</style>
diff --git a/src/components/signature/Summary.astro b/src/components/signature/Summary.astro
new file mode 100644
index 0000000..6ab6bf5
--- /dev/null
+++ b/src/components/signature/Summary.astro
@@ -0,0 +1,279 @@
+---
+import {
+ createVerificationSummary,
+ logLevel,
+ type Summary,
+ VerificationResult,
+} from "@lib/pgp/summary";
+import type { Verification } from "@lib/pgp/verify";
+import { Level } from "@utils/index";
+import type { NonEmptyArray } from "@utils/iterator";
+
+interface Props extends Verification {}
+
+let [errors, keys] = await createVerificationSummary(Astro.props);
+const failed = errors.filter((summary) => "reason" in summary);
+
+if (failed.length > 0) {
+ errors = failed as NonEmptyArray<Summary>;
+}
+
+let worst;
+
+for (const summary of errors) {
+ if (worst === undefined) {
+ worst = summary;
+ }
+
+ const { result } = summary;
+ const a = logLevel(worst.result);
+ const b = logLevel(result);
+ if (a[0] === b[0] && !a[1] && b[1]) {
+ worst = summary;
+ } else if (b[0] === Level.ERROR) {
+ worst = summary;
+ } else if (a[0] === Level.OK && b[0] === Level.WARN) {
+ worst = summary;
+ }
+}
+
+let lvl: [Level, boolean] | undefined = undefined;
+
+let label;
+
+let title = "";
+let content;
+const error = worst && "reason" in worst ? worst.reason : undefined;
+
+if (worst) {
+ lvl = logLevel(worst.result);
+ switch (lvl[0]) {
+ case Level.OK: {
+ label = "OK";
+ break;
+ }
+ case Level.WARN: {
+ label = "Aviso";
+ break;
+ }
+ case Level.ERROR: {
+ label = "Erro";
+ break;
+ }
+ default: {
+ throw new Error("Unreachable");
+ }
+ }
+
+ switch (worst.result) {
+ case VerificationResult.NO_SIGNATURE: {
+ title = "Assinatura não encontrada";
+ content = `<p>
+Este blog post não foi assinado.
+</p>
+<p>
+<strong>Não existe forma de verificar a autentacidade do autor ou a integridade do texto escrito</strong>.
+</p>
+`;
+ break;
+ }
+ case VerificationResult.MISSING_KEY: {
+ title = "Chave não encontrada";
+ content = `<p>
+Este blog post está assinado digitalmente, porém a chave pública com <code>KeyID</code> <samp>0x${worst.keyID}</samp> com que foi assinado não foi encontrada no chaveiro sendo <strong>impossível verificar a assinatura, quer dizer, não existe forma de verificar a autentacidade do autor ou a integridade do texto escrito</strong>.
+</p>
+<p>
+Procure a chave noutro sítio da internet para conseguir fazer a verificação manualmente.
+</p>
+`;
+ break;
+ }
+ case VerificationResult.SIGNATURE_CORRUPTED: {
+ title = "Assinatura corrumpida";
+ content = `<p>
+Exite um ficheiro que supostamente é a assinatura, mas ele está corrompido ou com um formato inválido.
+</p>
+<p>
+<strong>Não existe forma de verificar a autentacidade do autor ou a integridade do texto escrito</strong>.
+</p>
+`;
+ break;
+ }
+ case VerificationResult.SIGNATURE_COULD_NOT_BE_CHECKED: {
+ title = "Erro desconhecido";
+ content = `<p>
+A assinatura foi encontrada mas ocorreu um erro inesperado durante a verificação.
+</p>
+<p>
+<strong>Não existe forma de verificar a autentacidade do autor ou a integridade do texto escrito</strong>.
+</p>
+`;
+ break;
+ }
+ case VerificationResult.BAD_SIGNATURE: {
+ title = "Assinatura inválida";
+ content = `<p>
+Existe uma assinatura digital porém o conteúdo da blog post não corresponde à assinatura. Talvez o texto tenha sido alterado sem ter sido criada uma nova assinatura.
+</p>
+<p>
+Pode tentar verificar a assinatura com versões antigas do blog post, mas esta versão <strong> não pode ser verificada quanto à autentacidade do autor ou à integridade do texto escrito</strong>.
+</p>
+`;
+ break;
+ }
+ case VerificationResult.UNTRUSTED_KEY: {
+ title = "Assinatura válida (chave não confiada)";
+ content = `<p>
+A assinatura digital é criptograficamente válida, porém a chave utilizada não é suficientemente confiada pelo servidor. Mas podes ter a certeza que <strong>o dono da chave pública é a mesma pessoa que assinou este blog post</strong>.
+</p>
+`;
+ break;
+ }
+ case VerificationResult.TRUSTED_KEY: {
+ title = "Assinatura válida";
+ content = `<p>
+A assinatura digital é criptograficamente válida. <strong>O dono da chave pública é a mesma pessoa que assinou este blog post exatamente como ele está, sem alterações</strong>.
+</p>
+`;
+ break;
+ }
+ case VerificationResult.EXPIRATION_AFTER_SIGNATURE: {
+ break;
+ }
+ case VerificationResult.EXPIRATION_BEFORE_SIGNATURE: {
+ break;
+ }
+ case VerificationResult.REVOCATION_AFTER_SIGNATURE: {
+ break;
+ }
+ case VerificationResult.REVOCATION_BEFORE_SIGNATURE: {
+ break;
+ }
+ case VerificationResult.KEY_DOES_NOT_SIGN: {
+ break;
+ }
+ default: {
+ throw new Error("Unreachable");
+ }
+ }
+}
+---
+
+{
+ lvl &&
+ (
+ <details
+ class:list={{
+ ok: lvl[0] === Level.OK,
+ warn: lvl[0] === Level.WARN,
+ error: lvl[0] === Level.ERROR,
+ super: lvl[1],
+ }}
+ >
+ <summary>{label?.toUpperCase()}: {title.toUpperCase()}</summary>
+ <Fragment set:html={content} />
+ {error && <pre><samp>{error}</samp></pre>}
+ </details>
+ )
+}
+
+<style>
+ pre {
+ overflow-x: auto;
+ }
+ details {
+ &.error {
+ --bg: #fff;
+ --fg: var(--color-active);
+
+ &.super {
+ --bg: var(--color-active);
+ --fg: #fff;
+ }
+ }
+
+ &.warn {
+ --bg: #fff;
+ --fg: #f46d43;
+
+ &.super {
+ --bg: #f46d43;
+ --fg: #fff;
+ }
+ }
+
+ &.ok {
+ --bg: #fff;
+ --fg: var(--color-visited);
+
+ &.super {
+ --bg: var(--color-visited);
+ --fg: #fff;
+ }
+ }
+
+ padding-inline: 0.5em;
+ padding-block: 0.5em;
+
+ & > summary {
+ background-color: var(--bg);
+ padding-inline: 0.5em;
+ padding-block: calc(1em / 3);
+ color: var(--fg);
+ border-color: var(--fg);
+ border-width: 1px;
+ border-style: solid;
+ border-radius: calc(1em / 3);
+ font-weight: bolder;
+
+ &:focus {
+ outline-color: var(--fg);
+ }
+
+ &::marker {
+ color: var(--fg);
+ }
+ }
+
+ & > :not(summary) {
+ padding-inline: 1em;
+ /* font-size: smaller; */
+ }
+
+ & > summary + * {
+ margin-block: 0.5em;
+ padding-block-start: 1em;
+ border-block-start: 1px solid var(--fg);
+ }
+ }
+
+ @media (prefers-color-scheme: dark) {
+ details {
+ &.error {
+ --bg: #000;
+
+ &.super {
+ --fg: #000;
+ }
+ }
+
+ &.warn {
+ --bg: #000;
+ --fg: #f46d43;
+
+ &.super {
+ --bg: #f46d43;
+ --fg: #000;
+ }
+ }
+
+ &.ok {
+ --bg: #000;
+
+ &.super {
+ --fg: #000;
+ }
+ }
+ }
+ }
+</style>