summaryrefslogtreecommitdiff
path: root/src/pages
diff options
context:
space:
mode:
authorJoão Augusto Costa Branco Marado Torres <torres.dev@disroot.org>2025-06-24 12:08:41 -0300
committerJoão Augusto Costa Branco Marado Torres <torres.dev@disroot.org>2025-06-24 12:50:43 -0300
commitf9a77c5c27aede4e5978eb55d9b7af781b680a1d (patch)
treed545e325ba1ae756fc2eac66fac1001b6753c40d /src/pages
feat!: initial commit
Signed-off-by: João Augusto Costa Branco Marado Torres <torres.dev@disroot.org>
Diffstat (limited to 'src/pages')
-rw-r--r--src/pages/blog/[...year].astro165
-rw-r--r--src/pages/blog/keywords/[...slug].astro40
-rw-r--r--src/pages/blog/keywords/index.astro21
-rw-r--r--src/pages/blog/read/[...slug].astro333
-rw-r--r--src/pages/index.astro28
-rw-r--r--src/pages/robots.txt.ts13
-rw-r--r--src/pages/rss.xml.js16
7 files changed, 616 insertions, 0 deletions
diff --git a/src/pages/blog/[...year].astro b/src/pages/blog/[...year].astro
new file mode 100644
index 0000000..f148a76
--- /dev/null
+++ b/src/pages/blog/[...year].astro
@@ -0,0 +1,165 @@
+---
+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";
+
+type Props = {
+ posts: CollectionEntry<"blog">[];
+ next: string;
+ previous: string;
+ years: number[];
+ months: number[];
+ days?: number[];
+};
+
+export async function getStaticPaths() {
+ const posts = await getCollection("blog");
+
+ const archive = {
+ years: new Set<number>(),
+ monthsByYear: new Map<string, Set<number>>(),
+ daysByMonth: new Map<string, Set<number>>(),
+ postsByDate: new Map<string, typeof posts>(),
+ sortedDates: [] as string[],
+ };
+
+ const getYMD = (date: Date) => {
+ const y = date.getFullYear();
+ const m = date.getMonth() + 1;
+ const d = date.getDate();
+ return { y, m, d };
+ };
+
+ for (const post of posts) {
+ const { y, m, d } = getYMD(post.data.dateCreated);
+
+ archive.years.add(y);
+
+ if (!archive.monthsByYear.has(y.toString())) {
+ archive.monthsByYear.set(y.toString(), new Set());
+ }
+ archive.monthsByYear.get(y.toString())!.add(m);
+
+ const ym = `${y}/${String(m).padStart(2, "0")}`;
+ if (!archive.daysByMonth.has(ym)) archive.daysByMonth.set(ym, new Set());
+ archive.daysByMonth.get(ym)!.add(d);
+
+ const ymd = `${ym}/${String(d).padStart(2, "0")}`;
+ if (!archive.postsByDate.has(ymd)) archive.postsByDate.set(ymd, []);
+ archive.postsByDate.get(ymd)!.push(post);
+ }
+
+ archive.sortedDates = Array.from(archive.postsByDate.keys()).sort();
+
+ const paths = [];
+
+ const sortedYears = Array.from(archive.years).sort();
+
+ const lastYear = Math.max(...sortedYears.map(Number));
+ paths.push({
+ params: { year: undefined },
+ props: {
+ posts: posts.filter((p) =>
+ p.data.dateCreated.getFullYear() === lastYear
+ ),
+ next: undefined,
+ previous: sortedYears?.[sortedYears.length - 2],
+ years: sortedYears,
+ months: Array.from(archive.monthsByYear.get(lastYear.toString()) ?? []),
+ },
+ });
+
+ for (const y of sortedYears) {
+ const yearPosts = posts.filter((p) =>
+ p.data.dateCreated.getFullYear() === Number(y)
+ );
+ const idx = sortedYears.indexOf(y);
+ paths.push({
+ params: { year: y },
+ props: {
+ posts: yearPosts,
+ next: sortedYears?.[idx + 1],
+ previous: sortedYears?.[idx - 1],
+ years: sortedYears,
+ months: Array.from(archive.monthsByYear.get(y.toString()) ?? []),
+ },
+ });
+ }
+
+ const allMonths = Array.from(archive.monthsByYear.entries())
+ .flatMap(([year, mset]) =>
+ Array.from(mset).map((m) => `${year}/${String(m).padStart(2, "0")}`)
+ )
+ .sort();
+
+ for (const [y, months] of archive.monthsByYear) {
+ const sortedMonths = Array.from(months).sort();
+ for (const m of sortedMonths) {
+ const monthPosts = posts.filter((p) => {
+ const d = p.data.dateCreated;
+ return (
+ d.getFullYear() === Number(y) &&
+ d.getMonth() + 1 === m
+ );
+ });
+
+ const ym = `${y}/${String(m).padStart(2, "0")}`;
+ const idx = allMonths.indexOf(ym);
+
+ paths.push({
+ params: { year: ym },
+ props: {
+ posts: monthPosts,
+ next: allMonths?.[idx + 1],
+ previous: allMonths?.[idx - 1],
+ years: sortedYears,
+ months: Array.from(months).sort(),
+ days: Array.from(archive.daysByMonth.get(ym) ?? []).sort(),
+ },
+ });
+ }
+ }
+
+ for (let i = 0; i < archive.sortedDates.length; i++) {
+ const ymd = archive.sortedDates[i];
+ const [y, m] = ymd.split("/");
+ paths.push({
+ params: { year: ymd },
+ props: {
+ posts: archive.postsByDate.get(ymd),
+ next: archive.sortedDates?.[i + 1],
+ previous: archive.sortedDates?.[i - 1],
+ years: sortedYears,
+ months: Array.from(archive.monthsByYear.get(y) ?? []).sort(),
+ days: Array.from(archive.daysByMonth.get(`${y}/${m}`) ?? []).sort(),
+ },
+ });
+ }
+
+ return paths;
+}
+
+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()
+);
+const date = posts[0].data.dateCreated as Date;
+---
+
+<Base {title} {description}>
+ <main>
+ <h2>Blogue</h2>
+ {date && <DateSelector {date} {years} {months} {days} />}
+ {posts.map((post) => <BlogCard {...post} />)}
+ <div>
+ {previous && <a href={`/blog/${Astro.props.previous}`}>Previous</a>}
+ {next && <a href={`/blog/${Astro.props.next}`}>Next</a>}
+ </div>
+ </main>
+</Base>
diff --git a/src/pages/blog/keywords/[...slug].astro b/src/pages/blog/keywords/[...slug].astro
new file mode 100644
index 0000000..724e8b7
--- /dev/null
+++ b/src/pages/blog/keywords/[...slug].astro
@@ -0,0 +1,40 @@
+---
+import { type CollectionEntry, getCollection } from "astro:content";
+import Base from "@layouts/Base.astro";
+import BlogCard from "@components/BlogCard.astro";
+
+type Props = { posts: CollectionEntry<"blog">[] };
+
+export async function getStaticPaths() {
+ const posts = await getCollection("blog");
+ const keywords = [
+ ...new Set(
+ await getCollection("blog").then((x) =>
+ x.flatMap((x) => x.data.keywords)
+ ),
+ ).values(),
+ ];
+ return keywords.map((k) => ({
+ params: { slug: k },
+ props: {
+ posts: posts.filter((post) =>
+ post.data.keywords.some((i) => i.localeCompare(k) === 0)
+ ),
+ },
+ }));
+}
+
+const title = "Blog";
+const description = "Latest articles.";
+
+const posts = Astro.props.posts.sort((a, b) =>
+ new Date(b.data.dateCreated).valueOf() -
+ new Date(a.data.dateCreated).valueOf()
+);
+---
+
+<Base {title} {description}>
+ <main>
+ <h2>Blogue</h2> {posts.map((post) => <BlogCard {...post} />)}
+ </main>
+</Base>
diff --git a/src/pages/blog/keywords/index.astro b/src/pages/blog/keywords/index.astro
new file mode 100644
index 0000000..255fbf4
--- /dev/null
+++ b/src/pages/blog/keywords/index.astro
@@ -0,0 +1,21 @@
+---
+import { getCollection } from "astro:content";
+import Base from "@layouts/Base.astro";
+
+const title = "Keywords";
+const description = "Keywords";
+
+const blogs = await getCollection("blog");
+let keywords = [
+ ...new Set([
+ ...blogs.flatMap(({ data }) => [...(data.keywords ?? [])]),
+ ]),
+];
+---
+
+<Base {title} {description} {keywords}>
+ <h1>Keywords</h1>
+ <ul>
+ {keywords.map((k) => <li><a href={`/blog/keywords/${k}`}>{k}</a></li>)}
+ </ul>
+</Base>
diff --git a/src/pages/blog/read/[...slug].astro b/src/pages/blog/read/[...slug].astro
new file mode 100644
index 0000000..05d68e8
--- /dev/null
+++ b/src/pages/blog/read/[...slug].astro
@@ -0,0 +1,333 @@
+---
+import { type CollectionEntry, getCollection } from "astro:content";
+import { render } from "astro:content";
+import BaseHead from "@components/BaseHead.astro";
+import Translations from "@components/Translations.astro";
+import { toIso8601Full } from "@utils/datetime";
+import ReadingTime from "@components/ReadingTime.astro";
+import Keywords from "@components/Keywords.astro";
+import Citations from "@components/Citations.astro";
+import Signature from "@components/signature/Signature.astro";
+import CopyrightNotice from "@components/CopyrightNotice.astro";
+import { getEntries } from "astro:content";
+import { verifier as verifierPrototype } from "@lib/pgp/verify";
+import { defined, get } from "@utils/anonymous";
+import Authors from "@components/signature/Authors.astro";
+import { getEntry } from "astro:content";
+
+export async function getStaticPaths() {
+ const posts = await getCollection("blog");
+ return posts.map((post) => ({
+ params: { slug: post.id },
+ props: post,
+ }));
+}
+
+type Props = CollectionEntry<"blog">;
+
+const post = Astro.props;
+
+if (defined(post.data.translationOf)) {
+ const original = await getEntry(
+ post.data.translationOf as CollectionEntry<"blog">,
+ );
+
+ if (!original) {
+ throw new Error(`Original post not found for ${post.id}`);
+ }
+
+ const originalAuthor = (original.data.signers ?? []).filter(
+ (s) => s.role === "author",
+ ).map((s) => s.entity.id)?.[0];
+ const originalCoAuthors = new Set(
+ (original.data.signer ?? []).filter(
+ (s) => s.role === "co-author",
+ ).map((s) => s.entity.id),
+ );
+ const translationAuthor = (post.data.signer ?? []).filter(
+ (s) => s.role === "author",
+ ).map((s) => s.entity.id)?.[0];
+ const translationCoAuthors = new Set(
+ (post.data.signer ?? []).filter(
+ (s) => s.role === "co-author",
+ ).map((s) => s.entity.id),
+ );
+
+ if (
+ (translationAuthor !== undefined &&
+ translationAuthor !== originalAuthor) ||
+ !translationCoAuthors.isSubsetOf(originalCoAuthors)
+ ) {
+ throw new Error(
+ `Post ${post.id} has mismatched (co-)authors from original post ${original.id}`,
+ );
+ }
+
+ const translators = (post.data.signer ?? []).filter(
+ (s) => s.role === "translator",
+ ).map((s) => s.entity.id);
+
+ for (const translator of translators) {
+ if (
+ originalAuthor === translator || originalCoAuthors.has(translator)
+ ) {
+ throw new Error(
+ `Translator ${translator} in ${post.id} is already a (co-)author in original post`,
+ );
+ }
+ }
+} else {
+ if (post.data.signer?.some((x) => x.role === "translator")) {
+ throw new Error(
+ `Post ${post.id} is not a translation but has translators defined`,
+ );
+ }
+}
+
+// Add own post as a translation
+const translationsSet = new Set(
+ (await getCollection(
+ "blog",
+ (x) =>
+ x.data.translationOf?.id ===
+ (post.data.translationOf !== undefined
+ ? post.data.translationOf.id
+ : post.id),
+ ) ?? []).map(({ id }) => id),
+);
+
+translationsSet.add(post.id);
+const translations = [...translationsSet.values()].map((id) => ({
+ collection: post.collection,
+ id,
+}));
+
+const signers = await 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 verifier = await verifierPrototype.then((x) => x.clone());
+
+// Add signers public keys to keyring
+for (const { data } of signers.map(get("entity"))) {
+ if (data.publickey.armor !== undefined) {
+ verifier.addKeyFromArmor(data.publickey.armor);
+ }
+}
+
+const verification = post.filePath !== undefined
+ ? await verifier.verify([
+ new URL(`file://${Deno.cwd()}/${post.filePath}`),
+ ])
+ : undefined;
+
+const { Content } = await render(post);
+
+const { lang } = post.data;
+
+const commit = await verification?.commit;
+---
+
+<html lang="pt-PT">
+ <head>
+ <BaseHead title={post.data.title} description={post.data.description} />
+ </head>
+
+ <body>
+ <main>
+ <article
+ itemscope
+ itemtype="http://schema.org/BlogPosting"
+ itemid={Astro.url.href}
+ >
+ <Translations {translations} {lang} />
+ <hgroup>
+ <h1 itemprop="headline">{post.data.title}</h1>
+ {
+ post.data.subtitle && (
+ <p itemprop="alternativeHeadline" class="subtitle">
+ {post.data.subtitle}
+ </p>
+ )
+ }
+ </hgroup>
+ {
+ post.data.description && (
+ <section itemprop="abstract">
+ <h2>Resumo</h2>
+ {
+ post.data.description.split(new RegExp("\\s{2,}"))
+ .map((
+ x,
+ ) => (
+ <p>{x}</p>
+ ))
+ }
+ </section>
+ )
+ }
+ {verification && <Signature {lang} {verification} />}
+ <footer>
+ {
+ verification?.verifications &&
+ (
+ <Authors
+ verifications={verification.verifications}
+ expectedSigners={signers}
+ commitSignerKey={commit?.signature?.keyFingerPrint}
+ />
+ )
+ }
+ <dl>
+ <dt>Data de criação</dt>
+ <dd>
+ <time
+ itemprop="dateCreated"
+ datetime={toIso8601Full(post.data.dateCreated)}
+ >{
+ new Intl.DateTimeFormat([lang], {}).format(
+ post.data.dateCreated,
+ )
+ }</time>
+ </dd>
+ {
+ post.data.dateUpdated && (
+ <dt>Última atualização</dt><dd>
+ <time
+ itemprop="dateUpdated"
+ datetime={toIso8601Full(post.data.dateUpdated)}
+ >{
+ new Intl.DateTimeFormat([lang], {}).format(
+ post.data.dateUpdated,
+ )
+ }</time>
+ </dd>
+ )
+ }
+ {
+ post.data.locationCreated && (
+ <dt
+ itemprop="locationCreated"
+ itemscope
+ itemtype="https://schema.org/Place"
+ >
+ Local de criação
+ </dt><dd>
+ <span itemprop="name">{post.data.locationCreated}</span>
+ </dd>
+ )
+ }
+ </dl>
+ <ReadingTime body={post.body} {lang} />
+ </footer>
+ <hr />
+ <div itemprop="articleBody text"><Content /></div>
+ <hr />
+ <Keywords keywords={post.data.keywords} />
+ <Citations citations={post.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]}
+ title={post.data.title}
+ dateCreated={post.data.dateCreated}
+ license={post.data.license as License}
+ />
+ </article>
+ </main>
+ </body>
+</html>
+
+<script type="module" is:inline>
+ hashchange();
+
+ window.addEventListener("hashchange", hashchange);
+
+ document.addEventListener(
+ "click",
+ function (event) {
+ if (
+ event.target &&
+ event.target instanceof HTMLAnchorElement &&
+ event.target.href === location.href &&
+ location.hash.length > 1
+ ) {
+ requestIdleCallback(function () {
+ if (!event.defaultPrevented) {
+ hashchange();
+ }
+ });
+ }
+ },
+ false,
+ );
+
+ function hashchange() {
+ let hash;
+
+ try {
+ hash = decodeURIComponent(location.hash.slice(1)).toLowerCase();
+ } catch (e) {
+ return;
+ }
+
+ const name = "user-content-" + hash;
+ const target = document.getElementById(name) ||
+ document.getElementsByName(name)[0];
+
+ if (target) {
+ requestIdleCallback(function () {
+ target.scrollIntoView();
+ });
+ }
+ }
+</script>
+
+<style is:inline>
+ section[data-footnotes].footnotes {
+ word-wrap: break-word;
+ }
+</style>
+
+<style>
+ hgroup {
+ text-align: center;
+ }
+
+ .subtitle {
+ font-weight: lighter;
+ }
+
+ [itemprop~="articleBody"] {
+ line-height: 1.4;
+ font-size: 1.2em;
+ text-align: justify;
+
+ & h1,
+ & h2,
+ & h3 {
+ line-height: 1.2;
+ }
+ }
+
+ [itemprop="abstract"] {
+ margin-inline: 1em;
+ padding-block: 1em;
+ font-style: italic;
+ }
+
+ @media print {
+ body {
+ font-size: 1rem;
+ font-family: var(--ff-serif);
+ line-height: 1.62;
+ }
+ }
+</style>
diff --git a/src/pages/index.astro b/src/pages/index.astro
new file mode 100644
index 0000000..e1e97ef
--- /dev/null
+++ b/src/pages/index.astro
@@ -0,0 +1,28 @@
+---
+import Base from "@layouts/Base.astro";
+import { SITE_TITLE } from "src/consts";
+---
+
+<Base title={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;.
+ </blockquote>
+ <figcaption>
+ &mdash; Hugo Ch&aacute;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;
+ </p>
+ </figcaption>
+ </figure>
+ <p><em>Portugal <em>fez</em> diferente!</em></p>
+ </article>
+ </main>
+</Base>
diff --git a/src/pages/robots.txt.ts b/src/pages/robots.txt.ts
new file mode 100644
index 0000000..4edef8b
--- /dev/null
+++ b/src/pages/robots.txt.ts
@@ -0,0 +1,13 @@
+import type { APIRoute } from "astro";
+
+const getRobotsTxt = (sitemapURL: URL) => `
+User-agent: *
+Allow: /
+
+Sitemap: ${sitemapURL.href}
+`;
+
+export const GET: APIRoute = ({ site }) => {
+ 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
new file mode 100644
index 0000000..de5685b
--- /dev/null
+++ b/src/pages/rss.xml.js
@@ -0,0 +1,16 @@
+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}/`,
+ })),
+ });
+}