feat(writing): markdown-backed writing, and the first piece — a quote collection - #52
Merged
Merged
Conversation
…ce is a quote collection
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/<slug>.
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) <noreply@anthropic.com>
…h markdown
Three things the owner found on the first real look at the page, and the first two had one cause.
── THE PROSE STYLE WAS NEVER APPLIED AT ALL. "no space between A running collection... between sometimes..."
and "i cant even read sometimes... to be a quote."
Both are the same bug: the style block was SCOPED. Astro rewrites every selector in a scoped block with a
[data-astro-cid-…] attribute, and the elements <Content /> emits are generated from markdown at build time, so
they carry no such attribute. `.prose blockquote` compiled to `.prose[cid] blockquote[cid]` and matched
nothing. Every rule — the spacing, the quote treatment, the measure — was dead CSS, and what the owner was
looking at was the browser's default <blockquote>: no margin worth seeing, no distinguishing type.
The repo's guide warns that this attribute changes SPECIFICITY. Worth writing down that it also changes what a
selector can REACH: markdown output is outside the component's scope entirely, so no amount of specificity
would have saved it. The block is now is:global, with every rule nested under .prose so it cannot leak.
Verified in the built bundle rather than by eye: the CSS ships as `.prose blockquote{…}` with zero
`prose[data-astro-cid` occurrences.
── A QUOTE NOW LOOKS LIKE A QUOTE. Even with the CSS live, the old treatment was a size bump and a left rule,
which is what a CALLOUT looks like. This is a pull-quote: a hanging opening mark in ochre doing the announcing,
the words in the display serif large enough to be the loudest thing in the column, and the attribution in a
separate register beneath — mono, small, em-dashed — so the eye never has to work out where the quote stops and
the source starts. The mark hangs in the left margin so the quote's first line stays flush with the column, and
it comes inside the text block below 640px where there is no margin to hang into.
The attribution is styled by POSITION (`blockquote + p`), not by a class, so the markdown stays a plain .md
with nothing to remember and still renders correctly anywhere else.
── "QUOTES" BECOMES "MISCELLANEOUS". The owner: "having only one quote doc under quotes is weird." 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 will hold the reading log and talk
notes later without being renamed. If quotes ever outgrow it, promoting them to their own kind is a data edit.
The schema enum, the taxonomy key, the file's frontmatter and the drift test moved together — which is exactly
what that test exists to force.
Build clean, 819 tests across 46 files green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The owner, pointing at the source line under the Carney quote: "i want the Mark Carney xxx to the right." It is the convention for an attribution under a pull-quote, and it earns the place for a reason worth stating: the eye leaves a quote at its last line, bottom-right. Putting the source there means it is already where the reader arrives, instead of back at the left margin — where, set flush left in a new paragraph, it reads like the start of the next thought rather than the end of this one. The em dash was doing that job alone and losing. `margin-left: auto` on a max-width box is what moves it; text-align on its own would right-align the words inside a box still sitting at the left. The 2.6ch padding that used to align it under the hanging quote mark goes with the move. On a phone the 52ch cap is wider than the column, so the cap is dropped and the right alignment carries it — which is the part that keeps it reading as a source line at any width. Build clean, 819 tests green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…en it started
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
growing — and `date` alone can only carry the first.
An optional `updated` in the frontmatter, shown on the index and in the piece's kicker.
THREE DECISIONS WORTH STATING, because each had a tempting wrong answer:
· 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 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
it when a piece gains a quote or a paragraph; leave it alone for a CSS change.
· THE INDEX STILL ORDERS BY PUBLICATION DATE, on the owner's choice, so `sorted()` is untouched. Ordering by
last-touched reads like a blog and lets a one-word fix jump an old piece above a new one — the page would
reshuffle for reasons a reader cannot see. Order is stable; the "updated" line carries the news instead.
· RENDERED ONLY WHEN IT DIFFERS from `date`. Printing the same date twice is noise, and the schema allows
them to be equal.
A GUARD: an `updated` earlier than `date` fails the build ("updated must not be earlier than date"). That is a
typo, not a fact, and it would otherwise render as a piece edited before it existed.
NO `updated` ON THE QUOTES FILE YET, deliberately. Its body has not gained a quote since it was written — only
formatting and CSS have changed — and an `updated` that tracks stylesheet fiddling is exactly the noise the
hand-set rule exists to avoid. It goes in with the next quote.
VERIFIED BY EXERCISING ALL THREE PATHS rather than by reading the diff, because the last round shipped display
code that never applied and I do not intend to repeat it. Temporarily setting the field and rebuilding:
`updated: 2026-08-31` renders on BOTH the index and the piece as `updated <time datetime="2026-08-31">31 August
2026`; `updated: 2026-08-15` (equal to date) renders nothing on either; `updated: 2026-08-01` (before date)
fails the build with the intended message. The file was then restored — it carries no `updated`.
Build clean, 819 tests green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 /writing is markdown-backed now, and the first piece is a quote collection. Writing a post is no longer a code change — drop a
.mdintosrc/content/writing/, give it frontmatter, and it appears on /writing under its section with its own page at/writing/<slug>.The split
data/writing.tskeeps only the taxonomy — what a section is, what belongs in it, what to say while it's empty — because that's site voice, not content. The pieces are files.buildKinds()is the single seam between them, and it returns the sameWritingKindshape the page andlib/pageStopsalready consumed, so the side rail, the sorting, the section ids and all their existing tests are untouched. Nothing downstream had to learn that content moved.An Astro content collection, not
import.meta.globThe repo already globs images that way, so the alternative was familiar. The schema is the reason: frontmatter is validated at build time, so a misspelled
kind:or a missingdate:failsnpm run buildand names the file. The glob alternative renders a broken row instead. CI is the merge gate here, so that's the difference between catching it and shipping it.It earned its keep on the first build: YAML parses an unquoted
2026-08-15as a Date object, not a string, soz.string()rejected it. Requiring quotes would have worked and been a permanent trap — the error only appears at build time and reports a type mismatch rather than the missing quotes. The field now accepts both and normalises toYYYY-MM-DD, so either spelling is correct and downstream always gets one shape.draft: truekeeps unfinished work out of the build, filtered once at the source rather than in the template, so the rail, the count and the sections can't disagree about what exists.Three things the owner caught, and two had one cause
The prose styles never applied at all. No spacing between paragraphs, and the quote didn't read as a quote. Both were the same bug: the style block was scoped, and Astro rewrites scoped selectors with a
[data-astro-cid-…]attribute — but the elements<Content />emits are generated from markdown at build time and carry no such attribute..prose blockquotecompiled to.prose[cid] blockquote[cid]and matched nothing. Every rule was dead CSS; what was on screen was the browser's default<blockquote>.The repo's guide warns that this attribute changes specificity. Worth recording that it also changes what a selector can reach — markdown output is outside the component's scope entirely, so no amount of specificity would have saved it. Now
is:global, with every rule nested under.proseso it can't leak. Verified in the built bundle rather than by eye: ships as.prose blockquote{…}with zeroprose[data-astro-cidoccurrences.A quote now looks like a quote. Even with the CSS live, the first treatment was a size bump and a left rule — which is what a callout looks like. It's a pull-quote now: a hanging opening mark in ochre doing the announcing, the words in the display serif as the loudest thing in the column, and the source in a separate register beneath — mono, small, em-dashed, set to the column's right edge where the eye already arrives after the quote's last line. The attribution is styled by position (
blockquote + p), never by a class, so the file stays a plain.mdwith nothing to remember and still renders correctly anywhere else."Quotes" became "Miscellaneous." Owner: "having only one quote doc under quotes is weird." Right, and the fault is scale rather than taxonomy — a section named for a genre holding one file reads as an unfinished shelf. Named for the leftovers it holds one honestly, and will take the reading log and talk notes later without a rename. If quotes outgrow it, promoting them is a data edit. The schema enum, the taxonomy key, the frontmatter and the drift test all moved together, which is what that test exists to force.
A test I did not weaken
tests/writing.test.tsrequires every section's empty-state line to exceed 40 characters — the rule that an empty section must name what's coming rather than say "coming soon". My first draft for the new section was literally "Nothing here yet.", which is exactly the filler that rule exists to catch. The copy changed, not the test.New specs
tests/writingContent.test.ts(6 cases) covers the new seam, including a drift guard that parses the schema'skindenum out ofcontent.config.tsand 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 citation has no 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; Canadian Press: "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: this session has no web search tool, and both sources carrying 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 words are the owner's transcription with "all of a sudden" and "house in order" set in their canonical forms, and they want checking against the audio before this is treated as settled.
Gates
npm run buildclean; 819 tests across 46 files green (was 813/45). No browser pass — the owner asked for no more headless browsers after an earlier fleet took their machine down — so layout was checked by reading the built CSS and HTML, and by the owner's own eye on the served build.🤖 Generated with Claude Code