fix(editor): handle paired inline code input and complete markdown rendering

This commit is contained in:
2026-09-05 23:14:19 +08:00
parent a1ab1024f0
commit 6107b7ff1b
10 changed files with 317 additions and 2 deletions
+35
View File
@@ -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 输出、部分编辑交互、保存往返和主题变量。尚未完成所有浏览器、所有输入法及每个主题的逐页截图比对;不能据此宣称像素级视觉验收通过。测试使用隔离样例,没有修改用户笔记。
+2
View File
@@ -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",
+6
View File
@@ -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
@@ -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,
@@ -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)
@@ -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; } }
</style>
@@ -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<typeof setTimeout> | 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)
},
}
},
}))
+1 -1
View File
@@ -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); }
+25
View File
@@ -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, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;')
return `<${displayMode ? 'div' : 'span'} class="markdown-math" role="math" aria-label="${label}">${result}</${displayMode ? 'div' : 'span'}>`
}
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)
@@ -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\\`', '<script>alert(1)</script><img src="x" onerror="alert(1)">',
'```unknown-language\n<script>literal</script>\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('<script>literal</script>')
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$')
})