zfb
GitHub repository

Type to search...

to open search from anywhere

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

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

Tip

zfb also offers a class mode that gives each token a re-themeable CSS design token instead of a baked-in colour — the recommended choice for a themeable site. See Class mode (recommended) below.

Recognized languages

A fenced code block's language tag resolves through zfb's alias table onto one of syntect's bundled Sublime Text–compatible grammars, plus one grammar zfb adds itself (TOML — syntect does not bundle it). The mapped tags:

Tag(s)Grammar
ts, typescript, tsxJavaScript (see note below)
js, javascript, jsxJavaScript
rs, rustRust
py, pythonPython
sh, bash, zshBash (Bourne Again Shell)
md, markdown, mdxMarkdown
yaml, ymlYAML
jsonJSON
cC
cpp, c++C++
goGo
tomlTOML
htmlHTML
cssCSS

Note

ts/tsx are highlighted via the JavaScript grammar. syntect's bundled syntax set has no usable TypeScript/TSX grammar it can load — every redistributable source zfb evaluated ships either an unsupported file format or relies on an extends: inheritance mechanism syntect does not implement (see crates/zfb-content/assets/syntaxes/README.md for the full sourcing investigation). Both tags fall through to JavaScript highlighting instead of the plain unhighlighted fallback.

A tag not in this table falls back to the themed no-highlight path described above — the code is preserved and escaped, just without token colouring.

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.

Everything above is inline mode: zfb bakes each token's colour straight into the HTML as style="color:#…" (or the dual --shiki-* custom properties). Class mode instead gives every token a semantic role classhi-kw, hi-str, hi-com, … — and ships a stylesheet that colours those classes through CSS custom properties. Colours become re-themeable design tokens you can override from your own CSS, with no rebuild.

Enable it in zfb.config.ts:

// zfb.config.ts
export default {
  codeHighlight: {
    mode: "class",
  },
};

Note

inline is still the default in this release. Class mode is opt-in — the default has not flipped. Whether class becomes the default is a future decision; set mode: "class" explicitly to use it today.

Class mode is mutually exclusive with every theme knob (theme, themeLight, themeDark, themesDir): themes only affect inline colours, so setting one alongside mode: "class" is a build error rather than a silent no-op. Colour selection in class mode lives entirely in CSS.

Every highlighted token is classified into exactly one of 18 fixed semantic roles. The taxonomy is frozen — roles are never added or removed. Each role maps to a default class {classPrefix}{suffix} (the default classPrefix is hi-) and a matching CSS custom property --zfb-hi-{suffix}:

Role (config key)Default classCustom property
escapehi-esc--zfb-hi-esc
operatorhi-op--zfb-hi-op
commenthi-com--zfb-hi-com
stringhi-str--zfb-hi-str
numberhi-num--zfb-hi-num
constanthi-const--zfb-hi-const
keywordhi-kw--zfb-hi-kw
functionhi-fn--zfb-hi-fn
typehi-ty--zfb-hi-ty
namespacehi-ns--zfb-hi-ns
propertyhi-prop--zfb-hi-prop
variablehi-var--zfb-hi-var
taghi-tag--zfb-hi-tag
attributehi-attr--zfb-hi-attr
punctuationhi-punct--zfb-hi-punct
insertedhi-ins--zfb-hi-ins
deletedhi-del--zfb-hi-del
headinghi-hd--zfb-hi-hd

The config key is the full role name (keyword), while the emitted class uses a short suffix (kw). Beyond the 18 role properties, the stylesheet also defines --zfb-hi-fg / --zfb-hi-bg (the base foreground and background on .hi-root, the <pre> element) and --zfb-hi-ins-bg / --zfb-hi-del-bg (the diff line-tint backgrounds).

The <pre> element is classed {classPrefix}root (default hi-root) and each token is a <span> carrying its role class — with no inline colour:

<pre class="hi-root"><code><span class="line"><span class="hi-kw">fn</span> <span class="hi-fn">main</span>…</span></code></pre>

The <span class="line"> wrapper is identical to inline and dual mode; only the per-token colour mechanism differs.

With mode: "class", zfb injects a built-in token stylesheet (zfb-hi.css) into your combined styles.css. It defines the --zfb-hi-* custom properties and the .hi-* rules that consume them, wrapped in an @layer zfb-hi cascade layer so your own un-layered rules always win. The default palette is extracted from syntect's base16-ocean.light / base16-ocean.dark themes — the same family inline mode's default theme, base16-ocean.dark, belongs to.

To opt out and supply your own stylesheet entirely, set defaultStylesheet: false:

// zfb.config.ts
export default {
  codeHighlight: {
    mode: "class",
    defaultStylesheet: false,
  },
};

