/** * Highlights fenced code blocks with Shiki at build time. * * Output contract (consumed by `components/CodeBlock.tsx` and global.css): * - `
` carries `data-language`, and when present `data-title`,
* `data-line-numbers`, `data-line-start`, `data-line-count`, `data-diff-markers`.
* - `` keeps its class; its children are Shiki's
* `` elements, each optionally marked `data-highlighted`
* or `data-diff="add|remove"`.
* - Token spans carry only `--shiki-light` / `--shiki-dark` variables so the
* markup is identical in both color modes and CSS picks the theme.
*/
import { transformerNotationDiff, transformerNotationHighlight } from '@shikijs/transformers';
import {
bundledLanguages,
bundledThemes,
createHighlighter,
type Highlighter,
type ShikiTransformer,
} from 'shiki';
// Relative imports: this module is also loaded by vite.config.ts, which esbuild
// bundles without applying the '@/' resolve alias.
import { parseCodeMeta } from './code-meta.ts';
import { type MdNode, toText, walkTree } from './mdast.ts';
import type { ResolvedCodeBlockConfig } from './types.ts';
type Properties = Record;
const highlighters = new Map>();
const languageLoads = new Map>();
const warnedLanguages = new Set();
function assertTheme(name: string, key: 'light' | 'dark') {
if (!(name in bundledThemes)) {
throw new Error(
`[shiso] Unknown code theme "${name}" in docs.json styling.codeBlocks.theme.${key}. ` +
'Use a bundled Shiki theme name (https://shiki.style/themes).',
);
}
}
function getHighlighter(theme: ResolvedCodeBlockConfig['theme']): Promise {
const key = `${theme.light}|${theme.dark}`;
let highlighter = highlighters.get(key);
if (!highlighter) {
assertTheme(theme.light, 'light');
assertTheme(theme.dark, 'dark');
highlighter = createHighlighter({ themes: [theme.light, theme.dark], langs: [] });
highlighters.set(key, highlighter);
}
return highlighter;
}
async function ensureLanguage(highlighter: Highlighter, lang: string) {
if (highlighter.getLoadedLanguages().includes(lang)) {
return;
}
let load = languageLoads.get(lang);
if (!load) {
load = highlighter.loadLanguage(lang as keyof typeof bundledLanguages);
languageLoads.set(lang, load);
}
await load;
}
function resolveLanguage(requested: string | undefined): string {
if (!requested || requested === 'text' || requested === 'plaintext' || requested === 'txt') {
return 'text';
}
if (requested in bundledLanguages) {
return requested;
}
if (!warnedLanguages.has(requested)) {
warnedLanguages.add(requested);
console.warn(`[shiso] No syntax grammar for "${requested}"; rendering it as plain text.`);
}
return 'text';
}
/** Marks meta `{1,3-5}` lines and, for `diff` blocks, the added/removed lines. */
function shisoLineMarks(highlight: Set, diffLanguage: boolean): ShikiTransformer {
return {
name: 'shiso:line-marks',
line(node, line) {
if (highlight.has(line)) {
node.properties['data-highlighted'] = '';
}
if (diffLanguage) {
const text = toText(node as unknown as MdNode);
if (/^\+(?!\+\+)/.test(text)) {
node.properties['data-diff'] = 'add';
} else if (/^-(?!--)/.test(text)) {
node.properties['data-diff'] = 'remove';
}
}
},
};
}
/** Converts transformer classes (`highlighted`, `diff add|remove`) into data attributes. */
function normalizeLine(line: MdNode) {
const properties = (line.properties || {}) as Properties;
// Shiki transformers write `class`; mdast-util-to-hast writes `className`.
const classes = [properties.class, properties.className]
.flatMap(value => (Array.isArray(value) ? value : String(value || '').split(' ')))
.map(String)
.filter(Boolean);
if (classes.includes('highlighted')) {
properties['data-highlighted'] = '';
}
if (classes.includes('diff')) {
properties['data-diff'] = classes.includes('remove') ? 'remove' : 'add';
}
delete properties.class;
properties.className = ['line'];
line.properties = properties;
}
async function highlightBlock(pre: MdNode, code: MdNode, config: ResolvedCodeBlockConfig) {
const codeProperties = (code.properties || {}) as Properties;
const classes = Array.isArray(codeProperties.className)
? codeProperties.className.map(String)
: [];
const requested = classes.map(value => value.match(/^language-(\S+)$/)?.[1]).find(Boolean);
const meta = parseCodeMeta(typeof code.data?.meta === 'string' ? code.data.meta : '');
// Mermaid diagrams render client-side via the component; keep the
// raw definition so CodeBlock can hand it over untouched.
if (requested === 'mermaid') {
const metaRaw = typeof code.data?.meta === 'string' ? code.data.meta : '';
const placement = metaRaw.match(/placement=["'](top-left|top-right|bottom-left|bottom-right)["']/)?.[1];
const actions = metaRaw.match(/actions=\{(true|false)\}/)?.[1];
pre.properties = {
...(pre.properties as Properties),
'data-language': 'mermaid',
...(meta.title ? { 'data-title': meta.title } : {}),
...(placement ? { 'data-placement': placement } : {}),
...(actions ? { 'data-actions': actions } : {}),
};
return;
}
const lang = resolveLanguage(requested);
// mdast-util-to-hast appends a trailing newline to the code text.
const source = toText(code).replace(/\n$/, '');
const highlighter = await getHighlighter(config.theme);
if (lang !== 'text') {
await ensureLanguage(highlighter, lang);
}
const hast = highlighter.codeToHast(source, {
lang,
themes: config.theme,
defaultColor: false,
transformers: [
transformerNotationHighlight({ matchAlgorithm: 'v3' }),
transformerNotationDiff({ matchAlgorithm: 'v3' }),
shisoLineMarks(new Set(meta.highlightLines), lang === 'diff'),
],
}) as MdNode;
// root > pre > code
const shikiPre = hast.children?.[0] as MdNode;
const shikiCode = shikiPre?.children?.[0] as MdNode;
const lines = (shikiCode?.children || []).filter(node => node.tagName === 'span');
lines.forEach(normalizeLine);
const hasNotationDiff =
lang !== 'diff' && lines.some(line => (line.properties as Properties)['data-diff']);
const showLineNumbers = meta.showLineNumbers ?? config.lineNumbers;
code.children = shikiCode?.children || [];
pre.properties = {
...(pre.properties as Properties),
'data-language': lang,
...(meta.title ? { 'data-title': meta.title } : {}),
...(showLineNumbers ? { 'data-line-numbers': 'true' } : {}),
...(meta.startLine ? { 'data-line-start': String(meta.startLine) } : {}),
'data-line-count': String(lines.length),
...(hasNotationDiff ? { 'data-diff-markers': 'true' } : {}),
};
}
export function rehypeShiki(config: ResolvedCodeBlockConfig) {
return async (tree: MdNode) => {
const tasks: Promise[] = [];
walkTree(tree, pre => {
if (pre.tagName !== 'pre') {
return;
}
const code = pre.children?.find(child => child.tagName === 'code');
if (code) {
tasks.push(highlightBlock(pre, code, config));
}
});
await Promise.all(tasks);
};
}