package confluence
import (
"bytes"
"fmt"
"html"
"strings"
"github.com/yuin/goldmark/ast"
east "github.com/yuin/goldmark/extension/ast"
"github.com/yuin/goldmark/renderer"
"github.com/yuin/goldmark/util"
)
// Renderer renders goldmark AST nodes into Confluence storage format XML.
type Renderer struct {
taskIDCounter int
inTaskBody bool
inlineCommentDepth int
pageLinkDepth int
pendingTableAttrs string // stored from <!-- table-attrs: ... --> comment
pendingCodeMacroID string // stored from <!-- ac:code macro-id="..." --> comment
pendingCodeAttrOrder string // stored from <!-- ac:code ... attr-order="..." --> comment
pendingPanelName string // "info", "note", or "warning" — from <!-- ac:info ... --> comment
pendingPanelMacroID string // macro-id for the next blockquote-rendered panel
pendingPanelParams string // raw "param-key1=v1 param-key2=v2 ..." for the next panel
pendingPanelBodyBare bool // body-bare flag — emit body without <p> wrapper
inBareBody bool // currently inside a body-bare blockquote, suppress <p>
}
// NewRenderer creates a new Confluence storage format renderer.
func NewRenderer() renderer.NodeRenderer {
return &Renderer{}
}
func (r *Renderer) nextTaskID() int {
r.taskIDCounter++
return r.taskIDCounter
}
// RegisterFuncs implements renderer.NodeRenderer.
func (r *Renderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
// Block nodes
reg.Register(ast.KindDocument, r.renderDocument)
reg.Register(ast.KindHeading, r.renderHeading)
reg.Register(ast.KindParagraph, r.renderParagraph)
reg.Register(ast.KindTextBlock, r.renderTextBlock)
reg.Register(ast.KindFencedCodeBlock, r.renderFencedCodeBlock)
reg.Register(ast.KindCodeBlock, r.renderCodeBlock)
reg.Register(ast.KindThematicBreak, r.renderThematicBreak)
reg.Register(ast.KindBlockquote, r.renderBlockquote)
reg.Register(ast.KindList, r.renderList)
reg.Register(ast.KindListItem, r.renderListItem)
reg.Register(ast.KindHTMLBlock, r.renderHTMLBlock)
// Inline nodes
reg.Register(ast.KindText, r.renderText)
reg.Register(ast.KindString, r.renderString)
reg.Register(ast.KindEmphasis, r.renderEmphasis)
reg.Register(ast.KindCodeSpan, r.renderCodeSpan)
reg.Register(ast.KindLink, r.renderLink)
reg.Register(ast.KindAutoLink, r.renderAutoLink)
reg.Register(ast.KindImage, r.renderImage)
reg.Register(ast.KindRawHTML, r.renderRawHTML)
// GFM extensions
reg.Register(east.KindTable, r.renderTable)
reg.Register(east.KindTableHeader, r.renderTableHeader)
// Note: goldmark GFM has no KindTableBody
reg.Register(east.KindTableRow, r.renderTableRow)
reg.Register(east.KindTableCell, r.renderTableCell)
reg.Register(east.KindStrikethrough, r.renderStrikethrough)
reg.Register(east.KindTaskCheckBox, r.renderTaskCheckBox)
}
func (r *Renderer) renderDocument(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
return ast.WalkContinue, nil
}
func (r *Renderer) renderHeading(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
n := node.(*ast.Heading)
tag := fmt.Sprintf("h%d", n.Level)
if entering {
fmt.Fprintf(w, "<%s>", tag)
} else {
fmt.Fprintf(w, "</%s>\n", tag)
}
return ast.WalkContinue, nil
}
func (r *Renderer) renderParagraph(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
// Inside task items, don't wrap in <p> - the task-body handles it
if r.inTaskBody {
if !entering {
w.WriteString("</ac:task-body>\n")
r.inTaskBody = false
}
return ast.WalkContinue, nil
}
// Inside a body-bare panel (info/note/warning), the original had no <p>
// wrapping inside <ac:rich-text-body>. Skip the <p> here so round-trip
// matches.
if r.inBareBody {
return ast.WalkContinue, nil
}
if entering {
w.WriteString("<p>")
} else {
w.WriteString("</p>\n")
}
return ast.WalkContinue, nil
}
func (r *Renderer) renderTextBlock(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
if r.inTaskBody {
w.WriteString("</ac:task-body>\n")
r.inTaskBody = false
} else if _, ok := node.Parent().(*ast.ListItem); !ok {
w.WriteString("\n")
}
}
return ast.WalkContinue, nil
}
func (r *Renderer) renderFencedCodeBlock(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
n := node.(*ast.FencedCodeBlock)
language := ""
if n.Info != nil {
lang := n.Info.Segment.Value(source)
// Take first word only (e.g., "go title=foo" -> "go")
if idx := bytes.IndexByte(lang, ' '); idx > 0 {
lang = lang[:idx]
}
language = string(lang)
}
var buf bytes.Buffer
for i := 0; i < n.Lines().Len(); i++ {
line := n.Lines().At(i)
buf.Write(line.Value(source))
}
// Remove trailing newline from code content
code := strings.TrimRight(buf.String(), "\n")
w.WriteString(CodeMacroWithID(language, code, r.pendingCodeMacroID, r.pendingCodeAttrOrder))
r.pendingCodeMacroID = ""
r.pendingCodeAttrOrder = ""
w.WriteString("\n")
return ast.WalkSkipChildren, nil
}
func (r *Renderer) renderCodeBlock(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
n := node.(*ast.CodeBlock)
var buf bytes.Buffer
for i := 0; i < n.Lines().Len(); i++ {
line := n.Lines().At(i)
buf.Write(line.Value(source))
}
code := strings.TrimRight(buf.String(), "\n")
w.WriteString(CodeMacro("", code))
w.WriteString("\n")
return ast.WalkSkipChildren, nil
}
func (r *Renderer) renderThematicBreak(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if entering {
w.WriteString("<hr />\n")
}
return ast.WalkContinue, nil
}
func (r *Renderer) renderBlockquote(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if entering {
name := r.pendingPanelName
if name == "" {
name = "info"
}
w.WriteString(`<ac:structured-macro ac:name="` + name + `" ac:schema-version="1"`)
if r.pendingPanelMacroID != "" {
w.WriteString(` ac:macro-id="` + r.pendingPanelMacroID + `"`)
}
w.WriteString(`>`)
// Emit ac:parameter children from "param-name=value" tokens.
if r.pendingPanelParams != "" {
for _, p := range strings.Split(r.pendingPanelParams, " ") {
if p == "" || !strings.HasPrefix(p, "param-") {
continue
}
rest := strings.TrimPrefix(p, "param-")
eq := strings.IndexByte(rest, '=')
if eq < 0 {
continue
}
pname := rest[:eq]
pval := strings.Trim(rest[eq+1:], `"`)
w.WriteString(`<ac:parameter ac:name="` + pname + `">` + pval + `</ac:parameter>`)
}
}
w.WriteString(`<ac:rich-text-body>`)
r.inBareBody = r.pendingPanelBodyBare
} else {
w.WriteString(`</ac:rich-text-body></ac:structured-macro>` + "\n")
r.pendingPanelName = ""
r.pendingPanelMacroID = ""
r.pendingPanelParams = ""
r.pendingPanelBodyBare = false
r.inBareBody = false
}
return ast.WalkContinue, nil
}
func (r *Renderer) renderList(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
n := node.(*ast.List)
// Check if this is a task list (first item has a TaskCheckBox child)
if isTaskList(n) {
if entering {
w.WriteString("<ac:task-list>\n")
} else {
w.WriteString("</ac:task-list>\n")
}
return ast.WalkContinue, nil
}
tag := "ul"
if n.IsOrdered() {
tag = "ol"
}
if entering {
fmt.Fprintf(w, "<%s>\n", tag)
} else {
fmt.Fprintf(w, "</%s>\n", tag)
}
return ast.WalkContinue, nil
}
// isTaskList checks if a list node contains task checkbox items.
func isTaskList(n *ast.List) bool {
for child := n.FirstChild(); child != nil; child = child.NextSibling() {
if li, ok := child.(*ast.ListItem); ok {
for c := li.FirstChild(); c != nil; c = c.NextSibling() {
if hasCheckbox(c) {
return true
}
}
}
}
return false
}
func hasCheckbox(n ast.Node) bool {
for c := n.FirstChild(); c != nil; c = c.NextSibling() {
if c.Kind() == east.KindTaskCheckBox {
return true
}
}
return false
}
func (r *Renderer) renderListItem(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
// Check if parent is a task list
if parent, ok := node.Parent().(*ast.List); ok && isTaskList(parent) {
if entering {
w.WriteString("<ac:task>\n")
fmt.Fprintf(w, "<ac:task-id>%d</ac:task-id>\n", r.nextTaskID())
} else {
w.WriteString("</ac:task>\n")
}
return ast.WalkContinue, nil
}
if entering {
w.WriteString("<li>")
} else {
w.WriteString("</li>\n")
}
return ast.WalkContinue, nil
}
func (r *Renderer) renderHTMLBlock(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
n := node.(*ast.HTMLBlock)
for i := 0; i < n.Lines().Len(); i++ {
line := n.Lines().At(i)
raw := strings.TrimRight(string(line.Value(source)), "\n")
if converted, ok := r.convertComment(raw); ok {
w.WriteString(converted)
w.WriteString("\n")
} else {
w.WriteString(r.convertRawSpan(raw))
w.WriteString("\n")
}
}
return ast.WalkSkipChildren, nil
}
// convertComment converts preserved HTML comments back to Confluence XML.
func (r *Renderer) convertComment(raw string) (string, bool) {
trimmed := strings.TrimSpace(raw)
switch {
// Layout
case trimmed == "<!-- ac:layout -->":
return "<ac:layout>", true
case trimmed == "<!-- /ac:layout -->":
return "</ac:layout>", true
case trimmed == "<!-- ac:layout-cell -->":
return "<ac:layout-cell>", true
case trimmed == "<!-- /ac:layout-cell -->":
return "</ac:layout-cell>", true
case trimmed == "<!-- /ac:layout-section -->":
return "</ac:layout-section>", true
case strings.HasPrefix(trimmed, "<!-- ac:layout-section"):
sectionType := extractCommentAttr(trimmed, "type")
if sectionType != "" {
return `<ac:layout-section ac:type="` + sectionType + `">`, true
}
return "<ac:layout-section>", true
// TOC macro (in-p variant: wrap in <p> for original <p><toc/></p> shapes)
case strings.HasPrefix(trimmed, "<!-- ac:toc-in-p"):
macroID := extractCommentAttr(trimmed, "macro-id")
macro := `<ac:structured-macro ac:name="toc" ac:schema-version="1"`
if macroID != "" {
macro += ` ac:macro-id="` + macroID + `"`
}
macro += "/>"
return "<p>" + macro + "</p>", true
// TOC macro
case strings.HasPrefix(trimmed, "<!-- ac:toc"):
macroID := extractCommentAttr(trimmed, "macro-id")
macro := `<ac:structured-macro ac:name="toc" ac:schema-version="1"`
if macroID != "" {
macro += ` ac:macro-id="` + macroID + `"`
}
macro += "/>"
return macro, true
// Table attributes — store for next table
case strings.HasPrefix(trimmed, "<!-- table-attrs:"):
r.pendingTableAttrs = trimmed
return "", true
// Code macro-id — store for next code block
case strings.HasPrefix(trimmed, "<!-- ac:code"):
macroID := extractCommentAttr(trimmed, "macro-id")
attrOrder := extractCommentAttr(trimmed, "attr-order")
if macroID != "" {
r.pendingCodeMacroID = macroID
}
r.pendingCodeAttrOrder = attrOrder
return "", true
// Panel macros (info/note/warning) — store metadata for next blockquote
case strings.HasPrefix(trimmed, "<!-- ac:info") ||
strings.HasPrefix(trimmed, "<!-- ac:note") ||
strings.HasPrefix(trimmed, "<!-- ac:warning"):
r.pendingPanelName = extractPanelName(trimmed)
r.pendingPanelMacroID = extractCommentAttr(trimmed, "macro-id")
// Collect param-* tokens
r.pendingPanelParams = extractPanelParams(trimmed)
r.pendingPanelBodyBare = strings.Contains(trimmed, " body-bare")
return "", true
}
return "", false
}
// extractPanelName returns the panel kind ("info"/"note"/"warning") from a
// "<!-- ac:info ..." / "<!-- ac:note ..." / "<!-- ac:warning ..." marker.
func extractPanelName(comment string) string {
for _, n := range []string{"info", "note", "warning"} {
if strings.HasPrefix(comment, "<!-- ac:"+n) {
return n
}
}
return ""
}
// extractPanelParams collects all "param-key=value" tokens into a single
// space-separated string for renderBlockquote to consume.
func extractPanelParams(comment string) string {
// Strip the leading "<!--" and trailing "-->", then keep tokens starting with "param-".
body := strings.TrimSpace(comment)
body = strings.TrimPrefix(body, "<!--")
body = strings.TrimSuffix(body, "-->")
body = strings.TrimSpace(body)
var out []string
// Walk tokens manually so we can keep "param-key=\"value with spaces\"" intact.
i := 0
for i < len(body) {
// Skip whitespace.
for i < len(body) && (body[i] == ' ' || body[i] == '\t') {
i++
}
if i >= len(body) {
break
}
// Read until next unquoted whitespace.
start := i
inQuote := false
for i < len(body) {
c := body[i]
if c == '"' {
inQuote = !inQuote
} else if !inQuote && (c == ' ' || c == '\t') {
break
}
i++
}
tok := body[start:i]
if strings.HasPrefix(tok, "param-") {
out = append(out, tok)
}
}
return strings.Join(out, " ")
}
// extractCommentAttr extracts a key="value" from an HTML comment.
func extractCommentAttr(comment, key string) string {
search := key + `="`
idx := strings.Index(comment, search)
if idx == -1 {
return ""
}
start := idx + len(search)
end := strings.Index(comment[start:], `"`)
if end == -1 {
return ""
}
return comment[start : start+end]
}
func (r *Renderer) renderText(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
n := node.(*ast.Text)
w.WriteString(html.EscapeString(string(n.Segment.Value(source))))
if n.HardLineBreak() {
w.WriteString("<br/>")
} else if n.SoftLineBreak() {
w.WriteString("\n")
}
return ast.WalkContinue, nil
}
func (r *Renderer) renderString(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
n := node.(*ast.String)
w.Write(n.Value)
return ast.WalkContinue, nil
}
func (r *Renderer) renderEmphasis(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
n := node.(*ast.Emphasis)
tag := "em"
if n.Level == 2 {
tag = "strong"
}
if entering {
fmt.Fprintf(w, "<%s>", tag)
} else {
fmt.Fprintf(w, "</%s>", tag)
}
return ast.WalkContinue, nil
}
func (r *Renderer) renderCodeSpan(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if entering {
w.WriteString("<code>")
} else {
w.WriteString("</code>")
}
return ast.WalkContinue, nil
}
func (r *Renderer) renderLink(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
n := node.(*ast.Link)
if entering {
fmt.Fprintf(w, `<a href="%s">`, html.EscapeString(string(n.Destination)))
} else {
w.WriteString("</a>")
}
return ast.WalkContinue, nil
}
func (r *Renderer) renderAutoLink(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
n := node.(*ast.AutoLink)
if entering {
url := string(n.URL(source))
fmt.Fprintf(w, `<a href="%s">%s</a>`, html.EscapeString(url), html.EscapeString(url))
}
return ast.WalkContinue, nil
}
func (r *Renderer) renderImage(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
n := node.(*ast.Image)
url := string(n.Destination)
alt := nodeText(n, source)
if alt != "" {
fmt.Fprintf(w, `<ac:image ac:alt="%s"><ri:url ri:value="%s"/></ac:image>`,
html.EscapeString(alt), html.EscapeString(url))
} else {
fmt.Fprintf(w, `<ac:image><ri:url ri:value="%s"/></ac:image>`, html.EscapeString(url))
}
return ast.WalkSkipChildren, nil
}
func (r *Renderer) renderRawHTML(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
n := node.(*ast.RawHTML)
for i := 0; i < n.Segments.Len(); i++ {
seg := n.Segments.At(i)
raw := string(seg.Value(source))
w.WriteString(r.convertRawSpan(raw))
}
return ast.WalkContinue, nil
}
// convertRawSpan converts round-trip spans back to Confluence XML.
func (r *Renderer) convertRawSpan(raw string) string {
if !strings.HasPrefix(raw, "<span ") {
// Handle closing tag — convert back if we're inside an inline comment
// or page link. Page links nest inside inline comments rarely, so the
// page-link counter takes precedence when both could apply.
if raw == "</span>" {
if r.pageLinkDepth > 0 {
r.pageLinkDepth--
return "</ac:link>"
}
if r.inlineCommentDepth > 0 {
r.inlineCommentDepth--
return "</ac:inline-comment-marker>"
}
}
return raw
}
// <span data-inline-comment="ref">
if strings.Contains(raw, "data-inline-comment=") {
ref := extractAttrValue(raw, "data-inline-comment")
if ref != "" {
r.inlineCommentDepth++
return `<ac:inline-comment-marker ac:ref="` + ref + `">`
}
}
// <span data-user-key="key"/>
if strings.Contains(raw, "data-user-key=") {
key := extractAttrValue(raw, "data-user-key")
if key != "" {
return `<ac:link><ri:user ri:userkey="` + key + `"/></ac:link>`
}
}
// <span data-attachment="filename"/>
if strings.Contains(raw, "data-attachment=") {
filename := extractAttrValue(raw, "data-attachment")
if filename != "" {
alt := extractAttrValue(raw, "data-alt")
if alt != "" {
return `<ac:image ac:alt="` + alt + `"><ri:attachment ri:filename="` + filename + `"/></ac:image>`
}
return `<ac:image><ri:attachment ri:filename="` + filename + `"/></ac:image>`
}
}
// <span data-page-link="1" data-space-key="…" data-content-title="…">body</span>
// or self-closing form when the original ac:link had no anchor body.
if strings.Contains(raw, "data-page-link=") {
space := extractAttrValue(raw, "data-space-key")
title := extractAttrValue(raw, "data-content-title")
anchor := extractAttrValue(raw, "data-anchor")
var b strings.Builder
b.WriteString(`<ac:link`)
if anchor != "" {
b.WriteString(` ac:anchor="`)
b.WriteString(anchor)
b.WriteString(`"`)
}
b.WriteString(`><ri:page`)
if space != "" {
b.WriteString(` ri:space-key="`)
b.WriteString(space)
b.WriteString(`"`)
}
if title != "" {
b.WriteString(` ri:content-title="`)
b.WriteString(title)
b.WriteString(`"`)
}
b.WriteString(`/>`)
// Self-closing span emits both opener and closer in convertRawSpan; for
// `<span … />` the renderer never sees a separate closing tag, so wrap
// up the ac:link here and signal the inline-span tracker not to expect one.
if strings.HasSuffix(raw, "/>") {
b.WriteString(`</ac:link>`)
return b.String()
}
// For `<span …>body</span>`, defer the </ac:link> emission to the
// matching </span>.
r.pageLinkDepth++
return b.String()
}
return raw
}
func extractAttrValue(tag, attr string) string {
key := attr + `="`
idx := strings.Index(tag, key)
if idx == -1 {
return ""
}
start := idx + len(key)
end := strings.Index(tag[start:], `"`)
if end == -1 {
return ""
}
return tag[start : start+end]
}
// GFM Table support
func (r *Renderer) renderTable(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if entering {
if r.pendingTableAttrs != "" {
r.writeTableFromAttrs(w)
} else {
w.WriteString("<table>\n<tbody>\n")
}
} else {
w.WriteString("</tbody>\n</table>\n")
}
return ast.WalkContinue, nil
}
// writeTableFromAttrs reconstructs <table> with class/style/colgroup from stored comment.
func (r *Renderer) writeTableFromAttrs(w util.BufWriter) {
attrs := r.pendingTableAttrs
r.pendingTableAttrs = ""
cls := extractCommentAttr(attrs, "class")
style := extractCommentAttr(attrs, "style")
w.WriteString("<table")
if cls != "" {
fmt.Fprintf(w, ` class="%s"`, cls)
}
if style != "" {
fmt.Fprintf(w, ` style="%s"`, style)
}
w.WriteString(">")
// Extract colgroup
colsStart := strings.Index(attrs, "cols=[")
if colsStart != -1 {
colsEnd := strings.Index(attrs[colsStart:], "]")
if colsEnd != -1 {
colsStr := attrs[colsStart+len("cols=[") : colsStart+colsEnd]
colStyles := strings.Split(colsStr, "|")
w.WriteString("<colgroup>")
for _, cs := range colStyles {
if cs != "" {
fmt.Fprintf(w, `<col style="%s"/>`, cs)
}
}
w.WriteString("</colgroup>")
}
}
w.WriteString("\n<tbody>\n")
}
func (r *Renderer) renderTableHeader(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if entering {
w.WriteString("<tr>\n")
} else {
w.WriteString("</tr>\n")
}
return ast.WalkContinue, nil
}
func (r *Renderer) renderTableRow(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if entering {
w.WriteString("<tr>\n")
} else {
w.WriteString("</tr>\n")
}
return ast.WalkContinue, nil
}
func (r *Renderer) renderTableCell(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
n := node.(*east.TableCell)
tag := "td"
if n.Parent().Kind() == east.KindTableHeader {
tag = "th"
}
if entering {
fmt.Fprintf(w, "<%s><p>", tag)
} else {
fmt.Fprintf(w, "</p></%s>\n", tag)
}
return ast.WalkContinue, nil
}
func (r *Renderer) renderStrikethrough(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if entering {
w.WriteString("<del>")
} else {
w.WriteString("</del>")
}
return ast.WalkContinue, nil
}
func (r *Renderer) renderTaskCheckBox(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
n := node.(*east.TaskCheckBox)
if n.IsChecked {
w.WriteString("<ac:task-status>complete</ac:task-status>\n")
} else {
w.WriteString("<ac:task-status>incomplete</ac:task-status>\n")
}
w.WriteString("<ac:task-body>")
r.inTaskBody = true
return ast.WalkContinue, nil
}
// nodeText extracts plain text from a node tree.
func nodeText(n ast.Node, source []byte) string {
var buf bytes.Buffer
for c := n.FirstChild(); c != nil; c = c.NextSibling() {
if t, ok := c.(*ast.Text); ok {
buf.Write(t.Segment.Value(source))
}
}
return buf.String()
}