diff --git a/internal/convert/storage_to_md.go b/internal/convert/storage_to_md.go index 1d56c5e..10403f4 100644 --- a/internal/convert/storage_to_md.go +++ b/internal/convert/storage_to_md.go @@ -344,33 +344,154 @@ 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, -// with any nested lists indented beneath it. +// 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 +// unindented body ends the list (#211). The editor writes exactly that shape -- +//
  • text -- for a code block added to a +// list item. +// +// 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 { - var head strings.Builder - var tail []string + var segs []itemSeg + var line strings.Builder + 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, itemSeg{text: s, kind: segText}) + } + line.Reset() + } for _, k := range li.kids { - switch k.name { - case "ul": - tail = append(tail, r.renderList(k, false, cont)) - case "ol": - tail = append(tail, r.renderList(k, true, cont)) - case "p": - if s := r.renderInlineChildren(k); s != "" { - if head.Len() > 0 { - head.WriteString(" ") + switch { + 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": + flushRun() + addLine(r.renderInlineChildren(k)) + case listItemBlock(k): + flushLine() + if s := r.renderBlock(k, cont); s != "" { + kind := segBlock + if isFenceNode(k) { + kind = segFence } - head.WriteString(s) + segs = append(segs, itemSeg{text: s, kind: kind}) } default: - head.WriteString(r.renderInline(k)) + run = append(run, k) + } + } + flushLine() + var b strings.Builder + for i, s := range segs { + if i > 0 { + b.WriteString("\n") + if !tight(segs[i-1].kind, s.kind) { + b.WriteString("\n") + } + } + if s.kind == segList { + b.WriteString(s.text) + } else { + b.WriteString(prefixLines(s.text, cont)) } } - item := strings.TrimSpace(head.String()) - if len(tail) > 0 { - item += "\n" + 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 item + 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 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. 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", "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. 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..b4b27cc --- /dev/null +++ b/internal/convert/testdata/storage2md/list-blocks/input.storage @@ -0,0 +1,39 @@ +
      +
    1. add a comment; something like +
    2. +
    3. set the status to “IN PROGRESS”
    4. +
    +

    The shapes a code block takes:

    + +

    Blocks other than a code block:

    + +

    Order and separation:

    + 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..df5eea1 --- /dev/null +++ b/internal/convert/testdata/storage2md/list-blocks/output.md @@ -0,0 +1,84 @@ +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 | + +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