From 8b39d1a4647255973033811982e4df0994ac7b97 Mon Sep 17 00:00:00 2001 From: Alper Gundogdu Date: Mon, 11 May 2026 15:52:25 +0100 Subject: [PATCH 1/9] fix(a11y): add header IDs to /blog routes Frame hard-codes aria-labelledby={`${id}-h`}, so each needs a matching

. The two routes added in #61 declared ids "blog" and "post" but never wired up the corresponding headers, so screen readers announced both sections as unnamed. Closes #65 --- app/blog/[slug]/page.tsx | 1 + app/blog/page.tsx | 1 + 2 files changed, 2 insertions(+) diff --git a/app/blog/[slug]/page.tsx b/app/blog/[slug]/page.tsx index 3efb645..a01805c 100644 --- a/app/blog/[slug]/page.tsx +++ b/app/blog/[slug]/page.tsx @@ -68,6 +68,7 @@ export default async function BlogPost({ {post.date}

diff --git a/app/blog/page.tsx b/app/blog/page.tsx index 805c118..debb5bf 100644 --- a/app/blog/page.tsx +++ b/app/blog/page.tsx @@ -48,6 +48,7 @@ export default function BlogIndex() {
field notes

From a8f7bf1371bc7f5202c4e71a24548d3692b63064 Mon Sep 17 00:00:00 2001 From: Alper Gundogdu Date: Mon, 11 May 2026 15:53:18 +0100 Subject: [PATCH 2/9] fix(blog): throw on missing content directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously a missing or mis-named content/blog/ silently fell back to an empty post list — the blog index rendered "nothing yet.", the sitemap omitted post URLs, and generateStaticParams returned []. CI passed; prod silently regressed. Throw with an actionable message instead; .gitkeep is the escape hatch for the legitimate empty-posts case. Closes #66 --- lib/blog.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/lib/blog.ts b/lib/blog.ts index c708b47..a50e43d 100644 --- a/lib/blog.ts +++ b/lib/blog.ts @@ -123,9 +123,15 @@ let cache: PostSource[] | undefined; const readEntries = (): PostSource[] => { if (cache) return cache; + // The directory's presence is part of the project's contract — every + // statically wired blog route depends on it. Falling back to `[]` + // would silently regress to "nothing yet." in prod. If you really + // want zero posts, leave a `.gitkeep` in `content/blog/`. if (!fs.existsSync(POSTS_DIR)) { - cache = []; - return cache; + throw new Error( + `[blog] posts directory not found at ${POSTS_DIR}. ` + + `If you intentionally have zero posts, create the directory with a .gitkeep.`, + ); } cache = fs .readdirSync(POSTS_DIR) From d82a219f085f0f0cc0a87a0128f37b736ad78822 Mon Sep 17 00:00:00 2001 From: Alper Gundogdu Date: Mon, 11 May 2026 15:53:40 +0100 Subject: [PATCH 3/9] fix(blog): validate slug in getPost Today output: "export" + dynamicParams = false means the framework only routes pre-validated slugs to this handler. parseSlug at the function boundary makes the invariant explicit instead of relying on transitive framework guarantees, so a future caller from an unguarded path (RSS feed, search, new route) can't regress it. Closes #67 --- lib/blog.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/blog.ts b/lib/blog.ts index a50e43d..995aa60 100644 --- a/lib/blog.ts +++ b/lib/blog.ts @@ -164,5 +164,7 @@ export function listPosts(): PostMeta[] { } export function getPost(slug: string): PostSource | null { - return readEntries().find((e) => e.slug === slug) ?? null; + const valid = parseSlug(slug); + if (valid === null) return null; + return readEntries().find((e) => e.slug === valid) ?? null; } From 636c2f35540f00c1e7702639f6d2d8ead04fb157 Mon Sep 17 00:00:00 2001 From: Alper Gundogdu Date: Mon, 11 May 2026 15:54:04 +0100 Subject: [PATCH 4/9] fix(sitemap): assert SITE_URL is XML-safe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SLUG_RE guards per-post slug interpolation into bodies. The origin had no equivalent guard — a future preview-URL accident introducing &, <, >, ", or ' would silently emit invalid XML. Closes #71 --- app/sitemap-pages.xml/route.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/app/sitemap-pages.xml/route.ts b/app/sitemap-pages.xml/route.ts index 0fe0b43..e583fd7 100644 --- a/app/sitemap-pages.xml/route.ts +++ b/app/sitemap-pages.xml/route.ts @@ -1,6 +1,16 @@ import { SITE_URL } from "@/lib/links"; import { listPosts } from "@/lib/blog"; +// SITE_URL is interpolated raw into bodies below. SLUG_RE guards +// the per-post slug; this guards the origin against a future accident +// (preview-URL with query string, embedded entity) that would emit +// invalid XML and have search engines silently reject the sitemap. +if (/[<>&"']/.test(SITE_URL)) { + throw new Error( + `[sitemap] SITE_URL contains XML-significant characters: ${SITE_URL}`, + ); +} + // Required by Next 16 under `output: "export"` for route handlers that emit // static files at build time. GET-only is the only verb supported in export. export const dynamic = "force-static"; From 2b2d525c25567a0305b910a26c7533423c9638aa Mon Sep 17 00:00:00 2001 From: Alper Gundogdu Date: Mon, 11 May 2026 15:54:58 +0100 Subject: [PATCH 5/9] refactor(blog): brand Slug + IsoDate via unique symbol; drop dead conditional MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Slug brand is now backed by a module-local `unique symbol`, so no external module can write `"foo" as Slug` — parseSlug is the only callable construction path. - IsoDate mirrors the pattern; parseEntry consumes it so PostMeta.date surfaces as a branded value to downstream consumers (RSS feed, archive page, etc.) without them re-testing ISO_DATE_RE. - {post.summary ?

…

: null} on the blog index is dead defensive code — summary is required + trimmed-non-empty at parse — so emit the

