Skip to content

Commit 3b3cd1a

Browse files
committed
fix(rich-markdown-editor): strip leading paragraph indent; keep empty paragraphs  -free
A paragraph beginning with a 4-space/tab indent re-parsed as an indented code block on the next load. Leading whitespace never renders in a paragraph (CommonMark strips up to three leading spaces; four or more become code), so BlockSafeParagraph now strips it — lossless and idempotent. Composes with the existing leading-marker escaping (e.g. ' # x' → '\# x'). Also locks in that consecutive empty paragraphs serialize via blank lines rather than the upstream ' ' marker: replacing StarterKit's Paragraph already dropped the   path, and the round-trip preserves the empty-paragraph count without tripping the read-only safety gate (which flags   as a stable-loss pattern). Added tests for both.
1 parent 70bfe19 commit 3b3cd1a

2 files changed

Lines changed: 77 additions & 16 deletions

File tree

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

Lines changed: 22 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -59,29 +59,36 @@ const PipeSafeTable = Table.extend({
5959
})
6060

6161
/**
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.
62+
* Guards a paragraph's serialized text so its leading characters don't re-parse it into a different
63+
* block on the next load:
64+
*
65+
* - **Leading whitespace** is stripped. It never renders in a paragraph (CommonMark strips up to three
66+
* leading spaces, and four or more would re-parse the paragraph as an indented code block), so
67+
* removing it is lossless and makes the round-trip idempotent.
68+
* - **A leading block marker** (`#`, `-`, `+`, `1.`, `1)`, or a bare `---`) is backslash-escaped so the
69+
* paragraph doesn't become a heading / list / thematic break. The upstream serializer escapes inline
70+
* delimiters (`* _ \` [ ] ~`, so `*` bullets and `>` quotes already round-trip) but not these
71+
* block-starting markers. Escaping is idempotent: parsing consumes the backslash, so the stored
72+
* ProseMirror text never carries it and re-serialization is stable.
6773
*/
68-
function escapeLeadingBlockMarker(text: string): string {
69-
if (/^(#{1,6}([ \t]|$)|[-+][ \t]|-(?:[ \t]*-){2,}[ \t]*$)/.test(text)) {
70-
return `\\${text}`
74+
function guardParagraphLeading(text: string): string {
75+
const stripped = text.replace(/^[ \t]+/, '')
76+
if (/^(#{1,6}([ \t]|$)|[-+][ \t]|-(?:[ \t]*-){2,}[ \t]*$)/.test(stripped)) {
77+
return `\\${stripped}`
7178
}
72-
const ordered = /^(\d{1,9})([.)][ \t])/.exec(text)
73-
return ordered ? `${ordered[1]}\\${text.slice(ordered[1].length)}` : text
79+
const ordered = /^(\d{1,9})([.)][ \t])/.exec(stripped)
80+
return ordered ? `${ordered[1]}\\${stripped.slice(ordered[1].length)}` : stripped
7481
}
7582

7683
/**
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.
84+
* Paragraph that guards its leading characters on serialize (see {@link guardParagraphLeading}) —
85+
* otherwise a paragraph beginning with a block marker or an indent silently becomes a heading / list /
86+
* thematic break / code block on the next load. Block separators are owned by the parent joiner, so a
87+
* paragraph renders as just its inline children; this override wraps that with the leading guard.
8188
*/
8289
const BlockSafeParagraph = Paragraph.extend({
8390
renderMarkdown: (node: JSONContent, h: MarkdownRendererHelpers) =>
84-
escapeLeadingBlockMarker(h.renderChildren(node.content ?? [])),
91+
guardParagraphLeading(h.renderChildren(node.content ?? [])),
8592
})
8693

8794
/**

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

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -331,7 +331,7 @@ describe('link href sanitization — dangerous schemes from file content are neu
331331
})
332332
})
333333

334-
describe('paragraph leading block-marker escaping', () => {
334+
describe('paragraph leading guard (marker escaping + indent stripping)', () => {
335335
/** Serialize a doc whose first paragraph literally starts with `text`, then re-parse its first node. */
336336
function serializeParagraph(text: string): {
337337
md: string
@@ -379,4 +379,58 @@ describe('paragraph leading block-marker escaping', () => {
379379
expect(reparsedType).toBe('paragraph')
380380
expect(idempotent).toBe(true)
381381
})
382+
383+
it.each([
384+
[' four spaces', 'four spaces'],
385+
['\ttab indent', 'tab indent'],
386+
[' eight spaces', 'eight spaces'],
387+
[' # indented marker', '\\# indented marker'],
388+
])(
389+
'strips leading indent so %j stays a paragraph instead of an indented code block',
390+
(text, expectedMd) => {
391+
const { md, reparsedType, idempotent } = serializeParagraph(text)
392+
expect(md.trim()).toBe(expectedMd)
393+
expect(reparsedType).toBe('paragraph')
394+
expect(idempotent).toBe(true)
395+
}
396+
)
397+
})
398+
399+
describe('consecutive empty paragraphs', () => {
400+
/** Doc with `a`, then `count` empty paragraphs, then `b`; serialized and round-tripped. */
401+
function serializeEmpties(count: number) {
402+
editor = new Editor({ extensions: createMarkdownContentExtensions() })
403+
const emptyParas = Array.from({ length: count }, () => ({ type: 'paragraph', content: [] }))
404+
editor.commands.setContent(
405+
{
406+
type: 'doc',
407+
content: [
408+
{ type: 'paragraph', content: [{ type: 'text', text: 'a' }] },
409+
...emptyParas,
410+
{ type: 'paragraph', content: [{ type: 'text', text: 'b' }] },
411+
],
412+
},
413+
{ contentType: 'json' }
414+
)
415+
const md = postProcessSerializedMarkdown(editor.getMarkdown())
416+
editor.commands.setContent(md, { contentType: 'markdown' })
417+
const emptyCount = (editor.getJSON().content ?? []).filter(
418+
(n) => n.type === 'paragraph' && !n.content?.length
419+
).length
420+
const idempotent = postProcessSerializedMarkdown(editor.getMarkdown()) === md
421+
editor.destroy()
422+
editor = null
423+
return { md, emptyCount, idempotent }
424+
}
425+
426+
it.each([[1], [2], [3], [4]])(
427+
'preserves %i empty paragraph(s) via blank lines (no  , idempotent, no read-only trigger)',
428+
(count) => {
429+
const { md, emptyCount, idempotent } = serializeEmpties(count)
430+
expect(md).not.toContain(' ')
431+
expect(md).not.toContain(String.fromCharCode(0x00a0))
432+
expect(emptyCount).toBe(count)
433+
expect(idempotent).toBe(true)
434+
}
435+
)
382436
})

0 commit comments

Comments
 (0)