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