Skip to content
Domphy

Syntax Highlighting

The highlight option in parseMarkdown and markdownToDomphy lets you plug any syntax highlighter into fenced code block rendering. The highlighter receives the raw code and language identifier per block, and can return either a string of HTML or a Domphy element.

The Highlight type

type Highlight = (
  code:     string,
  language: string,
) => string | DomphyElement | null | undefined
ParameterDescription
codeRaw source text of the block. Not HTML-escaped — that is the highlighter's job.
languageLanguage identifier from the fence (e.g. "ts", "css", "sh"). Empty string when no language is given.
Return valueWhat happens
Non-empty stringWrapped in rawHtml() as the content of the <code> element. TextNode then runs sanitizeHTMLString (strips <script>, on* handlers, javascript: URLs) — defense in depth, not a sanitizer for untrusted input.
DomphyElementReplaces the entire block — no pre > code wrapper is added around it.
Falsy (null, undefined, empty string)Falls back to plain escaped text.

String return — rawHtml + sanitize

Most highlighters emit token markup as an HTML string. Return that string and @domphy/press wraps it in rawHtml() (core's explicit HTML opt-in). Do not interpolate raw code into an HTML string yourself — pass the source to a highlighter (Shiki, highlight.js, …) and return its output:

import { parseMarkdown } from "@domphy/press"

const { body } = parseMarkdown("```ts\nconst x = 1\n```", {
  highlight(code, language) {
    // Highlighter output only — never `...${code}...` as HTML.
    return myHighlighter(code, language)
  },
})

// body[0] ->
// { pre: [{ code: rawHtml('…token markup…'), dataLanguage: "ts", class: "language-ts" }] }

The dataLanguage and class properties on the <code> element are always emitted by the walker when a language identifier is present, regardless of whether a highlighter is supplied.

DomphyElement return — stay in the element tree

Return a DomphyElement when you want the highlighted output to remain as a Domphy tree node rather than a raw HTML string. The returned element replaces the whole block — there is no pre > code wrapper, so include your own if you need one:

import type { DomphyElement } from "@domphy/core"
import { parseMarkdown } from "@domphy/press"

const { body } = parseMarkdown("```js\nfoo()\n```", {
  highlight(code): DomphyElement {
    return { span: code, class: "code-block" }
  },
})

// body[0] ->
// { span: "foo()\n", class: "code-block" }

Falling back for unknown languages

Return null or undefined to skip highlighting for a specific block. The walker falls back to plain escaped text:

import { parseMarkdown } from "@domphy/press"

const knownLanguages = new Set(["ts", "js", "css", "html", "sh"])

const { body } = parseMarkdown(source, {
  highlight(code, language) {
    if (!language || !knownLanguages.has(language)) return null
    return myHighlighter(code, language)
  },
})

Integrating Shiki

Shiki emits HTML strings — a natural fit for the string-return path. Create the highlighter once outside the parse call to avoid re-initialising it per document:

import { createHighlighter } from "shiki"
import { parseMarkdown } from "@domphy/press"

// Initialise once at app startup or build time.
const shiki = await createHighlighter({
  themes: ["github-light"],
  langs:  ["ts", "js", "css", "html", "sh"],
})

function renderDoc(source: string) {
  return parseMarkdown(source, {
    highlight(code, language) {
      if (!language) return null
      try {
        // codeToHtml wraps its output in <pre><code>. Extract only the
        // inner content so we don't double-wrap with the walker's own <pre>.
        const full = shiki.codeToHtml(code, { lang: language, theme: "github-light" })
        // Shiki's output: <pre ...><code ...>...tokens...</code></pre>
        // We want only what's inside <code>...</code>.
        const match = full.match(/<code[^>]*>([\s\S]*)<\/code>/)
        return match ? match[1] : null
      } catch {
        return null // unknown language: fall back to plain text
      }
    },
  })
}

Integrating highlight.js

highlight.js emits annotated HTML strings directly without the outer wrapper:

import hljs from "highlight.js"
import { parseMarkdown } from "@domphy/press"

const { body } = parseMarkdown(source, {
  highlight(code, language) {
    if (!language) return null
    const registered = hljs.getLanguage(language)
    if (!registered) return null
    return hljs.highlight(code, { language }).value
  },
})

Then include the corresponding highlight.js CSS theme on the page. The <code> element already carries class="language-{lang}" from the walker, which most themes target.

Building a custom token-based highlighter

Return a DomphyElement when you want full control over the output structure without raw HTML strings:

import type { DomphyElement } from "@domphy/core"
import { parseMarkdown } from "@domphy/press"

// A tiny highlighter that wraps comment lines in a distinct span.
function commentHighlight(code: string, language: string): DomphyElement {
  const parts: (string | DomphyElement)[] = []
  const lines = code.split("\n")

  for (let i = 0; i < lines.length; i++) {
    const line = lines[i]
    if (line.trimStart().startsWith("//")) {
      parts.push({ span: line, class: "code-comment" })
    } else {
      parts.push(line)
    }
    // Add a newline between lines, but not after the last one.
    if (i < lines.length - 1) parts.push("\n")
  }

  return { span: parts, class: `language-${language}` }
}

const { body } = parseMarkdown("```ts\n// setup\nconst x = 1\n```", {
  highlight(code, language) {
    if (language !== "ts" && language !== "js") return null
    return commentHighlight(code, language)
  },
})

CSS-only highlighting without a highlighter option

Skip the highlight option entirely to let a client-side CSS library handle colouring. The walker already emits the right attributes:

<!-- rendered output for a fenced ts block -->
<pre>
  <code class="language-ts" data-language="ts">const x = 1;</code>
</pre>

Load a CSS theme on the page and the language-* class provides the hook:

<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
<script>hljs.highlightAll()</script>

Using highlight with walkMdast

The highlight option is also accepted by walkMdast when you build your own remark pipeline:

import { remark } from "remark"
import { splitFrontmatter, walkMdast, createUniqueSlugger, defaultSlugify } from "@domphy/press"

const processor = remark()
const { content } = splitFrontmatter(source)
const tree = processor.parse(content)
processor.runSync(tree, content)

const body = walkMdast(tree, {
  highlight(code, info) {
    return myHighlighter(code, info)
  },
  slug: createUniqueSlugger(defaultSlugify),
  toc: [],
})