Replace Shiki code highlighting with Prism.js

This commit is contained in:
eezd committed 2026-10-22 13:08:12 +08:00
1 parent b2d5a1f319
commit ed83b2c590
12 files changed
+194 -678

No files matched your search

+16 -12
View File
@@ -36,16 +36,11 @@ const codeTokenField = StateField.define<DecorationSet>({
const markCache = new Map<string, Decoration>()
function tokenMark(color: string, fontStyle: number): Decoration {
const key = color + '|' + fontStyle
let deco = markCache.get(key)
function tokenMark(classes: string): Decoration {
let deco = markCache.get(classes)
if (!deco) {
let style = color ? `color:${color}` : ''
if (fontStyle & 1) style += ';font-style:italic'
if (fontStyle & 2) style += ';font-weight:bold'
if (fontStyle & 4) style += ';text-decoration:underline'
deco = Decoration.mark({ attributes: { style } })
markCache.set(key, deco)
deco = Decoration.mark({ class: classes })
markCache.set(classes, deco)
}
return deco
}
@@ -53,10 +48,19 @@ function tokenMark(color: string, fontStyle: number): Decoration {
function tokensToDecorations(block: CodeBlock, run: TokenRun | null): Range<Decoration>[] {
const out: Range<Decoration>[] = []
if (!run) return out
for (let i = 0; i < run.length; i += 4) {
const from = block.from + (run[i] as number)
out.push(tokenMark(run[i + 2] as string, run[i + 3] as number).range(from, from + (run[i + 1] as number)))
let at = block.from
function visit(tokens: TokenRun) {
for (const token of tokens) {
if (typeof token === 'string') at += token.length
else {
const from = at
const index = out.length
visit(token.content)
if (at > from) out.splice(index, 0, tokenMark(token.classes).range(from, at))
}
}
}
visit(run)
return out
}
+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: [0, 5, '#fff', 0] } }))
await expect(recovered).resolves.toEqual([0, 5, '#fff', 0])
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'] }])
})
+2 -2
View File
@@ -6,8 +6,8 @@ export interface HighlightRequest {
code: string
}
// 扁平数组:[offset, length, color, fontStyle, offset, length, ...],避免大量小对象跨线程拷贝
export type TokenRun = (number | string)[]
// 保留 Prism 的嵌套 token 与原文,编辑器和分享页共用官方 token 类样式。
export type TokenRun = (string | { classes: string; content: TokenRun })[]
// 语言不受支持时 tokens 为 null;实际失败必须与纯文本结果区分。
export type HighlightResponse = { id: number; tokens: TokenRun | null } | { id: number; error: Error }
-22
View File
@@ -1,22 +0,0 @@
import { afterEach, expect, it, vi } from 'vitest'
import type { HighlightResponse } from './protocol'
afterEach(() => vi.unstubAllGlobals())
it('编辑器与分享页共用的 Worker 返回 GitHub Dark 原始配色', async () => {
let reply: HighlightResponse | undefined
const worker = new class extends EventTarget {
postMessage(response: HighlightResponse) { reply = response }
}()
vi.stubGlobal('self', worker)
await import('./worker')
const code = 'const value = "hello" // comment'
worker.dispatchEvent(new MessageEvent('message', { data: { id: 1, lang: 'javascript', code } }))
await vi.waitFor(() => expect(reply).toBeDefined())
if (!reply || !('tokens' in reply) || !reply.tokens) throw new Error('高亮 Worker 未返回颜色')
const colors = new Map<string | number, string | number>()
for (let i = 0; i < reply.tokens.length; i += 4) colors.set(reply.tokens[i], reply.tokens[i + 2])
expect(colors.get(0)).toBe('#F97583')
expect(colors.get(code.indexOf('"hello"'))).toBe('#9ECBFF')
expect(colors.get(code.indexOf('//'))).toBe('#6A737D')
})
+73 -24
View File
@@ -1,29 +1,78 @@
import { afterEach, expect, it, vi } from 'vitest'
import { bundledLanguages } from 'shiki/langs'
import type { HighlightResponse } from './protocol'
import { afterAll, beforeAll, expect, it, vi } from 'vitest'
import type { HighlightResponse, TokenRun } from './protocol'
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals() })
const replies = new Map<number, HighlightResponse>()
const worker = new class extends EventTarget {
postMessage(response: HighlightResponse) { replies.set(response.id, response) }
}()
let nextId = 1
it('未知语言正常完成,语言加载失败返回错误并允许下一次明确请求恢复', async () => {
const replies = new Map<number, HighlightResponse>()
const worker = new class extends EventTarget {
postMessage(response: HighlightResponse) { replies.set(response.id, response) }
}()
beforeAll(async () => {
vi.stubGlobal('self', worker)
vi.stubGlobal('Prism', {})
await import('./worker')
for (const [id, lang] of ['unknown-language', 'constructor', '__proto__'].entries()) {
worker.dispatchEvent(new MessageEvent('message', { data: { id, lang, code: 'text' } }))
await vi.waitFor(() => expect(replies.get(id)).toEqual({ id, tokens: null }))
}
const cause = new Error('语言资源加载失败')
const load = vi.spyOn(bundledLanguages, 'python').mockRejectedValueOnce(cause)
worker.dispatchEvent(new MessageEvent('message', { data: { id: 10, lang: 'python', code: 'print(1)' } }))
await vi.waitFor(() => expect(replies.has(10)).toBe(true))
const failure = replies.get(10)!
expect('error' in failure && failure.error.cause).toBe(cause)
load.mockRestore()
worker.dispatchEvent(new MessageEvent('message', { data: { id: 11, lang: 'python', code: 'print(1)' } }))
await vi.waitFor(() => expect(replies.has(11)).toBe(true))
const success = replies.get(11)!
expect('tokens' in success && success.tokens?.length).toBeGreaterThan(0)
})
afterAll(() => { vi.restoreAllMocks(); vi.unstubAllGlobals() })
async function highlight(lang: string, code: string) {
const id = nextId++
worker.dispatchEvent(new MessageEvent('message', { data: { id, lang, code } }))
await vi.waitFor(() => expect(replies.has(id)).toBe(true))
const reply = replies.get(id)!
if ('error' in reply) throw reply.error
return reply.tokens
}
function text(tokens: TokenRun): string {
return tokens.map((token) => typeof token === 'string' ? token : text(token.content)).join('')
}
function hasToken(tokens: TokenRun, classes: string, content: string): boolean {
return tokens.some((token) => typeof token !== 'string' && ((token.classes === classes && text(token.content) === content) || hasToken(token.content, classes, content)))
}
it('首次高亮 HTML 就包含内嵌脚本和样式,不依赖其他代码块先加载语法', async () => {
const source = '<script>const value = 1</script><style>a { color: red }</style>'
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)
})
it('Prism 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(await highlight('javascript', source)).toEqual(tokens)
})
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'],
]
await Promise.all(samples.map(async ([lang, source, classes, content]) => {
const tokens = await highlight(lang, source)
expect(tokens).not.toBeNull()
expect(text(tokens!)).toBe(source)
expect(hasToken(tokens!, classes, content), lang).toBe(true)
}))
})
it('未知语言保持纯文本,真实分词失败返回原始原因且不阻断下一次请求', async () => {
for (const lang of ['unknown-language', 'constructor', '__proto__']) expect(await highlight(lang, 'text')).toBeNull()
const { default: prism } = await import('prismjs')
const cause = new Error('分词失败')
vi.spyOn(prism, 'tokenize').mockImplementationOnce(() => { throw cause })
await expect(highlight('python', 'print(1)')).rejects.toMatchObject({ cause })
expect(await highlight('python', 'print(1)')).not.toBeNull()
})
+61 -34
View File
@@ -1,43 +1,70 @@
import { createHighlighterCore, type HighlighterCore } from 'shiki/core'
import { createJavaScriptRegexEngine } from 'shiki/engine/javascript'
import { bundledLanguages } from 'shiki/langs'
import githubDark from 'shiki/themes/github-dark.mjs'
import type { Grammar, Token } from 'prismjs'
import components from 'prismjs/components.json'
import getLoader, { type ComponentCategory } from 'prismjs/dependencies'
import type { HighlightRequest, HighlightResponse, TokenRun } from './protocol'
const THEME = 'github-dark'
const highlighterReady: Promise<HighlighterCore> = createHighlighterCore({
themes: [githubDark],
langs: [],
engine: createJavaScriptRegexEngine({ forgiving: true }),
})
// 同一语言的并发请求共用一次加载
const langLoads = new Map<string, Promise<boolean>>()
function ensureLanguage(hl: HighlighterCore, lang: string): Promise<boolean> {
let load = langLoads.get(lang)
if (!load) {
const importer = Object.hasOwn(bundledLanguages, lang) ? bundledLanguages[lang as keyof typeof bundledLanguages] : undefined
load = importer
? importer().then((mod) => hl.loadLanguage(mod.default)).then(() => true).catch((error) => { langLoads.delete(lang); throw error })
: Promise.resolve(false)
langLoads.set(lang, load)
// 复用应用自己的 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)
}
return load
}
// 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 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 hl = await highlighterReady
if (!(await ensureLanguage(hl, req.lang))) return null
const run: TokenRun = []
for (const line of hl.codeToTokensBase(req.code, { lang: req.lang, theme: THEME })) {
for (const t of line) {
if (!t.content.trim()) continue
run.push(t.offset, t.content.length, t.color ?? '', t.fontStyle ?? 0)
}
}
return run
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>) => {
+12 -16
View File
@@ -1,5 +1,5 @@
// 只读分享页(/s/{token}):取回笔记内容,用与编辑器同一套解析器渲染为 HTML。不加载编辑器
import { requestTokens } from '../highlight/client'
import { requestTokens, type TokenRun } from '../highlight/client'
import { renderMarkdown } from '../markdown/html'
import { hydrate, renderMermaid } from '../markdown/render'
import { applyAppearance, sanitizeAppearance } from '../themes/appearance'
@@ -278,22 +278,18 @@ async function decorateCode(code: HTMLElement) {
const run = await requestTokens(lang, text)
if (!run) return
const out = document.createDocumentFragment()
let at = 0
for (let i = 0; i < run.length; i += 4) {
const from = run[i] as number
const to = from + (run[i + 1] as number)
if (from > at) out.append(text.slice(at, from))
const span = document.createElement('span')
const fontStyle = run[i + 3] as number
if (run[i + 2]) span.style.color = run[i + 2] as string
if (fontStyle & 1) span.style.fontStyle = 'italic'
if (fontStyle & 2) span.style.fontWeight = 'bold'
if (fontStyle & 4) span.style.textDecoration = 'underline'
span.textContent = text.slice(from, to)
out.append(span)
at = to
function appendTokens(parent: DocumentFragment | HTMLElement, tokens: TokenRun) {
for (const token of tokens) {
if (typeof token === 'string') parent.append(token)
else {
const span = document.createElement('span')
span.className = token.classes
appendTokens(span, token.content)
parent.append(span)
}
}
}
out.append(text.slice(at))
appendTokens(out, run)
code.replaceChildren(out)
}
+7 -5
View File
@@ -1,6 +1,8 @@
@import 'prismjs/themes/prism-tomorrow.css';
/*
* 基础主题。--rm-* 变量即主题 API,第三方主题只需覆盖这些变量。
* 编辑排版适配用户提供的 Obsidian CSS 片段,代码块颜色使用 Shiki GitHub Dark。
* 编辑排版适配用户提供的 Obsidian CSS 片段,代码块颜色使用 Prism Tomorrow Night。
*/
:root {
color-scheme: light;
@@ -31,8 +33,8 @@
--rm-inline-code-bg: #f8f8f8;
--rm-inline-code-text: #e96900;
--rm-code-bg: #24292e;
--rm-code-text: #e1e4e8;
--rm-code-bg: #2d2d2d;
--rm-code-text: #ccc;
--rm-code-font-size: 14px;
--rm-highlight-bg: rgba(255, 213, 0, 0.35);
}
@@ -47,7 +49,7 @@
--rm-accent-strong: #42b883;
--rm-inline-code-bg: #2f2f2f;
--rm-inline-code-text: #f19a58;
--rm-code-bg: #24292e;
--rm-code-bg: #2d2d2d;
--rm-highlight-bg: rgba(255, 213, 0, 0.28);
--rm-sidebar-bg: #191b1d;
@@ -69,7 +71,7 @@
--rm-accent-strong: #42b883;
--rm-inline-code-bg: #2f2f2f;
--rm-inline-code-text: #f19a58;
--rm-code-bg: #24292e;
--rm-code-bg: #2d2d2d;
--rm-highlight-bg: rgba(255, 213, 0, 0.28);
--rm-sidebar-bg: #191b1d;