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
}
// 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
}
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 {
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(CodeMacro(language, code))
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 {
w.WriteString(`<ac:structured-macro ac:name="info" ac:schema-version="1"><ac:rich-text-body>`)
} else {
w.WriteString(`</ac:rich-text-body></ac:structured-macro>` + "\n")
}
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")
w.WriteString(r.convertRawSpan(raw))
w.WriteString("\n")
}
return ast.WalkSkipChildren, nil
}
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
if raw == "</span>" && 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>`
}
}
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 {
w.WriteString("<table>\n<tbody>\n")
} else {
w.WriteString("</tbody>\n</table>\n")
}
return ast.WalkContinue, nil
}
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()
}