From 8594058346cf45515f5b3903c069a904fc6835ae Mon Sep 17 00:00:00 2001 From: Aqueeb Qadri Date: Thu, 2 Jul 2026 21:08:00 -0400 Subject: [PATCH 1/2] fix: harden table conversion edge cases (#6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up hardening for the table list/header conversion, developed test-first (each test below was confirmed failing before the fix): - Drop empty
  • items so they don't leave a stray "•"/"N." with no text. - Strip any pre-existing intra-cell sentinel from the input so source content can never inject a spurious
    line break. - Promote a table's first row into only when it is a genuine, all- header row (a row mixing and is left in the body). Adds unit + end-to-end regression tests and brings flattenCellLists, cleanListItemText, and promoteTableHeaderRows to 100% statement coverage. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Aqueeb Qadri --- converter/markdown.go | 31 +++++++-- converter/markdown_test.go | 127 +++++++++++++++++++++++++++++++++++++ 2 files changed, 151 insertions(+), 7 deletions(-) diff --git a/converter/markdown.go b/converter/markdown.go index 7ead8e5..c877f51 100644 --- a/converter/markdown.go +++ b/converter/markdown.go @@ -200,6 +200,10 @@ func decodeHTMLEntities(html string) string { // preProcessHTML removes Confluence layout markup before Pandoc conversion. // This ensures layout divs don't get escaped and pollute the output. func preProcessHTML(html string) string { + // Neutralize any pre-existing occurrence of our internal sentinel so that + // source content can never inject an intra-cell line break (see flattenCellLists). + html = strings.ReplaceAll(html, cellLineBreakSentinel, "") + // First, decode HTML entities that represent actual HTML tags // Confluence sometimes double-encodes HTML, resulting in <p> instead of

    html = decodeHTMLEntities(html) @@ -375,20 +379,29 @@ func preProcessHTML(html string) string { // Limitation: nested sub-lists are flattened to a single bullet level (their items // are still preserved as text, just without indentation). func flattenCellLists(inner string) string { - // Ordered lists first so their items get numbers rather than bullets. + // Ordered lists first so their items get numbers rather than bullets. Empty + // items are dropped so they don't leave a stray "N. " with no text. inner = cellOrderedListPattern.ReplaceAllStringFunc(inner, func(block string) string { n := 0 items := cellListItemPattern.ReplaceAllStringFunc(block, func(li string) string { + text := cleanListItemText(li) + if text == "" { + return "" + } n++ - return fmt.Sprintf("%s%d. %s", cellLineBreakSentinel, n, cleanListItemText(li)) + return fmt.Sprintf("%s%d. %s", cellLineBreakSentinel, n, text) }) return cellListTagPattern.ReplaceAllString(items, "") }) - // Unordered lists. + // Unordered lists (empty items dropped). inner = cellUnorderedListPattern.ReplaceAllStringFunc(inner, func(block string) string { items := cellListItemPattern.ReplaceAllStringFunc(block, func(li string) string { - return cellLineBreakSentinel + "• " + cleanListItemText(li) + text := cleanListItemText(li) + if text == "" { + return "" + } + return cellLineBreakSentinel + "• " + text }) return cellListTagPattern.ReplaceAllString(items, "") }) @@ -396,7 +409,11 @@ func flattenCellLists(inner string) string { // Defensive: bullet any

  • not wrapped in a recognized list, then drop any // leftover list container/item tags so none survive into pandoc. inner = cellListItemPattern.ReplaceAllStringFunc(inner, func(li string) string { - return cellLineBreakSentinel + "• " + cleanListItemText(li) + text := cleanListItemText(li) + if text == "" { + return "" + } + return cellLineBreakSentinel + "• " + text }) inner = cellListTagPattern.ReplaceAllString(inner, "") inner = cellListItemTagPattern.ReplaceAllString(inner, "") @@ -414,8 +431,8 @@ func promoteTableHeaderRows(html string) string { return table // already has an explicit header } firstRow := tableRowPattern.FindString(table) - if firstRow == "" || !strings.Contains(firstRow, "") { - return table // first row is not a header row; leave as-is + if firstRow == "" || !strings.Contains(firstRow, "") || strings.Contains(firstRow, "") { + return table // promote only a genuine, all- header row } // Lift the first row out of the body and into a after . rest := strings.Replace(table, firstRow, "", 1) diff --git a/converter/markdown_test.go b/converter/markdown_test.go index 03579f3..c596432 100644 --- a/converter/markdown_test.go +++ b/converter/markdown_test.go @@ -1010,6 +1010,133 @@ func TestPreProcessHTML_PromoteHeaderRow_KeepsExistingThead(t *testing.T) { } } +// --- Phase 1 (TDD): edge cases expected to fail against the current code --- + +func TestFlattenCellLists_DropsEmptyItems(t *testing.T) { + // Empty or whitespace-only
  • items must not produce stray empty bullets. + input := `
    • x
    ` + + result := preProcessHTML(input) + + if !strings.Contains(result, "• x") { + t.Errorf("Expected a single non-empty bullet (• x), got: %s", result) + } +} + +func TestPreProcessHTML_SentinelCollisionNeutralized(t *testing.T) { + // User content that literally contains the internal sentinel must not be able + // to inject a line break; the sentinel should be stripped from the input. + input := `

    literal ` + cellLineBreakSentinel + ` text

    ` + + result := preProcessHTML(input) + + if strings.Contains(result, cellLineBreakSentinel) { + t.Errorf("Expected pre-existing sentinel to be neutralized, got: %s", result) + } +} + +func TestPreProcessHTML_MixedHeaderRowNotPromoted(t *testing.T) { + // A first row mixing and is not a genuine header row and must not + // be lifted into . + input := `
    Hd
    ab
    ` + + result := preProcessHTML(input) + + if strings.Contains(result, "") { + t.Errorf("Expected a mixed th/td first row NOT to be promoted, got: %s", result) + } +} + +// --- Phase 3 (TDD): regression locks for behavior that should already be correct --- + +func TestFlattenCellLists_OrderedListsRestartNumbering(t *testing.T) { + // Two separate ordered lists in one cell must each restart numbering at 1. + input := `
    1. a
    2. b
    1. c
    2. d
    ` + + result := preProcessHTML(input) + + for _, want := range []string{"1. a", "2. b", "1. c", "2. d"} { + if !strings.Contains(result, want) { + t.Errorf("Expected %q in numbered output, got: %s", want, result) + } + } +} + +func TestFlattenCellLists_OrderedEmptyItemAndStrayLi(t *testing.T) { + // Covers: an empty
  • in an ordered list (skipped, numbering not consumed), + // and stray
  • elements outside any recognized list (bulleted / dropped). + input := `
    1. keep
  • stray
  • ` + + result := preProcessHTML(input) + + if !strings.Contains(result, "1. keep") { + t.Errorf("Expected ordered numbering to skip the empty item, got: %s", result) + } + if !strings.Contains(result, "• stray") { + t.Errorf("Expected stray
  • to be bulleted, got: %s", result) + } + if strings.Contains(result, "A1` + + `
    B
    2
    ` + + result := preProcessHTML(input) + + if got := strings.Count(result, ""); got != 2 { + t.Errorf("Expected both tables promoted (2 ), got %d: %s", got, result) + } +} + +func TestConvertHTMLToMarkdown_ListItemInlineFormatting(t *testing.T) { + // Inline formatting and links inside list items must survive into Markdown. + if err := CheckPandoc(); err != nil { + t.Skipf("Pandoc not installed, skipping test: %v", err) + } + + html := `` + + `
    H
    ` + + md, err := ConvertHTMLToMarkdown(html) + if err != nil { + t.Fatalf("ConvertHTMLToMarkdown failed: %v", err) + } + + if strings.Contains(md, "OnlyHeader` + + md, err := ConvertHTMLToMarkdown(html) + if err != nil { + t.Fatalf("ConvertHTMLToMarkdown failed: %v", err) + } + if strings.Contains(md, " Date: Thu, 2 Jul 2026 21:08:00 -0400 Subject: [PATCH 2/2] test: seed fuzzers for table list/header/sentinel paths (#6) Add FuzzPreProcessHTML seeds for lists inside table cells, header-row promotion, mixed th/td rows, malformed/unclosed lists, empty items, and sentinel-like text; add FuzzPostProcessMarkdown seeds exercising the intra-cell line-break sentinel. Active fuzzing (45s each) surfaced no crashers. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Aqueeb Qadri --- converter/fuzz_test.go | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/converter/fuzz_test.go b/converter/fuzz_test.go index 72c3020..94e9a86 100644 --- a/converter/fuzz_test.go +++ b/converter/fuzz_test.go @@ -61,6 +61,20 @@ func FuzzPreProcessHTML(f *testing.F) { `

    Paragraph in cell

    `, `
    Cell
    `, + // Lists inside table cells (issue #6) and header-row promotion + `
    H1H2
    intro:
    • a
    • b
    x
    `, + `
    1. one
    2. two
    `, + `
    `, + `top
    • a
      • nested
    • b
    `, + `
    Hd
    ab
    `, + `
    Only
    `, + `
    • unclosed list item`, + `
      • a
      • b`, + `
        • malformed
        x
        `, + strings.Repeat(`
      • `, 100), + `literal @@C2MDBR@@ sentinel`, + `
        `, + // Spans (various classes) `No link text`, `STATUS`, @@ -208,6 +222,12 @@ func FuzzPostProcessMarkdown(f *testing.F) { "Line 1
        Line 2", "Line 1
        Line 2", + // Intra-cell line-break sentinel (restored to
        by post-processing) + "| a" + cellLineBreakSentinel + "b |", + cellLineBreakSentinel, + "text " + cellLineBreakSentinel + " more", + strings.Repeat(cellLineBreakSentinel, 100), + // Multiple newlines "Line 1\n\n\n\n\nLine 2",