Because colours resolve through CSS custom properties, you re-theme highlighting by overriding --zfb-hi-* from your own CSS — no zfb build required. Follow the tight-token / three-tier pattern: raw palette tokens feed semantic role overrides, which the shipped .hi-* rules consume.

/* Tier 1 — your raw palette tokens */
:root {
  --palette-purple: #8250df;
  --palette-green: #1a7f37;
  --palette-slate: #6e7781;
}

/* Tier 2 — map the palette onto the semantic --zfb-hi-* roles */
:root {
  --zfb-hi-kw: var(--palette-purple);
  --zfb-hi-str: var(--palette-green);
  --zfb-hi-com: var(--palette-slate);
}

/* Tier 3 is shipped by zfb: .hi-kw { color: var(--zfb-hi-kw) } … */

Your override sits outside @layer zfb-hi, so it beats the layered default with no !important.

The shipped stylesheet's values use light-dark(), and it also carries a @media (prefers-color-scheme: dark) fallback — so on a host that has not opted into color-scheme, dark mode still works with zero configuration.

Hosts with an explicit theme toggle (a class or data- attribute on <html>, not prefers-color-scheme) override the custom properties under the toggle selector:

:root[data-theme="dark"] {
  --zfb-hi-fg: #c0c5ce;
  --zfb-hi-bg: #2b303b;
  --zfb-hi-kw: #b48ead;
  --zfb-hi-str: #a3be8c;
  --zfb-hi-com: #65737e;
  /* …override the roles your theme changes… */
}

Tip

Because the defaults are written with light-dark(), an explicit-toggle host can instead just flip color-scheme on the toggled root —:root[data-theme="dark"] { color-scheme: dark; } — and let the shippedlight-dark() values resolve to their dark side, overriding individual properties only where you want to diverge from the defaults.

Instead of the default hi-* classes you can map any role onto your own utility classes with roleClasses — most usefully Tailwind:

// zfb.config.ts
export default {
  codeHighlight: {
    mode: "class",
    roleClasses: {
      keyword: "text-violet-600 dark:text-violet-400",
      string: "text-emerald-600 dark:text-emerald-400",
    },
  },
};

Keys must be one of the 18 role names above; a value may hold several space-separated classes, and must not contain the bare token line (it collides with the code-enrichment line-wrapper class). Roles you do not map keep their default {classPrefix}{suffix} class.

The mapped utilities are auto-safelisted. Highlighted markup is produced by the Rust pipeline and only lands in rendered dist/*.html, which Tailwind never scans — so without help the utilities would be tree-shaken away (green build, unstyled tokens). zfb emits each roleClasses value as a Tailwind @source inline("…") entry, so the classes are always generated.

Warning

Authored-CSS path (tailwind: { enabled: false }). Setting roleClasseswith Tailwind disabled is allowed but emits a build warning: no Tailwind safelist can be generated on that path, so the utilities you map to must already exist in your own hand-authored CSS.

classPrefix (default hi-) changes the prefix on both the <pre> root class and every role class — classPrefix: "syn-" yields syn-root, syn-kw, …. It must match /^[A-Za-z][A-Za-z0-9_-]*$/.

A custom prefix is honoured by the shipped stylesheet: zfb rewrites the default .hi-* selectors to your prefix at build time. The --zfb-hi-* custom properties are namespaced independently and stay --zfb-hi-* regardless of classPrefix, so your re-theming overrides do not change.

Class mode depends on an external stylesheet, so prefer inline (or dual) mode when the HTML must be self-contained: RSS/Atom feed content, HTML email, or any embed that travels without your site's CSS. There the baked-in style="color:…" is a feature — the colours survive with no stylesheet.

  • Markup roles inside md-language fences. Beyond headings and diff inserted/deleted lines, Markdown markup constructs are only lightly styled by design — the taxonomy intentionally collapses them rather than minting a role per construct.

  • The "no frontend library" claim is about the static-HTML render path. Class mode ships zero highlighting JavaScript for statically rendered HTML. The MDX React-component render path still delegates pre/code to your _components at runtime as before — class mode does not change that path.

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.

Highlighting code in the browser

Everything on this page runs at build time through zfb's syntect pipeline. If you instead need to highlight code at runtime in the browser — a live Markdown/MDX editor, a paste-and-preview box, or any surface where the source isn't known until after the page has loaded — use highlightCode() from @takazudo/zfb-md-wasm instead. See Direct semantic code highlighting for the walkthrough.

Tip

Only ever calling highlightCode? Import from the@takazudo/zfb-md-wasm/highlight subpath instead of the package root — a slim, highlight-only artifact built without the full MDX/Markdown pipeline, roughly half the download of the default entry point.

See zfb-md-wasm for the full API reference.

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

CreatedUpdated