Skip to main content

Three markdown pipelines and a theme toggle

9 min read

Every post on the site is markdown in a database column. Three different consumers turn that column into something readable, and each one wants the output in a different shape: blog pages want React with syntax highlighting; the Atom feed's <content> element wants self-contained HTML with no external CSS; the feed's <summary> and the OG/SEO meta description want plain text. One pipeline can't serve all three without compromising at least one of them, so I built three.

This post walks the rendering layer end to end: the three markdown processors, the React-only output trick that bypasses dangerouslySetInnerHTML, the design tokens that style what comes out, and two things the theme toggle taught me — why a no-flash init script has to be a bare <script> tag, and the bug that finally made useSyncExternalStore click.

Three processors, built once

// src/lib/content/markdown.ts
const prettyCodeOptions: Options = {
  theme: { light: "github-light", dark: "github-dark-dimmed" },
}
 
const markdownProcessor = unified()                          // ❶
  .use(remarkParse)         // markdown text → mdast (markdown AST, the structured tree the parser produces from the source text)
  .use(remarkGfm)           // GFM extensions: tables, strikethrough, task lists
  .use(remarkRehype)        // mdast → hast (HTML AST)
  .use(rehypePrettyCode, prettyCodeOptions)  // Shiki syntax highlighting
 
const htmlProcessor = unified()                              // ❷
  .use(remarkParse)
  .use(remarkGfm)
  .use(remarkRehype)
  .use(rehypeStringify)     // hast → HTML string
 
const textOnlyProcessor = unified()                          // ❸
  .use(remarkParse)
  .use(remarkGfm)

Three processors in one module, built at module load. unified().use(...) allocates a new pipeline object every call, so constructing per request would be wasteful on hot paths (feed render, blog page render). Module-load construction means each request reuses the same pipeline.

The split is by consumer:

  • ❶ Blog pages: parse → gfm → rehype → rehype-pretty-code. The pretty-code step runs Shiki and emits HAST with theme classes on every token. That HAST gets handed to React, not stringified to HTML.
  • ❷ Feed <content type="html">: parse → gfm → rehype → stringify. No Shiki. Feed readers don't load my stylesheet, so Shiki's span-based output would render as either unstyled monospace at best or a wall of empty <span> tags at worst, and the feed gets plain <pre><code> instead.
  • ❸ Feed <summary> and OG meta description: parse only. No rehype step at all — the text gets pulled directly off the mdast.

Each entry point uses the one it needs:

export async function markdownToReact(content: string): Promise<ReactNode> {
  const hast = await markdownProcessor.run(markdownProcessor.parse(content))
 
  return toJsxRuntime(hast, { Fragment, jsx, jsxs })          // ❶
}
 
export async function markdownToHtml(content: string): Promise<string> {
  const result = await htmlProcessor.process(content)
 
  return String(result)
}

At ❶, hast-util-to-jsx-runtime walks the HAST and returns React nodes directly, which is how the blog page avoids dangerouslySetInnerHTML entirely. The post body is rendered as React children, like any other component. There's no innerHTML write, no XSS surface from the markdown itself; the worst a malicious markdown payload can do is render badly.

Stripping markdown without rendering it

The summary path is parse-only and walks the mdast directly:

const BLOCK_TYPES = new Set(["paragraph", "heading", "blockquote", "listItem"])
 
function extractText(node: Nodes): string {
  if (node.type === "code") return ""                        // ❶
 
  if (node.type === "image" || node.type === "imageReference") {
    return node.alt ?? ""                                    // ❷
  }
 
  if ("value" in node) return node.value
 
  const children = "children" in node ? node.children : []
  const text = children.map(extractText).join("")
 
  return BLOCK_TYPES.has(node.type) ? text + "\n" : text     // ❸
}

Three calls in this walk that the obvious version wouldn't make:

  1. Fenced code blocks are skipped entirely ❶. They're noise in a 160-character excerpt: nobody's SEO meta description wants const foo = bar; at the front. Inline code (the value branch at the bottom) is kept, since it's often part of a sentence.
  2. Images contribute their alt text ❷, not their src. image and imageReference nodes don't have a value or a children array — alt is the only narrative content they carry, and treating it like link-label text is what makes alt-bearing posts produce sensible summaries.
  3. Block-level siblings get a \n between them ❸. Without it, two adjacent paragraphs become one run-on string after the recursive join. The \n survives until the final replace(/\s+/g, " ") collapses every whitespace run to a single space — but by then the paragraph boundary has already done its work.

The cap is 160 characters with word-boundary truncation, matching the Zod summary.max(160) on the post schema and Google's desktop snippet width. Authored summaries and derived summaries land at the same visual length.

What styles the output

markdownToReact returns React nodes; somebody has to render them. That somebody is a one-line server component:

// src/components/blog/PostMarkdownContent.tsx
export default async function PostMarkdownContent({ content }: Props) {
  const node = await markdownToReact(content)
 
  return <div className="prose dark:prose-invert max-w-none">{node}</div>
}

