Skip to content

feat(blog): add /blog with markdown-sourced static post pages - #61

Merged
thiras merged 9 commits into
mainfrom
claude/busy-morse-d37d4b
May 11, 2026
Merged

thiras merged 9 commits into
mainfrom
claude/busy-morse-d37d4b

Conversation

@alpergundogdu

Copy link
Copy Markdown
Contributor

Summary

  • Adds /blog (index) and /blog/[slug] (post), rendered as static HTML at build time under the existing output: "export" config.
  • Source files live in content/blog/*.mdx; gray-matter parses frontmatter, next-mdx-remote/rsc compiles bodies with remark-gfm for tables.
  • Sitemap pulls slugs from the same listPosts() source as generateStaticParams, so the two can't drift.
  • Seeds post 00 (what-were-building) from the internal staging area.

Closes #60.

Approach notes

  • No Tailwind typography plugin. Tailwind v4 + @theme inline in globals.css makes the plugin awkward to wire in. Instead, a small .prose rule reuses the site's existing --fs-body/--fs-h3/--fs-lead tokens and color-mixes against --ink so the block stays tone-agnostic.
  • Chrome.tsx hash anchors are now absolute (/#intro, /#compare, etc.) so the global nav still scrolls to home sections from /blog. The IntersectionObserver early-returns on /blog because no section IDs are present — nothing to update.
  • No syntax highlighting. One Rust block exists across the whole staging slate (post 10, not in this PR); plain styled <pre><code> reads fine. Easy to bolt on Shiki later.
  • Filename → slug strips a leading NN- ordering prefix, so 00-what-were-building.mdx publishes at /blog/what-were-building/. Numeric prefix is a private ordering hint inherited from internal/Blog/.

Test plan

  • pnpm install (deps updated: +gray-matter +next-mdx-remote +remark-gfm; removed unused unified/remark-parse/remark-rehype/rehype-stringify)
  • pnpm lint — clean
  • pnpm format:check — clean
  • pnpm build — /blog prerendered (○ static), /blog/what-were-building prerendered (● SSG); out/sitemap-pages.xml lists 3 URLs (/, /blog/, /blog/what-were-building/).
  • pnpm dev smoke: 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 /#compare and scrolls.
  • View-source on the post: <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

  • Per-post BlogPosting JSON-LD — depends on JSON-LD structured data renders in <body> instead of <head> #57 (so per-post schemas land in <head> rather than <body>).
  • RSS feed.
  • Syntax highlighting (Shiki via rehype-pretty-code).
  • Tags / categories / pagination — deferred until >5 posts.
  • Per-post OG images (inherits site OG for v1).

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.
Copilot AI review requested due to automatic review settings May 11, 2026 11:50
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented May 11, 2026 •

Copy link
Copy Markdown

Deploying website with  Cloudflare Pages  Cloudflare Pages

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

View logs

Comment thread app/blog/page.tsx Fixed
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 /.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 /blog index and /blog/[slug] post pages, rendering MDX via next-mdx-remote/rsc with remark-gfm.
  • Adds a filesystem-backed blog source (lib/blog.ts) and wires blog URLs into sitemap-pages.xml.
  • Updates global nav to link to Blog and to use absolute /#section anchors 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.

Comment thread lib/blog.ts Outdated
Comment thread lib/blog.ts Outdated
Comment thread app/blog/[slug]/page.tsx

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread lib/blog.ts Outdated
Comment thread lib/blog.ts
alpergundogdu and others added 6 commits May 11, 2026 13:04
- 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>
@thiras
thiras merged commit 754f606 into main May 11, 2026
8 checks passed
@thiras
thiras deleted the claude/busy-morse-d37d4b branch May 11, 2026 13:42
thiras pushed a commit that referenced this pull request May 11, 2026
)

* 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add /blog with markdown-sourced static post pages

4 participants