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; }
}