feat(web): shiki syntax highlighting for code surfaces

One highlighter for the client: a synchronous fine-grained shiki core
(JS regex engine, no WASM) in ui-primitives with an explicit grammar
allowlist (typescript, shellscript, json — aliases resolve, unknown
languages take a geometry-identical plain arm). The shared CodeBlock
component owns both arms; markdown fences, the run_code expanded
program body (typescript), and the details panel Input (json) all
route through it. Token colors live in a new ui-theme shiki.css sheet
as --shiki-* custom properties (light/dark blocks), wired through the
shell's base.css chain — tokens-only styling holds; shiki's generated
span tree is the sanctioned innerHTML path (static output, no user
HTML). jsdom specs pin token spans, aliases, both fallbacks, and the
fence route; the built-bundle snapshot asserts the highlighted program
under the code row.
This commit is contained in:
Tianyi Cui
2026-07-26 09:52:37 +08:00
parent 4987261d55
commit bb3dc50a4b
17 changed files with 399 additions and 19 deletions
@@ -0,0 +1,27 @@
/* One code-block geometry for highlighted and plain arms: the shiki <pre>
and the fallback <pre> draw identically except for token colors. */
.block :where(pre) {
margin: 0;
padding: 8px 10px;
border-radius: 8px;
overflow-x: auto;
background: var(--dsw-alias-markdown-code-block);
font: var(--dsw-font-markdown-code-block);
}
/* Shiki inlines its theme background var; route it to the repo token. */
.block :where(pre.shiki) {
background: var(--dsw-alias-markdown-code-block) !important;
}
.block :where(pre) code {
font: inherit;
background: none;
padding: 0;
}
.plain {
color: var(--dsw-alias-label-primary);
white-space: pre;
}
@@ -0,0 +1,37 @@
// CodeBlock: one code surface for every consumer — markdown fences, the
// run_code program body, and the details panel's raw args/output — with
// shiki highlighting for the registered grammars and an identical-geometry
// plain fallback for everything else. Shiki emits a single <pre class="shiki">
// tree of nested spans whose colors are --shiki-* custom properties
// (token sheets own the values); it produces no scripts or event handlers,
// so injecting its output is safe by construction.
import { useMemo } from 'react'
import clsx from 'clsx'
import { highlightToHtml } from './highlight.ts'
import css from './CodeBlock.module.css'
export interface CodeBlockProps {
/** The source text, rendered verbatim (trailing newline trimmed for display). */
code: string
/** Grammar hint (markdown fence info string or a fixed caller id); unknown = plain. */
lang?: string | undefined
/** Extra class merged onto the wrapper (callers position; this component draws). */
className?: string | undefined
}
export function CodeBlock({ code, lang, className }: CodeBlockProps) {
const trimmed = code.endsWith('\n') ? code.slice(0, -1) : code
const html = useMemo(() => highlightToHtml(trimmed, lang), [trimmed, lang])
if (html === undefined) {
return (
<div className={clsx(css.block, className)}>
<pre className={css.plain}><code>{trimmed}</code></pre>
</div>
)
}
// eslint-disable-next-line react/no-danger -- shiki's output is a static
// span tree it generated from `code` (no user HTML passes through), the
// sanctioned innerHTML consumption path per shiki's own docs.
return <div className={clsx(css.block, className)} dangerouslySetInnerHTML={{ __html: html }} />
}
@@ -1,6 +1,8 @@
import { isValidElement } from 'react'
import ReactMarkdown from 'react-markdown'
import type { Components, UrlTransform } from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { CodeBlock } from './CodeBlock.tsx'
import css from './MarkdownText.module.css'
const remarkPlugins = [remarkGfm]
@@ -42,6 +44,19 @@ const components: Components = {
<table>{children}</table>
</div>
),
// Fenced blocks route through the shared CodeBlock (shiki for registered
// grammars, identical-geometry plain fallback for unknown/absent languages);
// inline code keeps the default <code> path (the :not(pre) rule styles it).
pre: ({ children }) => {
const child = isValidElement<{ className?: string; children?: unknown }>(children) ? children : undefined
const raw = child?.props.children
const text = typeof raw === 'string' ? raw : Array.isArray(raw) && typeof raw[0] === 'string' ? raw[0] : undefined
// A fence whose content isn't one plain string (never produced by the
// markdown pipeline) keeps the stock <pre> rather than guessing.
if (text === undefined) return <pre>{children}</pre>
const lang = /language-([\w-]+)/.exec(child?.props.className ?? '')?.[1]
return <CodeBlock code={text} lang={lang} />
},
}
/**
@@ -0,0 +1,68 @@
/**
* The client's ONE syntax highlighter: a synchronous fine-grained shiki core
* (JavaScript regex engine — no oniguruma WASM, bundle-friendly) with an
* explicit grammar allowlist and a CSS-variables theme. Colors live in the
* theme package's token sheets as `--shiki-*` custom properties (light and
* dark blocks), never here — the repo's tokens-only styling rule.
*
* Grammars are the set the harness actually renders: TypeScript programs
* (`run_code` bodies; TS pulls in JS via grammar embedding), shell commands,
* and JSON payloads. An unknown or absent language falls back to plain text
* (no highlighting, still monospace) — never an error.
*/
import { createHighlighterCoreSync, createCssVariablesTheme } from 'shiki/core'
import { createJavaScriptRegexEngine } from 'shiki/engine/javascript'
import langTs from '@shikijs/langs/typescript'
import langBash from '@shikijs/langs/shellscript'
import langJson from '@shikijs/langs/json'
import type { HighlighterCore } from 'shiki/core'
/** Language ids (and aliases) the singleton registers; everything else renders plain. */
const LANG_ALIASES: Record<string, string> = {
typescript: 'typescript',
ts: 'typescript',
tsx: 'typescript',
javascript: 'typescript',
js: 'typescript',
shellscript: 'shellscript',
bash: 'shellscript',
sh: 'shellscript',
shell: 'shellscript',
zsh: 'shellscript',
json: 'json',
jsonc: 'json',
}
/** All token colors resolve through `--shiki-*` custom properties (theme package sheets). */
const cssVariablesTheme = createCssVariablesTheme({
name: 'css-variables',
variablePrefix: '--shiki-',
fontStyle: true,
})
let singleton: HighlighterCore | undefined
/** The lazily-created synchronous highlighter (one instance per document). */
function highlighter(): HighlighterCore {
singleton ??= createHighlighterCoreSync({
themes: [cssVariablesTheme],
langs: [langTs, langBash, langJson],
engine: createJavaScriptRegexEngine({ forgiving: true }),
})
return singleton
}
/**
* Highlight `code` into shiki's HTML (a single `<pre class="shiki">` tree)
* when `lang` maps to a registered grammar; `undefined` means the caller
* renders its plain fallback.
* @param code - the source text.
* @param lang - the language hint (a markdown fence info string or a fixed caller id).
* @returns the highlighted HTML, or `undefined` for unknown languages.
*/
export function highlightToHtml(code: string, lang: string | undefined): string | undefined {
const resolved = lang === undefined ? undefined : LANG_ALIASES[lang.toLowerCase()]
if (resolved === undefined) return undefined
return highlighter().codeToHtml(code, { lang: resolved, theme: 'css-variables' })
}