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 } // 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 } 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 { 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.pendingCodeMacroID = "" 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 { w.WriteString(``) } else { w.WriteString(`` + "\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("\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, "