---
title: "Three markdown pipelines and a theme toggle"
slug: three-markdown-pipelines-and-a-theme-toggle
section: tech
date: 2026-07-10T14:00:00.000Z
canonical: https://roland.leth.ro/blog/tech/three-markdown-pipelines-and-a-theme-toggle
---

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

```typescript
// 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:

```typescript
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:

```typescript
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:

```tsx
// 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`:

```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:

```css
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:

```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:

```typescript
// 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`:

```typescript
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][next] 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][].

[prev]: /blog/tech/three-surprises-about-server-components "Three surprises about server components"
[next]: /blog/tech/what-id-do-differently "What I'd do differently"
[@roland.leth.ro]: https://bsky.app/profile/roland.leth.ro "@roland.leth.ro on Bluesky"
