Skip to content

fix(html-to-markdown): preserve nested list structure in html → markdown - #247

Merged
pchuri merged 4 commits into
mainfrom
fix/html-to-markdown-nested-lists
Sep 30, 2026
Merged

pchuri merged 4 commits into
mainfrom
fix/html-to-markdown-nested-lists

Conversation

@pchuri

@pchuri pchuri commented Sep 29, 2026 •

Copy link
Copy Markdown
Owner

Pull Request Template

Description

Fixes #243.

convert --input-format html --output-format markdown uses the regex-based lib/html-to-markdown.js. Its non-greedy /<ul>(.*?)<\/ul>/gs and /<ol>(.*?)<\/ol>/gs patterns paired the outer opening tag with the inner closing tag. That flattened nested items and dropped the marker of the next sibling:

htmlToMarkdown('<ul><li>A<ul><li>A1</li><li>A2</li></ul></li><li>B</li></ul>')
// before: '- A A1\n- A2\nB'
// after:  '- A\n  - A1\n  - A2\n- B'

Options considered

Option Verdict
Route html input through StorageWalker Rejected. The walker collapses multi-line <pre><code class="language-x"> into inline code and drops the language. tests/convert.test.js explicitly guards against this, and inline/entity handling would change as well.
Rewrite the converter on htmlparser2 Rejected. It would change every html-to-markdown behavior at once, not just lists.
Replace just the list handling Chosen. It has the least regression risk: every other pass is untouched.

What changed (lib/html-to-markdown.js)

  • Balanced-tag scanner: the two list regexes are replaced by tokenizeLists, buildList and renderList. Tags are paired in a single linear pass.
    • <li> implicitly closes an open sibling item.
    • A list placed directly inside a list attaches to the preceding item.
    • Unclosed lists are left to the later tag-stripping passes, as before.
  • Rendering: mirrors StorageWalker#handleList / renderListItemBody from fix(storage-walker): preserve nested list structure in storage → markdown (#238) #239.
    • Inline text in an item is flattened exactly as before. That includes multiple <p>, which stay on one line (- a b), whereas the walker splits them into separate paragraphs.
    • Nested lists are indented to the content column.
    • Text after a nested list becomes a continuation paragraph.
    • <pre> inside an item stays a fenced block with its lines intact. A fence that leads the item is joined to the marker with LIST_INDENT, as in the walker, so splitOnFences skips its body. That join is used only for real fences: literal text starting with ``` keeps a plain space, so it can't pair with a later fence.
    • For single-paragraph items, nested output matches storage → markdown.
  • Indentation: nested lines use the shared LIST_INDENT sentinel, so the whitespace cleanup chain cannot collapse them. Input sentinels are escaped, and so are decoded numeric entities (&#57344;).
  • Self-closing lists: <ul/> / <ol/> are dropped before tag normalization, so they can't steal the enclosing list's close tag.
  • Depth cap: nesting deeper than 256 levels (the same value as StorageWalker's DEFAULT_MAX_DEPTH) is flattened the way the old code did, so rendering cannot overflow the stack.

Behavior vs. v2.25.3: output for flat lists of inline content and for all non-list constructs is byte-identical.

Intentional change: <pre> inside a list item now renders as a real fenced block instead of being flattened onto the marker line. This also fixes a regression found in review, where a <pre> following a nested list was mis-paired with a later top-level fence and that code body lost its whitespace:

Input Old New
<ul><li><pre><code>a b</code></pre></li></ul> - ``` a b ``` - ```\n a b\n ```
<ul><li><pre><code class="language-js">a\nb</code></pre></li></ul> - ```js a b ``` - ```js\n a\n b\n ```

Malformed HTML: these differ, and in most of them the old code lost items:

Input Old New
<ul><li>a</li><li>b</ul> - a - a\n- b
<ul><li>a<li>b</ul> (empty) - a\n- b
<ol><li>a</li><ol><li>b</li></ol></ol> 1. a\n2. b 1. a\n 1. b
<ul><li>a</li></ol><ol><li>b</li></ul> - a\n- b - a\n\n1. b
<ul><li>a<ul><li>inner</li></ul> (outer never closed) - a inner a\n- inner

Known follow-ups (out of scope here):

  • A continuation line after a nested list that starts with a literal bare (not a `<pre>`) can still pair with a later top-level fence. Example: `<ul><li>a<ul><li>b</li></ul>
