zfb
GitHub repository

Type to search...

to open search from anywhere

Syntax Highlighting

Created Jun 24, 2026Takeshi Takatsudo

zfb ships syntect-backed server-side syntax highlighting. This page explains the built-in behaviour, how to use custom .tmTheme files, and two supplementary patterns for client-side or theme-customised highlighting.

zfb ships server-side syntax highlighting via syntect — a Rust library that runs at build time inside crates/zfb-content. When the pipeline encounters a fenced code block, SyntectPlugin looks up the language tag, highlights the source with the configured theme, and replaces the <pre><code> element with a <pre class="syntect-…"><code>…</code></pre> HTML fragment baked into the output. No JavaScript is shipped to the browser for highlighting.

Built-in behaviour

PropertyValue
Enginesyntect (Sublime Text–compatible grammars)
ExecutionBuild-time (hast visitor phase in crates/zfb-content)
OutputInline HTML with class attributes — zero runtime JS
Unknown languagesThemed fallback: wrapped in <pre class="syntect-…">, code preserved
mermaid blocksSkipped — routed to MermaidPlugin instead

Customising the theme (built-in themes)

The simplest way to change the colour scheme is to pick one of syntect's bundled themes in zfb.config.ts:

// zfb.config.ts
export default {
  codeHighlight: {
    theme: "Solarized (light)",
  },
};

Built-in theme names: "base16-ocean.dark" (default), "base16-ocean.light", "InspiredGitHub", "Solarized (dark)", "Solarized (light)".

These are not Shiki theme names. Using a name like "dracula" without loading it via themesDir will produce an unknown theme error at build time.

Using custom .tmTheme files

Syntect is compatible with Sublime Text's .tmTheme format. You can load any .tmTheme file (Dracula, One Dark, Catppuccin, …) by dropping it into a directory and pointing codeHighlight.themesDir at it:

// zfb.config.ts
export default {
  codeHighlight: {
    themesDir: "./themes",   // relative to the project root
    theme: "Dracula",        // the `name` declared inside the .tmTheme file
  },
};

Directory layout:

my-project/
├── themes/
│   └── dracula.tmTheme      ← drop your .tmTheme files here
├── pages/
├── content/
└── zfb.config.ts

The .tmTheme filename does not matter — the name you pass to theme must match the <string> value of the name key inside the plist. For Dracula, the declared name is "Dracula".

Downloading Dracula: the official Dracula .tmTheme is available at https://draculatheme.com/sublime or directly from the Dracula GitHub repository.

Error reporting: if themesDir points at a missing directory or any .tmTheme file is malformed, zfb surfaces a clear error at build start (before rendering any pages) that includes the file path and the parse error.

Dual light/dark themes

The theme option above colours each token with an inline style="color:…", so a single theme is baked in. To support both a light and a dark site theme from one build, set themeLight and themeDark instead. Each block is highlighted twice and the two colours are emitted as CSS custom properties; the browser picks the active one with a light-dark() rule — still zero runtime JS.

// zfb.config.ts
export default {
  codeHighlight: {
    themeLight: "InspiredGitHub",
    themeDark: "base16-ocean.dark",
  },
};

Rules

  • Both are required together. Setting only themeLight or only themeDark is a build error.

  • Mutually exclusive with theme. Setting theme alongside the dual pair is a build error. Pick single-theme mode (theme) or dual-theme mode (themeLight + themeDark), not both.

  • themesDir applies to both modes. Custom .tmTheme files loaded via themesDir are available to themeLight / themeDark by their declared name, exactly as for theme.

  • These are syntect theme names, not Shiki names"base16-ocean.light" / "base16-ocean.dark", "InspiredGitHub", "Solarized (light)" / "Solarized (dark)", or any custom .tmTheme you loaded. A name like "dracula" that is not loaded via themesDir still errors at build time.

Emitted markup

In dual mode the <pre> element gets class="syntect-dual" and carries the two background colours as --shiki-light-bg / --shiki-dark-bg in its style. Each token <span> carries --shiki-light / --shiki-dark instead of an inline color::

<pre class="syntect-dual" style="--shiki-light-bg:#fff;--shiki-dark-bg:#2b303b">
  <code><span class="line"><span style="--shiki-light:#998;--shiki-dark:#65737e">token</span></span></code>
</pre>

The <span class="line"> wrapper structure is identical to single-theme mode — only the per-token colour mechanism differs.

Note

The variable names --shiki-light / --shiki-dark intentionally mirrorShiki's dual-theme CSS convention so the consumer-side CSS feels familiar. The theme names you pass, however, are syntect's — not Shiki's.

Resolving the colours (consumer CSS)

A light/dark site adds one CSS rule that maps the emitted variables through light-dark(), which follows the page's color-scheme:

pre[class^="syntect-"] span {
  color: light-dark(var(--shiki-light), var(--shiki-dark));
  background-color: light-dark(var(--shiki-light-bg), var(--shiki-dark-bg));
}

Make sure the surrounding context opts in to color-scheme: light dark (e.g. on :root or the <pre>) so light-dark() resolves to the active mode.

Supplementary pattern 1 — additional client-side highlighting

If you want interactive theme switching or per-user preferences, layer a client-side highlighter on top of the server-rendered output as a client island:

"use client";

import { useEffect, useRef } from "preact/hooks";
import Prism from "prismjs";
import "prismjs/components/prism-typescript";

export default function PrismRoot({ children }) {
  const ref = useRef(null);
  useEffect(() => { Prism.highlightAllUnder(ref.current); }, []);
  return <div ref={ref}>{children}</div>;
}

Wrap your article body in <PrismRoot> and the island will re-highlight the pre-rendered blocks in place. This is useful for themes that depend on media queries or user preference, but it adds JavaScript and causes a brief re-paint after hydration.

Supplementary pattern 2 — post-build script for custom grammars

syntect uses Sublime Text–compatible grammars. If you need a grammar that syntect does not bundle (an internal DSL, a niche language), you can run a post-build Node script to replace specific blocks after zfb build:

// post-build/highlight-custom.ts — runs after `zfb build`
import { glob } from "glob";
import { readFile, writeFile } from "node:fs/promises";
import { codeToHtml } from "shiki";

for (const file of await glob("dist/**/*.html")) {
  const html = await readFile(file, "utf8");
  const next = await highlightCustomBlocks(html, codeToHtml);
  if (next !== html) await writeFile(file, next);
}

This only makes sense for grammars absent from syntect's bundled set. For all standard languages (Rust, TypeScript, Python, Go, etc.), the built-in pipeline handles them without an extra step.

See also

  • Extending the Markdown Pipeline — how SyntectPlugin fits into the hast-phase pipeline and how to swap or extend it.

  • Custom Directivesmermaid blocks use this path instead of syntect.

  • crates/zfb-content/src/plugins/syntect_plugin.rs — plugin source.

Revision History

Takeshi TakatsudoCreated: 2026-06-25T05:17:25+09:00Updated: 2026-06-25T05:17:25+09:00

AI Assistant

Ask a question about the documentation.