Skip to content

Commit ca24608

Browse files
authored
fix(core): handle <details> in Markdown list item (#3194)
Fixes #3189
1 parent b8bad22 commit ca24608

11 files changed

Lines changed: 234 additions & 9 deletions

File tree

‎packages/core/src/api/exporters/markdown/htmlToMarkdown.ts‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -755,7 +755,7 @@ function serializeInlineContent(el: Element): string {
755755

756756
for (const child of Array.from(el.childNodes)) {
757757
if (child.nodeType === 3 /* Node.TEXT_NODE */) {
758-
result += child.textContent || "";
758+
result += escapeHtmlLikeText(child.textContent || "");
759759
} else if (child.nodeType === 1 /* Node.ELEMENT_NODE */) {
760760
const childEl = child as HTMLElement;
761761
const tag = childEl.tagName.toLowerCase();
@@ -846,6 +846,19 @@ function serializeInlineContent(el: Element): string {
846846
return result;
847847
}
848848

849+
/**
850+
* Escape `<` where the markdown parser would read raw HTML or an autolink, so
851+
* text like `<details>` loads back as text. A `<` before a space or digit
852+
* stays as is, as in `1 < 2`. Backslashes right before an escaped `<` are
853+
* doubled too, so `\<details>` doesn't become an escaped backslash plus HTML.
854+
*/
855+
function escapeHtmlLikeText(text: string): string {
856+
return text.replace(
857+
/(\\*)<(?=[A-Za-z/!?])/g,
858+
(_, backslashes: string) => `${backslashes}${backslashes}\\<`,
859+
);
860+
}
861+
849862
/**
850863
* Escape leading character after emphasis if it could break parsing.
851864
* For example, "Heading" after "**Bold **" — the 'H' should be escaped

‎packages/core/src/api/parsers/markdown/markdownToHtml.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ function tryBackslashEscape(
5959
return { html: "<br>\n", end: i + 2 };
6060
}
6161
// Escapable characters
62-
if ("\\`*_{}[]()#+-.!~|>".includes(next)) {
62+
if ("\\`*_{}[]()#+-.!~|<>".includes(next)) {
6363
return { html: escapeHtml(next), end: i + 2 };
6464
}
6565
return null;

‎packages/core/src/blocks/ListItem/getListItemContent.ts‎

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -77,10 +77,11 @@ export function getListItemContent(
7777

7878
// Structure above is `blockGroup<blockContainer<any>[]>`
7979
// We want to extract the first `blockContainer` node's content, and see if it is a text block.
80-
const listItemsFirstChild = blockGroupNode.firstChild?.firstChild;
80+
const firstBlockContainer = blockGroupNode.firstChild;
81+
const listItemsFirstChild = firstBlockContainer?.firstChild;
8182

8283
// If the first node is not a text block, then it's first child is not compatible with the list item node.
83-
if (!listItemsFirstChild?.isTextblock) {
84+
if (!firstBlockContainer || !listItemsFirstChild?.isTextblock) {
8485
// So, we do not try inserting anything into the list item, and instead return anything we found as children for the list item.
8586
return Fragment.from(blockGroupNode);
8687
}
@@ -93,11 +94,13 @@ export function getListItemContent(
9394
listItemsFirstChild.content,
9495
);
9596

96-
// We have `blockGroup<listItemsFirstChild, ...blockContainer<any>[]>`
97-
// We want to extract out the rest of the nodes as `<...blockContainer<any>[]>`
98-
const remainingListItemChildren = blockGroupNode.content.cut(
99-
// +2 for the `blockGroup` node's start and end markers
100-
listItemsFirstChild.nodeSize + 2,
97+
// We have `blockGroup<blockContainer<listItemsFirstChild, blockGroup?>, ...blockContainer<any>[]>`
98+
// We want to extract out the rest of the nodes as `<...blockContainer<any>[]>`.
99+
// The first block's own children (e.g. a parsed `<details>` body) come first.
100+
const firstBlockChildren =
101+
firstBlockContainer.maybeChild(1)?.content ?? Fragment.empty;
102+
const remainingListItemChildren = firstBlockChildren.append(
103+
blockGroupNode.content.cut(firstBlockContainer.nodeSize),
101104
);
102105
const hasRemainingListItemChildren = remainingListItemChildren.size > 0;
103106

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
[
2+
{
3+
"children": [],
4+
"content": [
5+
{
6+
"styles": {},
7+
"text": "<details>",
8+
"type": "text",
9+
},
10+
],
11+
"id": "1",
12+
"props": {
13+
"backgroundColor": "default",
14+
"textAlignment": "left",
15+
"textColor": "default",
16+
},
17+
"type": "numberedListItem",
18+
},
19+
]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
[
2+
{
3+
"children": [],
4+
"content": [
5+
{
6+
"styles": {},
7+
"text": "Escaped \<details> and \\<b> stay text, as does C:\Users.",
8+
"type": "text",
9+
},
10+
],
11+
"id": "1",
12+
"props": {
13+
"backgroundColor": "default",
14+
"textAlignment": "left",
15+
"textColor": "default",
16+
},
17+
"type": "paragraph",
18+
},
19+
]
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
[
2+
{
3+
"children": [],
4+
"content": [
5+
{
6+
"styles": {},
7+
"text": "Tags like <b>, </b> and <!-- note --> stay text, and 1 < 2.",
8+
"type": "text",
9+
},
10+
],
11+
"id": "1",
12+
"props": {
13+
"backgroundColor": "default",
14+
"textAlignment": "left",
15+
"textColor": "default",
16+
},
17+
"type": "paragraph",
18+
},
19+
]

‎tests/src/unit/core/formatConversion/exportParseEquality/exportParseEqualityTestInstances.ts‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -488,6 +488,44 @@ export const exportParseEqualityTestInstancesMarkdown: TestInstance<
488488
},
489489
executeTest: testExportParseEqualityMarkdown,
490490
},
491+
{
492+
testCase: {
493+
name: "markdown/numberedListItemWithDetailsText",
494+
content: [
495+
{
496+
type: "numberedListItem",
497+
content: "<details>",
498+
},
499+
],
500+
},
501+
executeTest: testExportParseEqualityMarkdown,
502+
},
503+
{
504+
testCase: {
505+
name: "markdown/paragraphWithHtmlLikeText",
506+
content: [
507+
{
508+
type: "paragraph",
509+
content:
510+
"Tags like <b>, </b> and <!-- note --> stay text, and 1 < 2.",
511+
},
512+
],
513+
},
514+
executeTest: testExportParseEqualityMarkdown,
515+
},
516+
{
517+
testCase: {
518+
name: "markdown/paragraphWithBackslashBeforeHtmlLikeText",
519+
content: [
520+
{
521+
type: "paragraph",
522+
content:
523+
"Escaped \\<details> and \\\\<b> stay text, as does C:\\Users.",
524+
},
525+
],
526+
},
527+
executeTest: testExportParseEqualityMarkdown,
528+
},
491529
{
492530
testCase: {
493531
name: "markdown/nestedLists",
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
[
2+
{
3+
"children": [
4+
{
5+
"children": [],
6+
"content": [],
7+
"id": "2",
8+
"props": {
9+
"backgroundColor": "default",
10+
"textAlignment": "left",
11+
"textColor": "default",
12+
},
13+
"type": "paragraph",
14+
},
15+
],
16+
"content": [],
17+
"id": "1",
18+
"props": {
19+
"backgroundColor": "default",
20+
"checked": false,
21+
"textAlignment": "left",
22+
"textColor": "default",
23+
},
24+
"type": "checkListItem",
25+
},
26+
]
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
[
2+
{
3+
"children": [
4+
{
5+
"children": [],
6+
"content": [],
7+
"id": "2",
8+
"props": {
9+
"backgroundColor": "default",
10+
"textAlignment": "left",
11+
"textColor": "default",
12+
},
13+
"type": "paragraph",
14+
},
15+
],
16+
"content": [],
17+
"id": "1",
18+
"props": {
19+
"backgroundColor": "default",
20+
"textAlignment": "left",
21+
"textColor": "default",
22+
},
23+
"type": "numberedListItem",
24+
},
25+
]
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
[
2+
{
3+
"children": [
4+
{
5+
"children": [],
6+
"content": [
7+
{
8+
"styles": {},
9+
"text": "Body",
10+
"type": "text",
11+
},
12+
],
13+
"id": "2",
14+
"props": {
15+
"backgroundColor": "default",
16+
"textAlignment": "left",
17+
"textColor": "default",
18+
},
19+
"type": "paragraph",
20+
},
21+
],
22+
"content": [
23+
{
24+
"styles": {},
25+
"text": "Summary",
26+
"type": "text",
27+
},
28+
],
29+
"id": "1",
30+
"props": {
31+
"backgroundColor": "default",
32+
"textAlignment": "left",
33+
"textColor": "default",
34+
},
35+
"type": "numberedListItem",
36+
},
37+
]

0 commit comments

Comments
 (0)