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.
38 lines
1.7 KiB
TypeScript
38 lines
1.7 KiB
TypeScript
// 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 }} />
|
|
}
|