@@ -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
2225interface 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
0 commit comments