diff options
author | João Augusto Costa Branco Marado Torres <torres.dev@disroot.org> | 2025-06-24 12:08:41 -0300 |
---|---|---|
committer | João Augusto Costa Branco Marado Torres <torres.dev@disroot.org> | 2025-06-24 12:50:43 -0300 |
commit | f9a77c5c27aede4e5978eb55d9b7af781b680a1d (patch) | |
tree | d545e325ba1ae756fc2eac66fac1001b6753c40d /src/pages/blog |
feat!: initial commit
Signed-off-by: João Augusto Costa Branco Marado Torres <torres.dev@disroot.org>
Diffstat (limited to 'src/pages/blog')
-rw-r--r-- | src/pages/blog/[...year].astro | 165 | ||||
-rw-r--r-- | src/pages/blog/keywords/[...slug].astro | 40 | ||||
-rw-r--r-- | src/pages/blog/keywords/index.astro | 21 | ||||
-rw-r--r-- | src/pages/blog/read/[...slug].astro | 333 |
4 files changed, 559 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> |