Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions src/content.config.ts
Original file line number Diff line number Diff line change
@@ -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/<slug>. 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 };
13 changes: 13 additions & 0 deletions src/content/writing/favourite-quotes.md
Original file line number Diff line number Diff line change
@@ -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.
48 changes: 48 additions & 0 deletions src/data/writing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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));
Expand Down
32 changes: 28 additions & 4 deletions src/pages/writing.astro
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand Down Expand Up @@ -82,7 +101,7 @@ const longDate = (iso: string) =>
)}

<div class="kind-list" hidden={total === 0}>
{KINDS.map((kind) => (
{kinds.map((kind) => (
<section class="kind" id={kindSectionId(kind.key)} aria-labelledby={`${kindSectionId(kind.key)}-h`}>
<header class="kind-head">
<h2 id={`${kindSectionId(kind.key)}-h`} class="kind-title">{kind.label}</h2>
Expand All @@ -99,6 +118,9 @@ const longDate = (iso: string) =>
<li class="piece" id={entrySectionId(kind.key, e.slug)}>
<p class="piece-meta">
<time datetime={e.date}>{longDate(e.date)}</time>
{e.updated && e.updated !== e.date && (
<span class="piece-upd">updated <time datetime={e.updated}>{longDate(e.updated)}</time></span>
)}
{e.minutes && <span class="piece-mins">{e.minutes} min</span>}
</p>
<h3 class="piece-title">
Expand Down Expand Up @@ -186,6 +208,8 @@ const longDate = (iso: string) =>
color: var(--ink-4); margin: 0 0 6px;
}
.piece-mins::before { content: '·'; margin-right: 12px; }
/* Same register as the reading time: a fact about the piece, not part of its title. */
.piece-upd::before { content: '·'; margin-right: 12px; }
.piece-title {
font-family: var(--font-display); font-weight: 500; font-size: clamp(17px, 1.7vw, 21px);
line-height: 1.25; color: var(--ink-1); margin: 0;
Expand Down
Loading
Loading