diff --git a/libs/pnpm-lock.yaml b/libs/pnpm-lock.yaml index 7111867..6f04c19 100644 --- a/libs/pnpm-lock.yaml +++ b/libs/pnpm-lock.yaml @@ -67,6 +67,9 @@ importers: '@tiptap/core': specifier: ^3.27.1 version: 3.27.1(@tiptap/pm@3.27.1) + '@tiptap/extension-code-block-lowlight': + specifier: ^3.27.1 + version: 3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/extension-code-block@3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1)(highlight.js@11.11.1)(lowlight@3.3.0) '@tiptap/extension-file-handler': specifier: ^3.27.1 version: 3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/extension-text-style@3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1)))(@tiptap/pm@3.27.1) @@ -94,6 +97,9 @@ importers: '@tiptap/suggestion': specifier: ^3.27.1 version: 3.27.1(@floating-ui/dom@1.7.6)(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1) + lowlight: + specifier: ^3.3.0 + version: 3.3.0 yggdrasil-core: {} @@ -370,6 +376,15 @@ packages: peerDependencies: '@tiptap/extension-list': 3.27.1 + '@tiptap/extension-code-block-lowlight@3.27.1': + resolution: {integrity: sha512-rDZYDwKpV3Uw8xjI5pzSQTLJ7pEyJkDrVhnzdh9mBY3sHVwP1maYh+0kRUrHi5N3EhiNp/GXG1kPjysPIQ4nqQ==} + peerDependencies: + '@tiptap/core': 3.27.1 + '@tiptap/extension-code-block': 3.27.1 + '@tiptap/pm': 3.27.1 + highlight.js: ^11 + lowlight: ^2 || ^3 + '@tiptap/extension-code-block@3.27.1': resolution: {integrity: sha512-pHlzmZx2OlHfyQ0yRlT5UL4mGokz947DthZuYefN1OleVqOkHpWBG+2JQwqoNq6bmzMne92zbH32rhcJUEYSjA==} peerDependencies: @@ -524,9 +539,15 @@ packages: '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/hast@3.0.4': + resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + '@types/node@26.0.1': resolution: {integrity: sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==} + '@types/unist@3.0.3': + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/whatwg-mimetype@3.0.2': resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==} @@ -583,10 +604,17 @@ packages: crelt@1.0.7: resolution: {integrity: sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==} + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + entities@7.0.1: resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} engines: {node: '>=0.12'} @@ -619,6 +647,10 @@ packages: resolution: {integrity: sha512-6QD0ilzDDt93tX44y8tbmZdAcdTRYDhUP+Asgi6pC8Pp5IA3cvaZGyoVN/EGtlq9ziT65iPuBBn3ASLr6hCgVw==} engines: {node: '>=20.0.0'} + highlight.js@11.11.1: + resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==} + engines: {node: '>=12.0.0'} + lightningcss-android-arm64@1.32.0: resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} engines: {node: '>= 12.0.0'} @@ -696,6 +728,9 @@ packages: linkifyjs@4.3.3: resolution: {integrity: sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==} + lowlight@3.3.0: + resolution: {integrity: sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==} + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -1186,6 +1221,14 @@ snapshots: dependencies: '@tiptap/extension-list': 3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1) + '@tiptap/extension-code-block-lowlight@3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/extension-code-block@3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1)(highlight.js@11.11.1)(lowlight@3.3.0)': + dependencies: + '@tiptap/core': 3.27.1(@tiptap/pm@3.27.1) + '@tiptap/extension-code-block': 3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1) + '@tiptap/pm': 3.27.1 + highlight.js: 11.11.1 + lowlight: 3.3.0 + '@tiptap/extension-code-block@3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1)': dependencies: '@tiptap/core': 3.27.1(@tiptap/pm@3.27.1) @@ -1356,10 +1399,16 @@ snapshots: '@types/estree@1.0.9': {} + '@types/hast@3.0.4': + dependencies: + '@types/unist': 3.0.3 + '@types/node@26.0.1': dependencies: undici-types: 8.3.0 + '@types/unist@3.0.3': {} + '@types/whatwg-mimetype@3.0.2': {} '@types/ws@8.18.1': @@ -1429,8 +1478,14 @@ snapshots: crelt@1.0.7: {} + dequal@2.0.3: {} + detect-libc@2.1.2: {} + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + entities@7.0.1: {} es-module-lexer@2.2.0: {} @@ -1461,6 +1516,8 @@ snapshots: - bufferutil - utf-8-validate + highlight.js@11.11.1: {} + lightningcss-android-arm64@1.32.0: optional: true @@ -1512,6 +1569,12 @@ snapshots: linkifyjs@4.3.3: {} + lowlight@3.3.0: + dependencies: + '@types/hast': 3.0.4 + devlop: 1.1.0 + highlight.js: 11.11.1 + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 diff --git a/libs/tiptap-editor/package.json b/libs/tiptap-editor/package.json index 5c8e27b..1cf85db 100644 --- a/libs/tiptap-editor/package.json +++ b/libs/tiptap-editor/package.json @@ -12,6 +12,7 @@ }, "dependencies": { "@tiptap/core": "^3.27.1", + "@tiptap/extension-code-block-lowlight": "^3.27.1", "@tiptap/extension-file-handler": "^3.27.1", "@tiptap/extension-image": "^3.27.1", "@tiptap/extension-link": "^3.27.1", @@ -20,6 +21,7 @@ "@tiptap/markdown": "^3.27.1", "@tiptap/pm": "^3.27.1", "@tiptap/starter-kit": "^3.27.1", - "@tiptap/suggestion": "^3.27.1" + "@tiptap/suggestion": "^3.27.1", + "lowlight": "^3.3.0" } } diff --git a/libs/tiptap-editor/src/__tests__/highlight.test.ts b/libs/tiptap-editor/src/__tests__/highlight.test.ts new file mode 100644 index 0000000..6546a6c --- /dev/null +++ b/libs/tiptap-editor/src/__tests__/highlight.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; +import { extractLang, lowlight } from '../highlight'; + +/** + * extractLang 测试:从完整 fence info string 提取语言名(首个 token)。 + * 与后端 parse_fence_info(src/api/code_runner/languages.rs)对齐。 + */ +describe('extractLang', () => { + it.each([ + ['python', 'python', '纯语言名'], + ['python runnable', 'python', 'runnable 标记'], + ['python runnable {"timeout_secs":10}', 'python', 'runnable + overrides JSON'], + ['node runnable', 'node', 'node runnable'], + [' python ', 'python', '前后空格(trim)'], + ['', '', '空字符串'], + [' ', '', '纯空格'], + ])('%s → %s (%s)', (input, expected) => { + expect(extractLang(input)).toBe(expected); + }); +}); + +/** + * lowlight wrapper 测试:highlight 经过 extractLang 处理, + * runnable info string 也能按正确语言高亮。 + */ +describe('lowlight wrapper', () => { + it('runnable info string 按 python 高亮(不抛错)', () => { + // 未包装的 lowlight 遇到 'python runnable {...}' 会回退 highlightAuto 或抛错; + // wrapper 提取 'python' 后正常高亮。 + const result = lowlight.highlight('python runnable {"timeout_secs":10}', 'def f():\n pass'); + expect(result).toBeDefined(); + }); + + it('python 代码高亮输出含 hljs-keyword class', () => { + const result = lowlight.highlight('python', 'def f():\n pass'); + const html = JSON.stringify(result); + expect(html).toContain('hljs-keyword'); + }); + + it('普通语言名(无 runnable)正常工作', () => { + const result = lowlight.highlight('javascript', 'const x = 1;'); + expect(JSON.stringify(result)).toContain('hljs-'); + }); + + it('未注册语言不抛错(回退处理)', () => { + // extractLang 返回未注册语言名时,lowlight 内部会抛错, + // CodeBlockLowlight 源码用 try/catch 兜底,这里只验 wrapper 不改变此行为。 + expect(() => lowlight.highlight('totally-unknown-lang', 'code')).toThrow(); + }); +}); diff --git a/libs/tiptap-editor/src/highlight.ts b/libs/tiptap-editor/src/highlight.ts new file mode 100644 index 0000000..3e3a43f --- /dev/null +++ b/libs/tiptap-editor/src/highlight.ts @@ -0,0 +1,29 @@ +import { createLowlight, common } from 'lowlight'; + +const base = createLowlight(common); + +/** + * 从完整 fence info string 提取语言名(首个 token)。 + * + * 与后端 parse_fence_info(src/api/code_runner/languages.rs:80)对齐: + * info string 形如 `python runnable {"timeout_secs":10}`,语言是首个空白分隔的 token。 + */ +export function extractLang(info: string): string { + return info.trim().split(/\s+/)[0] || ''; +} + +/** + * lowlight 实例,包装 highlight 方法以处理完整 info string。 + * + * CodeBlockLowlight 取 `block.node.attrs.language`(如 `python runnable {...}`) + * 直接调 `lowlight.highlight(language, code)`,但 lowlight 只认 `python` 这个 token。 + * wrapper 先 extractLang 提取首 token,再高亮,使 runnable 块也能按正确语言着色。 + * + * 普通 code block(language='python')也兼容:extractLang('python') → 'python'。 + */ +export const lowlight = { + ...base, + highlight(language: string, value: string) { + return base.highlight(extractLang(language), value); + }, +};