Skip to content
Merged
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
20 changes: 20 additions & 0 deletions converter/fuzz_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,20 @@ func FuzzPreProcessHTML(f *testing.F) {
`<td><p>Paragraph in cell</p></td>`,
`<div class="table-wrap"><table><tr><td>Cell</td></tr></table></div>`,

// Lists inside table cells (issue #6) and header-row promotion
`<table><tbody><tr><th>H1</th><th>H2</th></tr><tr><td>intro:<ul><li>a</li><li>b</li></ul></td><td>x</td></tr></tbody></table>`,
`<table><tbody><tr><td><ol><li>one</li><li>two</li></ol></td></tr></tbody></table>`,
`<td><ul><li></li><li></li><li> </li></ul></td>`,
`<td>top<ul><li>a<ul><li>nested</li></ul></li><li>b</li></ul></td>`,
`<table><tbody><tr><th>H</th><td>d</td></tr><tr><td>a</td><td>b</td></tr></tbody></table>`,
`<table><tbody><tr><th>Only</th></tr></tbody></table>`,
`<ul><li>unclosed list item`,
`<ul><li>a</li><li>b`,
`<table><tr><th>x</th><ul><li>malformed</li></ul></tr></table>`,
strings.Repeat(`<li></li>`, 100),
`<td>literal @@C2MDBR@@ sentinel</td>`,
`<table><tbody><tr><td><ul><li><strong>b</strong></li><li><a href="u">l</a></li></ul></td></tr></tbody></table>`,

// Spans (various classes)
`<span class="nolink">No link text</span>`,
`<span class="status-macro aui-lozenge">STATUS</span>`,
Expand Down Expand Up @@ -208,6 +222,12 @@ func FuzzPostProcessMarkdown(f *testing.F) {
"Line 1<br/>Line 2",
"Line 1<br />Line 2",

// Intra-cell line-break sentinel (restored to <br> by post-processing)
"| a" + cellLineBreakSentinel + "b |",
cellLineBreakSentinel,
"text " + cellLineBreakSentinel + " more",
strings.Repeat(cellLineBreakSentinel, 100),

// Multiple newlines
"Line 1\n\n\n\n\nLine 2",

Expand Down
31 changes: 24 additions & 7 deletions converter/markdown.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 &lt;p&gt; instead of <p>
html = decodeHTMLEntities(html)
Expand Down Expand Up @@ -375,28 +379,41 @@ 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, "")
})

// Defensive: bullet any <li> 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, "")
Expand All @@ -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, "<th>") {
return table // first row is not a header row; leave as-is
if firstRow == "" || !strings.Contains(firstRow, "<th>") || strings.Contains(firstRow, "<td>") {
return table // promote only a genuine, all-<th> header row
}
// Lift the first row out of the body and into a <thead> after <table>.
rest := strings.Replace(table, firstRow, "", 1)
Expand Down
127 changes: 127 additions & 0 deletions converter/markdown_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <li> items must not produce stray empty bullets.
input := `<table><tbody><tr><td><ul><li></li><li>x</li><li> </li></ul></td></tr></tbody></table>`

result := preProcessHTML(input)

if !strings.Contains(result, "<td>• x</td>") {
t.Errorf("Expected a single non-empty bullet (<td>• x</td>), 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 := `<p>literal ` + cellLineBreakSentinel + ` text</p>`

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 <th> and <td> is not a genuine header row and must not
// be lifted into <thead>.
input := `<table><tbody><tr><th>H</th><td>d</td></tr><tr><td>a</td><td>b</td></tr></tbody></table>`

result := preProcessHTML(input)

if strings.Contains(result, "<thead>") {
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 := `<table><tbody><tr><td><ol><li>a</li><li>b</li></ol><ol><li>c</li><li>d</li></ol></td></tr></tbody></table>`

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 <li> in an ordered list (skipped, numbering not consumed),
// and stray <li> elements outside any recognized list (bulleted / dropped).
input := `<table><tbody><tr><td><ol><li></li><li>keep</li></ol><li>stray</li><li></li></td></tr></tbody></table>`

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 <li> to be bulleted, got: %s", result)
}
if strings.Contains(result, "<li") || strings.Contains(result, "<ol") {
t.Errorf("Expected no list tags to survive, got: %s", result)
}
}

func TestPreProcessHTML_MultipleTablesHeaderPromotion(t *testing.T) {
// Every table in the document with a pure header row should be promoted.
input := `<table><tbody><tr><th>A</th></tr><tr><td>1</td></tr></tbody></table>` +
`<table><tbody><tr><th>B</th></tr><tr><td>2</td></tr></tbody></table>`

result := preProcessHTML(input)

if got := strings.Count(result, "<thead>"); got != 2 {
t.Errorf("Expected both tables promoted (2 <thead>), 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 := `<table><tbody><tr><th>H</th></tr>` +
`<tr><td><ul><li><strong>bold</strong></li><li><a href="https://ex.com">link</a></li></ul></td></tr></tbody></table>`

md, err := ConvertHTMLToMarkdown(html)
if err != nil {
t.Fatalf("ConvertHTMLToMarkdown failed: %v", err)
}

if strings.Contains(md, "<table") || strings.Contains(md, "<ul") {
t.Errorf("Expected a Markdown table (no raw HTML), got: %s", md)
}
for _, want := range []string{"• **bold**", "• [link](https://ex.com)"} {
if !strings.Contains(md, want) {
t.Errorf("Expected %q, got: %s", want, md)
}
}
}

func TestConvertHTMLToMarkdown_HeaderOnlyTable(t *testing.T) {
// A table whose only row is a header must convert without crashing or falling
// back to raw HTML.
if err := CheckPandoc(); err != nil {
t.Skipf("Pandoc not installed, skipping test: %v", err)
}

html := `<table><tbody><tr><th>Only</th><th>Header</th></tr></tbody></table>`

md, err := ConvertHTMLToMarkdown(html)
if err != nil {
t.Fatalf("ConvertHTMLToMarkdown failed: %v", err)
}
if strings.Contains(md, "<table") {
t.Errorf("Expected a Markdown table (no raw HTML), got: %s", md)
}
if !strings.Contains(md, "Only") || !strings.Contains(md, "Header") {
t.Errorf("Expected header content preserved, got: %s", md)
}
}

func TestPostProcessMarkdown_CellLineBreakSentinel(t *testing.T) {
input := "| left side:" + cellLineBreakSentinel + "• foo" + cellLineBreakSentinel + "• bar | right side |"

Expand Down
Loading