diff options
Diffstat (limited to 'src')
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} <{author.email}></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> - <<a href="/" hreflang="pt-PT">cravodeabril.pt</a>> Copyright - © 2025 João Augusto Costa Branco Marado Torres - </small> + <<a href="/" hreflang="pt-PT">cravodeabril.pt</a>> Copyright + © 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><<a href="/">cravodeabril.pt</a>></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"><</span><a href="/">cravodeabril.pt</a><span + class="bracket" + >></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"> - «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>«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> - — Hugo Chávez. + — Hugo Chávez. <p> - Tradução: “Aqueles que fecham o caminho para a - revolução pacífica abrem, ao mesmo tempo, o - caminho para a revolução violenta.” + <small>Tradução: “Aqueles que fecham o caminho para a + revolução pacífica abrem, ao mesmo tempo, o caminho para a + revolução violenta”.</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", () => { |