followed by a
. This is not a regression: main corrupts the same input through a different path. Since #249, a literal like ```` ```js x` ```` (backtick in the info string) no longer pairs.
  • Uppercase list tags.
  • start / type attributes on <ol>.
  • <strong> wrapping a nested list.
  • Unclosed lists are also much faster: 20k unclosed <ul><li>x</li> went from 514 ms to 12 ms.

    Type of Change

    • Bug fix (non-breaking change which fixes an issue)
    • New feature (non-breaking change which adds functionality)
    • Breaking change (fix or feature that would cause existing functionality to not work as expected)
    • Documentation update
    • Performance improvement
    • Code refactoring

    Testing

    • Tests pass locally with my changes
    • I have added tests that prove my fix is effective or that my feature works
    • New and existing unit tests pass locally with my changes

    npx jest: 1329 tests pass (after merging main with #249). npx eslint lib tests: clean.

    • tests/html-to-markdown.test.js: new exact-output tests under "nested lists (#243)" cover:
      • nested ul and ol, mixed nesting 3 levels deep, and a multi-digit marker indent
      • trailing text after a nested list, and an item that holds only a nested list
      • list-in-list, omitted </li>, unclosed and mismatched closes
      • empty nested items and numbering
      • <pre> inside a nested item
      • the depth cap, linear-time unclosed input, and sentinel round-trips
    • tests/convert.test.js: a CLI end-to-end test.
    • The #238 fence regression test from #239 is unchanged and still passes.

    Checklist

    • My code follows the style guidelines of this project
    • I have performed a self-review of my own code
    • I have commented my code, particularly in hard-to-understand areas
    • I have made corresponding changes to the documentation (n/a)
    • My changes generate no new warnings
    • Any dependent changes have been merged and published in downstream modules (n/a)

    Screenshots (if applicable)

    N/A

    Additional Context

    Follow-up to #238 / #239. renderListItemBody() fixed nested lists in the storage path. This PR brings the html path to the same output without touching its other passes.

    The non-greedy <ul>(.*?)</ul> / <ol>(.*?)</ol> regexes paired an outer
    open tag with an inner close tag, flattening nested items and dropping
    the marker of the next sibling (data loss).
    
    Replace only the two list passes with a balanced-tag scanner that mirrors
    StorageWalker's handleList/renderListItemBody output and indents nested
    lines with the shared LIST_INDENT sentinel. Tags are paired in a single
    linear pass, nesting deeper than 256 levels is flattened as before, and
    decoded numeric entities are sentinel-escaped. Flat-list output and all
    other html-to-markdown behavior are unchanged.
    
    Fixes #243
    Text after a nested list is emitted as a LIST_INDENT continuation line.
    A flattened <pre> there (``` x ```) was taken by splitOnFences for a
    sentinel-indented fence opener and paired with the next top-level
    fence, sending that code body through whitespace cleanup.
    
    Split item text on fences and keep fence segments as line-preserving
    blocks, joining a leading fence to the marker with LIST_INDENT (as
    StorageWalker does) only when it is a real fence. <pre> inside any list
    item now renders as a fenced block instead of being flattened.
    
    Also drop self-closing <ul/> / <ol/> before tag normalisation so they
    cannot steal the enclosing list's close tag, and update markdown-cleanup
    comments that described LIST_INDENT as StorageWalker-only.
    …nested-lists
    
    # Conflicts:
    #	lib/markdown-cleanup.js
    #	tests/html-to-markdown.test.js
    @pchuri
    pchuri merged commit 078886c into main Sep 30, 2026
    6 checks passed
    @pchuri
    pchuri deleted the fix/html-to-markdown-nested-lists branch September 30, 2026 00:24
    github-actions Bot pushed a commit that referenced this pull request Sep 30, 2026
    ## [2.25.5](v2.25.4...v2.25.5) (2026-09-30)
    
    ### Bug Fixes
    
    * **html-to-markdown:** preserve nested list structure in html → markdown ([#247](#247)) ([078886c](078886c)), closes [#243](#243)
    @github-actions

    Copy link
    Copy Markdown

    🎉 This PR is included in version 2.25.5 🎉

    The release is available on:

    Your semantic-release bot 📦🚀

    Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

    Labels

    Projects

    None yet

    Development

    Successfully merging this pull request may close these issues.

    html → markdown converter mangles nested lists

    1 participant