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