diff --git a/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.i18n.yaml b/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.i18n.yaml index d0e217941b..9fd37bcedb 100644 --- a/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.md b/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.md index 79ad2153b8..b329e35f1d 100644 --- a/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.md +++ b/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.md @@ -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. diff --git a/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.zh.md b/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.zh.md index 4cb3f0cead..8e9d1f0d0c 100644 --- a/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.zh.md +++ b/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.zh.md @@ -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"`)。输出有意保持纯文本——工具输出是任意文本,硬猜一种语法,带来的误高亮会多于帮助。 diff --git a/packages/client/ui-primitives/src/markdown/highlight.ts b/packages/client/ui-primitives/src/markdown/highlight.ts index 34e0359f60..1fa50f6d2f 100644 --- a/packages/client/ui-primitives/src/markdown/highlight.ts +++ b/packages/client/ui-primitives/src/markdown/highlight.ts @@ -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 = { - 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([ + ['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 `
` 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' })
 }
diff --git a/packages/client/ui-primitives/tests/markdown.spec.tsx b/packages/client/ui-primitives/tests/markdown.spec.tsx
index 00de9683ff..05c7ce0139 100644
--- a/packages/client/ui-primitives/tests/markdown.spec.tsx
+++ b/packages/client/ui-primitives/tests/markdown.spec.tsx
@@ -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()
+      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()
     expect(empty.container.querySelector('pre')?.outerHTML).toBe('
')