From 5015b6edebe8961657c2272a90d2da07e4913324 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 f3dfa02604..32cd9f156d 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 15df0a01704e2292d1cdf913cbbe65856e420c82 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 20732b760e..13f1d96cf0 100644 --- a/packages/core/src/api/exporters/markdown/htmlToMarkdown.ts +++ b/packages/core/src/api/exporters/markdown/htmlToMarkdown.ts @@ -754,7 +754,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(); @@ -860,6 +860,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, "\\<"); +} + /** * Extract trailing whitespace from emphasis content. * Moves trailing spaces outside the emphasis delimiters to produce valid markdown. 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 32cd9f156d..71b0c2d665 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 16af6ca883576b7517047bcd2102a2d357a88d4c 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 13f1d96cf0..7153ac41c7 100644 --- a/packages/core/src/api/exporters/markdown/htmlToMarkdown.ts +++ b/packages/core/src/api/exporters/markdown/htmlToMarkdown.ts @@ -863,10 +863,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 71b0c2d665..8240f658d2 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",