feat(rich-markdown-editor): round-trip gate, VSCode/style paste, highlight, block reorder (#5539)

* fix(rich-markdown-editor): tighten read-only gate for uppercase entities and orphan ref-defs

Two silent-corruption cases the idempotency probe can't see:

- The HTML-entity safe-list used a case-insensitive regex, so `&`/`<`/`>` were
  treated as the round-trippable canonical entities and let through as editable, but the
  serializer only round-trips the lowercase forms and mangles the uppercase ones. Make the
  safe-list case-sensitive.
- An unused link/image reference definition (`[x]: url` with no `[x]` reference) is dropped
  entirely on serialize, a deletion the idempotency probe misses. Detect orphan definitions
  and open read-only; used definitions still inline losslessly and stay editable. The use
  check tolerates bracket-internal padding (`[ x ]`), and GFM footnote definitions (`[^id]: …`)
  are excluded since they round-trip verbatim.

* feat(rich-markdown-editor): VSCode code paste and strip <style>/<script> from pasted HTML

- Code copied from VSCode carries a `vscode-editor-data` payload with the source language,
  but its text/html is per-token colored spans that ProseMirror flattens into plain
  paragraphs. Read the payload and paste a real fenced code block with the mapped language.
  markdown/plaintext modes resolve to no language and fall through, so markdown copied from
  VSCode still parses as rich content rather than a fenced block.
- Google Sheets and Word prepend a `<style>` block of CSS that PM's DOM parser walks into
  the document as literal text. Strip <style>/<script> in transformPastedHTML before parsing.
- Add a `==…==` inline-mark hint (allowing a lone interior `=`) so a plain-text paste of
  `==highlight==` routes through the markdown parser and becomes a highlight mark.

* feat(rich-markdown-editor): add highlight (==mark==) support

Adds a highlight mark rendered as <mark> and serialized to/from ==text== (Pandoc/Obsidian
syntax). A custom inline tokenizer parses ==text== (inner text parsed as inline markdown so
nested marks like ==**bold**== survive; the body allows a lone `=` so ==a=b== round-trips).
`==` cannot be encoded in the delimiter, so an appendTransaction guard strips the mark from
any text that ends up containing `==` (e.g. a toolbar highlight over a==b), keeping the text
and never emitting the corrupting ==a==b==. Comparison operators (x == y) stay literal.
Wired with an input rule, paste rule, Mod-Shift-H, a bubble-menu button, and themed <mark>
styling. Also locks in mark-stacking round-trips across contexts.

* feat(rich-markdown-editor): keyboard block reordering (Mod-Shift-Arrow)

Mod-Shift-ArrowUp/ArrowDown swaps the current top-level block with its neighbour, carrying
the caret at its original offset (newBefore + offset, no off-by-one), and no-ops at the
document edges. Exposed as moveBlockUp/moveBlockDown commands (the keyboard shortcuts call
them). Pure UI interaction, no schema change. Covered by tests (order, caret offset, edge
no-op, list stays intact).
This commit is contained in:
Waleed
2026-07-09 13:31:16 -07:00
committed by GitHub
parent 29c01fe40a
commit e802da1075
13 changed files with 545 additions and 7 deletions
@@ -0,0 +1,94 @@
import { Extension } from '@tiptap/core'
import type { EditorState, Transaction } from '@tiptap/pm/state'
import { TextSelection } from '@tiptap/pm/state'
/** The position range of the depth-1 block containing the cursor, or null at the document root. */
function currentTopLevelBlock(state: EditorState): { from: number; to: number } | null {
const { $from } = state.selection
if ($from.depth === 0) return null
return { from: $from.before(1), to: $from.after(1) }
}
/**
* Swaps the current top-level block with its neighbour in `direction`, keeping the caret on the moved
* block. Adjacent top-level blocks share a boundary position (no separator token between them), so the
* move is a single `replaceWith` of the two-block span with the pair reordered. No-ops (returns false)
* at the matching document edge or when the neighbour isn't a top-level block. `newBefore` is the moved
* block's new `before(1)` position; adding the caret's original offset (`selection.from - from`, also
* measured from `before(1)`) re-anchors the caret at the same spot within the block.
*/
function moveBlock(
state: EditorState,
dispatch: ((tr: Transaction) => void) | undefined,
direction: 'up' | 'down'
): boolean {
const block = currentTopLevelBlock(state)
if (!block) return false
const { from, to } = block
const up = direction === 'up'
if (up ? from === 0 : to >= state.doc.content.size) return false
const $neighbour = state.doc.resolve(up ? from - 1 : to + 1)
if ($neighbour.depth === 0) return false
if (!dispatch) return true
const spanFrom = up ? $neighbour.before(1) : from
const spanTo = up ? to : $neighbour.after(1)
const moving = state.doc.slice(from, to).content
const neighbour = up
? state.doc.slice(spanFrom, from).content
: state.doc.slice(to, spanTo).content
const tr = state.tr.replaceWith(
spanFrom,
spanTo,
up ? moving.append(neighbour) : neighbour.append(moving)
)
const newBefore = up ? spanFrom : spanFrom + neighbour.size
const offset = state.selection.from - from
tr.setSelection(
TextSelection.near(tr.doc.resolve(Math.min(newBefore + offset, newBefore + moving.size)))
)
dispatch(tr.scrollIntoView())
return true
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
blockMover: {
/** Move the current top-level block up one position, carrying the caret. */
moveBlockUp: () => ReturnType
/** Move the current top-level block down one position, carrying the caret. */
moveBlockDown: () => ReturnType
}
}
}
/**
* Reorders the current top-level block with `Mod-Shift-ArrowUp`/`ArrowDown` — the standard
* keyboard block-move affordance (Notion/Obsidian). Pure UI interaction: no schema change, and the
* caret rides along with the block. A no-op (returns false, falling through) at the document edges.
*/
export const BlockMover = Extension.create({
name: 'blockMover',
addCommands() {
return {
moveBlockUp:
() =>
({ state, dispatch }) =>
moveBlock(state, dispatch, 'up'),
moveBlockDown:
() =>
({ state, dispatch }) =>
moveBlock(state, dispatch, 'down'),
}
},
addKeyboardShortcuts() {
return {
'Mod-Shift-ArrowUp': ({ editor }) => editor.commands.moveBlockUp(),
'Mod-Shift-ArrowDown': ({ editor }) => editor.commands.moveBlockDown(),
}
},
})
@@ -1,5 +1,6 @@
import type { Extensions } from '@tiptap/core'
import Placeholder from '@tiptap/extension-placeholder'
import { BlockMover } from './block-mover'
import { CodeBlockWithLanguage } from './code-block'
import { CodeBlockHighlight } from './code-highlight'
import { LinkEmbed } from './embed/link-embed'
@@ -44,6 +45,7 @@ export function createMarkdownEditorExtensions({
SlashCommand,
Mention,
RichMarkdownKeymap,
BlockMover,
MarkdownPaste,
Placeholder.configure({ placeholder }),
...(embeds ? [LinkEmbed] : []),
@@ -12,6 +12,7 @@ import {
import { Markdown } from '@tiptap/markdown'
import StarterKit from '@tiptap/starter-kit'
import { MarkdownCodeBlock } from './code-block'
import { Highlight } from './highlight'
import { MarkdownImage } from './image'
import { MarkdownLinkInputRule } from './link-input-rule'
import { MarkdownMention } from './mention/mention-node'
@@ -130,6 +131,7 @@ export function createMarkdownContentExtensions(nodeViews: ContentNodeViews = {}
}),
BlockSafeParagraph,
InlineCode,
Highlight,
codeBlock,
(nodeViews.image ?? MarkdownImage).configure({ allowBase64: true }),
nodeViews.mention ?? MarkdownMention,
@@ -0,0 +1,105 @@
import type {
JSONContent,
MarkdownParseHelpers,
MarkdownRendererHelpers,
MarkdownToken,
} from '@tiptap/core'
import { Mark, markInputRule, markPasteRule, mergeAttributes } from '@tiptap/core'
import type { Transaction } from '@tiptap/pm/state'
import { Plugin } from '@tiptap/pm/state'
/**
* `==text==` with non-space edges — the Pandoc/Obsidian highlight syntax. The body allows a lone `=`
* (`=(?!=)`) but never `==`, so a highlight over text containing `=` (e.g. `==a=b==`) round-trips while
* the closing `==` still terminates the run.
*/
const HIGHLIGHT_BODY = String.raw`(?:[^=]|=(?!=))+?`
const HIGHLIGHT_TOKEN = new RegExp(String.raw`^==(?!\s)(${HIGHLIGHT_BODY})(?<!\s)==`)
/** Input/paste rule form (anchored on a preceding boundary) so typing `==x==` toggles the mark. */
const HIGHLIGHT_INPUT = new RegExp(String.raw`(?:^|\s)(==(?!\s)(${HIGHLIGHT_BODY})(?<!\s)==)$`)
const HIGHLIGHT_PASTE = new RegExp(String.raw`(?:^|\s)(==(?!\s)(${HIGHLIGHT_BODY})(?<!\s)==)`, 'g')
/**
* Highlight mark (`<mark>`), serialized to and parsed from `==text==`. CommonMark/`marked` has no
* highlight token, so this registers a custom inline tokenizer (parsing the inner text as inline
* markdown so nested marks like `==**bold**==` survive) and a `renderMarkdown` that wraps the content
* in `==`. Mirrors the verbatim-node registration pattern in `./raw-markdown-snippet`.
*
* The tokenizer's `start` returns the index of the next `==` (a plain string search, not the
* `createLexer()`-calling form the `RawHtmlBlock` caveat warns against) so `marked` breaks its inline
* text run there and gives this tokenizer a chance mid-line — `=` is not a default break char like `[`.
*
* A lone `=` is allowed inside a highlight (so `==a=b==` round-trips), but `==` cannot be encoded in the
* `==…==` delimiter (emitting `==a==b==` would split the highlight and corrupt the text on reload). The
* tokenizer/input rules already exclude `==`; an `appendTransaction` guard removes the mark from any
* text that ends up containing `==` (e.g. a toolbar highlight over `a==b`), so the doc never holds an
* unrepresentable highlight and serialization stays lossless.
*/
export const Highlight = Mark.create({
name: 'highlight',
parseHTML() {
return [{ tag: 'mark' }]
},
renderHTML({ HTMLAttributes }) {
return ['mark', mergeAttributes(HTMLAttributes), 0]
},
addInputRules() {
return [markInputRule({ find: HIGHLIGHT_INPUT, type: this.type })]
},
addPasteRules() {
return [markPasteRule({ find: HIGHLIGHT_PASTE, type: this.type })]
},
addKeyboardShortcuts() {
return { 'Mod-Shift-h': () => this.editor.commands.toggleMark(this.name) }
},
markdownTokenName: 'highlight',
markdownTokenizer: {
name: 'highlight',
level: 'inline' as const,
start: (src: string) => src.indexOf('=='),
tokenize(src: string): MarkdownToken | undefined {
const match = HIGHLIGHT_TOKEN.exec(src)
if (!match) return undefined
return { type: 'highlight', raw: match[0], text: match[1] }
},
},
parseMarkdown(token: MarkdownToken, helpers: MarkdownParseHelpers) {
const inner = token.text ?? ''
const tokens = helpers.tokenizeInline?.(inner)
const content = tokens ? helpers.parseInline(tokens) : [{ type: 'text', text: inner }]
return { mark: 'highlight', content }
},
renderMarkdown(node: JSONContent, h: MarkdownRendererHelpers) {
return `==${h.renderChildren(node.content ?? [])}==`
},
addProseMirrorPlugins() {
const markType = this.type
return [
new Plugin({
appendTransaction(transactions, _oldState, newState) {
if (!transactions.some((transaction) => transaction.docChanged)) return null
let tr: Transaction | null = null
newState.doc.descendants((node, pos) => {
if (
node.isText &&
node.text?.includes('==') &&
node.marks.some((mark) => mark.type === markType)
) {
tr = (tr ?? newState.tr).removeMark(pos, pos + node.nodeSize, markType)
}
})
return tr
},
}),
]
},
})
@@ -257,3 +257,73 @@ describe('verbatim block boundary (isolating)', () => {
}
)
})
describe('block reordering (Mod-Shift-Arrow)', () => {
beforeEach(() => {
Element.prototype.scrollIntoView = vi.fn()
})
function caretInto(editor: Editor, word: string): void {
editor.state.doc.descendants((node, pos) => {
if (node.isText && node.text?.includes(word)) editor.commands.setTextSelection(pos + 1)
})
}
it('moves the current top-level block up, carrying the caret', () => {
const editor = editorWith('')
editor.commands.setContent('# One\n\nTwo para\n\n- item', { contentType: 'markdown' })
editor.commands.focus()
caretInto(editor, 'Two')
editor.commands.moveBlockUp()
expect(editor.getMarkdown().trim().startsWith('Two para')).toBe(true)
editor.destroy()
})
it('moves the current top-level block down', () => {
const editor = editorWith('')
editor.commands.setContent('# One\n\nTwo para', { contentType: 'markdown' })
editor.commands.focus()
caretInto(editor, 'One')
editor.commands.moveBlockDown()
expect(editor.getMarkdown().trim().startsWith('Two para')).toBe(true)
editor.destroy()
})
it.each([
['up', '# One\n\nabcdef'],
['down', 'abcdef\n\n# Two'],
])('keeps the caret at its original offset after moving %s (no off-by-one)', (direction, md) => {
const editor = editorWith('')
editor.commands.setContent(md, { contentType: 'markdown' })
editor.commands.focus()
let textPos = -1
editor.state.doc.descendants((node, pos) => {
if (node.isText && node.text === 'abcdef') textPos = pos
})
editor.commands.setTextSelection(textPos + 3)
if (direction === 'up') editor.commands.moveBlockUp()
else editor.commands.moveBlockDown()
const at = editor.state.selection.from
expect(editor.state.doc.textBetween(at - 1, at)).toBe('c')
expect(editor.state.doc.textBetween(at, at + 1)).toBe('d')
editor.destroy()
})
it('is a no-op at the top edge and keeps a moved list intact', () => {
const top = editorWith('')
top.commands.setContent('# One\n\nTwo', { contentType: 'markdown' })
top.commands.focus()
caretInto(top, 'One')
top.commands.moveBlockUp()
expect(top.getMarkdown().trim().startsWith('# One')).toBe(true)
top.destroy()
const list = editorWith('')
list.commands.setContent('- a\n- b\n\npara', { contentType: 'markdown' })
list.commands.focus()
caretInto(list, 'para')
list.commands.moveBlockUp()
expect(list.getMarkdown().trim()).toBe('para\n\n- a\n- b')
list.destroy()
})
})
@@ -128,7 +128,8 @@ function selectAdjacentSelectedLeaf(editor: Editor, direction: 'up' | 'down'): b
* same scoped behavior as a code editor.
* - **ArrowUp/ArrowDown** select an adjacent divider or image, whether arrowing off a textblock edge
* ({@link selectAdjacentLeaf}) or stepping from one already-selected leaf to the next
* ({@link selectAdjacentSelectedLeaf}).
* ({@link selectAdjacentSelectedLeaf}). (The `Mod-Shift-Arrow` block-reorder chords live separately
* in `./block-mover.ts`.)
*
* Plus a plugin that (a) highlights dividers/images falling inside a range selection (e.g. select-all),
* which the browser's native text highlight skips because leaves carry no text, and (b) flags the
@@ -21,10 +21,11 @@ function mount(editable = true): Editor {
}
/** Run the plugin paste handlers the way ProseMirror would, with a mocked clipboard. */
function paste(ed: Editor, text: string, html = ''): boolean {
function paste(ed: Editor, text: string, html = '', extra: Record<string, string> = {}): boolean {
const event = {
clipboardData: {
getData: (type: string) => (type === 'text/plain' ? text : type === 'text/html' ? html : ''),
getData: (type: string) =>
type === 'text/plain' ? text : type === 'text/html' ? html : (extra[type] ?? ''),
},
} as unknown as ClipboardEvent
for (const plugin of ed.view.state.plugins) {
@@ -35,6 +36,16 @@ function paste(ed: Editor, text: string, html = ''): boolean {
return false
}
/** Run the plugin `transformPastedHTML` chain the way ProseMirror would. */
function transformHtml(ed: Editor, html: string): string {
let out = html
for (const plugin of ed.view.state.plugins) {
const fn = plugin.props?.transformPastedHTML
if (fn) out = fn.call(plugin.props, out, ed.view)
}
return out
}
describe('markdown paste', () => {
it('renders a pasted inline link as a link mark', () => {
editor = mount()
@@ -124,6 +135,8 @@ describe('markdown paste', () => {
['underscore italic', 'an _italic_ word', 'italic'],
['underscore bold', 'a __bold__ word', 'bold'],
['strikethrough', 'a ~~struck~~ word', 'strike'],
['highlight', 'a ==marked== word', 'highlight'],
['highlight with interior equals', 'x ==a=b== y', 'highlight'],
['inline code', 'some `code` here', 'code'],
['bullet list', '- one\n- two', 'bulletList'],
['ordered list', '1. one\n2. two', 'orderedList'],
@@ -178,4 +191,53 @@ describe('markdown paste', () => {
.filter((type) => type !== 'paragraph')
expect(structural).toEqual(['heading', 'bulletList', 'blockquote'])
})
it('pastes VSCode code (vscode-editor-data) as a fenced code block with its language', () => {
editor = mount()
const code = 'const x: number = 1\nreturn x'
const handled = paste(editor, code, '<div><span>const</span></div>', {
'vscode-editor-data': JSON.stringify({ mode: 'typescript' }),
})
expect(handled).toBe(true)
const block = (editor.getJSON().content ?? []).find((n) => n.type === 'codeBlock')
expect(block).toBeDefined()
expect(block?.attrs?.language).toBe('typescript')
expect(block?.content?.[0]?.text).toBe(code)
})
it.each([
['html', 'markup'],
['shellscript', 'bash'],
])('maps VSCode language id %s to our code-block value %s', (mode, expected) => {
editor = mount()
paste(editor, 'code', '', { 'vscode-editor-data': JSON.stringify({ mode }) })
const block = (editor.getJSON().content ?? []).find((n) => n.type === 'codeBlock')
expect(block?.attrs?.language).toBe(expected)
})
it.each(['markdown', 'md', 'mdx', 'plaintext'])(
'does NOT force a code block for VSCode %s copies (parses as markdown instead)',
(mode) => {
editor = mount()
const handled = paste(editor, '# Title\n\n- item', '', {
'vscode-editor-data': JSON.stringify({ mode }),
})
expect(handled).toBe(true)
const types = (editor.getJSON().content ?? []).map((n) => n.type)
expect(types).not.toContain('codeBlock')
expect(types).toContain('heading')
expect(types).toContain('bulletList')
}
)
it('strips <style>/<script> from pasted HTML so their text never leaks into the doc', () => {
editor = mount()
const gsheets =
'<google-sheets-html-origin><style>td{mso-1:2}</style><table><tr><td>a</td></tr></table></google-sheets-html-origin>'
const cleaned = transformHtml(editor, gsheets)
expect(cleaned).not.toContain('<style>')
expect(cleaned).not.toContain('mso-1')
expect(cleaned).toContain('<td>a</td>')
expect(transformHtml(editor, 'a<script>alert(1)</script>b')).toBe('ab')
})
})
@@ -31,12 +31,61 @@ const INLINE_MARK_HINTS: ReadonlyArray<RegExp> = [
/_[^_\n]+_/,
/~~[^~\n]+~~/,
/`[^`\n]+`/,
/==(?:[^=\n]|=(?!=))+==/,
]
function hasAny(hints: ReadonlyArray<RegExp>, text: string): boolean {
return hints.some((hint) => hint.test(text))
}
/**
* VSCode language ids that differ from our code-block language values. `markdown`/`plaintext` map to
* the empty string so they are NOT forced into a code block — markdown copied from VSCode should parse
* as markdown, and plain text should paste as text; other ids pass through as-is.
*/
const VSCODE_LANGUAGE_ALIASES: Readonly<Record<string, string>> = {
html: 'markup',
shellscript: 'bash',
shell: 'bash',
jsonc: 'json',
plaintext: '',
markdown: '',
md: '',
mdx: '',
}
/**
* Extracts the source language from VSCode's `vscode-editor-data` clipboard payload (a JSON blob with a
* `mode` field), mapping the few ids that differ from our code-block values. Returns `''` when the
* payload is absent, unparseable, or a non-code mode (plaintext/markdown). A real code language makes
* the paste handler emit a fenced code block — otherwise VSCode's per-token colored-span HTML would
* fall through to ProseMirror's default parser and flatten into plain paragraphs — while an empty
* result falls through so markdown copied from VSCode still parses as markdown.
*/
function parseVscodeLanguage(data: string | undefined): string {
if (!data) return ''
try {
const mode = (JSON.parse(data) as { mode?: unknown }).mode
if (typeof mode !== 'string') return ''
return VSCODE_LANGUAGE_ALIASES[mode] ?? mode
} catch {
return ''
}
}
/** `<style>`/`<script>` elements (with their content), matched as a pair via the tag backreference. */
const NON_CONTENT_HTML = /<(style|script)\b[\s\S]*?<\/\1>/gi
/**
* Strips `<style>`/`<script>` elements from pasted HTML. Google Sheets and Word prepend a `<style>`
* block of CSS (and Sheets a `<google-sheets-html-origin>` wrapper); ProseMirror's DOM parser has no
* rule for `<style>`, so it would walk the element's CSS text into the document as literal paragraphs.
* Removing these before parsing keeps the pasted content clean (PM already discards unknown wrappers).
*/
function stripNonContentHtml(html: string): string {
return html.replace(NON_CONTENT_HTML, '')
}
/**
* Parses pasted plain text that looks like markdown into rich content, via the strict CommonMark
* parser ({@link parseMarkdownToDoc}, `marked`). Pastes inside a code block or inline code are left
@@ -62,11 +111,20 @@ export const MarkdownPaste = Extension.create({
return [
new Plugin({
props: {
transformPastedHTML: (html) => stripNonContentHtml(html),
handlePaste: (_view, event) => {
if (!editor.isEditable) return false
if (editor.isActive('codeBlock') || editor.isActive('code')) return false
const text = event.clipboardData?.getData('text/plain')
if (!text) return false
const language = parseVscodeLanguage(event.clipboardData?.getData('vscode-editor-data'))
if (language) {
return editor.commands.insertContent({
type: 'codeBlock',
attrs: { language },
content: [{ type: 'text', text }],
})
}
if (!hasAny(STRUCTURAL_MARKDOWN_HINTS, text)) {
if (!hasAny(INLINE_MARK_HINTS, text)) return false
if (event.clipboardData?.getData('text/html')) return false
@@ -10,6 +10,7 @@ import {
Code,
Heading1,
Heading2,
Highlighter,
Italic,
Link as LinkIcon,
List,
@@ -76,6 +77,7 @@ export function EditorBubbleMenu({ editor, scrollContainerRef }: EditorBubbleMen
bold: e.isActive('bold'),
italic: e.isActive('italic'),
strike: e.isActive('strike'),
highlight: e.isActive('highlight'),
code: e.isActive('code'),
link: e.isActive('link'),
heading1: e.isActive('heading', { level: 1 }),
@@ -262,6 +264,13 @@ export function EditorBubbleMenu({ editor, scrollContainerRef }: EditorBubbleMen
isActive={active.strike}
onClick={() => editor.chain().focus().toggleStrike().run()}
/>
<ToolbarButton
icon={Highlighter}
label='Highlight'
shortcut='⌘⇧H'
isActive={active.highlight}
onClick={() => editor.chain().focus().toggleMark('highlight').run()}
/>
<ToolbarButton
icon={Code}
label='Code'
@@ -397,6 +397,20 @@
pointer-events: none;
}
/*
* Highlight mark (`==text==`). An opacity-based amber tint so it reads on both light and dark
* surfaces without a theme override; `color: inherit` keeps the text at the surrounding body color
* and `box-decoration-break: clone` keeps the tint clean where a highlight wraps across lines.
*/
.rich-markdown-prose mark {
background-color: rgba(255, 212, 0, 0.4);
color: inherit;
border-radius: 2px;
padding: 0 0.1em;
box-decoration-break: clone;
-webkit-box-decoration-break: clone;
}
/*
* Field variant (modal embed): match the surrounding chip fields' typography exactly —
* body at the chip `text-sm` (14px) scale and the placeholder at `--text-muted` (not the
@@ -55,6 +55,18 @@ describe('isRoundTripSafe', () => {
expect(isRoundTripSafe('a&nbsp;b')).toBe(false)
expect(isRoundTripSafe('a &amp; b &lt; c &gt; d')).toBe(true)
expect(isRoundTripSafe('AT&T and R&D')).toBe(true)
expect(isRoundTripSafe('a &AMP; b')).toBe(false)
expect(isRoundTripSafe('a &LT; b &GT; c')).toBe(false)
})
it('rejects an orphan reference definition (serializer drops it) but allows used ones', () => {
expect(isRoundTripSafe('Some text.\n\n[unused]: https://example.com "title"')).toBe(false)
expect(isRoundTripSafe('[a]: u1\n[b]: u2\n\nuse only [a]')).toBe(false)
expect(isRoundTripSafe('See [x][1].\n\n[1]: https://example.com "T"')).toBe(true)
expect(isRoundTripSafe('A [shortcut] ref.\n\n[shortcut]: https://example.com')).toBe(true)
expect(isRoundTripSafe('Case [Foo] insensitive.\n\n[foo]: https://example.com')).toBe(true)
expect(isRoundTripSafe('A note.\n\n[^x]: the footnote body')).toBe(true)
expect(isRoundTripSafe('See [ foo ] here.\n\n[foo]: https://example.com')).toBe(true)
})
it('does not flag HTML/comments/entities inside tilde or nested code fences', () => {
@@ -24,14 +24,16 @@ const PROBE_SIZE_LIMIT = 256 * 1024
* flattens `one<br>two` to `one two`. Matched on a table-shaped line (≥2 pipes) containing a `<br>`.
* - **Hard break inside a heading** (trailing two spaces or a backslash) — the serializer splits
* the heading, ejecting the second line into a separate paragraph.
* - **HTML entity** other than `&amp;`/`&lt;`/`&gt;` (e.g. `&copy;`, `&#39;`, `&nbsp;`) — the
* serializer escapes the `&`, turning the rendered character into literal entity source. A bare
* `&` with no `;` is left alone (it re-renders identically, so it's harmless churn).
* - **HTML entity** other than the lowercase canonical `&amp;`/`&lt;`/`&gt;` (e.g. `&copy;`, `&#39;`,
* `&nbsp;`, or the uppercase `&AMP;`) — the serializer escapes the `&`, turning the rendered character
* into literal entity source. The safe-list is deliberately case-*sensitive*: `@tiptap/markdown` only
* round-trips the lowercase forms, so `&AMP;`/`&LT;`/`&GT;` must fall through to read-only rather than
* be treated as safe. A bare `&` with no matching `;`-terminated name is left alone (harmless churn).
*/
const STABLE_LOSS_PATTERNS: ReadonlyArray<RegExp> = [
/^(?=(?:[^\n]*\|){2})[^\n]*<br\s*\/?>/im,
/^#{1,6}\s.*(?: {2,}|\\)$/m,
/&(?!(?:amp|lt|gt);)(?:#x?[0-9a-f]+|[a-z][a-z0-9]*);/i,
/&(?!(?:amp|lt|gt);)(?:#x?[0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]*);/,
]
/**
@@ -60,6 +62,45 @@ function linkedImageCount(content: string): number {
return content.match(LINKED_IMAGE_PATTERN)?.length ?? 0
}
/**
* A link/image reference definition line: `[label]: destination "optional title"` (up to 3 leading
* spaces). The `(?!\^)` excludes GFM footnote definitions (`[^id]: …`) — those are preserved verbatim
* by the footnote node and round-trip regardless of whether their reference is present, so they must
* not be treated as droppable orphan definitions.
*/
const REFERENCE_DEFINITION = /^ {0,3}\[(?!\^)([^\]]+)]:[ \t]+\S[^\n]*$/gm
/** CommonMark reference labels match case-insensitively with internal whitespace collapsed. */
function normalizeReferenceLabel(label: string): string {
return label.trim().replace(/\s+/g, ' ').toLowerCase()
}
/**
* True when `content` defines a link/image reference that nothing uses. A *used* reference inlines
* losslessly on serialize (`[x][id]` + `[id]: url` → `[x](url)`), but an *unused* definition is dropped
* entirely — a silent deletion the idempotency probe can't see (the drop happens on the first pass,
* which is then stable). We open such a file read-only rather than lose the definition on first edit.
* Conservative: a label counts as used if it appears bracketed anywhere in the body, so the rare
* inline-text collision errs toward editable, never toward a false read-only.
*/
function hasOrphanReferenceDefinition(content: string): boolean {
const labels = new Set<string>()
for (const match of content.matchAll(REFERENCE_DEFINITION)) {
labels.add(normalizeReferenceLabel(match[1]))
}
if (labels.size === 0) return false
const body = content
.replace(REFERENCE_DEFINITION, '')
.replace(/\s+/g, ' ')
.replace(/\[\s+/g, '[')
.replace(/\s+\]/g, ']')
.toLowerCase()
for (const label of labels) {
if (!body.includes(`[${label}]`)) return true
}
return false
}
/**
* Whether `content` survives the editor's markdown round-trip without data loss or autosave
* churn. The editor opens the content read-only when this is false, so the probe is deliberately
@@ -75,6 +116,7 @@ export function isRoundTripSafe(content: string): boolean {
if (content.length > PROBE_SIZE_LIMIT) return false
const stripped = stripCode(content)
if (STABLE_LOSS_PATTERNS.some((pattern) => pattern.test(stripped))) return false
if (hasOrphanReferenceDefinition(stripped)) return false
try {
const once = serializeMarkdownDocument(content)
if (linkedImageCount(stripped) !== linkedImageCount(stripCode(once))) return false
@@ -158,6 +158,23 @@ describe('editor markdown round-trip', () => {
'bold code': '**`x`**',
'heading strike code': '# ~~`x`~~',
'table with pipe': '| x \\| y | 2 |\n| --- | --- |\n| a | b |',
'bold italic nested': '**bold _italic_ word**',
'strike bold nested': '~~**struck bold**~~',
'bold code inline': '**bold `code` here**',
'triple nested marks': '*i **b ~~s~~** i*',
'all marks in heading': '# **b** ~~s~~ *i* `c`',
'marks in bullet': '- **a** ~~b~~ `c`',
'marks in quote': '> **a** ~~b~~ *c*',
'nested list marks': '- **a**\n - ~~b~~\n - *c*',
'bold link': '[**bold link**](https://x.com)',
'link inside bold': '**see [x](https://x.com)**',
'table with marks': '| **b** | ~~s~~ | `c` |\n| --- | --- | --- |\n| *i* | a | b |',
'bold across code boundary': '**a** `b` **c**',
highlight: 'a ==marked== word',
'highlight in heading': '# a ==mark== b',
'highlight nested in bold': '**bold ==mark== here**',
'highlight in list': '- ==a== item',
'highlight with interior equals': 'x ==a=b== y',
}
for (const [name, input] of Object.entries(cases)) {
@@ -434,3 +451,53 @@ describe('consecutive empty paragraphs', () => {
}
)
})
describe('highlight ==mark==', () => {
function markPresent(src: string): boolean {
editor = new Editor({ extensions: createMarkdownContentExtensions() })
editor.commands.setContent(src, { contentType: 'markdown' })
const has = JSON.stringify(editor.getJSON()).includes('"type":"highlight"')
editor.destroy()
editor = null
return has
}
it('parses ==text== into a highlight mark, including mid-line and in headings', () => {
expect(markPresent('a ==marked== word')).toBe(true)
expect(markPresent('# a ==mark== b')).toBe(true)
expect(markPresent('**bold ==mark== here**')).toBe(true)
})
it('parses a highlight body containing a lone `=` (so ==a=b== round-trips)', () => {
expect(markPresent('x ==a=b== y')).toBe(true)
})
it('strips a highlight whose text contains `==` (unrepresentable), keeping the text', () => {
editor = new Editor({ extensions: createMarkdownContentExtensions() })
editor.commands.setContent('x a==b y', { contentType: 'markdown' })
let from = -1
let to = -1
editor.state.doc.descendants((node, pos) => {
if (node.isText) {
const i = node.text?.indexOf('a==b') ?? -1
if (i >= 0) {
from = pos + i
to = from + 4
}
}
})
editor.commands.setTextSelection({ from, to })
editor.commands.toggleMark('highlight')
const md = postProcessSerializedMarkdown(editor.getMarkdown())
expect(JSON.stringify(editor.getJSON())).not.toContain('"type":"highlight"')
expect(md).not.toContain('==a==b==')
expect(editor.getText().trim()).toBe('x a==b y')
editor.destroy()
editor = null
})
it('leaves comparison / spaced == operators as literal text', () => {
expect(markPresent('if x == y then z')).toBe(false)
expect(markPresent('a == b == c')).toBe(false)
})
})