feat(highlight): replace Prism with highlight.js and Atom One Dark

This commit is contained in:
eezd committed 2026-10-24 18:12:49 +08:00
1 parent 634ef9fc11
commit b123ef36db
10 files changed
+72 -134

No files matched your search

+10 -18
View File
@@ -15,14 +15,13 @@
"@lezer/highlight": "^1.2.5",
"@lezer/markdown": "^1.8.0",
"gemoji": "^8.1.0",
"highlight.js": "^11.12.0",
"katex": "^0.18.11",
"mermaid": "^12.2.0",
"prismjs": "^1.30.0",
"turndown": "^7.2.4",
"vue": "^3.5.44"
},
"devDependencies": {
"@types/prismjs": "^1.26.6",
"@types/turndown": "^5.0.6",
"@vitejs/plugin-vue": "^6.0.10",
"commonmark-spec": "^0.31.2",
@@ -991,13 +990,6 @@
"undici-types": "~8.9.0"
}
},
"node_modules/@types/prismjs": {
"version": "1.26.6",
"resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.6.tgz",
"integrity": "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
@@ -2018,6 +2010,15 @@
"node": ">=20.0.0"
}
},
"node_modules/highlight.js": {
"version": "11.12.0",
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.12.0.tgz",
"integrity": "sha512-nbfWpyRMcMrPMmDwJB+dhX/eiaPKtc2RB+0QZskqJ3WjRA/FDS0e9hZrx8EC/lbEv8gXy98FcDbNa/dspAaJMg==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
@@ -2533,15 +2534,6 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/prismjs": {
"version": "1.30.0",
"resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz",
"integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/robust-predicates": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz",
+1 -2
View File
@@ -18,14 +18,13 @@
"@lezer/highlight": "^1.2.5",
"@lezer/markdown": "^1.8.0",
"gemoji": "^8.1.0",
"highlight.js": "^11.12.0",
"katex": "^0.18.11",
"mermaid": "^12.2.0",
"prismjs": "^1.30.0",
"turndown": "^7.2.4",
"vue": "^3.5.44"
},
"devDependencies": {
"@types/prismjs": "^1.26.6",
"@types/turndown": "^5.0.6",
"@vitejs/plugin-vue": "^6.0.10",
"commonmark-spec": "^0.31.2",
+2 -2
View File
@@ -34,6 +34,6 @@ it('启动和运行失败均结束请求,失败不会伪装成缓存结果且
await crashed
const recovered = requestTokens('python', 'print(1)')
const replacement = control.workers[1]!
replacement.dispatchEvent(new MessageEvent('message', { data: { id: replacement.request!.id, tokens: [{ classes: 'token function', content: ['print'] }] } }))
await expect(recovered).resolves.toEqual([{ classes: 'token function', content: ['print'] }])
replacement.dispatchEvent(new MessageEvent('message', { data: { id: replacement.request!.id, tokens: [{ classes: 'hljs-title function_', content: ['print'] }] } }))
await expect(recovered).resolves.toEqual([{ classes: 'hljs-title function_', content: ['print'] }])
})
+1 -1
View File
@@ -6,7 +6,7 @@ export interface HighlightRequest {
code: string
}
// 保留 Prism 的嵌套 token 与原文,编辑器使用官方 token 类样式。
// 保留 highlight.js 的嵌套 token 与原文,编辑器使用官方 token 类样式。
export type TokenRun = (string | { classes: string; content: TokenRun })[]
// 语言不受支持时 tokens 为 null;实际失败必须与纯文本结果区分。
+32 -18
View File
@@ -9,7 +9,6 @@ let nextId = 1
beforeAll(async () => {
vi.stubGlobal('self', worker)
vi.stubGlobal('Prism', {})
await import('./worker')
})
afterAll(() => { vi.restoreAllMocks(); vi.unstubAllGlobals() })
@@ -34,31 +33,31 @@ it('首次高亮 HTML 就包含内嵌脚本和样式,不依赖其他代码块
const tokens = await highlight('html', source)
expect(tokens).not.toBeNull()
expect(text(tokens!)).toBe(source)
expect(hasToken(tokens!, 'token keyword', 'const')).toBe(true)
expect(hasToken(tokens!, 'token property', 'color')).toBe(true)
expect(hasToken(tokens!, 'hljs-keyword', 'const')).toBe(true)
expect(hasToken(tokens!, 'hljs-attribute', 'color')).toBe(true)
})
it('Prism token 保留原文、语言别名与嵌套语法,不再返回旧颜色', async () => {
it('highlight.js token 保留原文、语言别名与嵌套语法,不再返回旧颜色', async () => {
const source = 'const value = `中文😀 ${name}` // comment\n'
const tokens = await highlight('js', source)
expect(tokens).not.toBeNull()
expect(text(tokens!)).toBe(source)
expect(hasToken(tokens!, 'token keyword', 'const')).toBe(true)
expect(hasToken(tokens!, 'token template-string', '`中文😀 ${name}`')).toBe(true)
expect(hasToken(tokens!, 'token interpolation', '${name}')).toBe(true)
expect(hasToken(tokens!, 'token comment', '// comment')).toBe(true)
expect(hasToken(tokens!, 'hljs-keyword', 'const')).toBe(true)
expect(hasToken(tokens!, 'hljs-string', '`中文😀 ${name}`')).toBe(true)
expect(hasToken(tokens!, 'hljs-subst', '${name}')).toBe(true)
expect(hasToken(tokens!, 'hljs-comment', '// comment')).toBe(true)
expect(await highlight('javascript', source)).toEqual(tokens)
})
it('按需加载语言依赖,并发请求和常见别名得到实际高亮', async () => {
it('并发请求和常见别名得到实际高亮', async () => {
const samples = [
['python', 'print("hello")', 'token string', '"hello"'],
['tsx', 'const App = () => <div title="hello">你好</div>', 'token attr-value', '="hello"'],
['sh', 'echo "$HOME"', 'token environment constant', '$HOME'],
['yaml', 'name: hello', 'token key atrule', 'name'],
['json', '{"value": true}', 'token boolean', 'true'],
['go', 'package main\nfunc main() {}', 'token keyword', 'package'],
['php', '<?php echo $value; ?><div>中文😀</div>', 'token keyword', 'echo'],
['python', 'print("hello")', 'hljs-string', '"hello"'],
['tsx', 'const App = () => <div title="hello">你好</div>', 'hljs-string', '"hello"'],
['sh', 'echo "$HOME"', 'hljs-variable', '$HOME'],
['yaml', 'name: hello', 'hljs-attr', 'name:'],
['json', '{"value": true}', 'hljs-keyword', 'true'],
['go', 'package main\nfunc main() {}', 'hljs-keyword', 'package'],
['php', '<?php echo $value; ?><div>中文😀</div>', 'hljs-keyword', 'echo'],
]
await Promise.all(samples.map(async ([lang, source, classes, content]) => {
const tokens = await highlight(lang, source)
@@ -70,9 +69,24 @@ it('按需加载语言依赖,并发请求和常见别名得到实际高亮', a
it('未知语言保持纯文本,真实分词失败返回原始原因且不阻断下一次请求', async () => {
for (const lang of ['unknown-language', 'constructor', '__proto__']) expect(await highlight(lang, 'text')).toBeNull()
const { default: prism } = await import('prismjs')
const { default: hljs } = await import('highlight.js')
const cause = new Error('分词失败')
vi.spyOn(prism, 'tokenize').mockImplementationOnce(() => { throw cause })
vi.spyOn(hljs, 'highlight').mockImplementationOnce(() => { throw cause })
await expect(highlight('python', 'print(1)')).rejects.toMatchObject({ cause })
expect(await highlight('python', 'print(1)')).not.toBeNull()
})
it('HTML 特殊字符、转义实体和伪造 span 原样保留,不作为标记解析', async () => {
const source = `const value = "&lt; &amp; &quot; &#x27; <span class='hljs-keyword'>中文😀</span>"; // > & '\n`
const tokens = await highlight('js', source)
expect(text(tokens!)).toBe(source)
expect(hasToken(tokens!, 'hljs-keyword', '中文😀')).toBe(false)
})
it('库安全模式返回的实际异常不伪装成成功的纯文本结果', async () => {
const { default: hljs } = await import('highlight.js')
const cause = new Error('语法运行失败')
const original = hljs.highlight('value', { language: 'js' })
vi.spyOn(hljs, 'highlight').mockReturnValueOnce({ ...original, errorRaised: cause })
await expect(highlight('js', 'value')).rejects.toMatchObject({ cause })
})
+22 -64
View File
@@ -1,76 +1,34 @@
import type { Grammar, Token } from 'prismjs'
import components from 'prismjs/components.json'
import getLoader, { type ComponentCategory } from 'prismjs/dependencies'
import hljs from 'highlight.js'
import type { HighlightRequest, HighlightResponse, TokenRun } from './protocol'
// 复用应用自己的 Worker 协议,禁止 Prism 注册另一套消息处理器。
Object.assign(globalThis, { Prism: { manual: true, disableWorkerMessageHandler: true } })
const prismReady = import('prismjs').then((mod) => mod.default)
const imports = import.meta.glob(['../../node_modules/prismjs/components/prism-*.js', '!../../node_modules/prismjs/components/*.min.js', '!../../node_modules/prismjs/components/prism-core.js'])
const { meta, ...entries } = components.languages
const languages: ComponentCategory = entries
const aliases = new Map<string, string>()
for (const [id, entry] of Object.entries(languages)) {
aliases.set(id, id)
if (typeof entry !== 'string') {
for (const alias of typeof entry.alias === 'string' ? [entry.alias] : entry.alias ?? []) aliases.set(alias, id)
// 只转换 highlight.js 生成的 span 和转义文本,不向编辑器插入 HTML。
function serialize(html: string): TokenRun {
const root: TokenRun = []
const stack = [root]
const entities: Record<string, string> = { '&amp;': '&', '&lt;': '<', '&gt;': '>', '&quot;': '"', '&#x27;': "'" }
for (const match of html.matchAll(/<span class="([^"]+)">|(<\/span>)|([^<]+)/g)) {
if (match[1]) {
const content: TokenRun = []
stack[stack.length - 1].push({ classes: match[1], content })
stack.push(content)
} else if (match[2]) stack.pop()
else stack[stack.length - 1].push(match[3].replace(/&(?:amp|lt|gt|quot|#x27);/g, entity => entities[entity]))
}
return root
}
// Prism 默认包预载这四种基础语法,确保 HTML 的样式和脚本嵌套高亮不依赖请求顺序。
const loaded = new Set(['markup', 'css', 'clike', 'javascript'])
let loadTail = Promise.resolve()
function loadLanguage(id: string): Promise<void> {
const ready = loadTail.then(async () => {
const wanted = new Set<string>()
function include(lang: string) {
if (wanted.has(lang) || loaded.has(lang)) return
wanted.add(lang)
const entry = languages[lang]
if (typeof entry !== 'string') {
// 首次加载时也包含可选嵌套语法,避免后续依赖 ESM 无法重复执行的组件。
for (const refs of [entry.require, entry.optional, entry.modify]) {
for (const dep of typeof refs === 'string' ? [refs] : refs ?? []) include(dep)
}
}
}
include(id)
for (const lang of getLoader({ languages }, [...wanted]).getIds()) {
if (loaded.has(lang)) continue
await imports[`../../node_modules/prismjs/components/prism-${lang}.js`]()
loaded.add(lang)
}
})
// 语法组件修改共享注册表,按顺序加载;单次失败不能阻塞后续独立请求。
loadTail = ready.catch(() => {})
return ready
function highlight(req: HighlightRequest): TokenRun | null {
if (!hljs.getLanguage(req.lang)) return null
const result = hljs.highlight(req.code, { language: req.lang, ignoreIllegals: true })
// 库的安全模式会随纯文本结果返回异常,仍需传递真实失败原因。
if (result.errorRaised) throw result.errorRaised
return serialize(result.value)
}
function serialize(content: string | Token | (string | Token)[]): TokenRun {
return (Array.isArray(content) ? content : [content]).map((token) => {
if (typeof token === 'string') return token
const aliases = typeof token.alias === 'string' ? [token.alias] : token.alias ?? []
return { classes: ['token', token.type, ...aliases].join(' '), content: serialize(token.content) }
})
}
async function highlight(req: HighlightRequest): Promise<TokenRun | null> {
const id = aliases.get(req.lang)
if (!id) return null
const prism = await prismReady
await loadLanguage(id)
const env: { code: string; grammar: Grammar; language: string; tokens?: (string | Token)[] } = { code: req.code, grammar: prism.languages[id], language: id }
prism.hooks.run('before-tokenize', env)
env.tokens = prism.tokenize(env.code, env.grammar)
prism.hooks.run('after-tokenize', env)
return serialize(env.tokens)
}
self.addEventListener('message', async (e: MessageEvent<HighlightRequest>) => {
self.addEventListener('message', (e: MessageEvent<HighlightRequest>) => {
let res: HighlightResponse
try {
res = { id: e.data.id, tokens: await highlight(e.data) }
res = { id: e.data.id, tokens: highlight(e.data) }
} catch (cause) {
res = { id: e.data.id, error: new Error('代码高亮失败', { cause }) }
}
+3 -20
View File
@@ -1,5 +1,5 @@
@import './fonts.css';
@import 'prismjs/themes/prism-tomorrow.css';
@import 'highlight.js/styles/atom-one-dark.css';
/* 基础主题。--rm-* 变量继续作为主题接口,字体在本地加载。 */
:root {
@@ -47,8 +47,8 @@
--rm-shadow: 0 12px 40px -12px rgba(19, 27, 46, 0.18), 0 2px 8px rgba(19, 27, 46, 0.03);
--rm-inline-code-bg: #eaedff;
--rm-inline-code-text: #3525cd;
--rm-code-bg: #ffffff;
--rm-code-text: #334155;
--rm-code-bg: #282c34;
--rm-code-text: #abb2bf;
--rm-code-font-size: 14px;
--rm-highlight-bg: rgba(255, 213, 0, 0.35);
}
@@ -63,8 +63,6 @@
--rm-accent-strong: #42b883;
--rm-inline-code-bg: #2f2f2f;
--rm-inline-code-text: #f19a58;
--rm-code-bg: #2d2d2d;
--rm-code-text: #ccc;
--rm-highlight-bg: rgba(255, 213, 0, 0.28);
--rm-sidebar-bg: #191b1d;
@@ -86,8 +84,6 @@
--rm-accent-strong: #42b883;
--rm-inline-code-bg: #2f2f2f;
--rm-inline-code-text: #f19a58;
--rm-code-bg: #2d2d2d;
--rm-code-text: #ccc;
--rm-highlight-bg: rgba(255, 213, 0, 0.28);
--rm-sidebar-bg: #191b1d;
@@ -343,14 +339,6 @@ button, input, select, textarea { font: inherit; }
.cm-line.cm-rm-frontmatter-begin { border-radius: 6px 6px 0 0; padding-top: 4px; }
.cm-line.cm-rm-frontmatter-end { border-radius: 0 0 6px 6px; padding-bottom: 4px; }
/* 浅色编辑器的 Prism token 颜色。 */
:root:not([data-rm-color-mode="dark"]) .cm-rm-code .token { text-shadow: none; background: none; }
:root:not([data-rm-color-mode="dark"]) .cm-rm-code :is(.comment,.prolog,.doctype,.cdata) { color: #94a3b8; }
:root:not([data-rm-color-mode="dark"]) .cm-rm-code :is(.punctuation,.operator) { color: #64748b; }
:root:not([data-rm-color-mode="dark"]) .cm-rm-code :is(.keyword,.atrule,.tag) { color: #7c3aed; }
:root:not([data-rm-color-mode="dark"]) .cm-rm-code :is(.string,.char,.attr-value,.builtin) { color: #059669; }
:root:not([data-rm-color-mode="dark"]) .cm-rm-code :is(.function,.class-name) { color: #2563eb; }
:root:not([data-rm-color-mode="dark"]) .cm-rm-code :is(.number,.boolean,.constant) { color: #d97706; }
.cm-line:is(.cm-rm-h1,.cm-rm-h2,.cm-rm-h3,.cm-rm-h4,.cm-rm-h5,.cm-rm-h6) { font-family: var(--rm-font-heading); font-weight: 600; }
.cm-line.cm-rm-h1 { font-size: 2.125em; line-height: 1.4; }
.cm-line.cm-rm-h2 { font-size: 1.5em; line-height: 1.5; }
@@ -361,10 +349,5 @@ button, input, select, textarea { font: inherit; }
.cm-line.cm-rm-code-end::before { border-bottom: 1px solid var(--rm-divider); }
.cm-line.cm-rm-paragraph-end { padding-bottom: var(--rm-paragraph-spacing); }
@media (max-width: 767px) {
:root:not([data-rm-color-mode="dark"]) { --rm-code-bg: #283044; --rm-code-text: #eef0ff; }
:root:not([data-rm-color-mode="dark"]) .cm-rm-code :is(.keyword,.atrule,.tag) { color: #c4b5fd; }
:root:not([data-rm-color-mode="dark"]) .cm-rm-code :is(.string,.char,.attr-value,.builtin) { color: #6ee7b7; }
:root:not([data-rm-color-mode="dark"]) .cm-rm-code :is(.function,.class-name) { color: #93c5fd; }
:root:not([data-rm-color-mode="dark"]) .cm-rm-code :is(.number,.boolean,.constant) { color: #fcd34d; }
.cm-line.cm-rm-h1 { font-size: 1.75em; }
}