fix(ui-primitives): prototype-safe alias lookup; pre-warm shiki off the render path

Responding to ds-review-bot round 2 on #662:

- LANG_ALIASES is a Map: an assistant-authored fence label like
  constructor or __proto__ now misses (plain render) instead of resolving
  an inherited object property and crashing shiki mid-conversation. Test
  sweeps the inherited-key labels.
- The singleton is pre-warmed in a deferred task at plugin boot (the
  ~120-175ms engine+grammar construction long task moves off the first
  finalized fence's render); the lazy path remains the correctness
  fallback, and unref keeps non-browser imports from pinning the loop.

Agent Note updated (both languages).
This commit is contained in:
Tianyi Cui
2026-07-26 18:33:51 +08:00
parent 28b617dd73
commit 79e72eb736
5 changed files with 44 additions and 21 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-07-26-web-syntax-highlighting-shiki.md: 79ad2153b8883fda92205dada300fd194834129b
2026-07-26-web-syntax-highlighting-shiki.zh.md: 4cb3f0ceadebc4837108463c149262bf8e36f93d
2026-07-26-web-syntax-highlighting-shiki.md: b329e35f1d0ce7b3de454758403a09f67056b5af
2026-07-26-web-syntax-highlighting-shiki.zh.md: 8e9d1f0d0c38ce64bcb5da1262538da762f70b12

View File

@@ -15,7 +15,7 @@ The client rendered every code surface — markdown fences in assistant prose, t
**Shiki in its synchronous fine-grained form, as one `ui-primitives` singleton, themed exclusively through CSS custom properties.**
- **Dependency**: `shiki/core` + `@shikijs/langs`, composed via `createHighlighterCoreSync` with `createJavaScriptRegexEngine({ forgiving: true })` — no oniguruma WASM, no async init, bundle-friendly. Grammar allowlist: `typescript` (embeds JS), `shellscript`, `json` — the languages the harness actually renders; everything else falls back to a geometry-identical plain block, never an error. Prior art: the VitePress site already renders all documentation code through shiki, and TextMate grammars materially beat regex highlighters on TypeScript — the payload that matters here.
- **Singleton**: `ui-primitives/src/markdown/highlight.ts` lazily creates one `HighlighterCore` per document and exposes `highlightToHtml(code, lang)` (undefined = render plain). The shared `CodeBlock` component owns both arms; its shiki arm injects the generated span tree via `dangerouslySetInnerHTML` — sanctioned because shiki emits a static span tree computed from the code text (no user HTML passes through, no scripts/handlers), shiki's own documented consumption path.
- **Singleton**: `ui-primitives/src/markdown/highlight.ts` creates one `HighlighterCore` per document and exposes `highlightToHtml(code, lang)` (undefined = render plain). Engine + grammar construction is a ~120-175ms long task, so the module pre-warms the singleton in a deferred task at plugin boot (the lazy path stays as the correctness fallback), keeping the cost off the render path where a stream's finalize swap would jank. The alias table is a `Map`, not an object: fence info strings are assistant-authored, so a label like `constructor` must miss instead of resolving an inherited property and crashing shiki. The shared `CodeBlock` component owns both arms; its shiki arm injects the generated span tree via `dangerouslySetInnerHTML` — sanctioned because shiki emits a static span tree computed from the code text (no user HTML passes through, no scripts/handlers), shiki's own documented consumption path.
- **Theming**: shiki's `createCssVariablesTheme` routes every token color through `--shiki-*` custom properties; the VALUES live in a new `ui-theme/styles/shiki.css` token sheet (light on `:root`, dark on `body[data-ds-dark-theme]` — the same cascade as every other sheet), imported by the shell's `base.css` chain. Component CSS stays tokens-only; no literal color ever enters JS or component sheets. Background/foreground alias the existing markdown code-block tokens so highlighted and plain blocks agree.
- **Surfaces**: markdown fences (`MarkdownText`'s `pre` component routes single-string fences through `CodeBlock`), the `run_code` expanded program body (ToolRow's code variant, `lang="typescript"`), and the details panel's Input args (`lang="json"`). Output stays plain deliberately — tool output is arbitrary text, and guessing a grammar would mis-highlight more than it helps.

View File

@@ -15,7 +15,7 @@ client 过去把每一处代码表面——assistant 正文里的 markdown 围
**采用同步细粒度形态的 shiki作为 `ui-primitives` 里的一个单例,主题化完全经由 CSS 自定义属性完成。**
- **依赖**`shiki/core` + `@shikijs/langs`,经 `createHighlighterCoreSync` 搭配 `createJavaScriptRegexEngine({ forgiving: true })` 组装——不带 oniguruma WASM、没有异步初始化、对 bundle 友好。语法grammar白名单`typescript`(内嵌 JS`shellscript``json`——即 harness 实际会渲染的那几种语言其余一律回退到几何完全一致的纯文本块绝不报错。先例VitePress 站点已经通过 shiki 渲染全部文档代码;而在 TypeScript正是此处要紧的载荷TextMate 语法实质性优于正则高亮器。
- **单例**`ui-primitives/src/markdown/highlight.ts` 为每个 document 惰性创建一个 `HighlighterCore`,并公开 `highlightToHtml(code, lang)`undefined 即渲染为纯文本)。共享的 `CodeBlock` 组件同时拥有两条分支;其 shiki 分支经 `dangerouslySetInnerHTML` 注入生成的 span 树——此用法获准,因为 shiki 输出的是从代码文本计算出的静态 span 树(不流经任何用户 HTML没有脚本或事件处理器这正是 shiki 自身文档载明的消费路径。
- **单例**`ui-primitives/src/markdown/highlight.ts` 为每个 document 创建一个 `HighlighterCore`,并公开 `highlightToHtml(code, lang)`undefined 即渲染为纯文本)。引擎加语法的构建是一次约 120-175ms 的长任务,因此模块在插件启动时用延迟任务预热单例(惰性路径保留为正确性兜底),把这笔开销挪出渲染路径——否则流式 finalize 交换的那一刻会卡顿。别名表用 `Map` 而非对象fence 信息串由 assistant 撰写,诸如 `constructor` 这样的标签必须落空,而不是解析到继承属性并让 shiki 崩溃。共享的 `CodeBlock` 组件同时拥有两条分支;其 shiki 分支经 `dangerouslySetInnerHTML` 注入生成的 span 树——此用法获准,因为 shiki 输出的是从代码文本计算出的静态 span 树(不流经任何用户 HTML没有脚本或事件处理器这正是 shiki 自身文档载明的消费路径。
- **主题化**shiki 的 `createCssVariablesTheme` 让每一种 token 颜色都经由 `--shiki-*` 自定义属性路由;取值本身住在新增的 `ui-theme/styles/shiki.css` token 表里(亮色在 `:root`、暗色在 `body[data-ds-dark-theme]`——层叠方式与其余每张样式表相同),由壳的 `base.css` 导入链引入。组件 CSS 保持只用 token任何字面颜色都不进入 JS 或组件样式表。背景/前景以别名指向既有的 markdown 代码块 token使高亮块与纯文本块彼此一致。
- **表面**markdown 围栏代码块(`MarkdownText``pre` 组件把单字符串围栏路由到 `CodeBlock`)、`run_code` 展开后的程序正文ToolRow 的 code 变体,`lang="typescript"`),以及 details 面板的 Input 参数(`lang="json"`)。输出有意保持纯文本——工具输出是任意文本,硬猜一种语法,带来的误高亮会多于帮助。

View File

@@ -18,21 +18,26 @@ 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',
}
/**
* Language ids (and aliases) the singleton registers; everything else renders
* plain. A Map, not an object: fence info strings are assistant-authored, so
* a label like `constructor` or `__proto__` must miss instead of resolving an
* inherited property and crashing the renderer inside shiki.
*/
const LANG_ALIASES = new Map<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({
@@ -43,7 +48,7 @@ const cssVariablesTheme = createCssVariablesTheme({
let singleton: HighlighterCore | undefined
/** The lazily-created synchronous highlighter (one instance per document). */
/** The synchronous highlighter (one instance per document); pre-warmed below, lazy as the fallback. */
function highlighter(): HighlighterCore {
singleton ??= createHighlighterCoreSync({
themes: [cssVariablesTheme],
@@ -53,6 +58,15 @@ function highlighter(): HighlighterCore {
return singleton
}
// Engine + grammar construction costs a long task (~120-175ms); building it
// during the first finalized fence's render would jank exactly when a stream
// completes. Warm the singleton in a deferred task at module load (= plugin
// boot) instead; the lazy path above stays as the correctness fallback for a
// fence that renders before the timer fires. `unref` (Node-only) keeps a
// non-browser import from pinning the event loop.
const warmupTimer = setTimeout(() => { highlighter() }, 0)
;(warmupTimer as { unref?: () => void }).unref?.()
/**
* Highlight `code` into shiki's HTML (a single `<pre class="shiki">` tree)
* when `lang` maps to a registered grammar; `undefined` means the caller
@@ -62,7 +76,7 @@ function highlighter(): HighlighterCore {
* @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()]
const resolved = lang === undefined ? undefined : LANG_ALIASES.get(lang.toLowerCase())
if (resolved === undefined) return undefined
return highlighter().codeToHtml(code, { lang: resolved, theme: 'css-variables' })
}

View File

@@ -64,6 +64,15 @@ describe('MarkdownText', () => {
expect(screen.getByRole('link', { name: 'https://deepseek.com' })).toBeTruthy()
})
it('a fence labeled with an inherited object key renders plain, never crashing shiki', () => {
for (const label of ['constructor', '__proto__', 'toString', 'hasOwnProperty']) {
const { container, unmount } = render(<MarkdownText text={'```' + label + '\ncode body\n```'} />)
expect(container.querySelector('pre.shiki')).toBeNull()
expect(container.querySelector('pre code')?.textContent).toContain('code body')
unmount()
}
})
it('an empty fence keeps the stock pre; a language-less fence renders the plain CodeBlock arm', () => {
const empty = render(<MarkdownText text={'```\n```'} />)
expect(empty.container.querySelector('pre')?.outerHTML).toBe('<pre><code></code></pre>')