Code blocks
Every fence is highlighted in Node at build time and wrapped in a <figure>
with a copy button. No highlighter reaches the browser — the only thing that
does is the listener that makes the button work.
The frame
A highlighted fence comes out as a <figure class="wave-docs-code"> holding an
optional <figcaption>, a <button> and the <pre> Shiki produced. Give it a
title and the caption becomes a bar across the top of the block:
```ts title="app/page.tsx"
export default function Page() {
return <h1>Hello</h1>;
}
```The title lands in three places at once — the caption, the button's
accessible name (Copy code from app/page.tsx, rather than eight controls all
called "Copy code"), and the search index. The caption is the one part of a
code block a reader can search for, which is why it is a <figcaption> and not
an attribute. Search covers what reaches the index.
A title= that is not double-quoted fails the build, naming the document
and quoting the offending meta string. An unquoted title=app/page.tsx ships a
caption truncated at the first space, and an unterminated title="app/page.tsx
ships no caption at all — both silently. The error code is invalid-code-meta;
Errors lists the rest. A filename containing a double
quote is inexpressible and gets no escape grammar.
The rest of the meta string
Nothing else on the fence is consumed. The parser reads title= and never
rewrites code.data.meta, so Shiki still forwards the raw string to its own
transformers as meta.__raw — {1,3-5} and showLineNumbers pass through
untouched. A parser that ate what it recognised would silently disable every
feature it had not been taught about yet.
Languages
Eighteen grammars load by default — what technical documentation actually contains:
typescript tsx javascript jsx json shellscript css html
markdown yaml diff sql python go rust prisma ini tomlThe usual aliases are accepted too: ts, js, bash, sh, shell, zsh,
md, yml, py, rs, properties.
A ```cfg fence (or ```conf) uses the ini grammar, because
the fence an author types follows the filename — nobody writes ```ini
above a file called server.cfg. Shiki resolves a fence against a grammar's own
aliases rather than against the loader table, and ini ships exactly one alias,
properties, so the two names are registered on the grammar itself.
Fence languages are matched case-insensitively. ```JSON and
```Bash highlight like their lowercase spellings. Folding is not
politeness: the Shiki integration tests its loaded-language list with no case
folding, and the plain-text fallback then swallowed the miss, so those fences
shipped monochrome with <pre> properties byte-identical to a highlighted
block — correct in the author's editor and on GitHub, wrong only in production.
A language outside the loaded set falls back to plain text rather than
throwing. A fence never fails a build over its language. Configuration does:
langs is typed as a union of the names this package can load, so 'typescrpt'
is a compile error, and a value arriving from JSON config throws
unknown-language naming the supported set. Pass langs to change the set,
themes to change the light/dark pair, or highlighter to supply your own —
see Configuration.
The copy button
One delegated listener for the page, not a component per block. DocContent
mounts it, and only when the tree it was handed contains a code frame — a page
with no fences ships none of it. The buttons are server-rendered HTML with no
React identity at all: one click listener on document, one live region on
<body>, and a data-copied attribute the stylesheet reads for the tick and
the cross. At most 1.1 KB gzipped, which pnpm size holds it to.
The alternative — mapping pre to a 'use client' component, which is what the
comparable packages do — puts one client reference and one hydration root in the
flight stream per fence, fifty of each on a page with fifty blocks, and drags
the highlighted subtree across the client boundary as children.
Internals puts that beside the rest of the shape decisions.
The button is visibility: hidden until that listener attaches. The markup
is there whether or not any JavaScript runs — scripts off, renderToStaticMarkup,
anyone rendering the hast by hand — and the runtime sets
data-wave-docs-code-ready on <html> once it installs. visibility: hidden
also takes the control out of the tab order, which opacity: 0 would not, so a
reader without JavaScript finds no dead tab stop where a button should be.
The button sits before the <pre>, so the tab order reads "copy this block",
then "the scrollable code region". Its accessible name never changes, including
on success — the outcome is announced through a live region instead. All four
strings (copyCode, copyCodeFrom, copied, copyFailed) are translatable,
and the first two are baked into the HTML at build time: see
Translating the chrome.
The language badge
The <figure> carries data-lang — the folded language, so ```JSON
gives json. A fence with no language carries no attribute at all rather than
data-lang="text", so a badge rule needs no exception for it. No badge is
rendered by default; one rule turns it on:
.wave-docs-code[data-lang]::before {
content: attr(data-lang);
}Keeping it in CSS is deliberate. A real element would enter the search index
and textContent, so every code block would pollute search results with its
language name and the copy button would copy it. Generated content does neither.
The colour tokens to position it against are in Theming.
Fences you render yourself
excludeLangs tells Shiki to leave a language alone, so the <pre> reaches your
own component untouched — for diagrams, or anything that is not really code:
import { createDocsRoute } from '@waveso/docs/next';
export const docs = createDocsRoute({
contentDir: 'content/docs',
excludeLangs: ['mermaid'],
});Those fences are deliberately not framed: a copy button on a rendered
diagram copies its source, which is not what the reader clicked. They keep the
surface of a highlighted block — the same background, border and horizontal
scroll, and the tabindex that lets a keyboard scroll it — so excludeLangs on
its own produces a page that looks deliberate rather than unstyled.
To render them, map pre:
import { isValidElement, type ReactNode } from 'react';
/** Yours: a `'use client'` component wrapping whichever renderer you like. */
declare function Mermaid(props: { children: string }): ReactNode;
function textOf(node: ReactNode): string {
if (typeof node === 'string') return node;
if (Array.isArray(node)) return node.map(textOf).join('');
if (isValidElement<{ children?: ReactNode }>(node)) {
return textOf(node.props.children);
}
return '';
}
export const components = {
pre: (props: { children?: ReactNode }) => {
const child = props.children;
const className = isValidElement<{ className?: string | string[] }>(child)
? child.props.className
: undefined;
// An array, not a string. See the caution below.
const languages = Array.isArray(className) ? className : [className];
if (languages.includes('language-mermaid')) {
return <Mermaid>{textOf(props.children)}</Mermaid>;
}
return <pre {...props} />;
},
};Pass it as components to createDocsRoute, or to DocContent directly.
Mermaid is yours. This package deliberately ships no renderer: several hundred
kilobytes of client JavaScript with its own CVE history, behind an option most
sites never set. A rehypePlugins entry sees excluded fences before they are
disguised, and sees every code block the same way — see
Plugins.