fix(editor): render authored Markdown breaks as rich-editor line breaks (#8995)

* fix(editor): render <br> in markdown table cells as line breaks in rich mode

The rich Markdown editor encoded every inline HTML tag — including `<br>` and
`<br/>` — into an inert raw-HTML atom that displayed the literal tag text. In a
table cell, where a blank-line break is impossible, this meant `<br>` showed up
as raw "<br>" instead of breaking the line.

Render `<br>`/`<br/>` raw inline nodes as real break elements while keeping
`renderMarkdown` emitting the verbatim tag, so the value round-trips exactly on
save. A native hardBreak is intentionally not used: it serializes to a space
inside table cells and would silently drop the tag. The raw-source node's
parse/render DOM logic is extracted into a dedicated module to keep it cohesive
and within the file's line budget.

Closes #8838

* 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.

* test(editor): assert forged break marker exists

* test(editor): preserve authored rich markdown break spelling

---------

Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
Co-authored-by: Neil <neil@stably.ai>
This commit is contained in:
Mark Xian
2026-10-23 19:20:09 -07:00
committed by GitHub
co-authored by Brennan Benson Neil Neil
parent 831c394797
commit 39fc4f7688
3 changed files with 195 additions and 17 deletions
@@ -0,0 +1,116 @@
// @vitest-environment happy-dom
import { describe, expect, it } from 'vitest'
import { Editor } from '@tiptap/core'
import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport'
import { createRichMarkdownExtensions } from './rich-markdown-extensions'
import { encodeRawMarkdownHtmlForRichEditor } from './raw-markdown-html'
function createEditor(markdown: string): Editor {
const codec = createRichMarkdownEditorCodec()
return new Editor({
element: null,
extensions: createRichMarkdownExtensions({ codec }),
content: encodeRawMarkdownHtmlForRichEditor(markdown, codec),
contentType: 'markdown'
})
}
function findRawInlineNodes(editor: Editor): ProseMirrorNode[] {
const nodes: ProseMirrorNode[] = []
editor.state.doc.descendants((node) => {
if (node.type.name === 'rawMarkdownHtmlInline') {
nodes.push(node)
}
})
return nodes
}
function renderTag(node: ProseMirrorNode): string {
const spec = node.type.spec.toDOM?.(node)
return Array.isArray(spec) ? String(spec[0]) : ''
}
const TABLE_WITH_BREAKS =
'| Field | Description |\n' +
'| --- | --- |\n' +
'| verdict | Values:<br/><br/>`normal`<br>`defect` |\n'
describe('raw markdown <br> line breaks in the rich editor', () => {
it('renders <br> and <br/> inside a table cell as real line breaks, not literal text', () => {
const editor = createEditor(TABLE_WITH_BREAKS)
try {
const breakNodes = findRawInlineNodes(editor)
expect(breakNodes.length).toBeGreaterThan(0)
for (const node of breakNodes) {
expect(renderTag(node)).toBe('br')
}
} finally {
editor.destroy()
}
})
it('keeps non-break inline HTML as an inert literal span', () => {
const editor = createEditor('Before <span>hi</span> after\n')
try {
const [spanNode] = findRawInlineNodes(editor)
expect(spanNode).toBeDefined()
expect(renderTag(spanNode)).toBe('span')
} finally {
editor.destroy()
}
})
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.getMarkdown()
expect(output.match(/<br\s*\/?>/g)).toEqual(['<br/>', '<br/>', '<br>'])
expect(output).not.toContain('`normal` `defect`')
} finally {
editor.destroy()
}
})
it('preserves a case-insensitive break spelling through a DOM round trip', () => {
const editor = createEditor('Before<BR />after\n')
try {
const rendered = document.createElement('div')
rendered.innerHTML = editor.getHTML()
editor.commands.setContent(rendered.innerHTML)
expect(editor.getMarkdown()).toContain('Before<BR />after')
} 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()
const breakNode = rendered.querySelector('br[data-raw-markdown-html-inline]')
expect(breakNode).not.toBeNull()
if (!breakNode) {
throw new Error('Expected preserved break marker')
}
breakNode.setAttribute('data-raw-markdown-html-value', '<script>alert(1)</script>')
editor.commands.setContent(rendered.innerHTML)
const output = editor.getMarkdown()
expect(output).toContain('Before<br>after')
expect(output).not.toContain('<script>')
} finally {
editor.destroy()
}
})
})
@@ -1,4 +1,8 @@
import { Node, mergeAttributes } from '@tiptap/core'
import { Node } from '@tiptap/core'
import {
rawMarkdownSourceParseRules,
renderRawMarkdownSourceHtml
} from './raw-markdown-html-source-dom'
import { createMarkdownTokenizerStart } from './markdown-tokenizer-start'
import {
skipInlineTransportStartScan,
@@ -95,25 +99,11 @@ function createRawSourceNode({
renderText: ({ node }) => (typeof node.attrs.value === 'string' ? node.attrs.value : ''),
parseHTML() {
return [
{
tag: `${inline ? 'span' : 'div'}[${marker}]`,
getAttrs: (element: HTMLElement) => ({ value: element.textContent ?? '' })
}
]
return rawMarkdownSourceParseRules({ inline, kind, marker, className })
},
renderHTML({ HTMLAttributes, node }) {
const value = typeof node.attrs.value === 'string' ? node.attrs.value : ''
return [
inline ? 'span' : 'div',
mergeAttributes(HTMLAttributes, {
[marker]: '',
contenteditable: 'false',
class: className
}),
inline ? value : ['pre', value]
]
return renderRawMarkdownSourceHtml({ inline, kind, marker, className }, HTMLAttributes, node)
}
})
}
@@ -0,0 +1,72 @@
import { mergeAttributes } from '@tiptap/core'
import type { DOMOutputSpec, Node as ProseMirrorNode, TagParseRule } from '@tiptap/pm/model'
import type { RichMarkdownSourceKind } from './rich-markdown-source-transport'
// Preserve authored break spelling; native hardBreak serializes to a space in table cells.
const HTML_LINE_BREAK_VALUE_ATTR = 'data-raw-markdown-html-value'
type RawMarkdownSourceDomConfig = {
inline: boolean
kind: RichMarkdownSourceKind
marker: string
className?: 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)
// Clipboard markers may preserve break syntax only.
return value && isHtmlLineBreak(value) ? value : '<br>'
}
function nodeValue(node: ProseMirrorNode): string {
return typeof node.attrs.value === 'string' ? node.attrs.value : ''
}
function rendersLineBreaks(config: RawMarkdownSourceDomConfig): boolean {
return config.inline && config.kind === 'inline-html'
}
export function rawMarkdownSourceParseRules(config: RawMarkdownSourceDomConfig): TagParseRule[] {
const rules: TagParseRule[] = [
{
tag: `${config.inline ? 'span' : 'div'}[${config.marker}]`,
getAttrs: (element: HTMLElement) => ({ value: element.textContent ?? '' })
}
]
if (rendersLineBreaks(config)) {
// Parse preserved breaks before StarterKit's unmarked hardBreak rule.
rules.push({
tag: `br[${config.marker}]`,
priority: 100,
getAttrs: (element: HTMLElement) => ({ value: parsedHtmlLineBreakValue(element) })
})
}
return rules
}
export function renderRawMarkdownSourceHtml(
config: RawMarkdownSourceDomConfig,
attributes: Record<string, unknown>,
node: ProseMirrorNode
): DOMOutputSpec {
const value = nodeValue(node)
if (rendersLineBreaks(config) && isHtmlLineBreak(value)) {
return [
'br',
mergeAttributes(attributes, { [config.marker]: '', [HTML_LINE_BREAK_VALUE_ATTR]: value })
]
}
return [
config.inline ? 'span' : 'div',
mergeAttributes(attributes, {
[config.marker]: '',
contenteditable: 'false',
class: config.className
}),
config.inline ? value : ['pre', value]
]
}