prose is @tailwindcss/typography's prebuilt set of styles for long-form content: headings, paragraphs, lists, blockquotes, inline code, links. Defaults are genuinely good, better than what I'd land on writing rules from scratch for the thirty-odd tags markdown emits; the few places I want different choices are one-line overrides on the prose wrapper, not a full rewrite.

What it doesn't cover well is theming. prose-invert flips colors for dark mode, but it does it against Tailwind's defaults, not against my tokens. The override lives in globals.css:

@theme inline {
  --color-primary: var(--color-primary-value);
  --color-secondary: var(--color-secondary-value);
  --color-background: var(--color-background-value);
  --color-border: var(--color-border-value);
  --color-accent: var(--color-accent-value);
}
 
:root {
  --color-primary-value: #030712;
  /* … light values … */
}
 
.dark {
  --color-primary-value: #f5f5f5;
  /* … dark values … */
}

Single source for every color token. Tailwind v4's @theme inline exposes them as utility classes (text-primary, bg-background); the values live in CSS variables that the .dark class swaps. Adding a color is one block in two places. Tuning a color for both modes is one variable per side.

rehype-pretty-code plugs into the same scheme: it emits data-theme="light dark" attributes that two small CSS rules resolve to the right colors per mode:

code[data-theme*=" "],
code[data-theme*=" "] span {
  color: var(--shiki-light);
  background-color: var(--shiki-light-bg);
}
 
.dark code[data-theme*=" "],
.dark code[data-theme*=" "] span {
  color: var(--shiki-dark);
  background-color: var(--shiki-dark-bg);
}

The Shiki theme switches with the page theme, with no JS and no FOUC inside the code blocks; the markdown doesn't need re-rendering when the toggle flips.

The theme toggle, and the bug that taught me useSyncExternalStore

Three modes: light, dark, system. The toggle writes the choice to localStorage, and a tiny inline <script> at the top of the document reads it — falling back to prefers-color-scheme for system and first visits — and sets the class on <html> before the browser paints. That kills the flash of the wrong theme on every navigation.

I tried a cookie first: written by the toggle, read server-side, stamped onto <html> during render. It worked. But a cookie read during render forces the whole route to render dynamically, and I'd just moved these pages to static rendering to cut server cost — so client-side was the price of keeping them static.

The subtle part is when the script runs: during HTML parsing, before first paint, or the flash is back. My first attempt used next/script with strategy="beforeInteractive". Despite the name, it still runs after the parser has moved on, so dark-mode readers got a white flash on every load.

The fix was low-tech: a raw inline <script> runs synchronously the instant the parser reaches it, where next/script's loader doesn't. Anything that has to land before paint wants the plain tag. (Yes, that's a dangerouslySetInnerHTML — the one the markdown pipeline works so hard to avoid; fine here, since the body is a compile-time constant with no user input near it, the whole reason it's otherwise dangerous.)

The escape hatch is still in the global CSS:

html:not(.dark):not(.light) {
  visibility: hidden;
}

If no class is set yet, the page stays invisible instead of flashing a wrong palette. With the inline script that window is imperceptible for anyone running JS; a <noscript> override turns visibility back on for the readers without it, so they get the default palette rather than a blank page.

The script owns the first paint. Keeping the theme in sync afterward — when someone toggles, or the OS flips while they're on system — is ThemeProvider. Its first version mirrored the OS preference into local state, seeded from a snapshot at mount:

// the bad version, paraphrased
const [systemIsDark, setSystemIsDark] = useState(getColorSchemeSnapshot())
 
useEffect(() => {
  const media = window.matchMedia("(prefers-color-scheme: dark)")
  const handler = () => setSystemIsDark(media.matches)
  media.addEventListener("change", handler)
  return () => media.removeEventListener("change", handler)
}, [])

That worked for most flips. It failed for the sequence "system → light → system." Going system → light unmounts the subscription; light → system re-mounts it; but the re-mount seeds systemIsDark from the snapshot at first mount of the component tree, not the current OS value. If the OS preference had changed during the light interlude, the second "system" choice resolved to the stale value.

The fix is useSyncExternalStore:

const systemIsDark = useSyncExternalStore(
  subscribeToColorScheme,           // ❶
  getColorSchemeSnapshot,           // ❷
  getServerColorSchemeSnapshot,     // ❸
)
 
const isDark = theme === "dark" || (theme === "system" && systemIsDark)

useSyncExternalStore is the React hook for reading from a source that lives outside React's state model. Three callbacks: ❶ subscribe, called once to register the change listener; ❷ getSnapshot, called on every render to read the current value; ❸ getServerSnapshot, called during SSR when there's no DOM to read. It reads on every render via getSnapshot, which makes "stale snapshot from a previous mount" impossible by construction.

useEffect + useState is the shape every React tutorial reaches for, and it doesn't fit "subscribe to an external value and re-read on change." The hook for that case has existed since React 18, and I'd skimmed it as "for libraries, not apps" until this bug.

What's next

The next post is the closer: a short retrospective on the patterns that paid off, the ones that didn't, and the things I'd change if I started this rebuild over.

The "no CSS in feeds" problem has more than one answer and I've only tried mine: @roland.leth.ro.