Skip to content

Commit 70bfe19

Browse files
committed
fix(rich-markdown-editor): escape leading block markers in paragraph serialization
A paragraph beginning with #, -, +, 1., 1), or a bare --- serialized unescaped, so it silently re-parsed into a heading / list / thematic break on the next load (reachable via type-a-marker then undo). The upstream serializer escapes inline delimiters (* _ ` [ ] ~, so * bullets and > quotes already round-trip) but not these block-starting markers. BlockSafeParagraph wraps the paragraph renderer with a leading-marker guard; escaping is idempotent (parsing consumes the backslash) and never over-escapes non-markers like #hashtag or -5.
1 parent 02cdbdc commit 70bfe19

2 files changed

Lines changed: 79 additions & 0 deletions

File tree

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { Extensions, JSONContent, MarkdownRendererHelpers, Node } from '@tiptap/core'
22
import { Code } from '@tiptap/extension-code'
33
import { TaskItem, TaskList } from '@tiptap/extension-list'
4+
import { Paragraph } from '@tiptap/extension-paragraph'
45
import {
56
renderTableToMarkdown,
67
Table,
@@ -57,6 +58,32 @@ const PipeSafeTable = Table.extend({
5758
.replace(/\n+$/, ''),
5859
})
5960

61+
/**
62+
* Backslash-escapes a leading block marker so paragraph text like `# note`, `- item`, `1. step`, or a
63+
* bare `---` serializes as a paragraph rather than re-parsing into a heading / list / thematic break on
64+
* the next load. The upstream serializer escapes inline delimiters (`* _ \` [ ] ~`, so `*` bullets and
65+
* `>` quotes already round-trip) but not these block-starting markers. Escaping is idempotent: parsing
66+
* consumes the backslash, so the stored ProseMirror text never carries it and re-serialization is stable.
67+
*/
68+
function escapeLeadingBlockMarker(text: string): string {
69+
if (/^(#{1,6}([ \t]|$)|[-+][ \t]|-(?:[ \t]*-){2,}[ \t]*$)/.test(text)) {
70+
return `\\${text}`
71+
}
72+
const ordered = /^(\d{1,9})([.)][ \t])/.exec(text)
73+
return ordered ? `${ordered[1]}\\${text.slice(ordered[1].length)}` : text
74+
}
75+
76+
/**
77+
* Paragraph that escapes a leading block marker on serialize (see {@link escapeLeadingBlockMarker}) —
78+
* otherwise a paragraph beginning with `#`/`-`/`+`/`1.`/`1)`/`---` silently becomes that block on the
79+
* next load. Block separators are owned by the parent joiner, so a paragraph renders as just its inline
80+
* children; this override wraps that with the leading-marker guard.
81+
*/
82+
const BlockSafeParagraph = Paragraph.extend({
83+
renderMarkdown: (node: JSONContent, h: MarkdownRendererHelpers) =>
84+
escapeLeadingBlockMarker(h.renderChildren(node.content ?? [])),
85+
})
86+
6087
/**
6188
* Node-view variants the live editor injects in place of the headless defaults — the code-block
6289
* language picker, the resizable image, and the mention chip. The mention chip pulls the block registry
@@ -92,7 +119,9 @@ export function createMarkdownContentExtensions(nodeViews: ContentNodeViews = {}
92119
underline: false,
93120
codeBlock: false,
94121
code: false,
122+
paragraph: false,
95123
}),
124+
BlockSafeParagraph,
96125
InlineCode,
97126
codeBlock,
98127
(nodeViews.image ?? MarkdownImage).configure({ allowBase64: true }),

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/round-trip.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,3 +330,53 @@ describe('link href sanitization — dangerous schemes from file content are neu
330330
expect(hrefs).toContain('mailto:x@y.com')
331331
})
332332
})
333+
334+
describe('paragraph leading block-marker escaping', () => {
335+
/** Serialize a doc whose first paragraph literally starts with `text`, then re-parse its first node. */
336+
function serializeParagraph(text: string): {
337+
md: string
338+
reparsedType: string
339+
idempotent: boolean
340+
} {
341+
editor = new Editor({ extensions: createMarkdownContentExtensions() })
342+
editor.commands.setContent(
343+
{ type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text }] }] },
344+
{ contentType: 'json' }
345+
)
346+
const md = postProcessSerializedMarkdown(editor.getMarkdown())
347+
editor.commands.setContent(md, { contentType: 'markdown' })
348+
const reparsedType = editor.getJSON().content?.[0]?.type ?? ''
349+
const idempotent = postProcessSerializedMarkdown(editor.getMarkdown()) === md
350+
editor.destroy()
351+
editor = null
352+
return { md, reparsedType, idempotent }
353+
}
354+
355+
it.each([
356+
['# note', '\\# note'],
357+
['###### note', '\\###### note'],
358+
['#', '\\#'],
359+
['- item', '\\- item'],
360+
['+ item', '\\+ item'],
361+
['1. step', '1\\. step'],
362+
['1) step', '1\\) step'],
363+
['---', '\\---'],
364+
['- - -', '\\- - -'],
365+
])('escapes a paragraph starting with %j so it stays a paragraph', (text, expectedMd) => {
366+
const { md, reparsedType, idempotent } = serializeParagraph(text)
367+
expect(md.trim()).toBe(expectedMd)
368+
expect(reparsedType).toBe('paragraph')
369+
expect(idempotent).toBe(true)
370+
})
371+
372+
it.each([
373+
['#hashtag'], // no space after # → not a heading
374+
['-5 degrees'], // no space after - → not a bullet
375+
['plain text'],
376+
])('does not over-escape %j', (text) => {
377+
const { md, reparsedType, idempotent } = serializeParagraph(text)
378+
expect(md.trim()).toBe(text)
379+
expect(reparsedType).toBe('paragraph')
380+
expect(idempotent).toBe(true)
381+
})
382+
})

0 commit comments

Comments
 (0)