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
159 changes: 140 additions & 19 deletions internal/convert/storage_to_md.go
Original file line number Diff line number Diff line change
Expand Up @@ -344,33 +344,154 @@ func (r *mdRenderer) renderList(n *snode, ordered bool, indent string) string {
return strings.Join(lines, "\n")
}

// renderListItem renders an <li>: its inline/paragraph content on the first line,
// with any nested lists indented beneath it.
// renderListItem renders an <li>: 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 --
// <li>text<ac:structured-macro ac:name="code"> -- 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 <p>
// 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
// <p>'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 <p>: 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 <li> 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 <hr> 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.
Expand Down
39 changes: 39 additions & 0 deletions internal/convert/testdata/storage2md/list-blocks/input.storage
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<ol>
<li>add a comment; something like
<ac:structured-macro ac:name="code" ac:schema-version="1" ac:macro-id="654369b8-b2d9-4ec2-9b76-b73f5d1d43ba"><ac:plain-text-body><![CDATA[Thanks for your request.]]></ac:plain-text-body></ac:structured-macro></li>
<li>set the status to &ldquo;IN PROGRESS&rdquo;</li>
</ol>
<p>The shapes a code block takes:</p>
<ul>
<li><p>the editor's paragraph form</p><ac:structured-macro ac:name="code" ac:schema-version="1"><ac:parameter ac:name="language">bash</ac:parameter><ac:plain-text-body><![CDATA[echo one

echo two]]></ac:plain-text-body></ac:structured-macro></li>
<li>text before<ac:structured-macro ac:name="code" ac:schema-version="1"><ac:plain-text-body><![CDATA[middle]]></ac:plain-text-body></ac:structured-macro>text after</li>
<li><ac:structured-macro ac:name="code" ac:schema-version="1"><ac:plain-text-body><![CDATA[only a code block]]></ac:plain-text-body></ac:structured-macro></li>
<li>a code block, then a nested list<ac:structured-macro ac:name="code" ac:schema-version="1"><ac:plain-text-body><![CDATA[code]]></ac:plain-text-body></ac:structured-macro>
<ul>
<li>nested<ac:structured-macro ac:name="code" ac:schema-version="1"><ac:plain-text-body><![CDATA[nested code]]></ac:plain-text-body></ac:structured-macro></li>
</ul>
</li>
<li>a status stays inline <ac:structured-macro ac:name="status" ac:schema-version="1"><ac:parameter ac:name="title">DONE</ac:parameter></ac:structured-macro></li>
</ul>
<p>Blocks other than a code block:</p>
<ul>
<li><p>a callout</p><ac:structured-macro ac:name="info" ac:schema-version="1"><ac:rich-text-body><p>note this</p></ac:rich-text-body></ac:structured-macro></li>
<li><p>a quote, then a nested list</p><blockquote><p>quoted</p></blockquote>
<ul>
<li>nested</li>
</ul>
</li>
<li><p>a table</p><table data-layout="align-start"><thead><tr><th>a</th><th>b</th></tr></thead><tbody><tr><td>1</td><td>2</td></tr></tbody></table></li>
</ul>
<p>Order and separation:</p>
<ul>
<li>a nested list before a code block<ul><li>n</li></ul><ac:structured-macro ac:name="code" ac:schema-version="1"><ac:plain-text-body><![CDATA[after the list]]></ac:plain-text-body></ac:structured-macro></li>
<li>a nested list before text<ul><li>n</li></ul>text after the list</li>
<li>a table Markdown cannot express, then a code block<table><tbody><tr><td>x</td></tr></tbody></table><ac:structured-macro ac:name="code" ac:schema-version="1"><ac:plain-text-body><![CDATA[after the table]]></ac:plain-text-body></ac:structured-macro></li>
<li><ac:structured-macro ac:name="code" ac:schema-version="1"><ac:plain-text-body><![CDATA[first]]></ac:plain-text-body></ac:structured-macro>a<br />
b</li>
<li>a<br />
b</li>
</ul>
84 changes: 84 additions & 0 deletions internal/convert/testdata/storage2md/list-blocks/output.md
Original file line number Diff line number Diff line change
@@ -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 <ac:structured-macro ac:name="status" ac:schema-version="1"><ac:parameter ac:name="title">DONE</ac:parameter></ac:structured-macro>

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

<table>
<tbody>
<tr>
<td>

x

</td>
</tr>
</tbody>
</table>

```
after the table
```
- ```
first
```
a
b
- a
b
Loading