feat(highlight): replace Prism with highlight.js and Atom One Dark
This commit is contained in:
1 parent
634ef9fc11
commit
b123ef36db
10 files changed
+72
-134
No files matched your search
Generated
+10
-18
@@ -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
@@ -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",
|
||||
|
||||
@@ -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'] }])
|
||||
})
|
||||
@@ -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;实际失败必须与纯文本结果区分。
|
||||
|
||||
@@ -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 = "< & " ' <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
@@ -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> = { '&': '&', '<': '<', '>': '>', '"': '"', ''': "'" }
|
||||
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
@@ -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; }
|
||||
}
|
||||
Reference in new issue
Block a user