Repository navigation
feat(blog): add /blog with markdown-sourced static post pages - #61
Conversation
Closes #60. - content/blog/*.mdx is the source; gray-matter parses frontmatter, next-mdx-remote/rsc compiles bodies at build via remark-gfm. - /blog index lists posts (date, title, summary), newest first. - /blog/[slug] uses generateStaticParams + dynamicParams=false so the closed-world set lines up with the static export. - Sitemap pulls slugs from the same listPosts() source so the URL set can't drift from generateStaticParams. - New .prose rule in globals.css styles MDX body output using existing --fs-* tokens (no Tailwind typography plugin under v4). - Chrome.tsx gains a Blog link and switches hash anchors to absolute (/#section) so global nav still works from /blog. Seeds the first post from internal/Blog/00-what-were-building.md.
Deploying website with
|
| Latest commit: |
e1f151d
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://3d23a4aa.website-70y.pages.dev |
| Branch Preview URL: | https://claude-busy-morse-d37d4b.website-70y.pages.dev |
Pull the copyright strip out of Close.tsx into its own component
rendered after `{children}` in the root layout, so /blog and
/blog/[slug] (and any future route) get the same footer as /.
There was a problem hiding this comment.
Pull request overview
Adds a statically-exported blog section to the Next.js App Router site, sourcing posts from local MDX files at build time and integrating them into navigation + sitemaps.
Changes:
- Introduces
/blogindex and/blog/[slug]post pages, rendering MDX vianext-mdx-remote/rscwithremark-gfm. - Adds a filesystem-backed blog source (
lib/blog.ts) and wires blog URLs intositemap-pages.xml. - Updates global nav to link to Blog and to use absolute
/#sectionanchors so home-section links work from/blog/*.
Reviewed changes
Copilot reviewed 13 out of 14 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| pnpm-lock.yaml | Locks new MDX/frontmatter-related dependencies. |
| package.json | Adds gray-matter, next-mdx-remote, and remark-gfm. |
| lib/links.ts | Adds canonical internal /blog/ link constant. |
| lib/blog.ts | Implements post discovery/parsing/sorting from content/blog/*.mdx. |
| content/blog/00-what-were-building.mdx | Seeds first blog post content + frontmatter. |
| components/ui/Prose.tsx | Adds a wrapper component to apply .prose styling to MDX bodies. |
| components/site/Chrome.tsx | Adds Blog link and switches section anchors to absolute /#... URLs. |
| app/sitemap-pages.xml/route.ts | Includes /blog/ and all post URLs in the pages sitemap. |
| app/globals.css | Adds .prose styles for long-form content (headings, code, tables, etc.). |
| app/blog/page.tsx | Implements the blog index page with metadata and post listing. |
| app/blog/[slug]/page.tsx | Implements static blog post pages, static params, and per-post metadata. |
Files not reviewed (1)
- pnpm-lock.yaml: Language not supported
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
Code Review
This pull request introduces a blog system with an index page, dynamic post routes, and MDX rendering. It includes a utility library for parsing Markdown files and updates the sitemap and navigation. Feedback focused on improving build performance by caching filesystem reads and refactoring the post retrieval logic for better efficiency.
- Slug must match /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/ — closes the js/stored-xss path CodeQL flagged on the slug→href edge by constraining the value at the source. - Throw on missing/invalid `title` or `date` so the build fails loud instead of rendering "undefined" into <time> tags or metadata. - Sort comparator now returns 0 for equal keys and tie-breaks on slug, so ordering stays deterministic across runs and engines. - readEntries() is hit from generateStaticParams + every per-slug page + the sitemap route; memoise so we parse the directory once per build instead of N+2 times. - Drop the internal `draft` field by filtering during parse, which removes the boilerplate around stripping it on the way out.
- 00 (what-were-building): date corrected to 2026-05-04, the date it
was actually signed off.
- 01 (why-now): the four-prerequisite-layers argument. Dropped the
specific "12–18 month head start" claim in favour of "a window" —
the post's argument doesn't need a number, and a number ages
awkwardly in public.
- 02 (decentralized-cdn-graveyard): six prior attempts and what each
got stuck on. Five fact-checked edits from the staging copy:
* Theta — acknowledge the 2024–25 EdgeCloud pivot (was: still
framed as a video CDN).
* Livepeer — corrected to "ETH+LPT split from day one"; the
previous "migrated from single-token" framing was factually
wrong (it launched dual-token in 2017).
* MaidSafe — "nineteen years to phase-1 mainnet" via Autonomi
(Feb 2025), instead of the false "still not in production".
* Filecoin — softened "no closest fast peer" by acknowledging
that successive CDN overlays (incl. Saturn) have come and
gone, and that Filecoin's 2025 roadmap (Onchain Cloud, Beam)
now targets pricing predictability as a gap.
* Storj — corrected to "storage origin integrating with Bunny /
Fastly / Varnish" instead of "no per-region cache layer, no
probe-based selection" (Storj does have probe-based piece
selection via long-tail cancellation).
URLs:
/blog/what-were-building/
/blog/why-now/
/blog/decentralized-cdn-graveyard/
…r attempts The original closer ended with a dense feature-list of what decdn does differently. The reframed version lands the actual insight: the technical problem was always solvable; what killed every prior attempt was the two things around it — operator pay and a takedown story. Three postures on the latter, only one ships into enterprise.
Resolve duplicate footer extraction by deferring to main's components/site/Footer.tsx (landed in #62) and dropping the redundant SiteFooter.tsx introduced on this branch. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Most user-visible: blog routes were shipping no og:image / twitter:image
because nested openGraph objects shallow-replace in Next 16, and
twitter:image fallback to og only fires at final resolution (not via
ResolvingMetadata). Re-include images in each generateMetadata, reusing
ogImages for twitter since no app/twitter-image convention exists. Same
fix on /blog/ and /blog/[slug]/. /blog/ index also gains an explicit
twitter block so its card title matches its og title.
Validator hardening in lib/blog.ts:
- Wrap gray-matter parse in try/catch and re-throw with filename, so
malformed YAML doesn't surface as "line 4, column 1" with no context.
- Throw on non-string slug/summary/bucket and non-boolean draft so
authors don't silently get the filename-derived slug, an unfiltered
draft, or a blank social preview.
- Make `summary` required (used by og:description/twitter:description).
- Trim title on storage, not just in the non-empty check.
- Tighten SLUG_RE to forbid consecutive hyphens, leading/trailing
hyphens, and empty strings.
- Brand `Slug` so the security-relevant invariant is part of PostMeta's
type, with parseSlug as the single constructor.
- generateMetadata for unknown slugs now notFound()s instead of
returning {} so a future loosening of dynamicParams can't render
stub pages.
Comment fixes (the prose was misleading the next reader):
- .prose: drop the false "tone-agnostic" claim and add the real
reason we skip @tailwindcss/typography under Tailwind v4.
- Sort tie-break: explain readdir order portability, not a comparator
contract (ES2019+ stable sort means same-date posts would otherwise
flip between machines).
- Cache: correct N+2 → 2N+3 callsites.
- Chrome.tsx: explain the leading slash on /#anchor (the nav also
renders on /blog/*, where bare #anchor would resolve relative).
- sitemap route: pin the XML-safety dependency on SLUG_RE.
.prose a now has hover and focus-visible states for keyboard affordance.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
) * fix(a11y): add header IDs to /blog routes Frame hard-codes aria-labelledby={`${id}-h`}, so each <Frame id="..."/> needs a matching <h1 id="...-h">. 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 * fix(blog): throw on missing content directory 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 * 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 * fix(sitemap): assert SITE_URL is XML-safe SLUG_RE guards per-post slug interpolation into <loc> bodies. The origin had no equivalent guard — a future preview-URL accident introducing &, <, >, ", or ' would silently emit invalid XML. Closes #71 * refactor(blog): brand Slug + IsoDate via unique symbol; drop dead conditional - 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 ? <p>…</p> : null} on the blog index is dead defensive code — summary is required + trimmed-non-empty at parse — so emit the <p> unconditionally. Closes #69 * docs(blog): tighten comments per AGENTS.md "one short line max" - 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 * 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 * chore(engines): bump Node floor to >=20.19.0 for Vite peer Vite 8 (pulled in transitively by Vitest 4 in 3dc0749) declares `engines.node: "^20.19.0 || >=22.12.0"`. The previous >=20.9.0 floor would let pnpm install on Node 20.9–20.18 with a peer-warning that the dev workflow won't actually run there. CI runs Node 24, so the practical floor is well clear of the new minimum. Addresses PR #73 review. * test(blog): cover getPost invalid-slug rejection #67 added a parseSlug check at the getPost boundary so unguarded callers can't reach readEntries with junk input. Add a regression test exercising the same boundary cases as parseSlug (uppercase, double-hyphen, empty, underscore, angle brackets) and asserting null. The invalid-slug short-circuit also means no filesystem read happens for these inputs — captured in a comment on the test block. Addresses PR #73 review.
Summary
/blog(index) and/blog/[slug](post), rendered as static HTML at build time under the existingoutput: "export"config.content/blog/*.mdx;gray-matterparses frontmatter,next-mdx-remote/rsccompiles bodies withremark-gfmfor tables.listPosts()source asgenerateStaticParams, so the two can't drift.what-were-building) from the internal staging area.Closes #60.
Approach notes
@theme inlineinglobals.cssmakes the plugin awkward to wire in. Instead, a small.proserule reuses the site's existing--fs-body/--fs-h3/--fs-leadtokens and color-mixes against--inkso the block stays tone-agnostic.Chrome.tsxhash anchors are now absolute (/#intro,/#compare, etc.) so the global nav still scrolls to home sections from/blog. The IntersectionObserver early-returns on/blogbecause no section IDs are present — nothing to update.<pre><code>reads fine. Easy to bolt on Shiki later.NN-ordering prefix, so00-what-were-building.mdxpublishes at/blog/what-were-building/. Numeric prefix is a private ordering hint inherited frominternal/Blog/.Test plan
pnpm install(deps updated:+gray-matter +next-mdx-remote +remark-gfm; removed unusedunified/remark-parse/remark-rehype/rehype-stringify)pnpm lint— cleanpnpm format:check— cleanpnpm build—/blogprerendered (○ static),/blog/what-were-buildingprerendered (● SSG);out/sitemap-pages.xmllists 3 URLs (/,/blog/,/blog/what-were-building/).pnpm devsmoke: navigate from/→ click "Blog" in nav → land on/blog/→ click post → land on/blog/what-were-building/→ from there, click "compare" in nav → routes back to/#compareand scrolls.<title>What we're building · deCDN</title>+<link rel="canonical" href="https://decdn.org/blog/what-were-building/">+ OG/Twitter meta +article:published_time.Follow-ups not in this PR
BlogPostingJSON-LD — depends on JSON-LD structured data renders in <body> instead of <head> #57 (so per-post schemas land in<head>rather than<body>).rehype-pretty-code).