unconditionally. Closes #69 --- app/blog/page.tsx | 14 ++++++-------- lib/blog.ts | 23 +++++++++++++++++------ 2 files changed, 23 insertions(+), 14 deletions(-) diff --git a/app/blog/page.tsx b/app/blog/page.tsx index debb5bf..49635d1 100644 --- a/app/blog/page.tsx +++ b/app/blog/page.tsx @@ -95,14 +95,12 @@ export default function BlogIndex() { > {post.title}

- {post.summary ? ( -

- {post.summary} -

- ) : null} +

+ {post.summary} +

diff --git a/lib/blog.ts b/lib/blog.ts index 995aa60..a88a738 100644 --- a/lib/blog.ts +++ b/lib/blog.ts @@ -13,17 +13,28 @@ const SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/; // Branded slug: once a `Slug` is produced, callers can rely on -// SLUG_RE having held. The only constructor sites are this module's -// own `parseEntry` (throws on failure) and `parseSlug` (returns null). -export type Slug = string & { readonly __brand: "Slug" }; +// SLUG_RE having held. `unique symbol` makes the brand unfakeable +// across modules — no external caller can write `"foo" as Slug` +// because they can't reference this module-local symbol. +declare const __slug: unique symbol; +export type Slug = string & { readonly [__slug]: true }; export const parseSlug = (s: unknown): Slug | null => typeof s === "string" && SLUG_RE.test(s) ? (s as Slug) : null; +// IsoDate mirrors Slug — parseEntry validates against ISO_DATE_RE, and +// downstream consumers (RSS feed, archive page) can rely on the brand +// rather than re-testing the regex. +declare const __isoDate: unique symbol; +export type IsoDate = string & { readonly [__isoDate]: true }; + +const parseIsoDate = (s: string): IsoDate | null => + ISO_DATE_RE.test(s) ? (s as IsoDate) : null; + export type PostMeta = { slug: Slug; title: string; - date: string; + date: IsoDate; summary: string; bucket?: string; }; @@ -86,8 +97,8 @@ const parseEntry = (filename: string): PostSource | null => { } const title = data.title.trim(); - const date = formatDate(data.date); - if (!ISO_DATE_RE.test(date)) { + const date = parseIsoDate(formatDate(data.date)); + if (date === null) { throw new Error( data.date === undefined ? `[blog] ${filename}: frontmatter \`date\` is required` From bb3e2c8a68c4a25ac2d0c613ab893fa4472394ae Mon Sep 17 00:00:00 2001 From: Alper Gundogdu Date: Mon, 11 May 2026 15:56:24 +0100 Subject: [PATCH 6/9] docs(blog): tighten comments per AGENTS.md "one short line max" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Chrome.tsx: a bare `#section` from /blog/foo/ resolves to /blog/foo/#section, not /blog/#section as the comment claimed. - SLUG_RE: drop the CodeQL rule ID (rots when the rule is renamed). - Memoise comment: drop the parentheticals enumerating call sites (count is correct but rots with use). - Sort tie-break: drop the HFS+ aside (irrelevant on modern macOS). - openGraph metadata: 8-line block trimmed to the two non-obvious gotchas — shallow merge + og→twitter fallback timing. Closes #68 --- app/blog/page.tsx | 11 +++-------- components/site/Chrome.tsx | 8 +++----- lib/blog.ts | 26 ++++++++------------------ 3 files changed, 14 insertions(+), 31 deletions(-) diff --git a/app/blog/page.tsx b/app/blog/page.tsx index 49635d1..4b8ef5e 100644 --- a/app/blog/page.tsx +++ b/app/blog/page.tsx @@ -6,14 +6,9 @@ import { listPosts } from "@/lib/blog"; const TITLE = "field notes"; const DESCRIPTION = "long-form posts on the deCDN protocol."; -// Inherit the root file-convention og image (app/opengraph-image.png). -// Next merges flat fields but shallow-replaces nested objects like -// `openGraph`, so declaring an `openGraph` here without re-including -// `images` strips the root image — which previously left every blog -// route sharing a blank card. We reuse `ogImages` for twitter too: -// the parent `twitter.images` field is empty here (no app/twitter-image -// convention exists) and Next's "fall back to og for twitter" only -// fires at the final resolution step, not via `parent`. +// openGraph shallow-replaces in Next 16, so re-pass `images` from parent +// or the root og image is dropped. Twitter `images` set explicitly because +// the og→twitter fallback fires at final resolution, not via ResolvingMetadata. export async function generateMetadata( _: unknown, parent: ResolvingMetadata, diff --git a/components/site/Chrome.tsx b/components/site/Chrome.tsx index 194b040..379621a 100644 --- a/components/site/Chrome.tsx +++ b/components/site/Chrome.tsx @@ -5,11 +5,9 @@ import Link from "next/link"; import { links } from "@/lib/links"; const SECTION_IDS = ["intro", "compare", "method", "faq", "contact"] as const; -// Hash anchors below are written `/#section`, not `#section`. The -// leading slash is load-bearing: this nav also renders on `/blog/*`, -// where a bare `#section` would resolve relative to the current path -// and land on `/blog/#section` (which doesn't exist) instead of -// `/#section` on the home page. +// Hash anchors are written `/#section`. Required because this nav also +// renders on /blog/*, where a bare `#section` resolves against the +// current URL (e.g. /blog/foo/#section) instead of the home page. const NAV = [ { id: "compare", label: "compare" }, { id: "method", label: "method" }, diff --git a/lib/blog.ts b/lib/blog.ts index a88a738..7cb348d 100644 --- a/lib/blog.ts +++ b/lib/blog.ts @@ -4,11 +4,8 @@ import matter from "gray-matter"; const POSTS_DIR = path.join(process.cwd(), "content", "blog"); -// Constrain slug shape so file-system-sourced strings can never carry -// HTML or URL-significant characters into hrefs. This is the central -// guard against the stored-XSS class — see CodeQL js/stored-xss. -// Also rejects empty strings, leading/trailing hyphens, and consecutive -// hyphens so canonical kebab-case is enforced at the boundary. +// Central guard against stored-XSS via slug interpolation. Also enforces +// canonical kebab-case (no empty, leading/trailing/consecutive hyphens). const SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/; @@ -123,13 +120,9 @@ const parseEntry = (filename: string): PostSource | null => { return { slug, title, date, summary, bucket, body: content }; }; -// Memoise: the build is single-process and source files are immutable -// mid-build. `readEntries` is hit from `listPosts` (BlogIndex + sitemap + -// generateStaticParams = 3 sites) and `getPost` (generateMetadata + the -// page render, 2× per slug), so without the cache we'd parse the -// directory 2N+3 times. Errors are thrown before `cache` is assigned, -// so a malformed post fails the build loud rather than poisoning the -// cache with a partial result. +// Single-process build with immutable source files; cache lets every +// call site share one parse. Errors throw before assignment so a +// malformed post can't poison the cache with a partial result. let cache: PostSource[] | undefined; const readEntries = (): PostSource[] => { @@ -150,12 +143,9 @@ const readEntries = (): PostSource[] => { .map(parseEntry) .filter((e): e is PostSource => e !== null) .sort((a, b) => { - // Newest first; ISO-8601 dates compare lexically. Tie-break on - // slug because `fs.readdirSync` order isn't portable across - // filesystems (Linux returns inode order, macOS returns lex - // order on APFS but not on case-insensitive HFS+). Without the - // tie-break, ES stable sort would preserve readdir order for - // same-date posts and output would flip between machines. + // Newest first; tie-break on slug because readdir order isn't + // portable across filesystems and stable sort would otherwise + // flip same-date post order between machines. if (a.date !== b.date) return b.date.localeCompare(a.date); return a.slug.localeCompare(b.slug); }); From 3dc07491128cd2f1a16270bac665471bd134fd7c Mon Sep 17 00:00:00 2001 From: Alper Gundogdu Date: Mon, 11 May 2026 15:58:09 +0100 Subject: [PATCH 7/9] test(blog): add Vitest + parseSlug regression tests SLUG_RE is the central guard against stored-XSS via slug interpolation; the regex was tightened in #61 to forbid empty strings, leading/trailing hyphens, and consecutive hyphens, but none of those tighter constraints were protected against regression. This is the first test in the repo. Vitest is the natural choice given the Next 16 + React 19 + ESM stack. Closes #70 --- lib/blog.test.ts | 23 ++ package.json | 4 +- pnpm-lock.yaml | 531 +++++++++++++++++++++++++++++++++++++++++++++++ vitest.config.ts | 8 + 4 files changed, 565 insertions(+), 1 deletion(-) create mode 100644 lib/blog.test.ts create mode 100644 vitest.config.ts diff --git a/lib/blog.test.ts b/lib/blog.test.ts new file mode 100644 index 0000000..e9105f8 --- /dev/null +++ b/lib/blog.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { parseSlug } from "./blog"; + +describe("parseSlug", () => { + it.each(["a", "a-b", "a-b-c", "abc123", "a1-b2"])("accepts %j", (input) => { + expect(parseSlug(input)).not.toBeNull(); + }); + + it.each(["", "-a", "a-", "a--b", "A", "a_b", "a b", "a.b", "