Syntax Highlighting
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
| Property | Value |
|---|---|
| Engine | syntect (Sublime Text–compatible grammars) |
| Execution | Build-time (hast visitor phase in crates/zfb-content) |
| Output | Inline HTML with class attributes — zero runtime JS |
| Unknown languages | Themed fallback: wrapped in <pre class="syntect-…">, code preserved |
mermaid blocks | Skipped — 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. tsThe .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:
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
themeLightor onlythemeDarkis a build error.Mutually exclusive with
theme. Settingthemealongside the dual pair is a build error. Pick single-theme mode (theme) or dual-theme mode (themeLight+themeDark), not both.themesDirapplies to both modes. Custom.tmThemefiles loaded viathemesDirare available tothemeLight/themeDarkby their declaredname, exactly as fortheme.These are syntect theme names, not Shiki names —
"base16-ocean.light"/"base16-ocean.dark","InspiredGitHub","Solarized (light)"/"Solarized (dark)", or any custom.tmThemeyou loaded. A name like"dracula"that is not loaded viathemesDirstill 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
SyntectPluginfits into the hast-phase pipeline and how to swap or extend it.Custom Directives —
mermaidblocks use this path instead of syntect.crates/— plugin source.zfb- content/ src/ plugins/ syntect_ plugin. rs