From b2749008a07998ce9cbb669e25379b4cdb1b62ad Mon Sep 17 00:00:00 2001 From: Will Kahn-Greene Date: Mon, 28 Sep 2026 08:07:26 -0400 Subject: [PATCH 1/2] fix(convert): render a block inside a list item as a block read/export sent every
  • child other than a

    or a nested list through renderInline. A code macro there rendered its fence onto the item's text line, where it is not a fence, and its unindented body ended the list. The editor writes exactly that shape for a code block added to a list item. A table, a quote, a callout or a

     in a list
    item was flattened into the item's text the same way.
    
    A block now renders indented under the item, in document order. A
    fence needs no blank line on either side, so an item with text and a
    code block stays tight and publishes back to the storage it came from.
    Every other block is set off by a blank line, which it needs.
    
    Fixes #211.
    ---
     internal/convert/storage_to_md.go             | 105 ++++++++++++++++--
     .../storage2md/list-blocks/input.storage      |  29 +++++
     .../testdata/storage2md/list-blocks/output.md |  48 ++++++++
     3 files changed, 170 insertions(+), 12 deletions(-)
     create mode 100644 internal/convert/testdata/storage2md/list-blocks/input.storage
     create mode 100644 internal/convert/testdata/storage2md/list-blocks/output.md
    
    diff --git a/internal/convert/storage_to_md.go b/internal/convert/storage_to_md.go
    index 1d56c5e..d28ab8c 100644
    --- a/internal/convert/storage_to_md.go
    +++ b/internal/convert/storage_to_md.go
    @@ -345,34 +345,115 @@ func (r *mdRenderer) renderList(n *snode, ordered bool, indent string) string {
     }
     
     // renderListItem renders an 
  • : its inline/paragraph content on the first line, -// with any nested lists indented beneath it. +// any block it holds (a code block, a table, a quote, a callout) indented under +// it in document order, and any nested lists indented beneath all of that. +// +// A block must not go through renderInline: a code macro there renders its +// fence onto the item's text line, where it is no fence at all, and its +// unindented body ends the list (#211). The editor writes exactly that shape -- +//
  • text -- for a code block added to a +// list item. +// +// Blocks are separated from their neighbours by a blank line, except that a +// fence needs none on either side: a fence may interrupt a paragraph and a +// closed fence ends itself, so the item stays tight and publishes back to the +// storage it came from rather than putting a

    around every item's text. +// Nothing else may go without the blank line: a quote swallows the text after +// it as a lazy continuation, "---" under text is a setext heading underline, +// and a raw HTML block runs until a blank line. func (r *mdRenderer) renderListItem(li *snode, cont string) string { - var head strings.Builder + type seg struct { + text string + block bool + } + var segs []seg + var line strings.Builder + flush := func() { + if s := strings.TrimSpace(line.String()); s != "" { + segs = append(segs, seg{text: s}) + } + line.Reset() + } var tail []string for _, k := range li.kids { - switch k.name { - case "ul": + switch { + case k.name == "ul": tail = append(tail, r.renderList(k, false, cont)) - case "ol": + case k.name == "ol": tail = append(tail, r.renderList(k, true, cont)) - case "p": + case k.name == "p": if s := r.renderInlineChildren(k); s != "" { - if head.Len() > 0 { - head.WriteString(" ") + if line.Len() > 0 { + line.WriteString(" ") } - head.WriteString(s) + line.WriteString(s) + } + case listItemBlock(k): + flush() + if s := r.renderBlock(k, cont); s != "" { + segs = append(segs, seg{text: s, block: true}) } default: - head.WriteString(r.renderInline(k)) + line.WriteString(r.renderInline(k)) + } + } + flush() + // tight reports whether two neighbours may go without a blank line between + // them. A nested list, which follows everything else, counts as text: it + // may interrupt a paragraph, which is how a tight item has always ended. + fence := func(s seg) bool { return s.block && strings.HasPrefix(s.text, "```") } + tight := func(a, b seg) bool { + return fence(a) || fence(b) || (!a.block && !b.block) + } + var b strings.Builder + for i, s := range segs { + if i > 0 { + b.WriteString("\n") + if !tight(segs[i-1], s) { + b.WriteString("\n") + } + b.WriteString(prefixLines(s.text, cont)) + continue + } + // The marker indents the first line; a block's other lines still need + // the continuation indent, or an item that opens with a code block + // leaves the list at the fence's second line. + first, rest, more := strings.Cut(s.text, "\n") + b.WriteString(first) + if more && s.block { + b.WriteString("\n" + prefixLines(rest, cont)) + } else if more { + b.WriteString("\n" + rest) } } - item := strings.TrimSpace(head.String()) + item := b.String() if len(tail) > 0 { - item += "\n" + strings.Join(tail, "\n") + item += "\n" + if len(segs) > 0 && !tight(segs[len(segs)-1], seg{}) { + item += "\n" + } + item += strings.Join(tail, "\n") } return item } +// listItemBlock reports whether an

  • child renders as a block of its own +// rather than as part of the item's text line. A macro qualifies only when it +// renders as a Markdown block (a code block or a callout): any other macro in a +// list item stays inline and raw, as a status lozenge must. +func listItemBlock(n *snode) bool { + switch n.name { + case "h1", "h2", "h3", "h4", "h5", "h6", "blockquote", "hr", "pre", "table": + return true + case "ac:structured-macro": + name := n.attrs["ac:name"] + return name == "code" || calloutMacroInverse[name] != "" + case "ac:adf-extension": + return adfPanelAlert(n) != "" + } + return false +} + // renderCellLines renders a table cell's content as a single physical line. // Confluence writes one

    per line when a cell holds more than one -- // hitting Enter inside a cell in the editor starts a new

    , not a
    -- and diff --git a/internal/convert/testdata/storage2md/list-blocks/input.storage b/internal/convert/testdata/storage2md/list-blocks/input.storage new file mode 100644 index 0000000..090203d --- /dev/null +++ b/internal/convert/testdata/storage2md/list-blocks/input.storage @@ -0,0 +1,29 @@ +

      +
    1. add a comment; something like +
    2. +
    3. set the status to “IN PROGRESS”
    4. +
    +

    The shapes a code block takes:

    +
      +
    • the editor's paragraph form

      bash
    • +
    • text beforetext after
    • +
    • +
    • a code block, then a nested list +
        +
      • nested
      • +
      +
    • +
    • a status stays inline DONE
    • +
    +

    Blocks other than a code block:

    +
      +
    • a callout

      note this

    • +
    • a quote, then a nested list

      quoted

      +
        +
      • nested
      • +
      +
    • +
    • a table

      ab
      12
    • +
    diff --git a/internal/convert/testdata/storage2md/list-blocks/output.md b/internal/convert/testdata/storage2md/list-blocks/output.md new file mode 100644 index 0000000..f087925 --- /dev/null +++ b/internal/convert/testdata/storage2md/list-blocks/output.md @@ -0,0 +1,48 @@ +1. add a comment; something like + ``` + Thanks for your request. + ``` +2. set the status to “IN PROGRESS” + +The shapes a code block takes: + +- the editor's paragraph form + ```bash + echo one + + echo two + ``` +- text before + ``` + middle + ``` + text after +- ``` + only a code block + ``` +- a code block, then a nested list + ``` + code + ``` + - nested + ``` + nested code + ``` +- a status stays inline DONE + +Blocks other than a code block: + +- a callout + + > [!NOTE] + > note this +- a quote, then a nested list + + > quoted + + - nested +- a table + + | a | b | + | --- | --- | + | 1 | 2 | From 4feae944c08898c0344742b4f62c44d9379b8709 Mon Sep 17 00:00:00 2001 From: Will Kahn-Greene Date: Mon, 28 Sep 2026 08:13:06 -0400 Subject: [PATCH 2/2] fix(convert): address the list-item-blocks review A fence was tight beside any block, but a raw HTML table runs until a blank line and swallowed a fence right after it. A fence is now tight only beside text, another fence or a nested list. Nested lists were still collected and written after everything else, so one before a code block moved below it. They now render in document order like every other child, and text after one is set off by a blank line, which it needs or it continues the list's last item. An
    is no longer a block: "- ---" is a thematic break rather than an item, and the editor offers no divider in a list. Loose inline children render as one run through renderInlineChildren, so text after a
    no longer gains a space on every round trip, and a mark split around a link is repaired as it is in a

    . Every segment, the first included, is indented the same way, and a fence is decided by the node rather than by the rendered string. --- internal/convert/storage_to_md.go | 162 +++++++++++------- .../storage2md/list-blocks/input.storage | 10 ++ .../testdata/storage2md/list-blocks/output.md | 36 ++++ 3 files changed, 147 insertions(+), 61 deletions(-) diff --git a/internal/convert/storage_to_md.go b/internal/convert/storage_to_md.go index d28ab8c..10403f4 100644 --- a/internal/convert/storage_to_md.go +++ b/internal/convert/storage_to_md.go @@ -344,9 +344,8 @@ func (r *mdRenderer) renderList(n *snode, ordered bool, indent string) string { return strings.Join(lines, "\n") } -// renderListItem renders an

  • : its inline/paragraph content on the first line, -// any block it holds (a code block, a table, a quote, a callout) indented under -// it in document order, and any nested lists indented beneath all of that. +// renderListItem renders an
  • : its text on the marker's line, then every +// block and nested list it holds, in document order, indented under it. // // A block must not go through renderInline: a code macro there renders its // fence onto the item's text line, where it is no fence at all, and its @@ -354,96 +353,137 @@ func (r *mdRenderer) renderList(n *snode, ordered bool, indent string) string { //
  • text -- for a code block added to a // list item. // -// Blocks are separated from their neighbours by a blank line, except that a -// fence needs none on either side: a fence may interrupt a paragraph and a -// closed fence ends itself, so the item stays tight and publishes back to the -// storage it came from rather than putting a

    around every item's text. -// Nothing else may go without the blank line: a quote swallows the text after -// it as a lazy continuation, "---" under text is a setext heading underline, -// and a raw HTML block runs until a blank line. +// Neighbours are separated by a blank line unless tight says otherwise; see +// there for which pairs may go without one. func (r *mdRenderer) renderListItem(li *snode, cont string) string { - type seg struct { - text string - block bool - } - var segs []seg + var segs []itemSeg var line strings.Builder - flush := func() { + var run []*snode + // addLine appends rendered text to the item's current text line. A

    + // boundary becomes a space, as it always has. + addLine := func(s string) { + if s == "" { + return + } + if line.Len() > 0 { + line.WriteString(" ") + } + line.WriteString(s) + } + // flushRun renders loose inline children as one run, so they get what a + //

    's children get: the whitespace trim after a hard break, and repair + // of a mark the editor split around a link. + flushRun := func() { + if len(run) > 0 { + addLine(r.renderInlineChildren(&snode{kids: run})) + run = nil + } + } + flushLine := func() { + flushRun() if s := strings.TrimSpace(line.String()); s != "" { - segs = append(segs, seg{text: s}) + segs = append(segs, itemSeg{text: s, kind: segText}) } line.Reset() } - var tail []string for _, k := range li.kids { switch { - case k.name == "ul": - tail = append(tail, r.renderList(k, false, cont)) - case k.name == "ol": - tail = append(tail, r.renderList(k, true, cont)) + case k.name == "ul", k.name == "ol": + flushLine() + // renderList indents its own lines, at cont. + segs = append(segs, itemSeg{text: r.renderList(k, k.name == "ol", cont), kind: segList}) case k.name == "p": - if s := r.renderInlineChildren(k); s != "" { - if line.Len() > 0 { - line.WriteString(" ") - } - line.WriteString(s) - } + flushRun() + addLine(r.renderInlineChildren(k)) case listItemBlock(k): - flush() + flushLine() if s := r.renderBlock(k, cont); s != "" { - segs = append(segs, seg{text: s, block: true}) + kind := segBlock + if isFenceNode(k) { + kind = segFence + } + segs = append(segs, itemSeg{text: s, kind: kind}) } default: - line.WriteString(r.renderInline(k)) + run = append(run, k) } } - flush() - // tight reports whether two neighbours may go without a blank line between - // them. A nested list, which follows everything else, counts as text: it - // may interrupt a paragraph, which is how a tight item has always ended. - fence := func(s seg) bool { return s.block && strings.HasPrefix(s.text, "```") } - tight := func(a, b seg) bool { - return fence(a) || fence(b) || (!a.block && !b.block) - } + flushLine() var b strings.Builder for i, s := range segs { if i > 0 { b.WriteString("\n") - if !tight(segs[i-1], s) { + if !tight(segs[i-1].kind, s.kind) { b.WriteString("\n") } - b.WriteString(prefixLines(s.text, cont)) - continue } - // The marker indents the first line; a block's other lines still need - // the continuation indent, or an item that opens with a code block - // leaves the list at the fence's second line. - first, rest, more := strings.Cut(s.text, "\n") - b.WriteString(first) - if more && s.block { - b.WriteString("\n" + prefixLines(rest, cont)) - } else if more { - b.WriteString("\n" + rest) + if s.kind == segList { + b.WriteString(s.text) + } else { + b.WriteString(prefixLines(s.text, cont)) } } - item := b.String() - if len(tail) > 0 { - item += "\n" - if len(segs) > 0 && !tight(segs[len(segs)-1], seg{}) { - item += "\n" - } - item += strings.Join(tail, "\n") + // The marker indents the first line, so it drops the continuation indent. + // An item that opens with a nested list starts it on the next line. + if len(segs) > 0 && segs[0].kind == segList { + return "\n" + b.String() + } + return strings.TrimPrefix(b.String(), cont) +} + +// itemSeg is one piece of a rendered list item: a text line, a block, or a +// nested list. +type itemSeg struct { + text string + kind segKind +} + +type segKind int + +const ( + segText segKind = iota + segFence + segBlock + segList +) + +// tight reports whether two neighbours in a list item may go without a blank +// line between them. Only pairs that keep the item tight qualify, since a +// blank line makes the whole list loose and every item's text publishes inside +// a

    : a fence may interrupt a paragraph, and a closed fence ends itself, so +// a fence is tight beside text, another fence or a nested list; a nested list +// may interrupt a paragraph, which is how a tight item has always ended. No +// other pair may go without the blank line: a quote swallows the text after it +// as a lazy continuation, "---" under text is a setext heading underline, a raw +// HTML block runs until a blank line (and so swallows even a fence), and text +// after a nested list continues the list's last item. +func tight(a, b segKind) bool { + switch { + case a == segFence || b == segFence: + return a != segBlock && b != segBlock + case a == segText: + return b == segList + case a == segList: + return b == segList } - return item + return false +} + +// isFenceNode reports whether a list item's block child renders as a fenced +// code block. +func isFenceNode(n *snode) bool { + return n.name == "pre" || (n.name == "ac:structured-macro" && n.attrs["ac:name"] == "code") } // listItemBlock reports whether an

  • child renders as a block of its own // rather than as part of the item's text line. A macro qualifies only when it // renders as a Markdown block (a code block or a callout): any other macro in a -// list item stays inline and raw, as a status lozenge must. +// list item stays inline and raw, as a status lozenge must. An
    is left out +// because Markdown cannot open an item with one ("- ---" is a thematic break, +// not an item), and the editor offers no divider inside a list. func listItemBlock(n *snode) bool { switch n.name { - case "h1", "h2", "h3", "h4", "h5", "h6", "blockquote", "hr", "pre", "table": + case "h1", "h2", "h3", "h4", "h5", "h6", "blockquote", "pre", "table": return true case "ac:structured-macro": name := n.attrs["ac:name"] diff --git a/internal/convert/testdata/storage2md/list-blocks/input.storage b/internal/convert/testdata/storage2md/list-blocks/input.storage index 090203d..b4b27cc 100644 --- a/internal/convert/testdata/storage2md/list-blocks/input.storage +++ b/internal/convert/testdata/storage2md/list-blocks/input.storage @@ -27,3 +27,13 @@ echo two]]>
  • a table

    ab
    12
  • +

    Order and separation:

    + diff --git a/internal/convert/testdata/storage2md/list-blocks/output.md b/internal/convert/testdata/storage2md/list-blocks/output.md index f087925..df5eea1 100644 --- a/internal/convert/testdata/storage2md/list-blocks/output.md +++ b/internal/convert/testdata/storage2md/list-blocks/output.md @@ -46,3 +46,39 @@ Blocks other than a code block: | a | b | | --- | --- | | 1 | 2 | + +Order and separation: + +- a nested list before a code block + - n + ``` + after the list + ``` +- a nested list before text + - n + + text after the list +- a table Markdown cannot express, then a code block + + + + + + + +
    + + x + +
    + + ``` + after the table + ``` +- ``` + first + ``` + a + b +- a + b