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:
@@ -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' })
|
||||
}
|
||||
Reference in New Issue
Block a user