From b123ef36db9faa21bc83f7937cc3ae0f8bf8ce7f Mon Sep 17 00:00:00 2001 From: eezd Date: Sat, 24 Oct 2026 18:12:49 +0800 Subject: [PATCH] feat(highlight): replace Prism with highlight.js and Atom One Dark --- README.md | 2 +- internal/api/themes/black.css | 2 - internal/api/themes/white.css | 6 --- web/package-lock.json | 28 ++++------- web/package.json | 3 +- web/src/highlight/client.test.ts | 4 +- web/src/highlight/protocol.ts | 2 +- web/src/highlight/worker.test.ts | 50 ++++++++++++------- web/src/highlight/worker.ts | 86 ++++++++------------------------ web/src/themes/base.css | 23 ++------- 10 files changed, 72 insertions(+), 134 deletions(-) diff --git a/README.md b/README.md index eea6974..dc0db4c 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ docker run -d --name rawmark \ - 笔记以普通 `.md` 文件保存在 `数据目录/vault/`,粘贴、上传和导入的图片统一存放在笔记同级的 `assets/`,链接使用 `./assets/文件名`,同目录相同内容去重。笔记跨目录移动时图片跟随,仍被其他笔记引用的原图保留;删除单篇笔记不会删除共享图片。旧 `笔记名.assets/` 仍可读取、移动与恢复;外部修改会实时同步到网页。 - 知识工具:标准 Markdown 链接构成图谱,支持正文 `#标签` 与 YAML `tags`。笔记或文件夹在应用内改名、移动时维护入链和相对出链;公式和代码示例不参与关系提取。 - 日记与模板:默认日记路径为 `Daily/YYYY-MM-DD.md`(本地日期),已有日记直接打开;模板为 `Templates/` 下的普通 Markdown,支持 `{{date}}`、`{{time}}`、`{{title}}`,资源链接按新笔记位置调整。可在设置中修改目录和日记模板。 -- 外观:浅色玻璃工作台与深色模式,字体资源随应用本地提供;界面字号默认 14px,可输入 14–20px 并单独重置,与编辑器及阅读字号独立;排版、玻璃透明度、选中文本工具栏均可在设置中调整并即时保存。 +- 外观:浅色玻璃工作台与深色模式,字体资源随应用本地提供;代码块使用 highlight.js,固定采用官网的 Atom One Dark 配色,与页面明暗模式独立;界面字号默认 14px,可输入 14–20px 并单独重置,与编辑器及阅读字号独立;排版、玻璃透明度、选中文本工具栏均可在设置中调整并即时保存。 - 选区格式:电脑与触屏选中文字后提供外观、加粗、斜体、下划线、删除线、高亮和链接。外观可设置配色、字体、局部字号与效果;下划线及外观以受限的标准 HTML 行内标签保存在 Markdown 中,源码与导出保留标签,其他软件的显示取决于其 HTML 支持。清除样式只重置局部外观;底部不再显示格式栏。 - 图片管理:侧栏默认隐藏 `assets` 和 `笔记名.assets`;右键文件夹选择「图片管理」,查看当前目录及直属附件目录的图片,支持检索、排序、网格/列表、批量上传、引用查看及预览、重命名、移动、替换。可切换整个知识库;清理孤立图片须确认候选,服务端再次核对全库引用后移入回收站,可恢复。上传至所选目录的 `assets/`,相同内容自动去重。根目录可在侧栏空白处右键或通过命令面板打开。图片改名/移动会更新笔记中的本地引用;替换保留路径,选择相同扩展名的图片(最大 50 MB),旧图片不进回收站。 - `--user` 让容器以宿主机用户身份读写数据目录;不指定时容器以 UID 65532 运行,需要该用户对目录有写权限。 diff --git a/internal/api/themes/black.css b/internal/api/themes/black.css index b5a91b9..a0e1810 100644 --- a/internal/api/themes/black.css +++ b/internal/api/themes/black.css @@ -16,8 +16,6 @@ --rm-shadow: 0 12px 36px rgba(0, 0, 0, 0.6); --rm-inline-code-bg: #202127; --rm-inline-code-text: #f19a58; - --rm-code-bg: #2d2d2d; - --rm-code-text: #ccc; --rm-highlight-bg: rgba(232, 196, 119, 0.25); } diff --git a/internal/api/themes/white.css b/internal/api/themes/white.css index 37eb178..dbe65db 100644 --- a/internal/api/themes/white.css +++ b/internal/api/themes/white.css @@ -16,12 +16,6 @@ --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-font-size: 14px; --rm-highlight-bg: rgba(255, 213, 0, 0.35); } - -@media (max-width: 767px) { - :root { --rm-code-bg: #283044; --rm-code-text: #eef0ff; } -} diff --git a/web/package-lock.json b/web/package-lock.json index e6f7afa..5b3ae58 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -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", diff --git a/web/package.json b/web/package.json index 2ee6c45..67b1b45 100644 --- a/web/package.json +++ b/web/package.json @@ -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", diff --git a/web/src/highlight/client.test.ts b/web/src/highlight/client.test.ts index 2945a91..5d33d31 100644 --- a/web/src/highlight/client.test.ts +++ b/web/src/highlight/client.test.ts @@ -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'] }]) }) diff --git a/web/src/highlight/protocol.ts b/web/src/highlight/protocol.ts index e498e6b..200bf03 100644 --- a/web/src/highlight/protocol.ts +++ b/web/src/highlight/protocol.ts @@ -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;实际失败必须与纯文本结果区分。 diff --git a/web/src/highlight/worker.test.ts b/web/src/highlight/worker.test.ts index 4a05b7d..a98d55a 100644 --- a/web/src/highlight/worker.test.ts +++ b/web/src/highlight/worker.test.ts @@ -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 = () =>
你好
', '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', '
中文😀
', 'token keyword', 'echo'], + ['python', 'print("hello")', 'hljs-string', '"hello"'], + ['tsx', 'const App = () =>
你好
', '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', '
中文😀
', '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 = "< & " ' 中文😀"; // > & '\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 }) +}) diff --git a/web/src/highlight/worker.ts b/web/src/highlight/worker.ts index 03fc13d..b8cd355 100644 --- a/web/src/highlight/worker.ts +++ b/web/src/highlight/worker.ts @@ -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() -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 = { '&': '&', '<': '<', '>': '>', '"': '"', ''': "'" } + for (const match of html.matchAll(/|(<\/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 { - const ready = loadTail.then(async () => { - const wanted = new Set() - 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 { - 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) => { +self.addEventListener('message', (e: MessageEvent) => { 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 }) } } diff --git a/web/src/themes/base.css b/web/src/themes/base.css index 071ca3f..822e342 100644 --- a/web/src/themes/base.css +++ b/web/src/themes/base.css @@ -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; } }