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
6 changes: 3 additions & 3 deletions docs/plan.md

Large diffs are not rendered by default.

34 changes: 22 additions & 12 deletions internal/adf/adf.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,27 +29,27 @@ type Mark struct {
// word in its place. A node of an unknown type shows its content, or the text that stands in for
// it.
func Markdown(node Node) string {
return blocks([]Node{node})
return blocks([]Node{node}, "\n\n")
}

// blocks renders nodes one after the other, with a blank line between them. A run of inline
// nodes makes one block, and an empty block makes none.
func blocks(nodes []Node) string {
// blocks renders nodes one after the other, with sep between them. A run of inline nodes makes
// one block, and an empty block makes none.
func blocks(nodes []Node, sep string) string {
parts := make([]string, 0, len(nodes))
for len(nodes) > 0 {
var part string
if run := inlineRun(nodes); run > 0 {
part, nodes = inline(nodes[:run]), nodes[run:]
parts, nodes = append(parts, inline(nodes[:run])), nodes[run:]
} else {
part, nodes = block(nodes[0]), nodes[1:]
}

if part != "" {
parts = append(parts, part)
parts, nodes = append(parts, block(nodes[0])), nodes[1:]
}
}

return strings.Join(parts, "\n\n")
return join(sep, parts...)
}

// join joins the parts that aren't empty, with sep between them.
func join(sep string, parts ...string) string {
return strings.Join(slices.DeleteFunc(parts, func(part string) bool { return part == "" }), sep)
}

// inlineRun returns how many of the nodes, from the first on, are inline nodes.
Expand Down Expand Up @@ -82,3 +82,13 @@ func attr(attrs map[string]any, key string) string {

return text
}

// number returns the number in the attribute, or fallback if there is none.
func number(attrs map[string]any, key string, fallback int) int {
value, ok := attrs[key].(float64)
if !ok {
return fallback
}

return int(value)
}
66 changes: 64 additions & 2 deletions internal/adf/block.go
Original file line number Diff line number Diff line change
@@ -1,15 +1,77 @@
package adf

import "cmp"
import (
"cmp"
"strings"
)

// deepestHeading is the deepest level that a heading can have, in ADF and in Markdown.
const deepestHeading = 6

// block renders a block node.
func block(node Node) string {
switch node.Type {
case "paragraph":
return inline(node.Content)
case "heading":
return heading(node)
case "bulletList", "orderedList", "taskList", "decisionList":
return list(node)
case "codeBlock":
return codeBlock(node)
case "blockquote", "panel":
return prefix(blocks(node.Content, "\n\n"), "> ", "> ")
case "rule":
return "---"
case "table":
return table(node)
case "media":
return attachment(node)
case "expand", "nestedExpand":
return expand(node)
default:
return cmp.Or(blocks(node.Content), standIn(node))
return cmp.Or(blocks(node.Content, "\n\n"), standIn(node))
}
}

// prefix puts first before the first line of the text and rest before each later line, without
// the spaces at their end on an empty line.
func prefix(text, first, rest string) string {
lines := strings.Split(text, "\n")
lead := first

for index, line := range lines {
if line == "" {
lead = strings.TrimRight(lead, " ")
}

lines[index], lead = lead+line, rest
}

return strings.Join(lines, "\n")
}

// heading renders a heading at its level.
func heading(node Node) string {
level := min(max(number(node.Attrs, "level", 1), 1), deepestHeading)

return strings.Repeat("#", level) + " " + inline(node.Content)
}

// codeBlock renders a code block in a fence that its code can't end.
func codeBlock(node Node) string {
code := inline(node.Content)
ticks := fence(code, len("```"))

return ticks + attr(node.Attrs, "language") + "\n" + code + "\n" + ticks
}

// expand renders an expand, a section that Jira shows folded, under its title in bold.
func expand(node Node) string {
title := attr(node.Attrs, "title")
if title != "" {
title = "**" + title + "**"
}

return join("\n\n", title, blocks(node.Content, "\n\n"))
}
10 changes: 5 additions & 5 deletions internal/adf/inline.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,15 +73,15 @@ func code(text string) string {
pad = " "
}

ticks := fence(text)
ticks := fence(text, 1)

return ticks + pad + text + pad + ticks
}

// fence returns the shortest run of backticks that the code doesn't hold, so that the code can't
// end its own fence.
func fence(code string) string {
ticks := "`"
// fence returns the shortest run of backticks, at least least long, that the code doesn't hold,
// so that the code can't end its own fence.
func fence(code string, least int) string {
ticks := strings.Repeat("`", least)
for strings.Contains(code, ticks) {
ticks += "`"
}
Expand Down
39 changes: 39 additions & 0 deletions internal/adf/list.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package adf

import (
"strconv"
"strings"
)

// list renders a list: each item after its marker, with the item's later lines indented under
// its first.
func list(node Node) string {
start := number(node.Attrs, "order", 1)
items := make([]string, 0, len(node.Content))

for i, item := range node.Content {
marker, body := bullet(node.Type, item, start+i), blocks(item.Content, "\n")
if item.Type == node.Type { // a task list holds a nested list as it is, not in an item
marker, body = " ", block(item)
}

items = append(items, prefix(body, marker, strings.Repeat(" ", len(marker))))
}

return strings.Join(items, "\n")
}

// bullet returns the marker of the list's item: its number in an ordered list, and its box in a
// task list.
func bullet(kind string, item Node, position int) string {
switch {
case kind == "orderedList":
return strconv.Itoa(position) + ". "
case attr(item.Attrs, "state") == "DONE":
return "- [x] "
case kind == "taskList":
return "- [ ] "
default:
return "- "
}
}
38 changes: 38 additions & 0 deletions internal/adf/table.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package adf

import "strings"

// table renders a table as a Markdown table, every row as wide as the widest. Its first row is
// the header, since a Markdown table needs one.
func table(node Node) string {
rows := make([][]string, 0, len(node.Content))
width := 0

for _, row := range node.Content {
cells := make([]string, 0, len(row.Content))
for _, cell := range row.Content {
cells = append(cells, cellText(cell))
}

rows = append(rows, cells)
width = max(width, len(cells))
}

lines := make([]string, 0, len(rows)+1)
for i, cells := range rows {
cells = append(cells, make([]string, width-len(cells))...)

lines = append(lines, "| "+strings.Join(cells, " | ")+" |")
if i == 0 {
lines = append(lines, "|"+strings.Repeat(" --- |", width))
}
}

return strings.Join(lines, "\n")
}

// cellText renders a cell's blocks on one line, as a Markdown table's cell holds no more: a line
// break becomes <br>, and a | is escaped.
func cellText(cell Node) string {
return strings.NewReplacer("|", `\|`, "\n", "<br>").Replace(blocks(cell.Content, "\n"))
}
21 changes: 20 additions & 1 deletion internal/adf/testdata/blocks.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
"version": 1,
"type": "doc",
"content": [
{"type": "paragraph", "content": [{"type": "text", "text": "A paragraph."}]},
{"type": "paragraph", "content": [{"type": "text", "text": "Above the rule."}]},
{"type": "rule"},
{
"type": "mediaSingle",
"attrs": {"layout": "center"},
Expand All @@ -29,6 +30,24 @@
{"type": "media", "attrs": {"type": "file", "id": "2", "collection": "c"}}
]
},
{
"type": "expand",
"attrs": {"title": "Details"},
"content": [
{"type": "paragraph", "content": [{"type": "text", "text": "Folded at first."}]},
{
"type": "nestedExpand",
"attrs": {"title": "More"},
"content": [{"type": "paragraph", "content": [{"type": "text", "text": "Inside."}]}]
}
]
},
{
"type": "expand",
"attrs": {"title": ""},
"content": [{"type": "paragraph", "content": [{"type": "text", "text": "No title."}]}]
},
{"type": "expand", "attrs": {"title": "Empty"}, "content": []},
{"type": "blockCard", "attrs": {"url": "https://example.com/DEMO-3"}},
{"type": "extension", "attrs": {"extensionType": "demo", "extensionKey": "k", "text": "A macro's text"}},
{
Expand Down
16 changes: 15 additions & 1 deletion internal/adf/testdata/blocks.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
A paragraph.
Above the rule.

---

[attachment: screen.png]

Expand All @@ -10,6 +12,18 @@ The *login* screen

[attachment]

**Details**

Folded at first.

**More**

Inside.

No title.

**Empty**

https://example.com/DEMO-3

A macro's text
Expand Down
36 changes: 36 additions & 0 deletions internal/adf/testdata/code.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
{
"version": 1,
"type": "doc",
"content": [
{
"type": "codeBlock",
"attrs": {"language": "go"},
"content": [{"type": "text", "text": "func main() {\n fmt.Println(\"hi\")\n}"}]
},
{"type": "codeBlock", "content": [{"type": "text", "text": "Fence code with ``` in Markdown."}]},
{
"type": "bulletList",
"content": [
{
"type": "listItem",
"content": [
{"type": "paragraph", "content": [{"type": "text", "text": "Run:"}]},
{"type": "codeBlock", "attrs": {"language": "sh"}, "content": [{"type": "text", "text": "make\n\nmake test"}]}
]
}
]
},
{
"type": "blockquote",
"content": [
{"type": "paragraph", "content": [{"type": "text", "text": "Quoted."}]},
{"type": "paragraph", "content": [{"type": "text", "text": "Twice."}]}
]
},
{
"type": "panel",
"attrs": {"panelType": "warning"},
"content": [{"type": "paragraph", "content": [{"type": "text", "text": "Mind the gap."}]}]
}
]
}
22 changes: 22 additions & 0 deletions internal/adf/testdata/code.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
```go
func main() {
fmt.Println("hi")
}
```

````
Fence code with ``` in Markdown.
````

- Run:
```sh
make

make test
```

> Quoted.
>
> Twice.

> Mind the gap.
15 changes: 15 additions & 0 deletions internal/adf/testdata/headings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"version": 1,
"type": "doc",
"content": [
{"type": "heading", "attrs": {"level": 1}, "content": [{"type": "text", "text": "Goal"}]},
{
"type": "heading",
"attrs": {"level": 3},
"content": [{"type": "text", "text": "Out of "}, {"type": "text", "text": "scope", "marks": [{"type": "em"}]}]
},
{"type": "heading", "attrs": {"level": 9}, "content": [{"type": "text", "text": "Too deep"}]},
{"type": "heading", "attrs": {"level": 0}, "content": [{"type": "text", "text": "Too high"}]},
{"type": "heading", "content": [{"type": "text", "text": "No level"}]}
]
}
9 changes: 9 additions & 0 deletions internal/adf/testdata/headings.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Goal

### Out of *scope*

###### Too deep

# Too high

# No level
4 changes: 2 additions & 2 deletions internal/adf/testdata/inline.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
Ask @Demo User 😄:demo: by 2025-12-31 or soon,
IN PROGRESS, https://example.com/DEMO-2, [attachment: log.txt], a placeholder and an extension
- [ ] Ask @Demo User 😄:demo: by 2025-12-31 or soon,
IN PROGRESS, https://example.com/DEMO-2, [attachment: log.txt], a placeholder and an extension
Loading
Loading