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 pendingTableAttrs string // stored from comment pendingCodeMacroID string // stored from comment pendingCodeAttrOrder string // stored from comment pendingPanelName string // "info", "note", or "warning" — from 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

wrapper inBareBody bool // currently inside a body-bare blockquote, suppress

} // 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, "\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

- the task-body handles it if r.inTaskBody { if !entering { w.WriteString("\n") r.inTaskBody = false } return ast.WalkContinue, nil } // Inside a body-bare panel (info/note/warning), the original had no

// wrapping inside . Skip the

here so round-trip // matches. if r.inBareBody { return ast.WalkContinue, nil } if entering { w.WriteString("

") } else { w.WriteString("

\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("\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("
\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(``) // 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(`` + pval + ``) } } w.WriteString(``) r.inBareBody = r.pendingPanelBodyBare } else { w.WriteString(`` + "\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("\n") } else { w.WriteString("\n") } return ast.WalkContinue, nil } tag := "ul" if n.IsOrdered() { tag = "ol" } if entering { fmt.Fprintf(w, "<%s>\n", tag) } else { fmt.Fprintf(w, "\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("\n") fmt.Fprintf(w, "%d\n", r.nextTaskID()) } else { w.WriteString("\n") } return ast.WalkContinue, nil } if entering { w.WriteString("
  • ") } else { w.WriteString("
  • \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 == "": return "", true case trimmed == "": return "", true case trimmed == "": return "", true case trimmed == "": return "", true case trimmed == "": return "", true case strings.HasPrefix(trimmed, "", then keep tokens starting with "param-". body := strings.TrimSpace(comment) body = strings.TrimPrefix(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("
    ") } 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, "", 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("") } else { w.WriteString("") } 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, ``, html.EscapeString(string(n.Destination))) } else { w.WriteString("") } 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, `%s`, 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, ``, html.EscapeString(alt), html.EscapeString(url)) } else { fmt.Fprintf(w, ``, 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, "" && r.inlineCommentDepth > 0 { r.inlineCommentDepth-- return "" } return raw } // if strings.Contains(raw, "data-inline-comment=") { ref := extractAttrValue(raw, "data-inline-comment") if ref != "" { r.inlineCommentDepth++ return `` } } // if strings.Contains(raw, "data-user-key=") { key := extractAttrValue(raw, "data-user-key") if key != "" { return `` } } // if strings.Contains(raw, "data-attachment=") { filename := extractAttrValue(raw, "data-attachment") if filename != "" { alt := extractAttrValue(raw, "data-alt") if alt != "" { return `` } return `` } } 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("\n\n") } } else { w.WriteString("\n
    \n") } return ast.WalkContinue, nil } // writeTableFromAttrs reconstructs 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("") // 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("") for _, cs := range colStyles { if cs != "" { fmt.Fprintf(w, ``, cs) } } w.WriteString("") } } w.WriteString("\n\n") } func (r *Renderer) renderTableHeader(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { if entering { w.WriteString("\n") } else { w.WriteString("\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("\n") } else { w.WriteString("\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>

    ", tag) } else { fmt.Fprintf(w, "

    \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("") } else { w.WriteString("") } 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("complete\n") } else { w.WriteString("incomplete\n") } w.WriteString("") 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() }