diff --git a/packages/core/src/api/exporters/markdown/htmlToMarkdown.ts b/packages/core/src/api/exporters/markdown/htmlToMarkdown.ts index 20732b760e..7153ac41c7 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,19 @@ 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`. 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, + (_, backslashes: string) => `${backslashes}${backslashes}\\<`, + ); +} + /** * 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/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..97ee71f553 --- /dev/null +++ b/tests/src/unit/core/formatConversion/exportParseEquality/__snapshots__/markdown/markdown/numberedListItemWithDetailsText.json @@ -0,0 +1,19 @@ +[ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "
", + "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/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/__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 f3dfa02604..8240f658d2 100644 --- a/tests/src/unit/core/formatConversion/exportParseEquality/exportParseEqualityTestInstances.ts +++ b/tests/src/unit/core/formatConversion/exportParseEquality/exportParseEqualityTestInstances.ts @@ -488,6 +488,44 @@ export const exportParseEqualityTestInstancesMarkdown: TestInstance< }, executeTest: testExportParseEqualityMarkdown, }, + { + testCase: { + name: "markdown/numberedListItemWithDetailsText", + content: [ + { + type: "numberedListItem", + content: "
", + }, + ], + }, + executeTest: testExportParseEqualityMarkdown, + }, + { + testCase: { + name: "markdown/paragraphWithHtmlLikeText", + content: [ + { + type: "paragraph", + content: + "Tags like , and stay text, and 1 < 2.", + }, + ], + }, + executeTest: testExportParseEqualityMarkdown, + }, + { + testCase: { + name: "markdown/paragraphWithBackslashBeforeHtmlLikeText", + content: [ + { + type: "paragraph", + content: + "Escaped \\
and \\\\ stay text, as does C:\\Users.", + }, + ], + }, + 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: {