summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/components/BaseHead.astro54
-rw-r--r--src/components/Commit.astro49
-rw-r--r--src/components/CopyrightNotice.astro5
-rw-r--r--src/components/Footer.astro125
-rw-r--r--src/components/Header.astro69
-rw-r--r--src/components/Search.astro32
-rw-r--r--src/components/organisms/ActiveLink.astro (renamed from src/components/HeaderLink.astro)0
-rw-r--r--src/components/organisms/Date.astro14
-rw-r--r--src/components/organisms/KeywordsList.astro31
-rw-r--r--src/components/signature/Authors.astro8
-rw-r--r--src/components/templates/MicroBlog.astro114
-rw-r--r--src/components/templates/Search.astro67
-rw-r--r--src/components/templates/SimplePostList.astro81
-rw-r--r--src/consts.ts29
-rw-r--r--src/content.config.ts103
-rw-r--r--src/custom-attributes.d.ts8
-rw-r--r--src/layouts/Base.astro12
-rw-r--r--src/lib/collection/helpers.ts107
-rw-r--r--src/lib/collection/schemas.ts133
-rw-r--r--src/lib/env.ts68
-rw-r--r--src/lib/git/log.ts3
-rw-r--r--src/lib/pgp/sign.ts1
-rw-r--r--src/lib/pgp/trust.ts5
-rw-r--r--src/lib/pgp/user.ts33
-rw-r--r--src/lib/pgp/verify.ts39
-rw-r--r--src/pages/blog/[...year].astro30
-rw-r--r--src/pages/blog/micro/[page].astro32
-rw-r--r--src/pages/blog/read/[...slug].astro74
-rw-r--r--src/pages/index.astro104
-rw-r--r--src/pages/robots.txt.ts4
-rw-r--r--src/pages/rss.xml.js16
-rw-r--r--src/pages/rss.xml.ts32
-rw-r--r--src/styles/global.css101
-rw-r--r--src/utils/datetime.test.ts1
34 files changed, 1144 insertions, 440 deletions
diff --git a/src/components/BaseHead.astro b/src/components/BaseHead.astro
index 5ac0410..b4dbb74 100644
--- a/src/components/BaseHead.astro
+++ b/src/components/BaseHead.astro
@@ -1,8 +1,6 @@
---
-// Import the global.css file here so that it is included on
-// all pages through the use of the <BaseHead /> component.
+import { env } from "@lib/env";
import "../styles/global.css";
-import { SITE_AUTHOR, SITE_DESCRIPTION, SITE_TITLE } from "../consts";
import { ClientRouter } from "astro:transitions";
export interface Props {
@@ -12,11 +10,24 @@ export interface Props {
keywords?: string[];
}
+const {
+ PUBLIC_SITE_TITLE,
+ PUBLIC_SITE_DESCRIPTION,
+ PUBLIC_SITE_AUTHOR,
+ PUBLIC_TOR_URL,
+} = env;
+
+const isOnion = Astro.url.origin.endsWith(".onion");
+const alternate = !isOnion ? PUBLIC_TOR_URL : Astro.site;
+
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'
+const {
+ title,
+ description = PUBLIC_SITE_DESCRIPTION,
+ image = new URL("favicon.svg", Astro.site),
+ keywords = [],
+} = Astro.props;
---
<!-- Global Metadata -->
@@ -28,24 +39,30 @@ const { title, description = SITE_DESCRIPTION, image, keywords = [] } =
<link
rel="alternate"
type="application/rss+xml"
- title={SITE_TITLE}
+ title={PUBLIC_SITE_TITLE}
href={new URL("rss.xml", Astro.site)}
/>
<meta name="generator" content={Astro.generator} />
<!-- Canonical URL -->
<link rel="canonical" href={canonicalURL} />
+<link
+ rel="alternate"
+ href={alternate}
+ type="text/html"
+ title={`${isOnion ? "Clearnet" : "Tor"} version`}
+>
<!-- Primary Meta Tags -->
<title>{title}</title>
<meta name="title" content={title} />
<meta name="description" content={description} />
-<meta name="author" content={SITE_AUTHOR} />
+<meta name="author" content={PUBLIC_SITE_AUTHOR} />
{keywords.length > 0 && <meta name="keywords" content={keywords.join(",")} />}
-<meta name="theme-color" content="#a50026" />
+<meta name="theme-color" content="oklch(0.4564 0.1835 20.81)" />
<meta
name="theme-color"
- content="#f46d43"
+ content="oklch(0.6923 0.1759 37.7)"
media="(prefers-color-scheme: dark)"
/>
@@ -54,26 +71,13 @@ const { title, description = SITE_DESCRIPTION, image, keywords = [] } =
<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)} />}
+<meta property="og:image" content={image} />
<!-- 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)} />}
+<meta property="twitter:image" content={image} />
<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/Commit.astro b/src/components/Commit.astro
deleted file mode 100644
index 3ee284a..0000000
--- a/src/components/Commit.astro
+++ /dev/null
@@ -1,49 +0,0 @@
----
-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
index 2aa72ad..6b3bd48 100644
--- a/src/components/CopyrightNotice.astro
+++ b/src/components/CopyrightNotice.astro
@@ -1,7 +1,10 @@
---
+import {
+ CREATIVE_COMMONS_LICENSES,
+ type LICENSES,
+} from "@lib/collection/schemas";
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;
diff --git a/src/components/Footer.astro b/src/components/Footer.astro
index 11c62c4..c3dffca 100644
--- a/src/components/Footer.astro
+++ b/src/components/Footer.astro
@@ -1,62 +1,107 @@
---
+import { env } from "@lib/env";
+const {
+ PUBLIC_GIT_URL,
+ PUBLIC_TOR_URL,
+ PUBLIC_GIT_TOR_URL,
+ PUBLIC_SIMPLE_X_ADDRESS,
+} = env;
+const isOnion = Astro.url.origin.endsWith(".onion");
+const site = isOnion ? PUBLIC_TOR_URL : Astro.site;
+const git = isOnion ? PUBLIC_GIT_TOR_URL ?? PUBLIC_GIT_URL : PUBLIC_GIT_URL;
---
-<footer>
+<footer class="small">
+ {
+ !isOnion && PUBLIC_TOR_URL && (
+ <p class="mute">
+ Disponível também em: <a class="tor" href={PUBLIC_TOR_URL}>{
+ PUBLIC_TOR_URL
+ }</a>
+ </p>
+ )
+ }
<address>
- Sítio web de <a href={Astro.site} target="_blank" rel="author"
- >João Augusto Costa Branco Marado Torres</a>
+ <p>
+ Sítio web de <a href={site} target="_blank" rel="author"
+ >João Augusto Costa Branco Marado Torres</a>
+ </p>
+ {
+ PUBLIC_SIMPLE_X_ADDRESS && (
+ <p>
+ Contacte-me através do <a href={PUBLIC_SIMPLE_X_ADDRESS}>SimpleX</a>!
+ </p>
+ )
+ }
</address>
- <section id="copying">
- <h2>Licença de <span lang="en">Software</span></h2>
+ <p>
+ Isto é <abbr title="Free Libre and Open Source Software">FLOSS</abbr>, <a
+ href={git}
+ >usa as tuas liberdades</a>
+ </p>
+ <section id="copying" class="mute">
+ <h2 class="sr-only">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>
+ &lt;<a href="/" hreflang="pt-PT">cravodeabril.pt</a>&gt; Copyright
+ &copy; 2025 João Augusto Costa Branco Marado Torres
</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>
+ 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.
</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>
+ This program is distributed in the hope that it will be useful, but
+ <strong>without any warranty</strong>; without even the implied warranty
+ of
+ <strong>merchantability</strong> or <strong>fitness for a particular
+ purpose</strong>. See the GNU Affero General Public License for more
+ details.
</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>
+ 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>
</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>
+ <li><a href="/">Código de Conduta</a></li>
+ <li><a href="/">Declaração de Exoneração de Responsabilidade</a></li>
+ <li><a href="/">Aviso sobre cookies</a></li>
+ <li><a href="/">Declaração de acessibilidade</a></li>
+ <li><a href="/">Apoio</a></li>
</ul>
</nav>
</footer>
+
+<style>
+ footer {
+ border-block-start: 1px solid var(--color-light);
+ padding-block-start: calc(var(--size-4) * 1em);
+ }
+
+ .tor {
+ word-wrap: break-word;
+ }
+
+ nav > ul {
+ display: flex;
+ flex-direction: column;
+ gap: calc(var(--size-1) * 1em);
+ & > li {
+ padding-block: calc(var(--size-1) * 1em);
+ }
+ }
+</style>
diff --git a/src/components/Header.astro b/src/components/Header.astro
index 28ab542..496337f 100644
--- a/src/components/Header.astro
+++ b/src/components/Header.astro
@@ -1,26 +1,53 @@
---
-import HeaderLink from "./HeaderLink.astro";
-import Search from "./Search.astro";
-
-export interface Props {
- showSearch?: boolean;
- showNav?: boolean;
-}
-
-const { showSearch, showNav } = Astro.props;
+import ActiveLink from "./organisms/ActiveLink.astro";
+import Search from "./templates/Search.astro";
---
<header>
- <h1>&lt;<a href="/">cravodeabril.pt</a>&gt;</h1>
- {showSearch && <Search />}
- {
- showNav && (
- <nav>
- <ul>
- <li><HeaderLink href="/blog">Publicações</HeaderLink></li>
- <li><HeaderLink href="/blog/keywords">Palavras-Chave</HeaderLink></li>
- </ul>
- </nav>
- )
- }
+ <h1>
+ <span class="bracket">&lt;</span><a href="/">cravodeabril.pt</a><span
+ class="bracket"
+ >&gt;</span>
+ </h1>
+ <Search />
+ <nav>
+ <ul>
+ <li class="small"><ActiveLink href="/blog">Publicações</ActiveLink></li>
+ <li class="small">
+ <ActiveLink href="/blog/keywords">Palavras-Chave</ActiveLink>
+ </li>
+ <li class="small">
+ <ActiveLink href="/blog/micro/1">Micro blogue</ActiveLink>
+ </li>
+ </ul>
+ </nav>
</header>
+
+<style>
+ header {
+ margin-block-end: calc(var(--size-4) * 1em);
+ border-block-end: 1px solid var(--color-light);
+ }
+ .bracket {
+ color: var(--color-active);
+ }
+ nav {
+ display: flex;
+ max-width: max-content;
+ align-items: center;
+ justify-content: center;
+ }
+ ul {
+ display: flex;
+ flex: 1;
+ gap: calc(var(--size-0) * 1em);
+ list-style-type: none;
+ align-items: center;
+ justify-content: center;
+ padding: calc(var(--size-2) * 1em);
+ margin-block-start: 0;
+ }
+ li {
+ padding: calc(var(--size-1) * 1em);
+ }
+</style>
diff --git a/src/components/Search.astro b/src/components/Search.astro
deleted file mode 100644
index 5ca4569..0000000
--- a/src/components/Search.astro
+++ /dev/null
@@ -1,32 +0,0 @@
----
-
----
-
-<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>
diff --git a/src/components/HeaderLink.astro b/src/components/organisms/ActiveLink.astro
index 8c01f92..8c01f92 100644
--- a/src/components/HeaderLink.astro
+++ b/src/components/organisms/ActiveLink.astro
diff --git a/src/components/organisms/Date.astro b/src/components/organisms/Date.astro
new file mode 100644
index 0000000..a8b643d
--- /dev/null
+++ b/src/components/organisms/Date.astro
@@ -0,0 +1,14 @@
+---
+interface Props {
+ date: Date;
+ locales: Intl.LocalesArgument;
+ options: Intl.DateTimeFormatOptions;
+}
+
+const { date, locales, options } = Astro.props;
+
+const datetime = date.toISOString();
+const format = new Intl.DateTimeFormat(locales, options).format(date);
+---
+
+<date {datetime}>{format}</date>
diff --git a/src/components/organisms/KeywordsList.astro b/src/components/organisms/KeywordsList.astro
new file mode 100644
index 0000000..4d4b140
--- /dev/null
+++ b/src/components/organisms/KeywordsList.astro
@@ -0,0 +1,31 @@
+---
+interface Props {
+ keywords: string[];
+}
+
+const { keywords } = Astro.props;
+---
+
+<p>
+ {keywords.map((x) => <span>#<b>{x}</b></span>)}
+</p>
+
+<style>
+ p {
+ display: flex;
+ flex-direction: row-reverse;
+ flex-wrap: wrap;
+ gap: calc(var(--size-0) * 1em);
+
+ & > * {
+ border-radius: calc(infinity * 1px);
+ background-color: color-mix(
+ in srgb,
+ var(--color-active) 10%,
+ transparent
+ );
+ color: var(--color-active);
+ padding-inline: calc(var(--size-2) * 1em);
+ }
+ }
+</style>
diff --git a/src/components/signature/Authors.astro b/src/components/signature/Authors.astro
index 43a2b36..71a3d62 100644
--- a/src/components/signature/Authors.astro
+++ b/src/components/signature/Authors.astro
@@ -3,16 +3,14 @@ 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 { 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: {
- entity: CollectionEntry<"entity">;
- role: z.infer<typeof EntityTypesEnum>;
- }[];
+ expectedSigners: Awaited<ReturnType<typeof getSigners>>;
commitSignerKey?: string;
}
diff --git a/src/components/templates/MicroBlog.astro b/src/components/templates/MicroBlog.astro
new file mode 100644
index 0000000..b7019c5
--- /dev/null
+++ b/src/components/templates/MicroBlog.astro
@@ -0,0 +1,114 @@
+---
+import Date from "@components/organisms/Date.astro";
+import KeywordsList from "@components/organisms/KeywordsList.astro";
+import { getFirstUserID, getLastUpdate } from "@lib/collection/helpers";
+import { Micro } from "@lib/collection/schemas";
+import type { CollectionEntry, z } from "astro:content";
+
+interface Props extends CollectionEntry<"blog"> {
+ data: z.infer<typeof Micro>;
+}
+
+const micro = Astro.props;
+const { id, data, rendered } = micro;
+const { title, lang, keywords } = data;
+const date = getLastUpdate(micro);
+const user = await getFirstUserID(micro);
+const display = user?.name ?? user?.email ?? user?.entity ?? "";
+const [first, ...names] = display.split(/\s/);
+const last = names.length > 0 ? names[names.length - 1] : "";
+const little = ((first?.[0] ?? "") + (last?.[0] ?? "")).slice(0, 2);
+---
+<article>
+ <header>
+ <h3 class="title">
+ <a href={`/blog/read/${id}`}>{title}</a>
+ </h3>
+ <span class="profile_picture">{
+ user?.website ? <a href={user.website}>{little}</a> : (
+ <span>{little}</span>
+ )
+ }</span>
+ <div>
+ {first} {last} <small>· <Date
+ {date}
+ locales={lang}
+ options={{ month: "short", day: "numeric" }}
+ /></small>
+ </div>
+ </header>
+ <div class="content">
+ <small {lang}>
+ <Fragment set:html={rendered?.html} />
+ </small>
+ <footer>
+ <div class="keywords small"><KeywordsList {keywords} /></div>
+ </footer>
+ </div>
+ <aside>
+ <small><a href="/blog/micro/1">Ver todos os microposts</a></small>
+ </aside>
+</article>
+
+<style is:inline>
+ .content > [lang] > *:first-child {
+ margin-block-start: 0;
+ }
+ .content > [lang] > *:last-child {
+ margin-block-end: 0;
+ }
+</style>
+<style>
+ article {
+ border-radius: calc(var(--size-1) * 1em);
+ box-shadow: 0 0 calc(var(--size-1) * 1em) var(--color-light);
+ padding: calc(var(--size-4) * 1em);
+ display: grid;
+ grid-template-rows: repeat(3, auto);
+ grid-template-columns: calc(var(--size-9) * 1em) auto;
+ gap: calc(var(--size-1) * 1em);
+
+ & > header {
+ display: contents;
+ }
+
+ & > aside {
+ grid-row: 3 / 4;
+ grid-column: 1 / 3;
+ border-block-start: 1px solid #e7e7e7;
+ padding-block-start: calc(var(--size-1) * 1em);
+ }
+ }
+
+ .profile_picture {
+ grid-row: 1 / 3;
+ grid-column: 1 / 2;
+
+ & > * {
+ display: inline-grid;
+ place-content: center;
+ width: calc(var(--size-9) * 1em);
+ height: calc(var(--size-9) * 1em);
+ aspect-ratio: 1 / 1;
+ background-color: var(--color-active);
+ color: #fff;
+ font-weight: 950;
+ font-size: smaller;
+ border-radius: calc(infinity * 1px);
+ text-align: center;
+ text-transform: uppercase;
+ }
+ }
+
+ .title {
+ display: none;
+ }
+ .content {
+ grid-row: 2/3;
+ grid-column: 2 / 3;
+ }
+
+ .keywords {
+ margin-block-end: 0;
+ }
+</style>
diff --git a/src/components/templates/Search.astro b/src/components/templates/Search.astro
new file mode 100644
index 0000000..5245643
--- /dev/null
+++ b/src/components/templates/Search.astro
@@ -0,0 +1,67 @@
+---
+const { site } = Astro;
+---
+
+<search>
+ <link rel="dns-prefetch" href="https://www.google.com/search">
+ <form
+ action="https://www.google.com/search"
+ target="_blank"
+ rel="external noreferrer search"
+ role="search"
+ autocomplete="on"
+ name="search"
+ >
+ <details>
+ <summary>Pesquisar no website</summary>
+ <div class="details">
+ <p>
+ <label>Barra de pesquisa <input
+ name="q"
+ type="search"
+ placeholder={`site:${site} consulta de pesquisa`}
+ value={`site:${site} `}
+ required
+ title={`"site:${site} " é usado para que os resultados da pesquisa fiquem restritos a este website`}
+ pattern={`site:${site} .+`}
+ size={`site:${site} .+`.length}
+ /></label>
+ </p>
+ <p class="mute">
+ <small>Esta pesquisa é efectuada pelo Google e <strong>utiliza
+ software proprietário.</strong></small>
+ </p>
+ <p><button type="submit">🔍 Pesquisar</button></p>
+ </div>
+ </details>
+ </form>
+</search>
+
+<style>
+ search {
+ padding-block-end: calc(var(--size-4) * 1em);
+ }
+
+ summary {
+ font-size: calc(var(--size-3) * 1rem);
+ font-weight: bolder;
+ }
+
+ .details {
+ border-radius: calc(var(--size-1) * 1em);
+ border: 1px solid var(--color-light);
+ margin-block-start: calc(var(--size-1) * 1em);
+ font-size: calc(var(--size-3) * 1rem);
+ padding-inline: calc(var(--size-4) * 1em);
+ padding-block: calc(var(--size-2) * 1em);
+
+ & > p {
+ margin-block: calc(var(--size-2) * 1em);
+ line-height: calc(var(--size-8) * 1rem);
+ }
+
+ & input[type="search"] {
+ width: 100%;
+ }
+ }
+</style>
diff --git a/src/components/templates/SimplePostList.astro b/src/components/templates/SimplePostList.astro
new file mode 100644
index 0000000..0ec33e3
--- /dev/null
+++ b/src/components/templates/SimplePostList.astro
@@ -0,0 +1,81 @@
+---
+import Date from "@components/organisms/Date.astro";
+import KeywordsList from "@components/organisms/KeywordsList.astro";
+import { getFirstUserID, getLastUpdate } from "@lib/collection/helpers";
+import type { Original } from "@lib/collection/schemas";
+import type { z } from "astro:content";
+import type { CollectionEntry } from "astro:content";
+
+interface Props {
+ posts: (CollectionEntry<"blog"> & { data: z.infer<typeof Original> })[];
+}
+
+const { posts } = Astro.props;
+---
+<ol>
+ {
+ await Promise.all(posts.map(async (post) => {
+ const { id, data } = post;
+ const { title, description, lang, keywords } = data;
+ const { name, email, entity } = await getFirstUserID(post);
+ const display = name ?? email ?? entity;
+ return (
+ <li>
+ <article>
+ <h3><a href={`/blog/read/${id}`}>{title}</a></h3>
+ {
+ description &&
+ description.split("\n\n").map((paragraph) => (
+ <p class="small">{paragraph}</p>
+ ))
+ }
+
+ <footer class="small">
+ <Date
+ date={getLastUpdate(post)}
+ locales={lang}
+ options={{
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ }}
+ />{display}
+ <KeywordsList {keywords} />
+ </footer>
+ </article>
+ </li>
+ );
+ }))
+ }
+</ol>
+
+<style>
+ ol {
+ margin-inline-start: calc(var(--size-7) * 1em);
+ margin-block: calc(var(--size-7) * 1em);
+ & > li {
+ margin-block-start: calc(var(--size-2) * 1em);
+ & > article {
+ padding-inline-end: calc(var(--size-9) * 1em);
+
+ & > p:not(:first-of-type) {
+ margin-block-start: 1.5em;
+ }
+
+ & > footer {
+ display: flex;
+ flex-direction: column;
+ gap: calc(var(--size-1) * 1em);
+ }
+ }
+ }
+ }
+
+ @media (width >= 40rem) {
+ ol > li > article > footer {
+ flex-direction: row;
+ align-items: center;
+ justify-content: space-between;
+ }
+ }
+</style>
diff --git a/src/consts.ts b/src/consts.ts
index ee6c580..e69de29 100644
--- a/src/consts.ts
+++ b/src/consts.ts
@@ -1,29 +0,0 @@
-import { z } from "astro/zod";
-
-export const SITE_TITLE = "Cravo de Abril";
-export const SITE_DESCRIPTION = "Um domínio da liberdade!";
-export const SITE_AUTHOR = "João Augusto Costa Branco Marado Torres";
-
-export const KEYWORDS = ["Portugal", "democracy"] as const;
-export const KeywordsEnum = z.enum(KEYWORDS);
-
-export const ENTITY_TYPES = ["author", "co-author", "translator"] as const;
-export const EntityTypesEnum = z.enum(ENTITY_TYPES);
-
-export const CREATIVE_COMMONS_LICENSES = [
- "CC0",
- "CC-BY",
- "CC-BY-SA",
- "CC-BY-ND",
- "CC-BY-NC",
- "CC-BY-NC-SA",
- "CC-BY-NC-ND",
-] as const;
-export const LICENSES = [
- ...CREATIVE_COMMONS_LICENSES,
- "WTFPL",
- "public domain",
-] as const;
-export const LicensesEnum = z.enum(LICENSES);
-
-export const TRUSTED_KEYS_DIR = new URL(`file://${Deno.cwd()}/public/keys/`);
diff --git a/src/content.config.ts b/src/content.config.ts
index f652cc3..821faf5 100644
--- a/src/content.config.ts
+++ b/src/content.config.ts
@@ -1,114 +1,17 @@
import { file, glob } from "astro/loaders";
-import { defineCollection, reference, z } from "astro:content";
+import { defineCollection, type z } from "astro:content";
//import { parse } from "@std/toml";
import { parse } from "toml";
-import { EntityTypesEnum, KeywordsEnum, LicensesEnum } from "./consts.ts";
-import { get, instanciate } from "./utils/anonymous.ts";
-import { isValidLocale } from "./utils/lang.ts";
-
-const Blog = z.object({
- title: z.string().trim(),
- subtitle: z.string().trim().optional(),
- description: z.string().trim().optional(),
- keywords: z.array(KeywordsEnum).optional().refine(
- (keywords) => new Set(keywords).size === (keywords?.length ?? 0),
- {
- message: "Keywords must be unique",
- },
- ).transform((keywords) =>
- keywords !== undefined ? new Set(keywords).values().toArray() : undefined
- ),
- dateCreated: z.coerce.date(),
- dateUpdated: z.coerce.date().optional(),
- locationCreated: z.string().trim().optional(),
- relatedPosts: z.array(reference("blog")).default([]).refine(
- (posts) => new Set(posts).size === (posts?.length ?? 0),
- {
- message: "Related posts referenced multiple times",
- },
- ).transform((x) => new Set(x)).transform((set) => set.values().toArray()),
- lang: z.string().trim().refine(isValidLocale),
- translationOf: reference("blog").optional(),
- signers: z.array(
- z.object({ entity: reference("entity"), role: EntityTypesEnum }),
- ).optional().refine(
- (signers) => {
- if (signers === undefined) return true;
- return signers.filter((s) => s.role === "author").length <= 1;
- },
- {
- message: "There can only be one author",
- },
- ).refine(
- (signers) => {
- const ids = signers?.map(get("entity")) ?? [];
- return new Set(ids).size === ids.length;
- },
- {
- message: "Reusing signers",
- },
- //).transform((signers) =>
- // Object.fromEntries(new Map(signers?.map(({ entity, ...rest }) => [entity, rest]) ?? []))
- ),
- license: LicensesEnum,
-}).refine(
- ({ dateCreated, dateUpdated }) =>
- dateUpdated === undefined || dateCreated.getTime() <= dateUpdated.getTime(),
- { message: "Update before creation" },
-).refine(
- ({ translationOf, keywords }) =>
- translationOf !== undefined || (keywords?.length ?? 0) > 0,
- {
- message: "Originals must include at least one keyword",
- path: ["keywords"],
- },
-).refine(
- ({ translationOf, keywords }) =>
- (translationOf === undefined) !== ((keywords?.length ?? 0) <= 0),
- {
- message: "we will use this information from the original, " +
- "so no need to specify it for translations",
- path: ["keywords"],
- },
-).refine(
- ({ translationOf, relatedPosts }) =>
- (translationOf === undefined) || (relatedPosts.length <= 0),
- {
- message: "we will use this information from the original, " +
- "so no need to specify it for translations",
- path: ["relatedPosts"],
- },
-).refine(
- ({ translationOf, signers = [] }) =>
- (translationOf === undefined) ||
- (signers.values().every(({ role }) => role !== "translator")),
- {
- message: "There can't be translator signers on non translated work",
- path: ["signers"],
- },
-);
+import { Blog, Entity } from "./lib/collection/schemas.ts";
const blog = defineCollection({
loader: glob({ base: "./public/blog", pattern: "+([0-9a-z-]).md" }),
schema: Blog,
});
-export type Blog = z.infer<typeof Blog>;
-
-const Entity = z.object({
- websites: z.array(z.string().url().trim()).default([]).transform((websites) =>
- websites.map(instanciate(URL))
- ),
- publickey: z.object({
- armor: z.string().trim(),
- }),
-});
-
-type Entity = z.infer<typeof Entity>;
-
const entity = defineCollection({
loader: file("./src/content/entities.toml", {
- parser: (text) => parse(text).entities as Entity[],
+ parser: (text) => parse(text).entities as z.infer<typeof Entity>[],
}),
schema: Entity,
});
diff --git a/src/custom-attributes.d.ts b/src/custom-attributes.d.ts
new file mode 100644
index 0000000..a9383ea
--- /dev/null
+++ b/src/custom-attributes.d.ts
@@ -0,0 +1,8 @@
+declare namespace astroHTML.JSX {
+ // interface HTMLAttributes<"form"> {
+ // "rel"?: string;
+ // }
+ interface FormHTMLAttributes {
+ "rel"?: string;
+ }
+}
diff --git a/src/layouts/Base.astro b/src/layouts/Base.astro
index d80d6a8..afee012 100644
--- a/src/layouts/Base.astro
+++ b/src/layouts/Base.astro
@@ -1,12 +1,12 @@
---
-import BaseHead, {
- type Props as HeadProps,
-} from "@components/BaseHead.astro";
-import { type Props as HeaderProps } from "@components/Header.astro";
+import BaseHead from "@components/BaseHead.astro";
import Footer from "@components/Footer.astro";
import Header from "@components/Header.astro";
+import type { ComponentProps } from "astro/types";
-interface Props extends HeadProps, HeaderProps {}
+interface Props extends ComponentProps<typeof BaseHead> {
+ children: any;
+}
---
<!DOCTYPE html>
@@ -32,7 +32,7 @@ interface Props extends HeadProps, HeaderProps {}
<BaseHead {...Astro.props} />
</head>
<body>
- <Header {...Astro.props} />
+ <Header />
<slot />
<Footer />
<noscript>I see, a man of culture :)</noscript>
diff --git a/src/lib/collection/helpers.ts b/src/lib/collection/helpers.ts
new file mode 100644
index 0000000..83eb21d
--- /dev/null
+++ b/src/lib/collection/helpers.ts
@@ -0,0 +1,107 @@
+import type { CollectionEntry } from "astro:content";
+import {
+ Blog,
+ Entity,
+ type Entry,
+ type MicroEntry,
+ type OriginalEntry,
+ type TranslationEntry,
+} from "./schemas.ts";
+import { getEntries, type z } from "astro:content";
+import { defined, get, identity } from "../../utils/anonymous.ts";
+import { createKeyFromArmor } from "../pgp/create.ts";
+import { getUserIDsFromKey } from "../pgp/user.ts";
+import type { UserIDPacket } from "openpgp";
+import { getCollection } from "astro:content";
+
+export function getLastUpdate({ data }: CollectionEntry<"blog">): Date {
+ return data.dateUpdated ?? data.dateCreated;
+}
+export const sortLastCreated = (
+ { data: a }: CollectionEntry<"blog">,
+ { data: b }: CollectionEntry<"blog">,
+): number => b.dateCreated - a.dateCreated;
+export const sortFirstCreated = (
+ a: CollectionEntry<"blog">,
+ b: CollectionEntry<"blog">,
+): number => sortLastCreated(b, a);
+export const sortLastUpdated = (
+ { data: a }: CollectionEntry<"blog">,
+ { data: b }: CollectionEntry<"blog">,
+): number =>
+ (b.dateUpdated ?? b.dateCreated) - (a.dateUpdated ?? a.dateCreated);
+export const sortFirstUpdated = (
+ a: CollectionEntry<"blog">,
+ b: CollectionEntry<"blog">,
+): number => sortLastUpdated(b, a);
+
+export async function getSigners(
+ { data }: CollectionEntry<"blog">,
+): Promise<{
+ id: string;
+ entity: CollectionEntry<"entity">;
+ role: z.infer<typeof Blog>["signers"][number]["role"] | undefined;
+}[]> {
+ 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))
+ );
+}
+
+export async function getFirstAuthorEmail(
+ blog: CollectionEntry<"blog">,
+): Promise<string | undefined> {
+ const signers = await getSigners(blog);
+ const emails = await Promise.all(
+ signers.filter(({ role }) => role === "author").map(async ({ entity }) => {
+ const { publickey } = Entity.parse(entity.data);
+ const key = await createKeyFromArmor(publickey.armor);
+ const users = getUserIDsFromKey(undefined, key);
+ return users.map(get("email")).filter(Boolean)?.[0];
+ }),
+ );
+ return emails.filter(defined)?.[0];
+}
+
+export async function getFirstUserID(
+ blog: CollectionEntry<"blog">,
+): Promise<
+ (Partial<UserIDPacket> & { entity: string; website: string | undefined })
+> {
+ const signers = await getSigners(blog);
+ const userIDs = await Promise.all(
+ signers.filter(({ role }) => role === "author").map(
+ async ({ id, 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 };
+ })?.[0];
+ },
+ ),
+ );
+ return userIDs.filter(defined)?.[0];
+}
+
+export async function fromPosts<T extends Entry, U>(
+ filter: (entry: CollectionEntry<"blog">) => entry is T,
+ predicate: (entries: T[]) => U = identity as (entries: T[]) => U,
+): Promise<U> {
+ const entries = await getCollection<"blog", T>("blog", filter);
+ return predicate(entries);
+}
+export const isOriginal = (
+ entry: CollectionEntry<"blog">,
+): entry is OriginalEntry => entry.data.kind === "original";
+export const isTranslation = (
+ entry: CollectionEntry<"blog">,
+): entry is TranslationEntry => entry.data.kind === "translation";
+export const isMicro = (
+ entry: CollectionEntry<"blog">,
+): entry is MicroEntry => entry.data.kind === "micro";
diff --git a/src/lib/collection/schemas.ts b/src/lib/collection/schemas.ts
new file mode 100644
index 0000000..eca996f
--- /dev/null
+++ b/src/lib/collection/schemas.ts
@@ -0,0 +1,133 @@
+import { reference, z } from "astro:content";
+import { isValidLocale } from "../../utils/lang.ts";
+import { get } from "../../utils/anonymous.ts";
+import type { CollectionEntry } from "astro:content";
+
+export const KEYWORDS = ["Portugal", "democracy", "test"] as const;
+export const KeywordsEnum = z.enum(KEYWORDS);
+
+export const ENTITY_TYPES = ["author", "co-author", "translator"] as const;
+export const EntityTypesEnum = z.enum(ENTITY_TYPES);
+
+export const CREATIVE_COMMONS_LICENSES = [
+ "CC0",
+ "CC-BY",
+ "CC-BY-SA",
+ "CC-BY-ND",
+ "CC-BY-NC",
+ "CC-BY-NC-SA",
+ "CC-BY-NC-ND",
+] as const;
+export const LICENSES = [
+ ...CREATIVE_COMMONS_LICENSES,
+ "WTFPL",
+ "public domain",
+] as const;
+export const LicensesEnum = z.enum(LICENSES);
+
+export const Original = z.object({
+ kind: z.literal("original"),
+ title: z.string().trim(),
+ subtitle: z.string().trim().optional(),
+ description: z.string().trim().optional(),
+ keywords: z.array(KeywordsEnum).refine(
+ (keywords) => new Set(keywords).size === keywords.length,
+ { message: "Keywords must be unique" },
+ ),
+ dateCreated: z.coerce.date(),
+ dateUpdated: z.coerce.date().optional(),
+ locationCreated: z.string().trim().optional(),
+ relatedPosts: z.array(reference("blog")).default([]).refine(
+ (posts) => new Set(posts).size === posts.length,
+ { message: "Related posts referenced multiple times" },
+ ),
+ lang: z.string().trim().refine(isValidLocale),
+ signers: z.array(
+ z.object({ entity: reference("entity"), role: EntityTypesEnum }),
+ ).default([]).refine(
+ (signers) => signers.filter((s) => s.role === "author").length <= 1,
+ { message: "There can only be one author" },
+ ).refine((signers) => {
+ const ids = signers.map(get("entity"));
+ return new Set(ids).size === ids.length;
+ }, { message: "Reusing signers" }).refine(
+ (signers) => signers.every(({ role }) => role !== "translator"),
+ { message: "There can't be translator signers on non translated work" },
+ ),
+ license: LicensesEnum.default("public domain"),
+});
+
+export const Translation = z.object({
+ kind: z.literal("translation"),
+ title: z.string().trim(),
+ subtitle: z.string().trim().optional(),
+ description: z.string().trim().optional(),
+ dateCreated: z.coerce.date(),
+ dateUpdated: z.coerce.date().optional(),
+ lang: z.string().trim().refine(isValidLocale),
+ translationOf: reference("blog"),
+ signers: z.array(
+ z.object({ entity: reference("entity"), role: EntityTypesEnum }),
+ ).default([]).refine(
+ (signers) => signers.filter((s) => s.role === "author").length <= 1,
+ { message: "There can only be one author" },
+ ).refine((signers) => {
+ const ids = signers.map(get("entity"));
+ return new Set(ids).size === ids.length;
+ }, { message: "Reusing signers" }),
+ license: LicensesEnum.default("public domain"),
+});
+
+export const Micro = z.object({
+ kind: z.literal("micro"),
+ title: z.string().trim(),
+ keywords: z.array(KeywordsEnum).refine(
+ (keywords) => new Set(keywords).size === keywords.length,
+ { message: "Keywords must be unique" },
+ ),
+ dateCreated: z.coerce.date(),
+ dateUpdated: z.coerce.date().optional(),
+ locationCreated: z.string().trim().optional(),
+ lang: z.string().trim().refine(isValidLocale),
+ signers: z.array(
+ z.object({ entity: reference("entity"), role: EntityTypesEnum }),
+ ).default([]).refine(
+ (signers) => signers.filter((s) => s.role === "author").length <= 1,
+ { message: "There can only be one author" },
+ ).refine((signers) => {
+ const ids = signers.map(get("entity"));
+ return new Set(ids).size === ids.length;
+ }, { message: "Reusing signers" }).refine(
+ (signers) => signers.every(({ role }) => role !== "translator"),
+ { message: "There can't be translator signers on non translated work" },
+ ),
+ license: LicensesEnum.default("public domain"),
+});
+
+export const Blog = z.discriminatedUnion("kind", [
+ Original,
+ Translation,
+ Micro,
+]).refine(
+ ({ dateCreated, dateUpdated }) =>
+ dateUpdated === undefined || dateCreated.getTime() <= dateUpdated.getTime(),
+ { message: "Update before creation" },
+);
+
+export type OriginalEntry = CollectionEntry<"blog"> & {
+ data: z.infer<typeof Original>;
+};
+export type TranslationEntry = CollectionEntry<"blog"> & {
+ data: z.infer<typeof Translation>;
+};
+export type MicroEntry = CollectionEntry<"blog"> & {
+ data: z.infer<typeof Micro>;
+};
+export type Entry = OriginalEntry | TranslationEntry | MicroEntry;
+
+export const Entity = z.object({
+ websites: z.array(z.string().url().trim()).default([]).transform((websites) =>
+ websites.map((x) => new URL(x).href)
+ ),
+ publickey: z.object({ armor: z.string().trim() }),
+});
diff --git a/src/lib/env.ts b/src/lib/env.ts
new file mode 100644
index 0000000..679c76f
--- /dev/null
+++ b/src/lib/env.ts
@@ -0,0 +1,68 @@
+import { createEnv } from "@t3-oss/env-core";
+import { z } from "astro:content";
+
+export const env = createEnv({
+ server: {
+ TRUSTED_KEYS_DIR: z.string().superRefine((val, ctx) => {
+ let url: URL;
+ const cwd = new URL(`file://${Deno.cwd()}/`);
+ try {
+ url = new URL(val, cwd);
+ } catch {
+ ctx.addIssue({
+ code: "custom",
+ message: `${cwd}${val} doesn't exist`,
+ fatal: true,
+ });
+ return;
+ }
+
+ const { isDirectory } = Deno.statSync(url);
+
+ if (isDirectory) return;
+
+ ctx.addIssue({
+ code: "custom",
+ message: `${url} it's not a directory`,
+ fatal: true,
+ });
+ }).transform((val) => new URL(val, new URL(`file://${Deno.cwd()}/`))),
+ },
+
+ /**
+ * The prefix that client-side variables must have. This is enforced both at
+ * a type-level and at runtime.
+ */
+ clientPrefix: "PUBLIC_",
+ client: {
+ PUBLIC_SITE_URL: z.string().url(),
+ PUBLIC_SITE_TITLE: z.string().trim().min(1),
+ PUBLIC_SITE_DESCRIPTION: z.string().trim().min(1),
+ PUBLIC_SITE_AUTHOR: z.string().trim().min(1),
+ PUBLIC_GIT_URL: z.string().url(),
+ PUBLIC_TOR_URL: z.string().url().optional(),
+ PUBLIC_GIT_TOR_URL: z.string().url().optional(),
+ PUBLIC_SIMPLE_X_ADDRESS: z.string().url().optional(),
+ },
+
+ /**
+ * What object holds the environment variables at runtime. This is usually
+ * `process.env` or `import.meta.env`.
+ */
+ runtimeEnv: import.meta.env ?? Deno.env.toObject(),
+
+ /**
+ * By default, this library will feed the environment variables directly to
+ * the Zod validator.
+ *
+ * This means that if you have an empty string for a value that is supposed
+ * to be a number (e.g. `PORT=` in a ".env" file), Zod will incorrectly flag
+ * it as a type mismatch violation. Additionally, if you have an empty string
+ * for a value that is supposed to be a string with a default value (e.g.
+ * `DOMAIN=` in an ".env" file), the default value will never be applied.
+ *
+ * In order to solve these issues, we recommend that all new projects
+ * explicitly specify this option as true.
+ */
+ emptyStringAsUndefined: true,
+});
diff --git a/src/lib/git/log.ts b/src/lib/git/log.ts
index 86bbe7b..bcf6888 100644
--- a/src/lib/git/log.ts
+++ b/src/lib/git/log.ts
@@ -29,6 +29,7 @@ export async function getLastCommitForOneOfFiles(
"-1",
`--pretty=format:${format.map((x) => `%${x}`).join("%n")}`,
"--",
+ // deno-lint-ignore no-undef
...Iterator.from(files).map((x) => x.pathname),
],
});
@@ -59,6 +60,7 @@ export async function getLastCommitForOneOfFiles(
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: {
@@ -112,6 +114,7 @@ async function fileStatusFromCommit(
return result.map((line) => {
const [status, path] = line.split("\t");
if (
+ // deno-lint-ignore no-undef
Iterator.from(files).some((file) =>
file.pathname.replace(dir.pathname, "").includes(path)
)
diff --git a/src/lib/pgp/sign.ts b/src/lib/pgp/sign.ts
index 5f7f5a8..6d1e78c 100644
--- a/src/lib/pgp/sign.ts
+++ b/src/lib/pgp/sign.ts
@@ -26,6 +26,7 @@ export class Signature {
getPackets(key?: MaybeIterable<KeyID>): Packet[] {
key ??= this.signingKeyIDs;
+ // deno-lint-ignore no-undef
const iterator = Iterator.from(surelyIterable(key));
return iterator.map((key) => this.#packets.get(key.bytes)).filter(defined)
.flatMap(identity).toArray();
diff --git a/src/lib/pgp/trust.ts b/src/lib/pgp/trust.ts
index cf022b4..34d454b 100644
--- a/src/lib/pgp/trust.ts
+++ b/src/lib/pgp/trust.ts
@@ -1,19 +1,20 @@
import type { Key } from "npm:openpgp@^6.1.1";
-import { TRUSTED_KEYS_DIR } from "../../consts.ts";
import { createKeysFromDir } from "./create.ts";
import type { AsyncYieldType } from "../../utils/iterator.ts";
import { equal, getCall } from "../../utils/anonymous.ts";
+import { env } from "../env.ts";
let trusted:
| Iterable<AsyncYieldType<ReturnType<typeof createKeysFromDir>>>
| undefined = undefined;
const fingerprints = () =>
+ // deno-lint-ignore no-undef
Iterator.from(trusted ?? []).map(getCall("getFingerprint"));
export async function keyTrust(key: Key): Promise<number> {
if (trusted === undefined) {
- trusted = await Array.fromAsync(createKeysFromDir(TRUSTED_KEYS_DIR));
+ trusted = await Array.fromAsync(createKeysFromDir(env.TRUSTED_KEYS_DIR));
}
return fingerprints().some(equal(key.getFingerprint())) ? 255 : 0;
}
diff --git a/src/lib/pgp/user.ts b/src/lib/pgp/user.ts
new file mode 100644
index 0000000..334fbde
--- /dev/null
+++ b/src/lib/pgp/user.ts
@@ -0,0 +1,33 @@
+import { PublicKey, type Subkey, UserIDPacket } from "openpgp";
+import type { Signature } from "./sign.ts";
+import { defined, get } from "../../utils/anonymous.ts";
+
+export function getUserIDsFromKey(
+ signature: Signature | undefined,
+ key: PublicKey | Subkey,
+): UserIDPacket[] {
+ const packet = signature?.getPackets?.()?.[0];
+ const userID = packet?.signersUserID;
+
+ if (userID) {
+ return [UserIDPacket.fromObject(parseUserID(userID))];
+ }
+
+ key = key instanceof PublicKey ? key : key.mainKey;
+ return key.users.map(get("userID")).filter(defined);
+}
+
+function parseUserID(input: string) {
+ const regex = /^(.*?)\s*(?:\((.*?)\))?\s*(?:<(.+?)>)?$/;
+ const match = input.match(regex);
+
+ if (!match) return {};
+
+ const [, name, comment, email] = match;
+
+ return {
+ name: name?.trim() || undefined,
+ comment: comment?.trim() || undefined,
+ email: email?.trim() || undefined,
+ };
+}
diff --git a/src/lib/pgp/verify.ts b/src/lib/pgp/verify.ts
index da2de7f..f37c0bb 100644
--- a/src/lib/pgp/verify.ts
+++ b/src/lib/pgp/verify.ts
@@ -3,7 +3,7 @@ import {
PublicKey,
readSignature,
type Subkey,
- UserIDPacket,
+ type UserIDPacket,
verify,
} from "openpgp";
import {
@@ -18,11 +18,12 @@ import {
type KeyFileFormat,
} from "./create.ts";
import { getLastCommitForOneOfFiles } from "../git/log.ts";
-import { defined, get, instanciate } from "../../utils/anonymous.ts";
+import { get, instanciate } from "../../utils/anonymous.ts";
import { Packet, Signature } from "./sign.ts";
import type { Commit } from "../git/types.ts";
-import { TRUSTED_KEYS_DIR } from "../../consts.ts";
import { findMapAsync, type MaybeIterable } from "../../utils/iterator.ts";
+import { getUserIDsFromKey } from "./user.ts";
+import { env } from "../env.ts";
type DataURL = [URL, URL?];
type Corrupted = [false] | [true, Error];
@@ -251,7 +252,7 @@ export class SignatureVerifier {
public static async instance(): Promise<SignatureVerifier> {
if (!SignatureVerifier.#instance) {
SignatureVerifier.#instance = new SignatureVerifier();
- await SignatureVerifier.#instance.addKeysFromDir(TRUSTED_KEYS_DIR);
+ await SignatureVerifier.#instance.addKeysFromDir(env.TRUSTED_KEYS_DIR);
}
return SignatureVerifier.#instance;
@@ -270,36 +271,6 @@ export class SignatureVerifier {
export const verifier = SignatureVerifier.instance();
-function getUserIDsFromKey(
- signature: Signature,
- key: PublicKey | Subkey,
-): UserIDPacket[] {
- const packet = signature.getPackets()[0];
- const userID = packet.signersUserID;
-
- if (userID) {
- return [UserIDPacket.fromObject(parseUserID(userID))];
- }
-
- key = key instanceof PublicKey ? key : key.mainKey;
- return key.users.map(get("userID")).filter(defined);
-}
-
-function parseUserID(input: string) {
- const regex = /^(.*?)\s*(?:\((.*?)\))?\s*(?:<(.+?)>)?$/;
- const match = input.match(regex);
-
- if (!match) return {};
-
- const [, name, comment, email] = match;
-
- return {
- name: name?.trim() || undefined,
- comment: comment?.trim() || undefined,
- email: email?.trim() || undefined,
- };
-}
-
async function isSignatureCorrupted(
verified: Awaited<
ReturnType<typeof verify>
diff --git a/src/pages/blog/[...year].astro b/src/pages/blog/[...year].astro
index f148a76..1742baa 100644
--- a/src/pages/blog/[...year].astro
+++ b/src/pages/blog/[...year].astro
@@ -1,20 +1,16 @@
---
+import type {
+ GetStaticPaths,
+ InferGetStaticParamsType,
+ InferGetStaticPropsType,
+} from "astro";
import { getCollection } from "astro:content";
-import type { CollectionEntry } from "astro:content";
import Base from "@layouts/Base.astro";
import DateSelector from "@components/DateSelector.astro";
import BlogCard from "@components/BlogCard.astro";
+import { sortLastCreated } from "@lib/collection/helpers";
-type Props = {
- posts: CollectionEntry<"blog">[];
- next: string;
- previous: string;
- years: number[];
- months: number[];
- days?: number[];
-};
-
-export async function getStaticPaths() {
+export const getStaticPaths = (async () => {
const posts = await getCollection("blog");
const archive = {
@@ -128,7 +124,7 @@ export async function getStaticPaths() {
paths.push({
params: { year: ymd },
props: {
- posts: archive.postsByDate.get(ymd),
+ posts: archive.postsByDate.get(ymd) ?? [],
next: archive.sortedDates?.[i + 1],
previous: archive.sortedDates?.[i - 1],
years: sortedYears,
@@ -139,16 +135,16 @@ export async function getStaticPaths() {
}
return paths;
-}
+}) satisfies GetStaticPaths;
+
+export type Params = InferGetStaticParamsType<typeof getStaticPaths>;
+export type Props = InferGetStaticPropsType<typeof getStaticPaths>;
const title = "Blog";
const description = "Latest articles.";
let { posts, previous, next, years, months, days } = Astro.props;
-posts = posts.sort((a, b) =>
- new Date(b.data.dateCreated).valueOf() -
- new Date(a.data.dateCreated).valueOf()
-);
+posts = posts.sort(sortLastCreated);
const date = posts[0].data.dateCreated as Date;
---
diff --git a/src/pages/blog/micro/[page].astro b/src/pages/blog/micro/[page].astro
new file mode 100644
index 0000000..9fb04f1
--- /dev/null
+++ b/src/pages/blog/micro/[page].astro
@@ -0,0 +1,32 @@
+---
+import MicroBlog from "@components/templates/MicroBlog.astro";
+import Base from "@layouts/Base.astro";
+import { fromPosts, isMicro } from "@lib/collection/helpers";
+import { identity } from "@utils/anonymous";
+import type {
+ GetStaticPaths,
+ InferGetStaticParamsType,
+ InferGetStaticPropsType,
+} from "astro";
+
+export const getStaticPaths = (async ({ paginate }) => {
+ const micros = await fromPosts(isMicro, identity);
+
+ return paginate(micros, { pageSize: 20 });
+}) satisfies GetStaticPaths;
+
+export type Params = InferGetStaticParamsType<typeof getStaticPaths>;
+export type Props = InferGetStaticPropsType<typeof getStaticPaths>;
+
+const { page } = Astro.props;
+---
+<Base title="Micro Blogue">
+ <h1>Page {page.currentPage}</h1>
+ <ul>
+ {page.data.map((micro) => <li><MicroBlog {...micro} /></li>)}
+ </ul>
+ {page.url.first ? <a href={page.url.first}>First</a> : null}
+ {page.url.prev ? <a href={page.url.prev}>Previous</a> : null}
+ {page.url.next ? <a href={page.url.next}>Next</a> : null}
+ {page.url.last ? <a href={page.url.last}>Last</a> : null}
+</Base>
diff --git a/src/pages/blog/read/[...slug].astro b/src/pages/blog/read/[...slug].astro
index 05d68e8..348a976 100644
--- a/src/pages/blog/read/[...slug].astro
+++ b/src/pages/blog/read/[...slug].astro
@@ -9,9 +9,9 @@ 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 { getEntries } from "astro:content";
import { verifier as verifierPrototype } from "@lib/pgp/verify";
-import { defined, get } from "@utils/anonymous";
+import { getSigners } from "@lib/collection/helpers";
+import { get } from "@utils/anonymous";
import Authors from "@components/signature/Authors.astro";
import { getEntry } from "astro:content";
@@ -27,8 +27,9 @@ type Props = CollectionEntry<"blog">;
const post = Astro.props;
-if (defined(post.data.translationOf)) {
- const original = await getEntry(
+let original: CollectionEntry<"blog">;
+if (post.data.kind === "translation") {
+ original = await getEntry(
post.data.translationOf as CollectionEntry<"blog">,
);
@@ -40,15 +41,15 @@ if (defined(post.data.translationOf)) {
(s) => s.role === "author",
).map((s) => s.entity.id)?.[0];
const originalCoAuthors = new Set(
- (original.data.signer ?? []).filter(
+ (original.data.signers ?? []).filter(
(s) => s.role === "co-author",
).map((s) => s.entity.id),
);
- const translationAuthor = (post.data.signer ?? []).filter(
+ const translationAuthor = (post.data.signers ?? []).filter(
(s) => s.role === "author",
).map((s) => s.entity.id)?.[0];
const translationCoAuthors = new Set(
- (post.data.signer ?? []).filter(
+ (post.data.signers ?? []).filter(
(s) => s.role === "co-author",
).map((s) => s.entity.id),
);
@@ -63,7 +64,7 @@ if (defined(post.data.translationOf)) {
);
}
- const translators = (post.data.signer ?? []).filter(
+ const translators = (post.data.signers ?? []).filter(
(s) => s.role === "translator",
).map((s) => s.entity.id);
@@ -77,7 +78,8 @@ if (defined(post.data.translationOf)) {
}
}
} else {
- if (post.data.signer?.some((x) => x.role === "translator")) {
+ 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`,
);
@@ -89,8 +91,8 @@ const translationsSet = new Set(
(await getCollection(
"blog",
(x) =>
- x.data.translationOf?.id ===
- (post.data.translationOf !== undefined
+ (x.data.kind === "translation") && x.data.translationOf.id ===
+ (post.data.kind === "translation"
? post.data.translationOf.id
: post.id),
) ?? []).map(({ id }) => id),
@@ -102,16 +104,7 @@ const translations = [...translationsSet.values()].map((id) => ({
id,
}));
-const signers = await getEntries(
- post.data.signer?.map(get("entity")) ?? [],
-).then((x) => x.filter(defined))
- .then((x) =>
- x.map((x) => ({
- entity: x,
- role: post.data.signer?.find((y) => y.entity.id === x.id)?.role,
- }))
- )
- .then((x) => x.filter((x) => x.role !== undefined));
+const signers = await getSigners(post);
const verifier = await verifierPrototype.then((x) => x.clone());
@@ -137,7 +130,12 @@ const commit = await verification?.commit;
<html lang="pt-PT">
<head>
- <BaseHead title={post.data.title} description={post.data.description} />
+ <BaseHead
+ title={post.data.title}
+ description={"description" in post.data
+ ? post.data.description
+ : post.data.title}
+ />
</head>
<body>
@@ -151,7 +149,7 @@ const commit = await verification?.commit;
<hgroup>
<h1 itemprop="headline">{post.data.title}</h1>
{
- post.data.subtitle && (
+ "subtitle" in post.data && (
<p itemprop="alternativeHeadline" class="subtitle">
{post.data.subtitle}
</p>
@@ -159,7 +157,8 @@ const commit = await verification?.commit;
}
</hgroup>
{
- post.data.description && (
+ "description" in post.data && post.data.description &&
+ (
<section itemprop="abstract">
<h2>Resumo</h2>
{
@@ -181,7 +180,7 @@ const commit = await verification?.commit;
<Authors
verifications={verification.verifications}
expectedSigners={signers}
- commitSignerKey={commit?.signature?.keyFingerPrint}
+ commitSignerKey={commit?.signature?.signer}
/>
)
}
@@ -201,7 +200,7 @@ const commit = await verification?.commit;
post.data.dateUpdated && (
<dt>Última atualização</dt><dd>
<time
- itemprop="dateUpdated"
+ itemprop="dateModified"
datetime={toIso8601Full(post.data.dateUpdated)}
>{
new Intl.DateTimeFormat([lang], {}).format(
@@ -212,7 +211,8 @@ const commit = await verification?.commit;
)
}
{
- post.data.locationCreated && (
+ "locationCreated" in post.data &&
+ post.data.locationCreated && (
<dt
itemprop="locationCreated"
itemscope
@@ -230,15 +230,23 @@ const commit = await verification?.commit;
<hr />
<div itemprop="articleBody text"><Content /></div>
<hr />
- <Keywords keywords={post.data.keywords} />
- <Citations citations={post.data.relatedPosts} />
+ {
+ "keywords" in original.data && (
+ <Keywords keywords={original.data.keywords} />
+ )
+ }
+ {
+ "relatedPosts" in original.data && (
+ <Citations citations={original.data.relatedPosts} />
+ )
+ }
<CopyrightNotice
- author={signers[0]?.entity.data.website?.[0] ?? "Anonymous"}
- website={signers[0]?.entity.data.website?.[0]}
- email={signers[0]?.entity.data.website?.[0]}
+ 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 as License}
+ license={post.data.license}
/>
</article>
</main>
diff --git a/src/pages/index.astro b/src/pages/index.astro
index eea5205..7e506bd 100644
--- a/src/pages/index.astro
+++ b/src/pages/index.astro
@@ -1,28 +1,112 @@
---
+import MicroBlog from "@components/templates/MicroBlog.astro";
+import SimplePostList from "@components/templates/SimplePostList.astro";
import Base from "@layouts/Base.astro";
-import { SITE_TITLE } from "src/consts";
+import {
+ fromPosts,
+ isMicro,
+ isOriginal,
+ sortLastUpdated,
+} from "@lib/collection/helpers";
+import { env } from "@lib/env";
+
+const { PUBLIC_SITE_TITLE } = env;
+
+const originals = await fromPosts(
+ isOriginal,
+ (originals) => originals.sort(sortLastUpdated).slice(0, 10),
+);
+const micro = await fromPosts(
+ isMicro,
+ (originals) => originals.sort(sortLastUpdated)?.[0],
+);
---
-<Base title={SITE_TITLE} showSearch={true} showNav={true}>
+<Base title={PUBLIC_SITE_TITLE}>
<main>
<article>
<h2>Viva abril!</h2>
<figure>
<blockquote lang="es-VE" translate="no">
- &laquo;Los que le cierran el camino a la revoluci&oacute;n
- pac&iacute;fica le abren al mismo tiempo el camino a la
- revoluci&oacute;n violenta&raquo;.
+ <i>«Los que le cierran el camino a la revolución pacífica le abren al
+ mismo tiempo el camino a la revolución violenta.»</i>
</blockquote>
<figcaption>
- &mdash; Hugo Ch&aacute;vez.
+ &mdash; Hugo Chávez.
<p>
- Tradu&ccedil;&atilde;o: &ldquo;Aqueles que fecham o caminho para a
- revolu&ccedil;&atilde;o pac&iacute;fica abrem, ao mesmo tempo, o
- caminho para a revolu&ccedil;&atilde;o violenta.&rdquo;
+ <small>Tradução: &ldquo;Aqueles que fecham o caminho para a
+ revolução pacífica abrem, ao mesmo tempo, o caminho para a
+ revolução violenta&rdquo;.</small>
</p>
</figcaption>
</figure>
- <p><em>Portugal <em>fez</em> diferente!</em></p>
+ <p class="lead"><em>Portugal <em>fez</em> diferente!</em></p>
</article>
+ {
+ (originals.length > 0 || micro) && (
+ <section id="posts">
+ <h2>Últimas aplicações atualizadas</h2>
+ {micro && <div id="last-micro"><MicroBlog {...micro} /></div>}
+ <div id="last-originals"><SimplePostList posts={originals} /></div>
+ </section>
+ )
+ }
</main>
</Base>
+
+<style>
+ figure:has(blockquote) {
+ border-inline-start: 2px solid var(--color-active);
+ padding-inline-start: calc(var(--size-7) * 1em);
+
+ & > blockquote {
+ margin-block-start: calc(var(--size-7) * 1em);
+ margin-inline-start: 0;
+ border-inline-start: 2px solid var(--color-light);
+ padding-inline-start: calc(var(--size-7) * 1em);
+ }
+ }
+
+ #posts {
+ position: relative;
+
+ & > h2 {
+ float: inline-start;
+ }
+ }
+
+ #last-micro {
+ clear: inline-start;
+ max-width: 40ch;
+ margin-inline: auto;
+ }
+
+ #last-originals {
+ clear: inline-start;
+ }
+
+ @media (width >= 30rem) {
+ #posts {
+ & > h2 {
+ float: inline-start;
+ max-width: calc(
+ 100svw
+ - calc(
+ 50svw
+ + calc(
+ calc(2 * calc(var(--size-4) * 1em)) + calc(var(--size-2) * 1em)
+ )
+ )
+ );
+ }
+ }
+
+ #last-micro {
+ clear: none;
+ float: inline-end;
+ width: 50svw;
+ margin-inline-start: calc(var(--size-2) * 1em);
+ margin-block-end: calc(var(--size-2) * 1em);
+ }
+ }
+</style>
diff --git a/src/pages/robots.txt.ts b/src/pages/robots.txt.ts
index 4edef8b..78c9fdf 100644
--- a/src/pages/robots.txt.ts
+++ b/src/pages/robots.txt.ts
@@ -1,4 +1,4 @@
-import type { APIRoute } from "astro";
+import type { APIContext, APIRoute } from "astro";
const getRobotsTxt = (sitemapURL: URL) => `
User-agent: *
@@ -7,7 +7,7 @@ Allow: /
Sitemap: ${sitemapURL.href}
`;
-export const GET: APIRoute = ({ site }) => {
+export const GET: APIRoute = ({ site }: APIContext): Response => {
const sitemapURL = new URL("sitemap-index.xml", site);
return new Response(getRobotsTxt(sitemapURL));
};
diff --git a/src/pages/rss.xml.js b/src/pages/rss.xml.js
deleted file mode 100644
index de5685b..0000000
--- a/src/pages/rss.xml.js
+++ /dev/null
@@ -1,16 +0,0 @@
-import rss from "@astrojs/rss";
-import { getCollection } from "astro:content";
-import { SITE_DESCRIPTION, SITE_TITLE } from "../consts";
-
-export async function GET(context) {
- const posts = await getCollection("blog");
- return rss({
- title: SITE_TITLE,
- description: SITE_DESCRIPTION,
- site: context.site,
- items: posts.map((post) => ({
- ...post.data,
- link: `/blog/${post.id}/`,
- })),
- });
-}
diff --git a/src/pages/rss.xml.ts b/src/pages/rss.xml.ts
new file mode 100644
index 0000000..c07f3bd
--- /dev/null
+++ b/src/pages/rss.xml.ts
@@ -0,0 +1,32 @@
+import rss, { type RSSFeedItem } from "@astrojs/rss";
+import { getCollection } from "astro:content";
+import type { APIContext, APIRoute } from "astro";
+import { Blog } from "../lib/collection/schemas.ts";
+import { getFirstAuthorEmail } from "../lib/collection/helpers.ts";
+import { env } from "../lib/env.ts";
+
+const { PUBLIC_SITE_TITLE, PUBLIC_SITE_DESCRIPTION, PUBLIC_SITE_URL } = env;
+
+export const GET: APIRoute = async (context: APIContext): Promise<Response> => {
+ const posts = await getCollection("blog");
+ return rss({
+ title: PUBLIC_SITE_TITLE,
+ description: PUBLIC_SITE_DESCRIPTION,
+ site: context.site ?? PUBLIC_SITE_URL,
+ items: await Promise.all(posts.map(async (post): Promise<RSSFeedItem> => {
+ const { id, rendered } = post;
+ const blog = Blog.parse(post.data);
+
+ const { title, dateUpdated, dateCreated } = blog;
+ return {
+ description: "description" in blog ? blog.description : undefined,
+ title,
+ author: await getFirstAuthorEmail(post),
+ content: rendered?.html,
+ pubDate: dateUpdated ?? dateCreated,
+ categories: "keywords" in blog ? blog.keywords : undefined,
+ link: `/blog/read/${id}/`,
+ };
+ })),
+ });
+};
diff --git a/src/styles/global.css b/src/styles/global.css
index b7ee55d..47dc065 100644
--- a/src/styles/global.css
+++ b/src/styles/global.css
@@ -3,16 +3,40 @@
--ff-sans: ui-sans-serif, sans-serif;
--ff-mono: ui-monospace, monospace;
--ff-icons: "glyphicons", emoji;
- --color-link: #106535;
- --color-visited: #00331b;
- --color-active: #a50026;
+
+ --color-background: white;
+ --color-foreground: contrast-color(var(--color-background));
+ --color-foreground: black;
+ --color-link: oklch(0.4539 0.0946 153.93);
+ --color-visited: color-mix(in oklch, var(--color-link), black);
+ --color-active: oklch(0.4564 0.1835 20.81);
+ --color-mute: oklch(0.46 0 0);
+ --color-light: oklch(0.66 0 0);
+ --color-dark: oklch(0.47 0 0);
+
+ --size-13: 7.4375rem;
+ --size-12: 5.5625rem;
+ --size-11: 4.1875;
+ --size-10: 3.125;
+ --size-9: 2.3125;
+ --size-8: 1.75;
+ --size-7: 1.5;
+ --size-6: 1.3125rem;
+ --size-5: 1.15625rem;
+ --size-4: 1;
+ --size-3: 0.75;
+ --size-2: 0.5625;
+ --size-1: 0.4375;
+ --size-0: 0.3125;
}
body {
- margin: 1rem auto;
+ background: var(--color-background);
+ color: var(--color-foreground);
+ margin: calc(var(--size-4) * 1em) auto;
max-width: 80ch;
font-family: var(--ff-sans);
- padding: 0 0.62em 3.24em;
+ padding: 0 calc(var(--size-2) * 1em) calc(var(--size-10) * 1em);
}
a:link {
@@ -29,14 +53,14 @@ a:active {
@media (prefers-color-scheme: dark) {
:root {
- --color-link: #66bd63;
- --color-visited: #a6d96a;
- --color-active: #f46d43;
- }
-
- body {
- background: #000;
- color: #fff;
+ --color-background: black;
+ --color-foreground: white;
+ --color-link: oklch(0.7223 0.1514 143.16);
+ --color-visited: color-mix(in oklch, var(--color-link), white);
+ --color-active: oklch(0.6923 0.1759 37.7);
+ --color-mute: oklch(0.67 0 0);
+ --color-dark: oklch(0.66 0 0);
+ --color-light: oklch(0.47 0 0);
}
}
@@ -47,6 +71,14 @@ a:active {
}
}
+[lang="pt-PT"] * {
+ hyphens: auto;
+}
+
+[lang]:not([lang="pt-PT"]) * {
+ hyphens: initial;
+}
+
.emoji {
font-family: var(--ff-icons);
}
@@ -55,6 +87,41 @@ a:active {
border-bottom: thin dashed;
}
+h1 {
+ text-align: center;
+ font-size: calc(var(--size-9) * 1rem);
+ font-weight: 800;
+}
+h1, h2, h3 {
+ scroll-margin: calc(var(--size-12) * 1rem);
+}
+
+.lead {
+ font-size: calc(var(--size-6) * 1rem);
+}
+
+.small {
+ font-size: calc(var(--size-3) * 1rem);
+ font-weight: 500;
+}
+
+.mute {
+ color: var(--color-mute);
+ font-size: calc(var(--size-3) * 1rem);
+}
+
+.sr-only {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border-width: 0;
+}
+
dt::after {
content: ":";
}
@@ -63,7 +130,7 @@ dl {
display: grid;
grid-template-columns: max-content 1fr;
grid-auto-rows: auto;
- gap: 0.25rem 1rem;
+ gap: calc(var(--size-0) * 1em) calc(var(--size-3) * 1em);
align-items: start;
}
@@ -88,11 +155,11 @@ dl.divider dl {
gap: 0;
}
dl.divider dt {
- padding-inline-end: 1em;
+ padding-inline-end: calc(var(--size-4) * 1em);
}
dl.divider dt + dd:not(:first-of-type) {
- border-block-start: 1px solid #181818;
+ border-block-start: 1px solid var(--color-dark);
}
dl.divide dd + dt {
- border-block-start: 1px solid #181818;
+ border-block-start: 1px solid var(--color-dark);
}
diff --git a/src/utils/datetime.test.ts b/src/utils/datetime.test.ts
index dd239b2..5f0749d 100644
--- a/src/utils/datetime.test.ts
+++ b/src/utils/datetime.test.ts
@@ -1,7 +1,6 @@
import { assertEquals, assertMatch } from "@std/assert";
import { describe, it } from "@std/testing/bdd";
import { toIso8601Full, toIso8601FullUTC } from "./datetime.ts";
-import { FakeTime } from "@std/testing/time";
describe("toIso8601Full", () => {
it("formats current local time with offset", () => {