From 75f20946685f9c977f8308eea1baf52117e2b9ad Mon Sep 17 00:00:00 2001 From: Zeying Tian Date: Sun, 30 Aug 2026 18:23:30 -0400 Subject: [PATCH 1/4] feat(writing): the writing section is markdown now, and the first piece is a quote collection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/ and it appears on /writing under its kind with its own page at /writing/. THE SPLIT. data/writing.ts keeps only the TAXONOMY — what a kind is, what belongs in it, and what to say while it is empty — because that is site voice, not content. The pieces are markdown. buildKinds() is the single seam between the two, and it returns the same WritingKind shape the page and lib/pageStops already consume, so the rail, the sorting, the section ids and all of their existing tests are untouched. Nothing downstream had to learn that content moved. AN ASTRO CONTENT COLLECTION, not the import.meta.glob this repo already uses for gallery images, and the reason is the schema: frontmatter is validated at BUILD time, so a misspelled `kind:` or a missing `date:` fails `npm run build` and names the file. The glob alternative renders a broken row instead. CI is the merge gate here, so that is the difference between catching it and shipping it. It earned its keep immediately — see the date note below. A FOURTH KIND, "Quotes", which data/writing.ts had already anticipated ("a fourth kind (talks, teaching, a reading log) is a data edit"). A commonplace book is not notes, essays or explainers; filing it under one of those would have made that section's gloss untrue. THE PROSE STYLE IS WHERE THE DESIGN WENT, and blockquotes are the part that had to be right, because a quote collection in markdown is a run of `>` blocks each followed by its attribution. The quote sets in the display serif with an ochre rule; the paragraph IMMEDIATELY AFTER a quote is styled as its source line — small, mono, quiet — via `blockquote + p`, so the file stays a plain .md carrying no classes and still renders correctly anywhere else. Written once for every piece that will ever exist, not per file. TWO THINGS THE SCHEMA TAUGHT ME, both worth keeping: · YAML PARSES AN UNQUOTED `2026-08-15` AS A DATE OBJECT, not a string, so the first build failed on `z.string()`. Requiring quotes would work and would be a trap for every future piece — the error appears only at build time and reports a type mismatch rather than the missing quotes. The field now accepts both and normalises to YYYY-MM-DD, so either spelling is correct and downstream always gets one shape. · `draft: true` keeps unfinished work out of the build, filtered once at the source rather than in the template, so the rail, the count and the sections cannot disagree about what exists. A TEST I DID NOT WEAKEN: tests/writing.test.ts requires every kind's `empty` line to exceed 40 characters — the rule that an empty section must NAME what is coming rather than say "coming soon". My first draft for Quotes was "Nothing here yet.", which is exactly the filler that rule exists to catch. The copy changed, not the test. The new seam gets its own spec (tests/writingContent.test.ts, 6 cases), including a drift guard that parses the schema's kind enum out of content.config.ts and asserts it matches the taxonomy's keys. Both failure modes it covers are SILENT: a piece whose kind no section declares never renders, and a mis-grouped entry appears under the wrong heading. Neither throws. THE CARNEY ENTRY SHIPS WITHOUT A SOURCE LINK, deliberately. The moment is corroborated three ways — his statement and Q&A on the collapsed Canada-U.S. trade negotiations, 22 August 2026 (NYT that day: "Carney Slams U.S.-Canada Trade Proposal and Vows Retaliation", described as a Saturday morning address; CP: "PM Carney explains why Canada left the U.S. negotiating table"; and an American Rhetoric transcript of the Q&A) — but I could not retrieve the transcript text to confirm the wording, because this session has no web search tool and both sources that carry it refused the fetcher. So the citation names the event and stops there. On a page whose entire value is exactness, a plausible-looking URL nobody checked is the one unrecoverable mistake. The wording is the owner's transcription with "all of a sudden" and "house in order" set in their canonical forms; it wants his confirmation against the video. Build clean, 819 tests across 46 files green (was 813/45). Co-Authored-By: Claude Opus 5 (1M context) --- src/content.config.ts | 55 +++++++++ src/content/writing/favourite-quotes.md | 13 ++ src/data/writing.ts | 39 ++++++ src/pages/writing.astro | 26 +++- src/pages/writing/[...slug].astro | 156 ++++++++++++++++++++++++ tests/writingContent.test.ts | 70 +++++++++++ 6 files changed, 355 insertions(+), 4 deletions(-) create mode 100644 src/content.config.ts create mode 100644 src/content/writing/favourite-quotes.md create mode 100644 src/pages/writing/[...slug].astro create mode 100644 tests/writingContent.test.ts diff --git a/src/content.config.ts b/src/content.config.ts new file mode 100644 index 0000000..2475293 --- /dev/null +++ b/src/content.config.ts @@ -0,0 +1,55 @@ +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', 'quotes']), + /** 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(), + }), +}); + +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..41ea226 --- /dev/null +++ b/src/content/writing/favourite-quotes.md @@ -0,0 +1,13 @@ +--- +title: Favourite quotes +date: 2026-08-15 +kind: quotes +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..1ad5790 100644 --- a/src/data/writing.ts +++ b/src/data/writing.ts @@ -88,8 +88,47 @@ 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 a lie. + */ + { + key: 'quotes', + label: 'Quotes', + railLabel: 'Quotes', + gloss: + 'A commonplace book: lines worth keeping, with their source and nothing else. Kept honest by exactness — a quote nobody can check is just a paraphrase with confidence.', + // 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..5a677a9 100644 --- a/src/pages/writing.astro +++ b/src/pages/writing.astro @@ -17,12 +17,30 @@ 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, + 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 +100,7 @@ const longDate = (iso: string) => )}