diff --git a/docs/development/Markdown渲染检查.md b/docs/development/Markdown渲染检查.md new file mode 100644 index 0000000..01aab97 --- /dev/null +++ b/docs/development/Markdown渲染检查.md @@ -0,0 +1,35 @@ +# Markdown 渲染检查 + +日期:2026-09-05。范围为当前工程启用的 CommonMark、GFM、Milkdown Crepe 扩展及静态 Markdown 预览,不代表所有 Markdown 方言。 + +## 本次修复 + +- 行内代码:保留普通输入规则,为绕过 `handleTextInput` 的浏览器文本输入与输入法组合结束增加单反引号补偿处理。跳过代码节点、已有代码标记、转义反引号;不改写文件中的转义文本。同时识别先输入空反引号对、再移入填字的路径,并在转换后保留继续输入的代码标记;空反引号对序列化时的转义不会阻断识别。回归测试覆盖缺失事件数据、替换文本、延迟组合结束与粘贴/撤销排除。独立浏览器编辑器已实测逐字输入、段落/行内换行,以及先输入反引号对再向中间填入 s。 +- 工具栏:未选中文字时,行内代码按钮可切换后续输入的代码标记;此前上游命令在空选区直接返回。 +- 样式:工作区与静态预览使用主题代码背景、文字及边框变量,避免行内代码与正文难以区分。 +- 静态预览:补齐 `$...$`、`$$...$$` 和编辑器保存的 `LaTeX` 围栏公式;代码中的公式符号保持原文。公式使用 KaTeX,禁用可信 HTML 命令并经过最终清理。 +- 静态表格:恢复 GFM 中间、右侧对齐,避免通用单元格样式覆盖对齐属性。 + +## 检查矩阵 + +| 格式 | 工作区编辑 | 静态预览 | 验证 | +| --- | --- | --- | --- | +| H1–H6 | 标题节点 | 标题元素 | 新增格式矩阵 | +| 粗体、斜体、删除线 | 标记渲染 | strong / em / del | 新增格式矩阵 | +| 单反引号、多反引号代码 | 行内代码;保存保留定界符 | code,转义内容不执行 | 加载、普通键入、组合输入、工具栏与序列化 | +| 有序、无序、嵌套列表 | 列表节点 | ol / ul | 新增格式矩阵 | +| 任务列表 | GFM 任务项 | 禁用复选框 | 格式矩阵及 GFM 输出 | +| 引用、分割线 | 原生节点 | blockquote / hr | 新增格式矩阵 | +| 链接、引用式链接、图片 | Crepe 原生组件 | 安全链接及图片 | 静态格式矩阵;图片实际加载受路径可访问性影响 | +| GFM 表格 | 表格组件 | table;主题边框和对齐 | 格式矩阵、对齐规则检查 | +| 硬换行、转义符 | 编辑器保留 Markdown 语义 | br / 转义文本 | 静态格式矩阵 | +| 围栏代码、未知语言 | CodeMirror / Shiki | Shiki;未知语言回退纯文本 | 既有语言测试与新增回退测试 | +| 行内、块级数学公式 | Crepe LaTeX | KaTeX | 编辑器格式矩阵与新增静态公式测试 | +| Mermaid | 图形预览 | SVG 图形 | 既有主题、错误回退、大图文字及缩放测试 | +| YAML 元数据 | 独立属性栏 | 普通 Markdown 场景不视为属性表单 | 既有标题、标签、引号、锚点、编码与往返测试 | +| 自定义字号 span | 装饰渲染 | 清理后 HTML | 既有字号标记测试 | +| 原始 HTML | 编辑器按自身 HTML 节点规则保留 | 清理后展示,脚本及事件属性移除 | 新增安全 HTML 测试 | + +源码模式展示 Markdown 原文,不隐藏反引号、星号和围栏。脚注、定义列表、Wiki 双链、Obsidian callout、图表以外的自定义围栏等未作为独立渲染扩展启用,不在“已支持”范围内。 + +自动检查覆盖解析、DOM 输出、部分编辑交互、保存往返和主题变量。尚未完成所有浏览器、所有输入法及每个主题的逐页截图比对;不能据此宣称像素级视觉验收通过。测试使用隔离样例,没有修改用户笔记。 diff --git a/frontend/package.json b/frontend/package.json index 8250775..83b878c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -36,6 +36,7 @@ "codemirror": "^6.0.0", "dompurify": "^3.4.14", "fflate": "^0.8.3", + "katex": "0.18.4", "marked": "^15.0.0", "mermaid": "^11.17.2", "pinia": "^4.0.0", @@ -46,6 +47,7 @@ "yaml": "^2.9.0" }, "devDependencies": { + "@types/katex": "0.16.8", "@types/node": "^22.0.0", "@types/semver": "^7.8.0", "@vitejs/plugin-vue": "^5.0.0", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index d27224b..261889a 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -83,6 +83,9 @@ importers: fflate: specifier: ^0.8.3 version: 0.8.3 + katex: + specifier: 0.18.4 + version: 0.18.4 marked: specifier: ^15.0.0 version: 15.0.12 @@ -108,6 +111,9 @@ importers: specifier: ^2.9.0 version: 2.9.0 devDependencies: + '@types/katex': + specifier: 0.16.8 + version: 0.16.8 '@types/node': specifier: ^22.0.0 version: 22.20.1 diff --git a/frontend/src/components/common/MarkdownContent.vue b/frontend/src/components/common/MarkdownContent.vue index 956efdc..ebb1576 100644 --- a/frontend/src/components/common/MarkdownContent.vue +++ b/frontend/src/components/common/MarkdownContent.vue @@ -33,11 +33,17 @@ watch([() => props.source, diagramTheme, () => themeStore.currentThemeId], async .markdown-content .shiki { overflow: auto; margin: .85em 0; padding: 16px; border: 1px solid var(--color-code-border); border-radius: 6px; background: var(--color-code-background) !important; color: var(--color-code-text); font-family: var(--font-ui-mono); font-size: .875em; line-height: 1.45; tab-size: 4; } .markdown-content code { padding: .1em .3em; border-radius: var(--radius-sm); background: var(--color-background-tertiary); font-family: var(--font-ui-mono); } .markdown-content .shiki code { display: block; min-width: max-content; padding: 0; background: transparent; font: inherit; } +.markdown-content :not(pre) > code { background: var(--color-code-background); color: var(--color-code-text); border: 1px solid var(--color-code-border); } +.markdown-content div.markdown-math { overflow-x: auto; padding-block: .5em; } +.markdown-content h4, .markdown-content h5, .markdown-content h6 { margin: 1em 0 .5em; font-weight: 600; } +.markdown-content input[type="checkbox"] { margin-right: .45em; accent-color: var(--color-accent-primary); } .markdown-content .shiki .line { display: block; min-height: 1.45em; } .markdown-content blockquote { padding-left: 1em; border-left: 3px solid var(--color-accent-primary); color: var(--color-text-secondary); } .markdown-content table { width: 100%; margin: .65em 0; border-collapse: collapse; } .markdown-content th, .markdown-content td { padding: .45em .65em; border: 1px solid var(--color-markdown-grid); text-align: left; } .markdown-content th { background: var(--color-markdown-table-header); font-weight: 700; } +.markdown-content :is(th, td)[align="center"] { text-align: center; } +.markdown-content :is(th, td)[align="right"] { text-align: right; } .markdown-content img { max-width: 100%; } .markdown-content hr { margin: 1em 0; border: 0; border-top: 1px solid var(--color-border-default); } [data-code-theme='github-light'] .markdown-content .shiki, diff --git a/frontend/src/features/editor/VisualMarkdownEditor.spec.ts b/frontend/src/features/editor/VisualMarkdownEditor.spec.ts index 95c2c59..ffdc997 100644 --- a/frontend/src/features/editor/VisualMarkdownEditor.spec.ts +++ b/frontend/src/features/editor/VisualMarkdownEditor.spec.ts @@ -1,5 +1,7 @@ // @vitest-environment happy-dom -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +// The application has a doctype; happy-dom otherwise reports quirks mode to KaTeX. +vi.hoisted(() => { Object.defineProperty(document, 'compatMode', {value:'CSS1Compat',configurable:true}) }) import { mount, type VueWrapper } from '@vue/test-utils' import { createPinia, setActivePinia } from 'pinia' import { editorViewCtx, type Editor } from '@milkdown/kit/core' @@ -49,6 +51,106 @@ afterEach(() => { }) describe('VisualMarkdownEditor formatting toolbars', () => { + it('renders the supported format matrix and preserves inline code', async () => { + const source = ['# H1','## H2','### H3','#### H4','##### H5','###### H6', + '正文 **粗体** *斜体* ~~删除~~ `s` 与 ``a`b``', '> 引用', '- 项目\n - 子项', '1. 第一\n2. 第二', + '- [x] 完成\n- [ ] 未完成', '[链接](https://example.com)', + '| A | B |\n| --- | --- |\n| x | y |', '---', '$x^2$', '$$\nx^2\n$$', '```js\nconst n = 1\n```'].join('\n\n') + const wrapper = mount(VisualMarkdownEditor, {props:{initialContent:source},attachTo:document.body}) + mounted.push(wrapper) + const editor = await waitForEditor(wrapper) + expect(wrapper.get('.ProseMirror code').text()).toBe('s') + for (const selector of ['h1','h2','h3','h4','h5','h6','strong','em','del','blockquote','ol','ul','table','hr','a']) expect(wrapper.find(`.ProseMirror ${selector}`).exists(), selector).toBe(true) + expect(editor.action(getMarkdown())).toContain('`s`') + expect(editor.action(getMarkdown())).toContain('``a`b``') + }) + it('converts a typed closing backtick to inline code', async () => { + const wrapper = mount(VisualMarkdownEditor, {props:{initialContent:''},attachTo:document.body}) + mounted.push(wrapper) + const editor = await waitForEditor(wrapper) + editor.action(ctx => { + const view = ctx.get(editorViewCtx) + for (const text of '`s`') { + const {from,to} = view.state.selection + let handled = false + view.someProp('handleTextInput', handler => { if (handler(view,from,to,text, () => view.state.tr.insertText(text,from,to))) { handled = true; return true } }) + if (!handled) view.dispatch(view.state.tr.insertText(text,from,to)) + } + }) + await wrapper.vm.$nextTick() + expect(wrapper.get('.ProseMirror code').text()).toBe('s') + }) + it('reconciles IME composition text without handleTextInput', async () => { + const wrapper = mount(VisualMarkdownEditor, {props:{initialContent:''},attachTo:document.body}) + mounted.push(wrapper) + const editor = await waitForEditor(wrapper) + editor.action(ctx => ctx.get(editorViewCtx).dispatch(ctx.get(editorViewCtx).state.tr.insertText('`s`'))) + await wrapper.get('.ProseMirror').trigger('compositionend', {data:'`s`'}) + await new Promise(resolve => setTimeout(resolve, 30)) + expect(wrapper.get('.ProseMirror code').text()).toBe('s') + expect(editor.action(getMarkdown()).trim()).toBe('`s`') + }) + it.each(['insertText', 'insertCompositionText', 'insertReplacementText'])('reconciles %s without event.data after the DOM update', async (inputType) => { + const wrapper = mount(VisualMarkdownEditor, {props:{initialContent:''},attachTo:document.body}) + mounted.push(wrapper) + const editor = await waitForEditor(wrapper) + // Chromium/IME can omit data and commit its DOM change after the input event. + await wrapper.get('.ProseMirror').trigger('input', {inputType, data:null}) + await new Promise(resolve => setTimeout(resolve, 10)) + editor.action(ctx => ctx.get(editorViewCtx).dispatch(ctx.get(editorViewCtx).state.tr.insertText('`s`'))) + await vi.waitFor(() => expect(wrapper.get('.ProseMirror code').text()).toBe('s')) + }) + it('waits for composition cleanup before converting committed text', async () => { + const wrapper = mount(VisualMarkdownEditor, {props:{initialContent:''},attachTo:document.body}) + mounted.push(wrapper) + const editor = await waitForEditor(wrapper) + const view = editor.action(ctx => ctx.get(editorViewCtx)) + const composing = vi.spyOn(view, 'composing', 'get').mockReturnValue(true) + await wrapper.get('.ProseMirror').trigger('compositionstart') + view.dispatch(view.state.tr.insertText('`s`')) + await wrapper.get('.ProseMirror').trigger('compositionend', {data:'`s`'}) + await new Promise(resolve => setTimeout(resolve, 50)) + expect(wrapper.find('.ProseMirror code').exists()).toBe(false) + composing.mockReturnValue(false) + await vi.waitFor(() => expect(wrapper.get('.ProseMirror code').text()).toBe('s')) + composing.mockRestore() + }) + it('converts content typed between an existing pair of backticks', async () => { + const wrapper = mount(VisualMarkdownEditor, {props:{initialContent:''},attachTo:document.body}) + mounted.push(wrapper) + const editor = await waitForEditor(wrapper) + const view = editor.action(ctx => ctx.get(editorViewCtx)) + view.dispatch(view.state.tr.insertText('``')) + await wrapper.get('.ProseMirror').trigger('input', {inputType:'insertText', data:'`'}) + await new Promise(resolve => setTimeout(resolve, 60)) + // Empty pairs are serialized as escaped literal text, but that must not + // prevent recognition after the user moves back and fills in the content. + expect(editor.action(getMarkdown())).toContain('\\`') + view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, 2)).insertText('s')) + await wrapper.get('.ProseMirror').trigger('input', {inputType:'insertText', data:'s'}) + await vi.waitFor(() => expect(wrapper.get('.ProseMirror code').text()).toBe('s')) + expect(editor.action(getMarkdown()).trim()).toBe('`s`') + expect(view.state.selection.from).toBe(2) + view.dispatch(view.state.tr.insertText('tring')) + expect(editor.action(getMarkdown()).trim()).toBe('`string`') + }) + it.each(['insertFromPaste', 'historyUndo', 'deleteContentBackward'])('does not reinterpret literals on %s', async (inputType) => { + const wrapper = mount(VisualMarkdownEditor, {props:{initialContent:''},attachTo:document.body}) + mounted.push(wrapper) + const editor = await waitForEditor(wrapper) + editor.action(ctx => ctx.get(editorViewCtx).dispatch(ctx.get(editorViewCtx).state.tr.insertText('`s`'))) + await wrapper.get('.ProseMirror').trigger('input', {inputType, data:null}) + await new Promise(resolve => setTimeout(resolve, 60)) + expect(wrapper.find('.ProseMirror code').exists()).toBe(false) + }) + it('enables inline code from the toolbar at an empty selection', async () => { + const wrapper = mount(VisualMarkdownEditor, {props:{initialContent:''},attachTo:document.body}) + mounted.push(wrapper) + const editor = await waitForEditor(wrapper) + await wrapper.get('[aria-label="行内代码"]').trigger('pointerdown') + editor.action(ctx => ctx.get(editorViewCtx).dispatch(ctx.get(editorViewCtx).state.tr.insertText('value'))) + expect(editor.action(getMarkdown()).trim()).toBe('`value`') + }) it.each([['jsonc', 'JSON with Comments', '// comment\n{"answer": 42}'], ['ahk', 'AutoHotkey', 'MsgBox "Hello"']])('persists %s from the language menu and renders it with Shiki', async (id, label, source) => { const wrapper = mount(VisualMarkdownEditor, { props: { initialContent: `\`\`\`text\n${source}\n\`\`\`` }, attachTo: document.body }) mounted.push(wrapper) diff --git a/frontend/src/features/editor/VisualMarkdownEditor.vue b/frontend/src/features/editor/VisualMarkdownEditor.vue index e5cf8e3..717425b 100644 --- a/frontend/src/features/editor/VisualMarkdownEditor.vue +++ b/frontend/src/features/editor/VisualMarkdownEditor.vue @@ -33,6 +33,7 @@ import { useEditorStore } from '@/stores/editor' import { useSettingsStore } from '@/stores/settings' import { useThemeStore } from '@/stores/theme' import { applyMarkdownFontSize, fontSizeMarkdownPlugin } from './fontSizeMarkdown' +import { inlineCodeInputPlugin } from './inlineCodeInput' import { t } from '@/i18n' import '@milkdown/crepe/theme/common/style.css' import '@milkdown/crepe/theme/frame.css' @@ -93,6 +94,16 @@ type ToolbarCommand = 'bold' | 'italic' | 'ordered-list' | 'bullet-list' | 'inli function runCommand(command: ToolbarCommand) { const editor = crepe?.editor if (!editor) return + if (command === 'inline-code' && editor.action(ctx => ctx.get(editorViewCtx).state.selection.empty)) { + editor.action(ctx => { + const view = ctx.get(editorViewCtx) + const mark = view.state.schema.marks.inlineCode! + const active = (view.state.storedMarks ?? view.state.selection.$from.marks()).some(item => item.type === mark) + view.dispatch(active ? view.state.tr.removeStoredMark(mark) : view.state.tr.setStoredMarks([mark.create()])) + view.focus() + }) + return + } // 顶部工具栏复用 Milkdown 命令,因此选区与浮动工具栏共享同一文档事务。 const actions = { bold: callCommand(toggleStrongCommand.key), @@ -228,6 +239,7 @@ onMounted(async () => { extensions: [basicSetup, keymap.of([indentWithTab]), shikiEditorTheme(themeStore.resolvedCodeBlockTheme)], }))) crepe.editor.use(fontSizeMarkdownPlugin) + crepe.editor.use(inlineCodeInputPlugin) crepe.on((listener) => { listener.markdownUpdated((_ctx, markdown, previousMarkdown) => { // 忽略编辑器初始化/回显事件,防止无内容变化时触发自动保存循环。 @@ -404,6 +416,7 @@ defineExpose({ getEditor: () => crepe?.editor }) .milkdown-host :deep(.milkdown-list-item-block li .label-wrapper) { color: var(--color-markdown-marker); font-weight: 700; } .milkdown-host :deep(.milkdown-list-item-block li .label-wrapper svg) { fill: var(--color-markdown-marker); } .milkdown-host :deep(code) { font-family: var(--font-editor-mono); } +.milkdown-host :deep(.ProseMirror :not(pre) > code) { padding: .12em .35em; border: 1px solid var(--color-code-border); border-radius: var(--radius-sm); background: var(--color-code-background); color: var(--color-code-text); font-size: .9em; box-decoration-break: clone; } :global([data-theme='dark'] .milkdown-host .milkdown) { color-scheme: dark; } @media (max-width: 680px) { .toolbar-select select { min-width: 46px; width: 46px; } } diff --git a/frontend/src/features/editor/inlineCodeInput.ts b/frontend/src/features/editor/inlineCodeInput.ts new file mode 100644 index 0000000..16467ce --- /dev/null +++ b/frontend/src/features/editor/inlineCodeInput.ts @@ -0,0 +1,99 @@ +import { $prose } from '@milkdown/kit/utils' +import { Plugin } from '@milkdown/kit/prose/state' +import type { EditorView } from '@milkdown/kit/prose/view' + +function reconcile(view: EditorView) { + if (view.isDestroyed || view.composing || !view.state.selection.empty) return + const { $from } = view.state.selection + if (!$from.parent.isTextblock || $from.parent.type.spec.code) return + const text = $from.parent.textBetween(0, $from.parent.content.size, '\n', '\ufffc') + // Also inspect the closing delimiter AFTER the caret: users commonly type + // a pair of backticks first, move left, and then fill in the code. + const spans = /(^|[^\\`])`([^`\n\ufffc]+)`(?!`)/g + let candidate: { start: number; end: number } | undefined + for (const match of text.matchAll(spans)) { + const start = match.index! + match[1]!.length + const end = match.index! + match[0].length + if (match[2]!.trim() && $from.parentOffset > start && $from.parentOffset <= end) { + candidate = { start: $from.start() + start, end: $from.start() + end } + break + } + } + if (!candidate) return + const mark = view.state.schema.marks.inlineCode + if (!mark) return + const { start, end } = candidate + if (view.state.doc.rangeHasMark(start, end, mark)) return + const tr = view.state.tr.delete(end - 1, end).delete(start, start + 1) + tr.removeMark(start, end - 2).addMark(start, end - 2, mark.create()) + // Filling an existing pair must keep subsequent letters inside code. Typing + // the closing delimiter explicitly should instead leave code as usual. + if ($from.pos < end) tr.setStoredMarks([mark.create()]) + else tr.removeStoredMark(mark) + view.dispatch(tr) +} + +// DOM input can bypass handleTextInput (IME, replacement text, missing event.data). +// Observe it in capture phase, then wait for ProseMirror's DOM observer and its +// composition cleanup before inspecting the document. Never reconcile on load, +// paste, undo, or a selection change alone. +export const inlineCodeInputPlugin = $prose(() => new Plugin({ + view(view) { + let timer: ReturnType | undefined + let pending = false + let composing = false + let attempts = 0 + const cancel = () => { clearTimeout(timer); pending = false } + const schedule = () => { + clearTimeout(timer) + if (!pending || composing) return + timer = setTimeout(() => { + if (view.isDestroyed) return cancel() + if (view.composing) { + if (++attempts < 10) schedule() + else cancel() + return + } + pending = false + reconcile(view) + }, 30) + } + const input = (event: Event) => { + const type = (event as InputEvent).inputType + if (type && (!type.startsWith('insert') || /paste|drop/i.test(type))) return cancel() + pending = true + attempts = 0 + schedule() + } + const start = () => { composing = true; cancel() } + const end = () => { composing = false; pending = true; attempts = 0; schedule() } + const keydown = (event: KeyboardEvent) => { + // ProseMirror handles undo/paste itself, so those actions need not emit input. + if (event.ctrlKey || event.metaKey || ['Backspace', 'Delete', 'Escape'].includes(event.key)) cancel() + else if (pending && !composing && !view.composing && event.key === 'Enter') { + cancel() + reconcile(view) + } + } + view.dom.addEventListener('input', input, true) + view.dom.addEventListener('keydown', keydown, true) + view.dom.addEventListener('paste', cancel, true) + view.dom.addEventListener('drop', cancel, true) + view.dom.addEventListener('compositionstart', start, true) + view.dom.addEventListener('compositionend', end, true) + return { + update(current, previous) { + if (pending && !current.state.doc.eq(previous.doc)) schedule() + }, + destroy() { + cancel() + view.dom.removeEventListener('input', input, true) + view.dom.removeEventListener('keydown', keydown, true) + view.dom.removeEventListener('paste', cancel, true) + view.dom.removeEventListener('drop', cancel, true) + view.dom.removeEventListener('compositionstart', start, true) + view.dom.removeEventListener('compositionend', end, true) + }, + } + }, +})) diff --git a/frontend/src/features/search/SearchView.vue b/frontend/src/features/search/SearchView.vue index 58fd00a..7db49e4 100644 --- a/frontend/src/features/search/SearchView.vue +++ b/frontend/src/features/search/SearchView.vue @@ -80,7 +80,7 @@ async function openResult(result: SearchResult) { .search-history { display: flex; flex-wrap: wrap; gap: var(--space-sm); margin-bottom: var(--space-md); } .advanced { grid-column: 1 / -1; } .results-header, .result-title, .result-meta { display: flex; align-items: center; justify-content: space-between; gap: var(--space-md); } -.results-header { margin: var(--space-xl) 0 var(--space-md); color: var(--color-text-secondary); } +.results-header { margin-block: var(--space-xl) var(--space-md); color: var(--color-text-secondary); } .result-list { display: grid; gap: var(--space-md); } .result-card { position: relative; cursor: pointer; overflow: hidden; } .result-card::before { content: ''; position: absolute; inset: 0 auto 0 0; width: 3px; background: var(--color-accent-primary); opacity: 0; transform: scaleY(.45); transition: opacity var(--motion-fast), transform var(--motion-fast); } diff --git a/frontend/src/utils/markdown.ts b/frontend/src/utils/markdown.ts index c621133..9067109 100644 --- a/frontend/src/utils/markdown.ts +++ b/frontend/src/utils/markdown.ts @@ -7,6 +7,27 @@ import githubDark from '@shikijs/themes/github-dark' import githubLight from '@shikijs/themes/github-light' import { renderMermaid } from '@/services/mermaidService' import { appendDiagramControls } from './diagramControls' +import katex from 'katex' +import 'katex/dist/katex.min.css' + +function mathHtml(source: string, displayMode: boolean) { + const result = katex.renderToString(source, {displayMode, throwOnError:false, trust:false, maxExpand:1000, output:'html'}) + const label = source.replace(/&/g, '&').replace(/"/g, '"').replace(/${result}` +} + +marked.use({extensions:[ + {name:'blockMath',level:'block',tokenizer(source) { + const match = /^ {0,3}\$\$\s*\n?([\s\S]+?)\n?\$\$[ \t]*(?:\n|$)/.exec(source) + if (match) return {type:'blockMath',raw:match[0],text:match[1]!.trim()} + return undefined + }, renderer(token) { return mathHtml(token.text, true) }}, + {name:'inlineMath',level:'inline',start(source) { return source.indexOf('$') },tokenizer(source) { + const match = /^\$([^$\n]+?)\$(?!\$)/.exec(source) + if (match && !/^\s|\s$/.test(match[1]!)) return {type:'inlineMath',raw:match[0],text:match[1]!} + return undefined + },renderer(token) { return mathHtml(token.text, false) }}, +]}) marked.setOptions({ gfm: true, breaks: true }) @@ -72,6 +93,10 @@ export async function renderMarkdown(source: string, options?: { theme?: 'light' mermaidBlocks.push({ pre: code.parentElement!, source: code.textContent ?? '' }) continue } + if (requestedLanguage.toLowerCase() === 'latex') { + code.parentElement?.replaceWith(document.createRange().createContextualFragment(mathHtml(code.textContent ?? '', true))) + continue + } const highlighted = await highlightCode(code.textContent ?? '', requestedLanguage) const fragment = document.createRange().createContextualFragment(highlighted) code.parentElement?.replaceWith(fragment) diff --git a/frontend/src/utils/markdownRendering.spec.ts b/frontend/src/utils/markdownRendering.spec.ts new file mode 100644 index 0000000..4c33703 --- /dev/null +++ b/frontend/src/utils/markdownRendering.spec.ts @@ -0,0 +1,27 @@ +// @vitest-environment jsdom +import { expect, it } from 'vitest' +import { renderMarkdown } from './markdown' + +it('renders CommonMark and GFM formats, escaped literals and safe HTML', async () => { + const source = ['# H1','## H2','### H3','#### H4','##### H5','###### H6', + '**bold** *italic* ~~deleted~~ `inline` ``a`b``', '> quote', '- item\n - nested', + '1. first\n2. second', '- [x] done\n- [ ] todo', '[link][ref]\n\n[ref]: https://example.com', + '![alt](https://example.com/image.png)', '| A | B |\n| --- | --- |\n| x | y |', '---', + 'line \nbreak', '\\`literal\\`', '', + '```unknown-language\n\n```'].join('\n\n') + const root = document.createElement('div') + root.innerHTML = await renderMarkdown(source) + for (const selector of ['h1','h2','h3','h4','h5','h6','strong','em','del','blockquote','ul','ol','table','hr','br','a','img','input[type=checkbox]','pre code']) expect(root.querySelector(selector), selector).not.toBeNull() + expect(root.querySelector('code')?.textContent).toBe('inline') + expect(root.textContent).toContain('`literal`') + expect(root.querySelector('pre code')?.textContent).toContain('') + expect(root.querySelector('script,[onerror]')).toBeNull() +}) + +it('renders inline, display and editor LaTeX fences while leaving code literals alone', async () => { + const root = document.createElement('div') + root.innerHTML = await renderMarkdown('$x^2$\n\n$$\nx^2\n$$\n\n```LaTeX\nx^2\n```\n\n`$literal$`\n\n```text\n$literal$\n```') + expect(root.querySelectorAll('.katex')).toHaveLength(3) + expect(root.querySelector('code')?.textContent).toBe('$literal$') + expect(root.querySelector('pre code')?.textContent).toContain('$literal$') +})