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..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, @@ -48,6 +43,7 @@ export default function BlogIndex() {
field notes

@@ -94,14 +90,12 @@ export default function BlogIndex() { > {post.title}

- {post.summary ? ( -

- {post.summary} -

- ) : null} +

+ {post.summary} +

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"; 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.test.ts b/lib/blog.test.ts new file mode 100644 index 0000000..ee40e4a --- /dev/null +++ b/lib/blog.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { getPost, 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", "