fix(editor): validate preserved break source

Use the editor model's supported DOM parse-rule type instead of a private duplicate. Exercise the rendered-DOM round trip and reject forged preservation attributes before they can become hidden Markdown source.
This commit is contained in:
Brennan Benson
2026-07-20 17:55:47 -07:00
parent af48c13a5e
commit 010668255d
2 changed files with 39 additions and 17 deletions
@@ -1,3 +1,5 @@
// @vitest-environment happy-dom
import { describe, expect, it } from 'vitest'
import { Editor } from '@tiptap/core'
import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
@@ -62,16 +64,39 @@ describe('raw markdown <br> line breaks in the rich editor', () => {
}
})
it('round-trips the verbatim <br> tags in a table cell on save', () => {
it('round-trips verbatim <br> tags after the rendered DOM is parsed again', () => {
const editor = createEditor(TABLE_WITH_BREAKS)
try {
const rendered = document.createElement('div')
rendered.innerHTML = editor.getHTML()
expect(rendered.querySelectorAll('br[data-raw-markdown-html-inline]')).toHaveLength(3)
expect(rendered.textContent).not.toContain('<br')
editor.commands.setContent(rendered.innerHTML)
const output = (editor as Editor & { getMarkdown(): string }).getMarkdown()
expect(output).toContain('<br/>')
expect(output).toContain('<br>')
expect(output.match(/<br\s*\/?>/g)).toEqual(['<br/>', '<br/>', '<br>'])
// The break must not silently collapse into a space on serialization.
expect(output).not.toContain('`normal` `defect`')
} finally {
editor.destroy()
}
})
it('does not accept arbitrary Markdown source from a forged break marker', () => {
const editor = createEditor('Before<br/>after\n')
try {
const rendered = document.createElement('div')
rendered.innerHTML = editor.getHTML()
rendered
.querySelector('br[data-raw-markdown-html-inline]')
?.setAttribute('data-raw-markdown-html-value', '<script>alert(1)</script>')
editor.commands.setContent(rendered.innerHTML)
const output = (editor as Editor & { getMarkdown(): string }).getMarkdown()
expect(output).toContain('Before<br>after')
expect(output).not.toContain('<script>')
} finally {
editor.destroy()
}
})
})
@@ -1,5 +1,5 @@
import { mergeAttributes } from '@tiptap/core'
import type { DOMOutputSpec, Node as ProseMirrorNode } from '@tiptap/pm/model'
import type { DOMOutputSpec, Node as ProseMirrorNode, TagParseRule } from '@tiptap/pm/model'
import type { RichMarkdownSourceKind } from './rich-markdown-source-transport'
// Why: preserves the exact authored <br> spelling on the rendered break node so
@@ -14,16 +14,17 @@ type RawMarkdownSourceDomConfig = {
className?: string
}
type RawMarkdownSourceParseRule = {
tag: string
priority?: number
getAttrs: (element: HTMLElement) => { value: string }
}
function isHtmlLineBreak(value: string): boolean {
return /^<br\s*\/?>$/i.test(value.trim())
}
function parsedHtmlLineBreakValue(element: HTMLElement): string {
const value = element.getAttribute(HTML_LINE_BREAK_VALUE_ATTR)
// Why: DOM input can come from the clipboard, so only trusted break syntax
// may become hidden Markdown source through the internal preservation marker.
return value && isHtmlLineBreak(value) ? value : '<br>'
}
function nodeValue(node: ProseMirrorNode): string {
return typeof node.attrs.value === 'string' ? node.attrs.value : ''
}
@@ -34,10 +35,8 @@ function rendersLineBreaks(config: RawMarkdownSourceDomConfig): boolean {
return config.inline && config.kind === 'inline-html'
}
export function rawMarkdownSourceParseRules(
config: RawMarkdownSourceDomConfig
): RawMarkdownSourceParseRule[] {
const rules: RawMarkdownSourceParseRule[] = [
export function rawMarkdownSourceParseRules(config: RawMarkdownSourceDomConfig): TagParseRule[] {
const rules: TagParseRule[] = [
{
tag: `${config.inline ? 'span' : 'div'}[${config.marker}]`,
getAttrs: (element: HTMLElement) => ({ value: element.textContent ?? '' })
@@ -50,9 +49,7 @@ export function rawMarkdownSourceParseRules(
rules.push({
tag: `br[${config.marker}]`,
priority: 100,
getAttrs: (element: HTMLElement) => ({
value: element.getAttribute(HTML_LINE_BREAK_VALUE_ATTR) ?? '<br>'
})
getAttrs: (element: HTMLElement) => ({ value: parsedHtmlLineBreakValue(element) })
})
}
return rules