From 6f6dd1d87be79fdb51f6f02cd23af5ef515ac9a6 Mon Sep 17 00:00:00 2001 From: jkugsiya Date: Wed, 14 Oct 2026 12:53:11 +0530 Subject: [PATCH 1/3] fix(core): don't throw when a Markdown list item starts with
When a list item's first block had children, like a parsed
toggle, the list item content split inside that block. The leftover block had children but no content, so parsing threw. Split after the whole first block and keep its children nested under the list item. Fixes #3189 --- .../src/blocks/ListItem/getListItemContent.ts | 17 +++++---- .../numberedListItemWithDetailsText.json | 25 +++++++++++++ .../exportParseEqualityTestInstances.ts | 14 +++++++ .../checkListItemStartsWithDetails.json | 26 +++++++++++++ .../numberedListItemStartsWithDetails.json | 25 +++++++++++++ ...beredListItemStartsWithDetailsAndBody.json | 37 +++++++++++++++++++ .../parse/parseTestInstances.ts | 26 +++++++++++++ 7 files changed, 163 insertions(+), 7 deletions(-) create mode 100644 tests/src/unit/core/formatConversion/exportParseEquality/__snapshots__/markdown/markdown/numberedListItemWithDetailsText.json create mode 100644 tests/src/unit/core/formatConversion/parse/__snapshots__/markdown/checkListItemStartsWithDetails.json create mode 100644 tests/src/unit/core/formatConversion/parse/__snapshots__/markdown/numberedListItemStartsWithDetails.json create mode 100644 tests/src/unit/core/formatConversion/parse/__snapshots__/markdown/numberedListItemStartsWithDetailsAndBody.json diff --git a/packages/core/src/blocks/ListItem/getListItemContent.ts b/packages/core/src/blocks/ListItem/getListItemContent.ts index 8a200b4dab..439c8e12d6 100644 --- a/packages/core/src/blocks/ListItem/getListItemContent.ts +++ b/packages/core/src/blocks/ListItem/getListItemContent.ts @@ -77,10 +77,11 @@ export function getListItemContent( // Structure above is `blockGroup[]>` // We want to extract the first `blockContainer` node's content, and see if it is a text block. - const listItemsFirstChild = blockGroupNode.firstChild?.firstChild; + const firstBlockContainer = blockGroupNode.firstChild; + const listItemsFirstChild = firstBlockContainer?.firstChild; // If the first node is not a text block, then it's first child is not compatible with the list item node. - if (!listItemsFirstChild?.isTextblock) { + if (!firstBlockContainer || !listItemsFirstChild?.isTextblock) { // So, we do not try inserting anything into the list item, and instead return anything we found as children for the list item. return Fragment.from(blockGroupNode); } @@ -93,11 +94,13 @@ export function getListItemContent( listItemsFirstChild.content, ); - // We have `blockGroup[]>` - // We want to extract out the rest of the nodes as `<...blockContainer[]>` - const remainingListItemChildren = blockGroupNode.content.cut( - // +2 for the `blockGroup` node's start and end markers - listItemsFirstChild.nodeSize + 2, + // We have `blockGroup, ...blockContainer[]>` + // We want to extract out the rest of the nodes as `<...blockContainer[]>`. + // The first block's own children (e.g. a parsed `
` body) come first. + const firstBlockChildren = + firstBlockContainer.maybeChild(1)?.content ?? Fragment.empty; + const remainingListItemChildren = firstBlockChildren.append( + blockGroupNode.content.cut(firstBlockContainer.nodeSize), ); const hasRemainingListItemChildren = remainingListItemChildren.size > 0; diff --git a/tests/src/unit/core/formatConversion/exportParseEquality/__snapshots__/markdown/markdown/numberedListItemWithDetailsText.json b/tests/src/unit/core/formatConversion/exportParseEquality/__snapshots__/markdown/markdown/numberedListItemWithDetailsText.json new file mode 100644 index 0000000000..a4f12a7d09 --- /dev/null +++ b/tests/src/unit/core/formatConversion/exportParseEquality/__snapshots__/markdown/markdown/numberedListItemWithDetailsText.json @@ -0,0 +1,25 @@ +[ + { + "children": [ + { + "children": [], + "content": [], + "id": "2", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": [], + "id": "1", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "numberedListItem", + }, +] \ No newline at end of file diff --git a/tests/src/unit/core/formatConversion/exportParseEquality/exportParseEqualityTestInstances.ts b/tests/src/unit/core/formatConversion/exportParseEquality/exportParseEqualityTestInstances.ts index 6a0c21dd34..4c0260e846 100644 --- a/tests/src/unit/core/formatConversion/exportParseEquality/exportParseEqualityTestInstances.ts +++ b/tests/src/unit/core/formatConversion/exportParseEquality/exportParseEqualityTestInstances.ts @@ -488,6 +488,20 @@ export const exportParseEqualityTestInstancesMarkdown: TestInstance< }, executeTest: testExportParseEqualityMarkdown, }, + // Markdown export doesn't escape HTML-like text, so this loads back as an + // empty list item. It must load without throwing. + { + testCase: { + name: "markdown/numberedListItemWithDetailsText", + content: [ + { + type: "numberedListItem", + content: "
", + }, + ], + }, + executeTest: testExportParseEqualityMarkdown, + }, { testCase: { name: "markdown/nestedLists", diff --git a/tests/src/unit/core/formatConversion/parse/__snapshots__/markdown/checkListItemStartsWithDetails.json b/tests/src/unit/core/formatConversion/parse/__snapshots__/markdown/checkListItemStartsWithDetails.json new file mode 100644 index 0000000000..4f962f43fd --- /dev/null +++ b/tests/src/unit/core/formatConversion/parse/__snapshots__/markdown/checkListItemStartsWithDetails.json @@ -0,0 +1,26 @@ +[ + { + "children": [ + { + "children": [], + "content": [], + "id": "2", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": [], + "id": "1", + "props": { + "backgroundColor": "default", + "checked": false, + "textAlignment": "left", + "textColor": "default", + }, + "type": "checkListItem", + }, +] \ No newline at end of file diff --git a/tests/src/unit/core/formatConversion/parse/__snapshots__/markdown/numberedListItemStartsWithDetails.json b/tests/src/unit/core/formatConversion/parse/__snapshots__/markdown/numberedListItemStartsWithDetails.json new file mode 100644 index 0000000000..a4f12a7d09 --- /dev/null +++ b/tests/src/unit/core/formatConversion/parse/__snapshots__/markdown/numberedListItemStartsWithDetails.json @@ -0,0 +1,25 @@ +[ + { + "children": [ + { + "children": [], + "content": [], + "id": "2", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": [], + "id": "1", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "numberedListItem", + }, +] \ No newline at end of file diff --git a/tests/src/unit/core/formatConversion/parse/__snapshots__/markdown/numberedListItemStartsWithDetailsAndBody.json b/tests/src/unit/core/formatConversion/parse/__snapshots__/markdown/numberedListItemStartsWithDetailsAndBody.json new file mode 100644 index 0000000000..f67eb16451 --- /dev/null +++ b/tests/src/unit/core/formatConversion/parse/__snapshots__/markdown/numberedListItemStartsWithDetailsAndBody.json @@ -0,0 +1,37 @@ +[ + { + "children": [ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Body", + "type": "text", + }, + ], + "id": "2", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": [ + { + "styles": {}, + "text": "Summary", + "type": "text", + }, + ], + "id": "1", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "numberedListItem", + }, +] \ No newline at end of file diff --git a/tests/src/unit/core/formatConversion/parse/parseTestInstances.ts b/tests/src/unit/core/formatConversion/parse/parseTestInstances.ts index 33b00531c5..33f4be5e4d 100644 --- a/tests/src/unit/core/formatConversion/parse/parseTestInstances.ts +++ b/tests/src/unit/core/formatConversion/parse/parseTestInstances.ts @@ -1723,6 +1723,32 @@ Invalid schemeless domains stay plain: www.invalid_domain.com`, }, executeTest: testParseMarkdown, }, + // A list item whose first block is a `
` toggle. The toggle's text + // becomes the list item's content, and its children stay nested under it. + { + testCase: { + name: "numberedListItemStartsWithDetails", + content: `1.
`, + }, + executeTest: testParseMarkdown, + }, + { + testCase: { + name: "checkListItemStartsWithDetails", + content: `- [ ]
`, + }, + executeTest: testParseMarkdown, + }, + { + testCase: { + name: "numberedListItemStartsWithDetailsAndBody", + content: `1.
+ Summary + Body +
`, + }, + executeTest: testParseMarkdown, + }, // Ordered list with start number { testCase: { From 0bdea2f1c26c112fd5baeb622e37d48f552eeab2 Mon Sep 17 00:00:00 2001 From: jkugsiya Date: Wed, 14 Oct 2026 13:33:14 +0530 Subject: [PATCH 2/3] fix(core): escape HTML-like text in Markdown export Markdown export wrote text like
as is, so loading the file back read it as HTML and dropped the text. Escape < where it would start a tag, comment or autolink, and accept \< in the parser. Refs #3189 --- .../api/exporters/markdown/htmlToMarkdown.ts | 11 ++++++++++- .../api/parsers/markdown/markdownToHtml.ts | 2 +- .../numberedListItemWithDetailsText.json | 16 +++++----------- .../markdown/paragraphWithHtmlLikeText.json | 19 +++++++++++++++++++ .../exportParseEqualityTestInstances.ts | 15 +++++++++++++-- 5 files changed, 48 insertions(+), 15 deletions(-) create mode 100644 tests/src/unit/core/formatConversion/exportParseEquality/__snapshots__/markdown/markdown/paragraphWithHtmlLikeText.json diff --git a/packages/core/src/api/exporters/markdown/htmlToMarkdown.ts b/packages/core/src/api/exporters/markdown/htmlToMarkdown.ts index f3091afbf7..adb2237a9f 100644 --- a/packages/core/src/api/exporters/markdown/htmlToMarkdown.ts +++ b/packages/core/src/api/exporters/markdown/htmlToMarkdown.ts @@ -755,7 +755,7 @@ function serializeInlineContent(el: Element): string { for (const child of Array.from(el.childNodes)) { if (child.nodeType === 3 /* Node.TEXT_NODE */) { - result += child.textContent || ""; + result += escapeHtmlLikeText(child.textContent || ""); } else if (child.nodeType === 1 /* Node.ELEMENT_NODE */) { const childEl = child as HTMLElement; const tag = childEl.tagName.toLowerCase(); @@ -846,6 +846,15 @@ function serializeInlineContent(el: Element): string { return result; } +/** + * Escape `<` where the markdown parser would read raw HTML or an autolink, so + * text like `
` loads back as text. A `<` before a space or digit + * stays as is, as in `1 < 2`. + */ +function escapeHtmlLikeText(text: string): string { + return text.replace(/<(?=[A-Za-z/!?])/g, "\\<"); +} + /** * Escape leading character after emphasis if it could break parsing. * For example, "Heading" after "**Bold **" — the 'H' should be escaped diff --git a/packages/core/src/api/parsers/markdown/markdownToHtml.ts b/packages/core/src/api/parsers/markdown/markdownToHtml.ts index 01296dd3f1..9d6e1981e6 100644 --- a/packages/core/src/api/parsers/markdown/markdownToHtml.ts +++ b/packages/core/src/api/parsers/markdown/markdownToHtml.ts @@ -59,7 +59,7 @@ function tryBackslashEscape( return { html: "
\n", end: i + 2 }; } // Escapable characters - if ("\\`*_{}[]()#+-.!~|>".includes(next)) { + if ("\\`*_{}[]()#+-.!~|<>".includes(next)) { return { html: escapeHtml(next), end: i + 2 }; } return null; diff --git a/tests/src/unit/core/formatConversion/exportParseEquality/__snapshots__/markdown/markdown/numberedListItemWithDetailsText.json b/tests/src/unit/core/formatConversion/exportParseEquality/__snapshots__/markdown/markdown/numberedListItemWithDetailsText.json index a4f12a7d09..97ee71f553 100644 --- a/tests/src/unit/core/formatConversion/exportParseEquality/__snapshots__/markdown/markdown/numberedListItemWithDetailsText.json +++ b/tests/src/unit/core/formatConversion/exportParseEquality/__snapshots__/markdown/markdown/numberedListItemWithDetailsText.json @@ -1,19 +1,13 @@ [ { - "children": [ + "children": [], + "content": [ { - "children": [], - "content": [], - "id": "2", - "props": { - "backgroundColor": "default", - "textAlignment": "left", - "textColor": "default", - }, - "type": "paragraph", + "styles": {}, + "text": "
", + "type": "text", }, ], - "content": [], "id": "1", "props": { "backgroundColor": "default", diff --git a/tests/src/unit/core/formatConversion/exportParseEquality/__snapshots__/markdown/markdown/paragraphWithHtmlLikeText.json b/tests/src/unit/core/formatConversion/exportParseEquality/__snapshots__/markdown/markdown/paragraphWithHtmlLikeText.json new file mode 100644 index 0000000000..269c03fef5 --- /dev/null +++ b/tests/src/unit/core/formatConversion/exportParseEquality/__snapshots__/markdown/markdown/paragraphWithHtmlLikeText.json @@ -0,0 +1,19 @@ +[ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Tags like , and stay text, and 1 < 2.", + "type": "text", + }, + ], + "id": "1", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, +] \ No newline at end of file diff --git a/tests/src/unit/core/formatConversion/exportParseEquality/exportParseEqualityTestInstances.ts b/tests/src/unit/core/formatConversion/exportParseEquality/exportParseEqualityTestInstances.ts index 4c0260e846..c03a0cc075 100644 --- a/tests/src/unit/core/formatConversion/exportParseEquality/exportParseEqualityTestInstances.ts +++ b/tests/src/unit/core/formatConversion/exportParseEquality/exportParseEqualityTestInstances.ts @@ -488,8 +488,6 @@ export const exportParseEqualityTestInstancesMarkdown: TestInstance< }, executeTest: testExportParseEqualityMarkdown, }, - // Markdown export doesn't escape HTML-like text, so this loads back as an - // empty list item. It must load without throwing. { testCase: { name: "markdown/numberedListItemWithDetailsText", @@ -502,6 +500,19 @@ export const exportParseEqualityTestInstancesMarkdown: TestInstance< }, executeTest: testExportParseEqualityMarkdown, }, + { + testCase: { + name: "markdown/paragraphWithHtmlLikeText", + content: [ + { + type: "paragraph", + content: + "Tags like , and stay text, and 1 < 2.", + }, + ], + }, + executeTest: testExportParseEqualityMarkdown, + }, { testCase: { name: "markdown/nestedLists", From 0604b05dad1013faa2351210aca0dbe885f9697e Mon Sep 17 00:00:00 2001 From: jkugsiya Date: Wed, 14 Oct 2026 15:19:45 +0530 Subject: [PATCH 3/3] fix(core): keep backslashes before escaped HTML-like text Text like \
exported as \\
, which the parser reads as a literal backslash followed by raw HTML, so the text was lost. Double the backslashes right before an escaped < so they load back unchanged. Refs #3189 --- .../api/exporters/markdown/htmlToMarkdown.ts | 8 ++++++-- ...agraphWithBackslashBeforeHtmlLikeText.json | 19 +++++++++++++++++++ .../exportParseEqualityTestInstances.ts | 13 +++++++++++++ 3 files changed, 38 insertions(+), 2 deletions(-) create mode 100644 tests/src/unit/core/formatConversion/exportParseEquality/__snapshots__/markdown/markdown/paragraphWithBackslashBeforeHtmlLikeText.json diff --git a/packages/core/src/api/exporters/markdown/htmlToMarkdown.ts b/packages/core/src/api/exporters/markdown/htmlToMarkdown.ts index adb2237a9f..f99b3d36dc 100644 --- a/packages/core/src/api/exporters/markdown/htmlToMarkdown.ts +++ b/packages/core/src/api/exporters/markdown/htmlToMarkdown.ts @@ -849,10 +849,14 @@ function serializeInlineContent(el: Element): string { /** * Escape `<` where the markdown parser would read raw HTML or an autolink, so * text like `
` loads back as text. A `<` before a space or digit - * stays as is, as in `1 < 2`. + * stays as is, as in `1 < 2`. Backslashes right before an escaped `<` are + * doubled too, so `\
` doesn't become an escaped backslash plus HTML. */ function escapeHtmlLikeText(text: string): string { - return text.replace(/<(?=[A-Za-z/!?])/g, "\\<"); + return text.replace( + /(\\*)<(?=[A-Za-z/!?])/g, + (_, backslashes: string) => `${backslashes}${backslashes}\\<`, + ); } /** diff --git a/tests/src/unit/core/formatConversion/exportParseEquality/__snapshots__/markdown/markdown/paragraphWithBackslashBeforeHtmlLikeText.json b/tests/src/unit/core/formatConversion/exportParseEquality/__snapshots__/markdown/markdown/paragraphWithBackslashBeforeHtmlLikeText.json new file mode 100644 index 0000000000..79e0494134 --- /dev/null +++ b/tests/src/unit/core/formatConversion/exportParseEquality/__snapshots__/markdown/markdown/paragraphWithBackslashBeforeHtmlLikeText.json @@ -0,0 +1,19 @@ +[ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Escaped \
and \\ stay text, as does C:\Users.", + "type": "text", + }, + ], + "id": "1", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, +] \ No newline at end of file diff --git a/tests/src/unit/core/formatConversion/exportParseEquality/exportParseEqualityTestInstances.ts b/tests/src/unit/core/formatConversion/exportParseEquality/exportParseEqualityTestInstances.ts index c03a0cc075..28c9fb34fd 100644 --- a/tests/src/unit/core/formatConversion/exportParseEquality/exportParseEqualityTestInstances.ts +++ b/tests/src/unit/core/formatConversion/exportParseEquality/exportParseEqualityTestInstances.ts @@ -513,6 +513,19 @@ export const exportParseEqualityTestInstancesMarkdown: TestInstance< }, executeTest: testExportParseEqualityMarkdown, }, + { + testCase: { + name: "markdown/paragraphWithBackslashBeforeHtmlLikeText", + content: [ + { + type: "paragraph", + content: + "Escaped \\
and \\\\ stay text, as does C:\\Users.", + }, + ], + }, + executeTest: testExportParseEqualityMarkdown, + }, { testCase: { name: "markdown/nestedLists",