This document describes how to migrate from PrismJS-powered code blocks to @ngrok/mantle's Shiki-powered CodeBlock component. There are three packages involved:
Key design principle: syntax highlighting never runs in the browser. It happens at build time (Vite plugin), at request time (server highlighter), or not at all (graceful fallback to plain text).
Security note:
preHtmlis rendered viadangerouslySetInnerHTML. Only pass HTML produced by Shiki (via the Vite plugin, server highlighter, or highlight server). Never pass unsanitized user input aspreHtml.
If your app currently uses PrismJS (directly or via react-syntax-highlighter), here are the patterns you need to find and replace.
Look for these imports and usages in your codebase:
1// OLD: PrismJS imports (remove these)2import Prism from "prismjs";3import "prismjs/components/prism-typescript";4import "prismjs/themes/prism.css";5import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";6import SyntaxHighlighter from "react-syntax-highlighter";7import { Light as SyntaxHighlighter } from "react-syntax-highlighter";8 9// OLD: PrismJS usage patterns10<SyntaxHighlighter language="typescript">{code}</SyntaxHighlighter>11<SyntaxHighlighter language="typescript" showLineNumbers>{code}</SyntaxHighlighter>12 13// OLD: manual Prism.highlight calls14const html = Prism.highlight(code, Prism.languages.typescript, "typescript");15 16// OLD: Legacy mantle CodeBlock with separate language prop17import { CodeBlock, fmtCode } from "@ngrok/mantle/code-block";18<CodeBlock.Code language="typescript" value={fmtCode`const x = 1;`} />19 20// OLD: useEffect-based highlighting21useEffect(() => {22 Prism.highlightAll();23}, []);1// NEW: Static code known at build time (Vite plugin transforms this)2import { CodeBlock, mantleCode } from "@ngrok/mantle/code-block";3 4<CodeBlock.Root>5 <CodeBlock.Body>6 <CodeBlock.CopyButton />7 <CodeBlock.Code value={mantleCode("typescript")`const x = 1;`} />8 </CodeBlock.Body>9</CodeBlock.Root>;10 11// NEW: Dynamic code (not known at build time, no highlighting)12import { CodeBlock, createMantleCodeBlockValue } from "@ngrok/mantle/code-block";13 14<CodeBlock.Code value={createMantleCodeBlockValue({ code, language: "typescript" })} />;15 16// NEW: Dynamic code WITH highlighting (requires server highlighter or sidecar)17// See Use Cases 1 and 3 below for detailsRemove PrismJS theme CSS imports and any custom Prism token styles:
1// REMOVE these2import "prismjs/themes/prism.css";3import "prismjs/themes/prism-tomorrow.css";4// or any custom .token.keyword { color: ... } stylesMantle's mantle.css already includes the Shiki CSS variable theme. Just ensure you import it:
1import "@ngrok/mantle/mantle.css";pnpm remove prismjs react-syntax-highlighter @types/prismjs @types/react-syntax-highlighterThis is the most common setup. You have a Vite-built React app served by a Node.js server (React Router 7, Express, Fastify, etc.). You want:
mantleCode tagged templates)@ngrok/mantle-server-syntax-highlighter)1# runtime2pnpm add -E @ngrok/mantle3 4# build-time (Vite plugin for mantleCode tagged templates + MDX rehype plugin)5pnpm add -DE @ngrok/mantle-vite-plugins6 7# server-side (for dynamic/user-provided code highlighting at request time)8pnpm add -E @ngrok/mantle-server-syntax-highlighter1// vite.config.ts2import { mantleCodeBlockPlugins } from "@ngrok/mantle-vite-plugins";3import { defineConfig } from "vite";4 5const codeBlockPlugins = mantleCodeBlockPlugins();6 7export default defineConfig({8 plugins: [9 // Vite plugin: transforms mantleCode("lang")`...` tagged templates at build time10 ...codeBlockPlugins.vitePlugins,11 12 // If using MDX, add the rehype plugin to your MDX config:13 // mdx({14 // rehypePlugins: [...codeBlockPlugins.rehypePlugins],15 // }),16 ],17});mantleCodeBlockPlugins() accepts an options object:
1mantleCodeBlockPlugins({2 runtime: true, // (default true) enables mantleCode`` tagged template transforms3 mdx: true, // (default true) enables MDX fenced code block rehype plugin4});If you don't use MDX, you can disable it: mantleCodeBlockPlugins({ mdx: false }).
If you only use MDX and not tagged templates: mantleCodeBlockPlugins({ runtime: false }).
1import { CodeBlock, mantleCode } from "@ngrok/mantle/code-block";2 3function MyComponent() {4 return (5 <CodeBlock.Root>6 <CodeBlock.Header>7 <CodeBlock.Icon preset="file" />8 <CodeBlock.Title>example.ts</CodeBlock.Title>9 </CodeBlock.Header>10 <CodeBlock.Body>11 <CodeBlock.CopyButton />12 <CodeBlock.Code13 value={mantleCode("typescript")`14 const greeting = "Hello, world!";15 console.log(greeting);16 `}17 />18 </CodeBlock.Body>19 </CodeBlock.Root>20 );21}The Vite plugin rewrites mantleCode("typescript")\...`at build time, injecting pre-rendered Shiki HTML into the~preHtml` property. No Shiki code ships to the browser.
1mantleCode("typescript", {2 showLineNumbers: true,3 highlightLines: [1, "3-5"],4 lineNumberStart: 10,5 indentation: "spaces", // or "tabs" — overrides language default6})`const x = 1;`;Languages have default indentation styles. Tab-indented by default: csharp, css, go, html, java, javascript, js, jsx, typescript, ts, tsx, xml. All other languages default to spaces unless overridden. Use the indentation option to override.
1mantleCode("typescript")`const name = "${userName}";`;Interpolated values are replaced at runtime via placeholder substitution. The Vite plugin highlights the template with placeholders, and the component swaps in real values at render time. The copy button copies the plain-text version with real values.
Limitation: If an interpolated value changes the token type at that position (e.g., injecting a bare identifier where the placeholder sits inside string quotes), the syntax highlighting color of the surrounding span may be incorrect. This is acceptable for typical usage where interpolated values appear inside strings, template expressions, or command substitutions.
For code that isn't known at build time (user input, API responses, database content), use the server syntax highlighter in an API route or server action.
1// app/routes/api.shiki-highlight.ts (React Router 7 example)2import { createMantleServerSyntaxHighlighter } from "@ngrok/mantle-server-syntax-highlighter";3 4const highlighter = createMantleServerSyntaxHighlighter();5 6export async function action({ request }: { request: Request }) {7 const { code, language } = await request.json();8 9 const result = await highlighter.highlight({10 code,11 language,12 // optional:13 // showLineNumbers: true,14 // highlightLines: [1, "3-5"],15 // lineNumberStart: 1,16 });17 18 return Response.json({19 code: result.code,20 html: result.html,21 language: result.language,22 highlightLines: result.highlightLines,23 lineNumberStart: result.lineNumberStart,24 showLineNumbers: result.showLineNumbers,25 });26}Then on the client, fetch from the API and construct a MantleCodeBlockValue:
1import {2 CodeBlock,3 createMantleCodeBlockValue,4 parseCodeBlockHighlightLines,5 parseLanguage,6} from "@ngrok/mantle/code-block";7 8// After fetching from your highlight API:9const value = createMantleCodeBlockValue({10 code: data.code,11 language: parseLanguage(data.language),12 preHtml: data.html,13 showLineNumbers: data.showLineNumbers,14 highlightLines: parseCodeBlockHighlightLines(data.highlightLines) ?? [],15 lineNumberStart: data.lineNumberStart,16});17 18// Render it:19<CodeBlock.Root>20 <CodeBlock.Body>21 <CodeBlock.CopyButton />22 <CodeBlock.Code value={value} />23 </CodeBlock.Body>24</CodeBlock.Root>;If you use MDX, fenced code blocks are automatically highlighted at build time via the rehype plugin. You need to map the <pre> element to a CodeBlock component in your MDX provider:
1// components/mdx-provider.tsx2import {3 CodeBlock,4 createMantleCodeBlockValue,5 resolvePreRenderedCodeBlockProps,6 type CodeBlockPreElementInput,7} from "@ngrok/mantle/code-block";8import { MDXProvider } from "@mdx-js/react";9 10const components = {11 pre: (props: ComponentProps<"pre"> & CodeBlockPreElementInput) => {12 const { children, className, ...rawProps } = props;13 const { mantleCode: preRendered, props: rest } = resolvePreRenderedCodeBlockProps(rawProps);14 15 // Fallback to plain <pre> if not a mantle-highlighted code block16 if (!preRendered) {17 return (18 <pre className={className} {...rest}>19 {children}20 </pre>21 );22 }23 24 const { code, language, preHtml } = preRendered;25 if (!code || !language || !preHtml) {26 return (27 <pre className={className} {...rest}>28 {children}29 </pre>30 );31 }32 33 const value = createMantleCodeBlockValue({34 language,35 code,36 preHtml,37 highlightLines: preRendered.highlightLines,38 lineNumberStart: preRendered.lineNumberStart,39 showLineNumbers: preRendered.showLineNumbers ?? true,40 });41 42 return (43 <CodeBlock.Root>44 {preRendered.title && (45 <CodeBlock.Header>46 {preRendered.mode && <CodeBlock.Icon preset={preRendered.mode} />}47 <CodeBlock.Title>{preRendered.title}</CodeBlock.Title>48 </CodeBlock.Header>49 )}50 <CodeBlock.Body>51 <CodeBlock.CopyButton />52 <CodeBlock.Code value={value} />53 </CodeBlock.Body>54 </CodeBlock.Root>55 );56 },57};58 59export function MdxProvider({ children }) {60 return <MDXProvider components={components}>{children}</MDXProvider>;61}MDX fenced code blocks support metadata in the opening fence:
```tsx title="example.tsx" mode=file showLineNumbers highlight="2-3"
const x = 1;
const y = 2;
const z = x + y;
```
Supported meta keys: title, mode (cli | file | traffic-policy), showLineNumbers, highlight / highlightLines, lineNumberStart, collapsible, disableCopy.
Full example with all meta keys:
```tsx title="example.tsx" mode=file showLineNumbers highlight="2-4" lineNumberStart=10 collapsible disableCopy
const x = 1;
const y = 2;
const z = x + y;
```
This is for static sites or documentation sites where all code is known at build time in MDX files. No server-side highlighting is needed.
1# runtime2pnpm add -E @ngrok/mantle3 4# build-time (Vite plugin + MDX rehype plugin)5pnpm add -DE @ngrok/mantle-vite-pluginsYou do NOT need @ngrok/mantle-server-syntax-highlighter because all code is static and highlighted at build time.
1// vite.config.ts2import mdx from "@mdx-js/rollup";3import { mantleCodeBlockPlugins } from "@ngrok/mantle-vite-plugins";4import { defineConfig } from "vite";5 6const codeBlockPlugins = mantleCodeBlockPlugins();7 8export default defineConfig({9 plugins: [10 // Vite plugin for mantleCode`` tagged templates11 ...codeBlockPlugins.vitePlugins,12 13 // MDX with the rehype plugin for fenced code block highlighting14 mdx({15 rehypePlugins: [...codeBlockPlugins.rehypePlugins],16 }),17 ],18});# Getting Started
Install the package:
```bash mode=cli title="Command Line"
npm install @ngrok/mantle
```
Then use it:
```tsx title="app.tsx" highlight="3"
import { Button } from "@ngrok/mantle/button";
function App() {
return <Button>Click me</Button>;
}
```
All fenced code blocks are pre-rendered to HTML at build time. No Shiki ships to the browser.
Same as Use Case 1, Step 5. Map <pre> to CodeBlock in your MDX provider using resolvePreRenderedCodeBlockProps.
If you also have code blocks in React components (not MDX), use mantleCode tagged templates exactly as shown in Use Case 1, Step 3. The Vite plugin handles both MDX and tagged templates.
@ngrok/mantle-server-syntax-highlighter — all highlighting is build-timecreateMantleCodeBlockValue unless you're composing values manually in React componentsThis is for apps where the frontend is a Vite-built React 18 SPA and the backend is a Go server. The Go server cannot run Shiki (it's JavaScript/Wasm), so you have two options for dynamic code:
Option A: Use mantleCode tagged templates for all static code (build-time, via Vite plugin) and skip server highlighting entirely. Code not known at build time renders as plain text (no syntax highlighting).
Option B: Run a sidecar Node.js highlighting service that the frontend calls directly for dynamic code, using @ngrok/mantle-server-syntax-highlighter to produce highlighted HTML.
1# runtime2pnpm add -E @ngrok/mantle3 4# build-time (Vite plugin for mantleCode tagged templates)5pnpm add -DE @ngrok/mantle-vite-plugins1// vite.config.ts2import { mantleCodeBlockPlugins } from "@ngrok/mantle-vite-plugins";3import { defineConfig } from "vite";4 5const codeBlockPlugins = mantleCodeBlockPlugins({6 mdx: false, // disable MDX plugin if you don't use MDX7});8 9export default defineConfig({10 plugins: [...codeBlockPlugins.vitePlugins],11});Exactly the same as Use Case 1, Step 3. mantleCode tagged templates are transformed at Vite build time — the Go server is not involved.
1import { CodeBlock, mantleCode } from "@ngrok/mantle/code-block";2 3function TrafficPolicyExample() {4 return (5 <CodeBlock.Root>6 <CodeBlock.Header>7 <CodeBlock.Icon preset="traffic-policy" />8 <CodeBlock.Title>traffic-policy.yaml</CodeBlock.Title>9 </CodeBlock.Header>10 <CodeBlock.Body>11 <CodeBlock.CopyButton />12 <CodeBlock.Code13 value={mantleCode("yaml")`14 on_http_request:15 actions:16 type: custom-response17 config:18 status_code: 20019 content: Hello, World!20 `}21 />22 </CodeBlock.Body>23 </CodeBlock.Root>24 );25}If ~preHtml is not provided, CodeBlock gracefully falls back to rendering the code as escaped plain text (no colors, but still functional with copy button, line numbers, etc.).
1import { CodeBlock, createMantleCodeBlockValue } from "@ngrok/mantle/code-block";2 3function DynamicCodeBlock({ code, language }: { code: string; language: string }) {4 const value = createMantleCodeBlockValue({5 code,6 language,7 // preHtml is omitted — renders as plain text8 });9 10 return (11 <CodeBlock.Root>12 <CodeBlock.Body>13 <CodeBlock.CopyButton />14 <CodeBlock.Code value={value} />15 </CodeBlock.Body>16 </CodeBlock.Root>17 );18}Run a small Node.js sidecar alongside your Go server that uses @ngrok/mantle-server-syntax-highlighter to highlight code on demand. A minimal service should expose an HTTP endpoint like the following:
1curl -X POST http://127.0.0.1:4444/ \2 -H 'content-type: application/json' \3 -d '{4 "code": "const sum = (a, b) => a + b;",5 "language": "typescript",6 "showLineNumbers": true7 }'Response:
1{2 "code": "const sum = (a, b) => a + b;",3 "highlightLines": [],4 "html": "<span class=\"mantle-code-line\">...</span>",5 "language": "typescript",6 "lineNumberStart": 1,7 "showLineNumbers": true8}Then fetch from the frontend and construct a MantleCodeBlockValue:
1import {2 CodeBlock,3 createMantleCodeBlockValue,4 parseCodeBlockHighlightLines,5 parseLanguage,6} from "@ngrok/mantle/code-block";7 8async function fetchHighlightedCode(code: string, language: string) {9 const response = await fetch("http://localhost:4444/", {10 method: "POST",11 headers: { "Content-Type": "application/json" },12 body: JSON.stringify({ code, language, showLineNumbers: true }),13 });14 return response.json();15}16 17function DynamicCodeBlock({ code, language }: { code: string; language: string }) {18 const [value, setValue] = useState(null);19 20 useEffect(() => {21 fetchHighlightedCode(code, language).then((data) => {22 setValue(23 createMantleCodeBlockValue({24 code: data.code,25 language: parseLanguage(data.language),26 preHtml: data.html,27 showLineNumbers: data.showLineNumbers,28 highlightLines: parseCodeBlockHighlightLines(data.highlightLines) ?? [],29 lineNumberStart: data.lineNumberStart,30 }),31 );32 });33 }, [code, language]);34 35 if (!value) {36 // Render plain text while loading37 return (38 <CodeBlock.Root>39 <CodeBlock.Body>40 <CodeBlock.Code value={createMantleCodeBlockValue({ code, language })} />41 </CodeBlock.Body>42 </CodeBlock.Root>43 );44 }45 46 return (47 <CodeBlock.Root>48 <CodeBlock.Body>49 <CodeBlock.CopyButton />50 <CodeBlock.Code value={value} />51 </CodeBlock.Body>52 </CodeBlock.Root>53 );54}@ngrok/mantle supports React 18. The CodeBlock component uses "use client" directives, useId(), and standard hooks — all available in React 18. No React 19 features are required.
1<CodeBlock.Root>2 {/* Container — manages context, applies base styles */}3 <CodeBlock.Header>4 {/* Optional — header bar with icon and title */}5 <CodeBlock.Icon /> {/* preset="file" | "cli" | "traffic-policy", or svg={<CustomIcon />} */}6 <CodeBlock.Title /> {/* Renders <h3> by default, supports asChild */}7 <CodeBlock.TabList>8 {/* Optional — pill-styled tabs in header (Radix-based) */}9 <CodeBlock.TabTrigger value="example-tab" /> {/* Individual tab trigger */}10 </CodeBlock.TabList>11 </CodeBlock.Header>12 <CodeBlock.Body>13 {/* Content wrapper — positions CopyButton */}14 <CodeBlock.CopyButton /> {/* Copy-to-clipboard with "Copied" feedback */}15 <CodeBlock.Code /> {/* Renders <pre><code> with highlighted HTML */}16 <CodeBlock.TabContent value="..." /> {/* Conditional content for each tab */}17 </CodeBlock.Body>18 <CodeBlock.ExpanderButton /> {/* Optional — "Show more" / "Show less" toggle */}19</CodeBlock.Root>Use tabs when showing the same concept in multiple languages:
1import { CodeBlock, mantleCode } from "@ngrok/mantle/code-block";2 3function MultiLanguageExample() {4 return (5 <CodeBlock.Root defaultTab="typescript">6 <CodeBlock.Header>7 <CodeBlock.Icon preset="file" />8 <CodeBlock.TabList>9 <CodeBlock.TabTrigger value="typescript">TypeScript</CodeBlock.TabTrigger>10 <CodeBlock.TabTrigger value="python">Python</CodeBlock.TabTrigger>11 </CodeBlock.TabList>12 </CodeBlock.Header>13 <CodeBlock.Body>14 <CodeBlock.CopyButton />15 <CodeBlock.TabContent value="typescript">16 <CodeBlock.Code value={mantleCode("typescript")`const greeting = "Hello, world!";`} />17 </CodeBlock.TabContent>18 <CodeBlock.TabContent value="python">19 <CodeBlock.Code value={mantleCode("python")`greeting = "Hello, world!"`} />20 </CodeBlock.TabContent>21 </CodeBlock.Body>22 </CodeBlock.Root>23 );24}For controlled tabs, use activeTab and onActiveTabChange instead of defaultTab.
Use ExpanderButton for long code blocks. The collapsed height is approximately 4 lines (~13.6rem):
1<CodeBlock.Root>2 <CodeBlock.Body>3 <CodeBlock.CopyButton />4 <CodeBlock.Code5 value={mantleCode("typescript")`6 // ... many lines of code ...7 `}8 />9 </CodeBlock.Body>10 <CodeBlock.ExpanderButton />11</CodeBlock.Root>In MDX, use the collapsible meta key:
```typescript collapsible
// Long code block that will be collapsed by default
```
bash, cs, csharp, css, go, html, java, javascript, js, json, jsx, plain, plaintext, py, python, rb, ruby, rust, sh, shell, text, ts, tsx, txt, typescript, xml, yaml, yml
Many are aliases: js↔javascript, ts↔typescript, py↔python, rb↔ruby, sh↔bash↔shell, cs↔csharp, yml↔yaml, plain↔plaintext↔text↔txt. Unsupported languages fall back to "text" (plain text, no highlighting).
1// Components and factories2import {3 CodeBlock,4 mantleCode,5 createMantleCodeBlockValue,6 type MantleCodeBlockValue,7} from "@ngrok/mantle/code-block";8 9// Utilities (for MDX provider integration)10import {11 resolvePreRenderedCodeBlockProps,12 type CodeBlockPreElementInput,13} from "@ngrok/mantle/code-block";14 15// Parsing helpers16import {17 parseLanguage,18 isSupportedLanguage,19 parseCodeBlockHighlightLines,20 parseCodeBlockLineNumberStart,21 parseCodeBlockShowLineNumbers,22 supportedLanguages,23} from "@ngrok/mantle/code-block";24 25// React-free utilities (safe for server-only code, no React dependency)26import { decorateHighlightedHtml, escapeHtml } from "@ngrok/mantle/highlight-utils";27 28// Vite plugins29import { mantleCodeBlockPlugins } from "@ngrok/mantle-vite-plugins";30 31// Server highlighter32import { createMantleServerSyntaxHighlighter } from "@ngrok/mantle-server-syntax-highlighter";The sidecar highlight server accepts POST / with:
Responds with { code, html, language, highlightLines, lineNumberStart, showLineNumbers }.
Health check: GET /health returns 200 {"status":"ok"} when ready, 503 {"status":"starting"} during Shiki preload.
CodeBlock uses Mantle's built-in CSS variable theme (mantle-css-variables). Ensure your app imports the Mantle CSS:
1import "@ngrok/mantle/mantle.css";Light/dark mode is handled automatically via the Mantle ThemeProvider. The highlighted HTML uses CSS variable class names that respond to the active theme.
mantleCode returns plain text (no syntax highlighting)The Vite plugin is not configured. mantleCode is a no-op at runtime — it requires the Vite plugin to transform tagged templates at build time. Ensure mantleCodeBlockPlugins().vitePlugins is in your Vite config. In development, you may see a warning in the console.
If you pass a language string that isn't in the supported list, parseLanguage returns "text" (no highlighting, no error). Check isSupportedLanguage(lang) if you need to detect this.
0 is silently ignoredLine numbers are 1-based. Passing 0 in highlightLines (e.g., [0, 3]) silently filters it out. Ranges like "0-3" are also filtered.
preHtml must come from a trusted sourcepreHtml is rendered via dangerouslySetInnerHTML. Only pass HTML produced by Shiki through the Vite plugin, createMantleServerSyntaxHighlighter, or the highlight server. Never pass user-provided HTML strings.
The <pre> element renders with tab-size: 2. This is not configurable via props or options.
The title meta key value is trimmed — leading/trailing whitespace is removed.
All sub-components besides Root, Body, and Code are optional. A minimal code block is:
1<CodeBlock.Root>2 <CodeBlock.Body>3 <CodeBlock.Code value={mantleCode("typescript")`const x = 1;`} />4 </CodeBlock.Body>5</CodeBlock.Root>