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", "