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 stringUsed as the inner HTML of the <code> element. Markup is preserved verbatim.
DomphyElementUsed as the sole child of the <code> element. Stays in the element tree.
Falsy (null, undefined, empty string)Falls back to plain escaped text.

String return — inner HTML

Most highlighters emit HTML strings. Return the string and @domphy/markdown sets it as the inner HTML of the <code> element:

import { parseMarkdown } from "@domphy/markdown"

const { body } = parseMarkdown("```ts\nconst x = 1\n```", {
  highlight(code, language) {
    return `<span class="hl-${language}">${code}</span>`
  },
})

// body[0] ->
// { pre: [{ code: '<span class="hl-ts">const x = 1\n</span>', 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:

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

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

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

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/markdown"

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/markdown"

// 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/markdown"

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/markdown"

// 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">

Using highlight with tokensToDomphy

The highlight option is also accepted by tokensToDomphy when you supply your own markdown-it instance:

import MarkdownIt from "markdown-it"
import { splitFrontmatter, tokensToDomphy } from "@domphy/markdown"

const md = new MarkdownIt({ html: true })

const { content } = splitFrontmatter(source)
const tokens = md.parse(content, {})

const { body } = tokensToDomphy(tokens, {
  highlight(code, language) {
    return myHighlighter(code, language)
  },
})