Skip to content

Commit 5b8a7c5

Browse files
nperez0111claude
andcommitted
fix(markdown): emit tight lists when serializing blocks to markdown
Each list item ended every marker line with `\n\n`, producing a blank line between consecutive items and turning every exported list into a "loose" CommonMark list. Marker lines now end with a single `\n` so sibling items render tightly; continuation paragraphs and other block-level children inside a list item still inject the blank line they need, and the trailing block separator after a list is now emitted once at the list level (suppressed when the list is nested inside a parent list item, where it would otherwise break parent tightness). Closes #1881 Closes #1885 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent a77c887 commit 5b8a7c5

5 files changed

Lines changed: 37 additions & 22 deletions

File tree

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

Lines changed: 37 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,19 @@ export function htmlToMarkdown(html: string): string {
1515
// globally available in Node.js.
1616
const container = document.createElement("div");
1717
container.innerHTML = html;
18-
const result = serializeChildren(container, { indent: "", inList: false });
18+
const result = serializeChildren(container, {
19+
indent: "",
20+
inListItem: false,
21+
});
1922
return result.trim() + "\n";
2023
}
2124

2225
interface SerializeContext {
2326
indent: string; // current indentation prefix for list nesting
24-
inList: boolean; // whether we're inside a list
27+
// True when the current node is being serialized as continuation content
28+
// of a parent list item. Used to suppress trailing blank lines that would
29+
// otherwise turn the parent list into a "loose" list.
30+
inListItem: boolean;
2531
}
2632

2733
// ─── Main Serializer ─────────────────────────────────────────────────────────
@@ -101,7 +107,7 @@ function serializeParagraph(el: HTMLElement, ctx: SerializeContext): string {
101107
const content = serializeInlineContent(el);
102108
// Trim leading/trailing hard breaks (matching remark behavior)
103109
const trimmed = trimHardBreaks(content);
104-
if (ctx.inList) {
110+
if (ctx.inListItem) {
105111
return trimmed;
106112
}
107113
return ctx.indent + trimmed + "\n\n";
@@ -130,7 +136,7 @@ function serializeBlockquote(el: HTMLElement, ctx: SerializeContext): string {
130136
if (tag === "p") {
131137
parts.push(serializeInlineContent(child as HTMLElement));
132138
} else {
133-
const innerCtx: SerializeContext = { indent: "", inList: false };
139+
const innerCtx: SerializeContext = { indent: "", inListItem: false };
134140
parts.push(serializeNode(child, innerCtx).trim());
135141
}
136142
}
@@ -215,6 +221,12 @@ function serializeUnorderedList(
215221
result += serializeListItem(item as HTMLElement, "bullet", ctx);
216222
}
217223

224+
// Trailing blank line separates the list from the next block. Skip when
225+
// this list is nested inside another list item — adding it would convert
226+
// the parent list into a "loose" list (or break tightness).
227+
if (!ctx.inListItem) {
228+
result += "\n";
229+
}
218230
return result;
219231
}
220232

@@ -230,6 +242,9 @@ function serializeOrderedList(el: HTMLElement, ctx: SerializeContext): string {
230242
result += serializeListItem(items[i] as HTMLElement, "ordered", ctx, num);
231243
}
232244

245+
if (!ctx.inListItem) {
246+
result += "\n";
247+
}
233248
return result;
234249
}
235250

@@ -284,11 +299,15 @@ function serializeListItem(
284299
inlineContent = firstContentEl ? serializeInlineContent(firstContentEl) : "";
285300
}
286301

287-
let result = ctx.indent + marker + inlineContent + "\n\n";
302+
// The marker line ends with a single `\n` so that consecutive list items
303+
// produce a "tight" list (no blank line between markers). Continuation
304+
// content within the item (nested lists, continuation paragraphs, other
305+
// blocks) injects its own spacing as needed.
306+
let result = ctx.indent + marker + inlineContent + "\n";
288307

289308
// Serialize child content (nested lists, continuation paragraphs, etc.)
290309
const childIndent = ctx.indent + " ".repeat(markerWidth);
291-
const childCtx: SerializeContext = { indent: childIndent, inList: true };
310+
const childCtx: SerializeContext = { indent: childIndent, inListItem: true };
292311

293312
// For toggle items, also serialize children inside the details element
294313
if (details) {
@@ -298,7 +317,10 @@ function serializeListItem(
298317
const childTag = child.tagName.toLowerCase();
299318
if (childTag === "p") {
300319
const content = serializeInlineContent(child as HTMLElement);
301-
result += childIndent + content + "\n\n";
320+
// Continuation paragraph needs a blank line to separate it from the
321+
// previous content; CommonMark would otherwise treat it as a soft
322+
// wrap of that content.
323+
result += "\n" + childIndent + content + "\n";
302324
} else {
303325
result += serializeNode(child, childCtx);
304326
}
@@ -315,13 +337,18 @@ function serializeListItem(
315337

316338
// Nested lists and other block content
317339
if (childTag === "ul" || childTag === "ol") {
340+
// Nested list flows directly under the parent marker — no blank line.
318341
result += serializeNode(child, childCtx);
319342
} else if (childTag === "p") {
320-
// Continuation paragraph within list item
343+
// Continuation paragraph within list item — requires blank line before
344+
// so it isn't read as part of the marker line's text.
321345
const content = serializeInlineContent(child as HTMLElement);
322-
result += childIndent + content + "\n\n";
346+
result += "\n" + childIndent + content + "\n";
323347
} else {
324-
result += serializeNode(child, childCtx);
348+
// Other block-level children (code blocks, blockquotes, etc.) already
349+
// emit their own separating newlines; prefix with a blank line so they
350+
// are recognized as separate blocks.
351+
result += "\n" + serializeNode(child, childCtx);
325352
}
326353
}
327354

tests/src/unit/core/formatConversion/export/__snapshots__/markdown/complex/document.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ Introduction paragraph.
77
Text with **bold** and [a link](https://example.com).
88

99
* First point
10-
1110
* Second point
1211

1312
***
Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,9 @@
11
* Bullet List Item 1
2-
32
* Bullet List Item 2
43

54
1. Numbered List Item 1
6-
75
2. Numbered List Item 2
86

97
* [ ] Check List Item 1
10-
118
* [x] Check List Item 2
12-
139
* Toggle List Item 1
Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,7 @@
11
* Bullet List Item 1
2-
32
* Bullet List Item 2
4-
53
1. Numbered List Item 1
6-
74
2. Numbered List Item 2
8-
95
* [ ] Check List Item 1
10-
116
* [x] Check List Item 2
12-
137
* Toggle List Item 1
Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,2 @@
11
5. Item 5
2-
32
6. Item 6

0 commit comments

Comments
 (0)