import { existsSync } from "node:fs";
import { getCollection, type CollectionEntry } from "astro:content";
export type Post = CollectionEntry<"posts">;
const BASE = import.meta.env.BASE_URL.replace(/\/$/, "");
export const withBase = (path: string): string => `${BASE}${path}`;
// drafts show up in `astro dev` and nowhere else
export const getPosts = async (): Promise<Post[]> => {
const posts = await getCollection("posts", (post) => import.meta.env.DEV || !post.data.draft);
return posts.sort((a, b) => b.data.date.getTime() - a.data.date.getTime());
};
// "5 October 2026", set in capitals by .dateline
const dateFormat = new Intl.DateTimeFormat("en-GB", {
year: "numeric",
month: "long",
day: "numeric",
timeZone: "UTC",
});
export const formatDate = (date: Date): string => dateFormat.format(date);
export const profileUrl = (handle: string): string => `https://tangled.org/${handle}`;
// fetched once from bluesky's public api into public/avatars: the build is
// offline and tangled's avatar service needs signed urls
export const avatarSrc = (handle: string): string | undefined =>
existsSync(`public/avatars/${handle}.jpg`) ? withBase(`/avatars/${handle}.jpg`) : undefined;
export const byline = ({ data }: Post): string => data.authors.map((author) => author.name).join(", ");
// pages build to <slug>/index.html, and cloudflare redirects /<slug> to /<slug>/, so
// links and canonical urls take the slash (the feed keeps its old ids, without it)
export const postPath = ({ data }: Post): string => withBase(`/${data.slug}/`);
// a post as plain markdown, for /<slug>.md and llms-full.txt: the source body, with
// root-relative links made absolute so they still resolve once the text is lifted
// out of the site. an .mdx post's imports and component lines (its diagrams) have
// no markdown form, so they're left out; an inline component that only dresses up
// some text (<Ripple text="…" />) is left as that text
const MDX_ONLY = /^(import|export) .*$|^<[A-Z][\w.]*\b[^>]*\/>$/gm;
const MDX_TEXT = /<[A-Z][\w.]*\s+text="([^"]*)"\s*\/>/g;
export const postMarkdown = (post: Post, site: URL): string => {
const { title, subtitle, date } = post.data;
const body = (post.body ?? "")
.replace(MDX_ONLY, "")
.replace(MDX_TEXT, "$1")
.replace(/\n{3,}/g, "\n\n")
.replace(/\]\(\//g, `](${new URL("/", site).href}`);
const lede = subtitle ? `\n> ${subtitle}\n` : "";
return `# ${title}\n${lede}\n${byline(post)} · ${formatDate(date)} · ${new URL(postPath(post), site).href}\n\n${body.trim()}\n`;
};
// the standard.site records `blog publish` (cmd/blog, blog/standard.go) keeps on
// the tangled.org pds; the pages link to them for clients that verify the site
const REPO_DID = "did:plc:wshs7t2adsemcrrd4snkeqli";
export const PUBLICATION_URI = `at://${REPO_DID}/site.standard.publication/3myiuoozs2222`;
export const documentUri = ({ data }: Post): string | undefined =>
data.rkey && `at://${REPO_DID}/site.standard.document/${data.rkey}`;
const publisher = {
"@type": "Organization",
name: "Tangled",
url: "https://tangled.org",
logo: "https://tangled.org/blog/logos/dolly.png",
};
export const postAuthors = ({ data }: Post) =>
data.authors.map((author) => ({ name: author.name, url: profileUrl(author.handle) }));
// schema.org BlogPosting, for the post page's json-ld and the index's blogPost list
export const postLd = (post: Post, site: URL) => {
const { title, subtitle, date, image } = post.data;
const url = new URL(postPath(post), site).href;
return {
"@type": "BlogPosting",
headline: title,
description: subtitle || undefined,
datePublished: date.toISOString(),
url,
mainEntityOfPage: url,
image,
author: postAuthors(post).map((author) => ({ "@type": "Person", ...author })),
publisher,
};
};