Replace Shiki code highlighting with Prism.js
This commit is contained in:
1 parent
b2d5a1f319
commit
ed83b2c590
12 files changed
+194
-678
No files matched your search
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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'] }])
|
||||
})
|
||||
@@ -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 }
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user