The notes pipeline has no CMS, no admin panel, and no database. A post is a markdown file. A Vite plugin reads it, validates the frontmatter, and either prerendered HTML comes out the other end or the build fails loudly. That is the whole system.
Where Files Live
Every note is a single file at site/content/notes/<slug>.md. The slug is the URL. A file called my-finding.md becomes https://dil.dev/notes/my-finding/. Nothing maps slugs to routes in a config file. The filesystem is the router.
The build logic lives in site/lib/notes.mjs, verified against the actual source on 2026-09-25. It is the single source of truth for every output that references notes: the sitemap, the RSS feed at /notes/rss.xml, llms.txt, and the per-post markdown copies that tools like LLM crawlers can fetch directly.
Frontmatter Is a Contract
The frontmatter is not advisory. The Vite plugin treats it as a typed contract and breaks the build if the contract is violated. Required fields:
- title: plain string, shown in the page title and og:title.
- date: ISO date string, used for sorting and RSS pubDate.
- status: either
draftorlive, nothing else. - tags: array, two to five short strings.
- summary: string, hard cap at 200 characters, used in meta description and RSS.
- imageAlt: describes the featured image for screen readers and structured data.
- takeaways: array, three to five complete-claim bullets.
- faq: array of
{q, a}objects, minimum three items.
Optional fields are updated, imageCaption. That is the full list of permitted keys.
The Field the Build Actively Rejects
Reading time is not a field here. Not optional, not deprecated. If readingTime or reading_time appears in frontmatter, the build rejects the post. The system sets the featured image path; the post author supplies imageAlt and optionally imageCaption, but never the image path itself.
This matters because it removes a whole category of stale metadata. Reading time estimates drift as posts are edited. Removing the field entirely means it cannot go wrong.
Draft Behaviour
Status draft means the post is invisible in production. The production build filters it out before any rendering happens. It does not appear in the sitemap, RSS, or any index page.
In two situations drafts do render:
- Local dev: running the dev server shows drafts alongside live posts.
NOTES_DRAFTS=1preview: setting that environment variable on a preview deploy also surfaces drafts.
In both cases the post gets a visible DRAFT badge and a noindex meta tag. It will not appear in search results even if someone shares the URL. The badge is a reminder, not a gate.
What a Live Post Must Satisfy
Flipping status to live is not enough on its own. The build runs a checklist against every live post and fails if any item is missing:
- Featured image: the system must be able to resolve one. No image, no build.
- Minimum three FAQ items: the
faqarray needs at least three{q, a}pairs. - No unresolved placeholders: any unfilled placeholder string causes a build failure. This is the mechanism that prevents half-finished notes from shipping.
- A
## What I'd do differentlysection: the exact heading text must appear in the body. The build checks for it literally.
The em dash rule is enforced by convention rather than the build, but it is in the authoring spec: no em dash, no en dash anywhere in the file.
Outputs Generated from the Same List
Once the live-posts list is assembled by site/lib/notes.mjs, several outputs are generated from it in one pass:
- Sitemap: every live post URL included with its date.
- RSS at
/notes/rss.xml: full feed, live posts only. - llms.txt: a plain-text index for LLM tools that crawl for context.
- Per-post markdown copies: raw markdown available at a predictable path, useful for tools that want the source rather than the rendered HTML.
Drafts appear in none of these. The filter is applied once, upstream, and everything downstream inherits it.
Email Sign-ups
There is a subscription form on the notes index. It submits to the notes-subscribe Netlify Form. That is where it stops. Nothing is sent automatically when a post goes live. Sending is a deliberate manual step, which means a post can be live and indexed before any email goes out. That is a feature, not an oversight: it gives time to catch errors after the build but before notifying anyone.
JSON-LD and Structured Data
Each live post gets JSON-LD structured data generated from the same frontmatter fields. imageAlt feeds into the image object. summary becomes the description. takeaways and faq map to FAQ structured data, which is part of why the minimum item count is enforced at build time rather than left to the author's discretion. A FAQ schema with two items is technically valid but practically useless for rich results.
If you are thinking about the SEO angle, the every URL on the apex host note covers the broader URL and canonicalisation decisions that sit alongside this pipeline.
What I'd do differently
The placeholder check is a string scan, which means a placeholder inside a code block would also fail the build. That has not caused a real problem yet, but it is a latent footgun. A smarter check would exclude fenced code blocks from the scan.
The email step being fully manual is fine for now, but as the post volume grows it will become a friction point. A Netlify Function triggered by a deploy-succeeded webhook, gated on a post's status being live and its date matching today, would remove the manual step without losing the deliberate delay. That is the obvious next build.
The draft badge is visual only. A more robust approach would be a middleware check that returns a 404 for draft URLs unless the preview flag is set, so draft URLs cannot be accidentally shared and indexed via a misconfigured preview.




