diff --git a/src/content.config.ts b/src/content.config.ts new file mode 100644 index 0000000..d61f5d8 --- /dev/null +++ b/src/content.config.ts @@ -0,0 +1,80 @@ +import { defineCollection, z } from 'astro:content'; +import { glob } from 'astro/loaders'; + +/** + * WRITING IS MARKDOWN NOW. The owner: "i want the writing to be flexible. so essentially writing.ts displays + * some markdown notes i have. My favorite quotes is just a markdown file." + * + * So a new piece is a FILE, not a code change: drop a .md into src/content/writing/, give it frontmatter, and + * it appears on /writing under its kind and gets its own page at /writing/. data/writing.ts keeps only + * the taxonomy — what the kinds ARE and what belongs in each — which is editorial copy rather than content. + * + * WHY A COLLECTION AND NOT import.meta.glob, which this repo already uses for the gallery's images: the schema + * below is validated at BUILD time, so a typo in `kind:` or a missing `date:` fails `npm run build` with the + * offending file named. The glob alternative would render a broken row instead. CI is the merge gate here, so + * the difference is between catching it and shipping it. + */ +const writing = defineCollection({ + loader: glob({ base: './src/content/writing', pattern: '**/*.md' }), + schema: z.object({ + title: z.string(), + /** + * ISO date, normalised to YYYY-MM-DD. + * + * ACCEPTS BOTH A STRING AND A DATE ON PURPOSE. YAML parses an unquoted `2026-08-15` as a Date OBJECT, not a + * string, so a plain z.string() rejects the most natural way to write a date — which is exactly how the + * first file failed to build. Requiring quotes would work and would be a trap for every future piece, since + * the error only appears at build time and names a type mismatch rather than the missing quotes. Coercing + * here means either spelling is correct and downstream code always gets the same string, which is what + * `datetime=` and the sort both want. + */ + date: z + .union([z.string(), z.date()]) + .transform((d) => (typeof d === 'string' ? d : d.toISOString().slice(0, 10))) + .refine((d) => /^\d{4}-\d{2}-\d{2}$/.test(d), { message: 'date must be YYYY-MM-DD' }), + /** + * Which section of /writing this belongs to. An ENUM rather than a free string, so a misspelled kind is a + * build error instead of a piece that silently belongs to no section and never renders. + * Must stay in step with the keys in data/writing.ts — there is a test asserting exactly that. + */ + kind: z.enum(['notes', 'essays', 'explainers', 'misc']), + /** + * When the piece last GAINED SOMETHING. Optional, and meant to stay optional. + * + * The owner: "we might need to keep track of date vs. last edited at because we will add more quotes to + * this doc later." A running collection has two different facts about it — when it started, and whether it + * is still being added to — and `date` alone can only carry the first. + * + * SET BY HAND, NOT DERIVED FROM GIT. The file's last commit date is available at build time and is the + * wrong number: a reformat, a typo fix or a rebase would each register as an edit, and CI's shallow clones + * make it unreliable besides. A date that moves on its own teaches a reader to distrust every date on the + * site. Set this when the piece gains a quote or a paragraph; leave it alone for a CSS change. + * Rendered only when it DIFFERS from `date`, so a piece written once shows one date rather than two + * identical ones. + */ + updated: z + .union([z.string(), z.date()]) + .transform((d) => (typeof d === 'string' ? d : d.toISOString().slice(0, 10))) + .refine((d) => /^\d{4}-\d{2}-\d{2}$/.test(d), { message: 'updated must be YYYY-MM-DD' }) + .optional(), + /** One or two sentences: what it argues, in plain language. Shown on the index, not on the piece. */ + blurb: z.string(), + /** Rough reading time. Omit rather than guess — the index only prints it when it is real. */ + minutes: z.number().int().positive().optional(), + /** Marks a piece worth leading with, if a kind ever holds many. */ + featured: z.boolean().optional(), + /** + * Unfinished work can be committed without appearing. Worth having from the first file rather than added + * later in a hurry: the alternative is keeping drafts out of git, which is where drafts go to die. + */ + draft: z.boolean().optional(), + }) + // An `updated` before `date` is a typo, not a fact, and it would render as a piece edited before it + // existed. Both fields are normalised to YYYY-MM-DD above, so a string compare is a date compare. + .refine((d) => !d.updated || d.updated >= d.date, { + message: 'updated must not be earlier than date', + path: ['updated'], + }), +}); + +export const collections = { writing }; diff --git a/src/content/writing/favourite-quotes.md b/src/content/writing/favourite-quotes.md new file mode 100644 index 0000000..841173a --- /dev/null +++ b/src/content/writing/favourite-quotes.md @@ -0,0 +1,13 @@ +--- +title: Favourite quotes +date: 2026-08-15 +kind: misc +blurb: Lines I keep coming back to, mostly about risk, regimes, and the gap between a model and the world it is supposed to describe. +--- + +A running collection. No commentary — if a line needs me to explain it, it has not earned its place here. + +> Sometimes markets ignore these fundamentals, and all of a sudden they focus on them. When they do, if you +> don't have your house in order, it's too late. + +**Mark Carney** — statement and Q&A on the collapsed Canada–U.S. trade negotiations, 22 August 2026. diff --git a/src/data/writing.ts b/src/data/writing.ts index c01b790..f28edf7 100644 --- a/src/data/writing.ts +++ b/src/data/writing.ts @@ -25,6 +25,9 @@ export interface WritingEntry { title: string; /** ISO date, so ordering is unambiguous and the page can format it as it likes. */ date: string; + /** ISO date the piece last GAINED something. Absent when it has not changed since publication; the pages + * render it only when it differs from `date`, so an untouched piece shows one date, not two identical. */ + updated?: string; /** One or two sentences: what it argues, in plain language. */ blurb: string; /** Rough reading time in minutes. Omitted when it would be a guess. */ @@ -88,8 +91,53 @@ export const KINDS: WritingKind[] = [ 'Nothing here yet. The homepage explainer is the prototype; these would be the full-length versions of its three slides.', entries: [], }, + /** + * The fourth kind, and the file above anticipated it: "a fourth kind (talks, teaching, a reading log) is a + * data edit". A quote collection is none of notes, essays or explainers — it is not an argument, it is a + * commonplace book — so filing it under one of those would have made that section's gloss untrue. + * + * MISCELLANEOUS, NOT "QUOTES", on the owner's call: "having only one quote doc under quotes is weird." He is + * right, and the fault is scale rather than taxonomy — a section whose name promises a genre and holds one + * file reads as an unfinished shelf. A section named for the leftovers holds one honestly, and holds the + * reading log and the talk notes later without being renamed. If quotes ever outgrow it, THAT is the moment + * to promote them to their own kind, which is a data edit. + */ + { + key: 'misc', + label: 'Miscellaneous', + railLabel: 'Misc', + gloss: + 'The shelf for things that are not papers, essays or explainers — quote collections, reading notes, whatever else is worth keeping. Kept honest by exactness: anything quoted here carries its source.', + // Long enough to name what belongs here, because a test enforces that — and the test is right. "Nothing + // here yet" was the first thing written in this slot and it is exactly the filler the rule exists to catch. + empty: + 'Nothing here yet. The first entries are lines about risk and regime change — what markets do when they stop ignoring the fundamentals — each kept with its source so it can be checked.', + entries: [], + }, ]; +/** + * Fill the taxonomy above with entries that came from markdown. + * + * THE SPLIT THIS ENFORCES: KINDS is editorial copy — what a section IS and what belongs in it — and stays in + * TypeScript because it is site voice, not content. The pieces themselves are files in src/content/writing/, + * so writing a new one is never a code change. This function is the only seam between the two. + * + * Returns the same WritingKind shape the page and lib/pageStops already consume, deliberately: nothing + * downstream needs to learn that content moved, so the rail, the sorting and their tests are untouched. + * Kinds with no files keep their `empty` copy and still render as a section, which is what makes an + * announced-but-unwritten section read as a plan rather than an omission. + */ +export function buildKinds( + entries: readonly (WritingEntry & { kind: string })[], + taxonomy: readonly WritingKind[] = KINDS, +): WritingKind[] { + return taxonomy.map((k) => ({ + ...k, + entries: entries.filter((e) => e.kind === k.key).map(({ kind: _kind, ...rest }) => rest), + })); +} + /** Entries of a kind, newest first. Sorting here rather than in the page keeps the page dumb. */ export function sorted(kind: WritingKind): WritingEntry[] { return [...kind.entries].sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : 0)); diff --git a/src/pages/writing.astro b/src/pages/writing.astro index ba7b224..50ccadf 100644 --- a/src/pages/writing.astro +++ b/src/pages/writing.astro @@ -17,12 +17,31 @@ import PlushCow from '../components/PlushCow.astro'; import FluidSky from '../components/proto/FluidSky.astro'; import SkyWash from '../components/SkyWash.astro'; import SideRail from '../components/SideRail.astro'; -import { KINDS, sorted, totalEntries, kindSectionId, entrySectionId } from '../data/writing'; +import { getCollection } from 'astro:content'; +import { KINDS, buildKinds, sorted, totalEntries, kindSectionId, entrySectionId } from '../data/writing'; import { COW_LINES_WRITING } from '../data/cowGlyph'; import { writingStops } from '../lib/pageStops'; -const stops = writingStops(KINDS); -const total = totalEntries(KINDS); +// THE PIECES COME FROM MARKDOWN; the kinds stay in data/writing.ts. Drafts are dropped here rather than +// filtered in the template, so nothing downstream — the rail, the count, the sections — can disagree about +// what exists. Sorting stays in sorted() where it already lives and is already tested. +const entries = (await getCollection('writing')) + .filter((e) => !e.data.draft) + .map((e) => ({ + slug: e.id, + title: e.data.title, + date: e.data.date, + updated: e.data.updated, + blurb: e.data.blurb, + minutes: e.data.minutes, + featured: e.data.featured, + kind: e.data.kind, + href: `/writing/${e.id}`, + })); + +const kinds = buildKinds(entries, KINDS); +const stops = writingStops(kinds); +const total = totalEntries(kinds); // Long-form dates, computed from the ISO string rather than typed twice. const longDate = (iso: string) => @@ -82,7 +101,7 @@ const longDate = (iso: string) => )}