Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion packages/core/src/api/exporters/markdown/htmlToMarkdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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 `<details>` 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 `\<details>` 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.
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/api/parsers/markdown/markdownToHtml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ function tryBackslashEscape(
return { html: "<br>\n", end: i + 2 };
}
// Escapable characters
if ("\\`*_{}[]()#+-.!~|>".includes(next)) {
if ("\\`*_{}[]()#+-.!~|<>".includes(next)) {
return { html: escapeHtml(next), end: i + 2 };
}
return null;
Expand Down
17 changes: 10 additions & 7 deletions packages/core/src/blocks/ListItem/getListItemContent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,10 +77,11 @@ export function getListItemContent(

// Structure above is `blockGroup<blockContainer<any>[]>`
// 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);
}
Expand All @@ -93,11 +94,13 @@ export function getListItemContent(
listItemsFirstChild.content,
);

// We have `blockGroup<listItemsFirstChild, ...blockContainer<any>[]>`
// We want to extract out the rest of the nodes as `<...blockContainer<any>[]>`
const remainingListItemChildren = blockGroupNode.content.cut(
// +2 for the `blockGroup` node's start and end markers
listItemsFirstChild.nodeSize + 2,
// We have `blockGroup<blockContainer<listItemsFirstChild, blockGroup?>, ...blockContainer<any>[]>`
// We want to extract out the rest of the nodes as `<...blockContainer<any>[]>`.
// The first block's own children (e.g. a parsed `<details>` body) come first.
const firstBlockChildren =
firstBlockContainer.maybeChild(1)?.content ?? Fragment.empty;
const remainingListItemChildren = firstBlockChildren.append(
blockGroupNode.content.cut(firstBlockContainer.nodeSize),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
);
const hasRemainingListItemChildren = remainingListItemChildren.size > 0;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
[
{
"children": [],
"content": [
{
"styles": {},
"text": "<details>",
"type": "text",
},
],
"id": "1",
"props": {
"backgroundColor": "default",
"textAlignment": "left",
"textColor": "default",
},
"type": "numberedListItem",
},
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
[
{
"children": [],
"content": [
{
"styles": {},
"text": "Escaped \<details> and \\<b> stay text, as does C:\Users.",
"type": "text",
},
],
"id": "1",
"props": {
"backgroundColor": "default",
"textAlignment": "left",
"textColor": "default",
},
"type": "paragraph",
},
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
[
{
"children": [],
"content": [
{
"styles": {},
"text": "Tags like <b>, </b> and <!-- note --> stay text, and 1 < 2.",
"type": "text",
},
],
"id": "1",
"props": {
"backgroundColor": "default",
"textAlignment": "left",
"textColor": "default",
},
"type": "paragraph",
},
]
Original file line number Diff line number Diff line change
Expand Up @@ -488,6 +488,44 @@ export const exportParseEqualityTestInstancesMarkdown: TestInstance<
},
executeTest: testExportParseEqualityMarkdown,
},
{
testCase: {
name: "markdown/numberedListItemWithDetailsText",
content: [
{
type: "numberedListItem",
content: "<details>",
},
],
},
executeTest: testExportParseEqualityMarkdown,
},
{
testCase: {
name: "markdown/paragraphWithHtmlLikeText",
content: [
{
type: "paragraph",
content:
"Tags like <b>, </b> and <!-- note --> stay text, and 1 < 2.",
},
],
},
executeTest: testExportParseEqualityMarkdown,
},
{
testCase: {
name: "markdown/paragraphWithBackslashBeforeHtmlLikeText",
content: [
{
type: "paragraph",
content:
"Escaped \\<details> and \\\\<b> stay text, as does C:\\Users.",
},
],
},
executeTest: testExportParseEqualityMarkdown,
},
{
testCase: {
name: "markdown/nestedLists",
Expand Down
Original file line number Diff line number Diff line change
@@ -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",
},
]
Original file line number Diff line number Diff line change
@@ -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",
},
]
Original file line number Diff line number Diff line change
@@ -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",
},
]
26 changes: 26 additions & 0 deletions tests/src/unit/core/formatConversion/parse/parseTestInstances.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1723,6 +1723,32 @@ Invalid schemeless domains stay plain: www.invalid_domain.com`,
},
executeTest: testParseMarkdown,
},
// A list item whose first block is a `<details>` toggle. The toggle's text
// becomes the list item's content, and its children stay nested under it.
{
testCase: {
name: "numberedListItemStartsWithDetails",
content: `1. <details>`,
},
executeTest: testParseMarkdown,
},
{
testCase: {
name: "checkListItemStartsWithDetails",
content: `- [ ] <details>`,
},
executeTest: testParseMarkdown,
},
{
testCase: {
name: "numberedListItemStartsWithDetailsAndBody",
content: `1. <details>
<summary>Summary</summary>
Body
</details>`,
},
executeTest: testParseMarkdown,
},
// Ordered list with start number
{
testCase: {
Expand Down