From d2717f031dd4cc26929248fb94c7f02de4d45601 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20R=C3=BCger?= Date: Fri, 27 Feb 2026 20:31:10 +0100 Subject: [PATCH] Add Confluence task list renderer for GFM task lists Convert GFM task list items (- [x] / - [ ]) to Confluence ac:task-list XML format instead of HTML checkboxes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- markdown/markdown.go | 1 + renderer/tasklist.go | 170 ++++++++++++++++++++++++++++++++++ testdata/tasklists-mixed.html | 5 + testdata/tasklists-mixed.md | 3 + testdata/tasklists.html | 17 ++++ testdata/tasklists.md | 3 + 6 files changed, 199 insertions(+) create mode 100644 renderer/tasklist.go create mode 100644 testdata/tasklists-mixed.html create mode 100644 testdata/tasklists-mixed.md create mode 100644 testdata/tasklists.html create mode 100644 testdata/tasklists.md diff --git a/markdown/markdown.go b/markdown/markdown.go index d412670..15885ab 100644 --- a/markdown/markdown.go +++ b/markdown/markdown.go @@ -56,6 +56,7 @@ func (c *ConfluenceExtension) Extend(m goldmark.Markdown) { util.Prioritized(crenderer.NewConfluenceImageRenderer(c.Stdlib, c, c.Path), 100), util.Prioritized(crenderer.NewConfluenceParagraphRenderer(), 100), util.Prioritized(crenderer.NewConfluenceLinkRenderer(), 100), + util.Prioritized(crenderer.NewConfluenceTaskListRenderer(), 100), )) if slices.Contains(c.MarkConfig.Features, "mkdocsadmonitions") { diff --git a/renderer/tasklist.go b/renderer/tasklist.go new file mode 100644 index 0000000..c18bfd7 --- /dev/null +++ b/renderer/tasklist.go @@ -0,0 +1,170 @@ +package renderer + +import ( + "fmt" + + "github.com/yuin/goldmark/ast" + ext_ast "github.com/yuin/goldmark/extension/ast" + "github.com/yuin/goldmark/renderer" + "github.com/yuin/goldmark/renderer/html" + "github.com/yuin/goldmark/util" +) + +// ConfluenceTaskListRenderer renders GFM task lists as Confluence ac:task-list elements. +type ConfluenceTaskListRenderer struct { + html.Config + taskID int +} + +func NewConfluenceTaskListRenderer(opts ...html.Option) renderer.NodeRenderer { + return &ConfluenceTaskListRenderer{ + Config: html.NewConfig(), + } +} + +func (r *ConfluenceTaskListRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { + reg.Register(ast.KindList, r.renderList) + reg.Register(ast.KindListItem, r.renderListItem) + reg.Register(ext_ast.KindTaskCheckBox, r.renderTaskCheckBox) +} + +// isTaskList returns true only if every top-level list item is a task item. +// A mixed list (some task items, some regular items) is not considered a task +// list, because rendering it as while falling back to
  • for +// non-task items produces invalid Confluence storage XML. +func isTaskList(list *ast.List) bool { + hasChildren := false + for child := list.FirstChild(); child != nil; child = child.NextSibling() { + hasChildren = true + if getTaskCheckBox(child) == nil { + return false + } + } + return hasChildren +} + +// getTaskCheckBox returns the TaskCheckBox node for a ListItem, or nil if not a task item. +// The structure is: ListItem -> TextBlock -> TaskCheckBox +func getTaskCheckBox(item ast.Node) *ext_ast.TaskCheckBox { + fc := item.FirstChild() + if fc == nil { + return nil + } + gfc := fc.FirstChild() + if gfc == nil { + return nil + } + checkbox, ok := gfc.(*ext_ast.TaskCheckBox) + if !ok { + return nil + } + return checkbox +} + +func (r *ConfluenceTaskListRenderer) renderList(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { + n := node.(*ast.List) + if !isTaskList(n) { + return r.goldmarkRenderList(w, source, node, entering) + } + if entering { + r.taskID = 0 + _, _ = w.WriteString("\n") + } else { + _, _ = w.WriteString("\n") + } + return ast.WalkContinue, nil +} + +func (r *ConfluenceTaskListRenderer) renderListItem(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { + checkbox := getTaskCheckBox(node) + parentList, _ := node.Parent().(*ast.List) + if checkbox == nil || parentList == nil || !isTaskList(parentList) { + return r.goldmarkRenderListItem(w, source, node, entering) + } + if entering { + r.taskID++ + status := "incomplete" + if checkbox.IsChecked { + status = "complete" + } + _, _ = fmt.Fprintf(w, "\n%d\n%s\n", r.taskID, status) + } else { + _, _ = w.WriteString("\n\n") + } + return ast.WalkContinue, nil +} + +// renderTaskCheckBox skips checkbox rendering when inside an ac:task-list (status +// is already encoded by renderListItem). For any other list (e.g. mixed lists that +// fall back to plain
      /
        ), a textual "[x]"/"[ ]" marker is emitted so that +// completion state is not silently lost. +func (r *ConfluenceTaskListRenderer) renderTaskCheckBox(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { + // Traverse up: TaskCheckBox -> TextBlock -> ListItem -> List + var parentList *ast.List + if tb := node.Parent(); tb != nil { + if li := tb.Parent(); li != nil { + parentList, _ = li.Parent().(*ast.List) + } + } + if parentList != nil && isTaskList(parentList) { + // Status is encoded by renderListItem; nothing to emit here. + return ast.WalkSkipChildren, nil + } + // Fallback: emit a textual marker so completion state is preserved. + if entering { + checkbox := node.(*ext_ast.TaskCheckBox) + if checkbox.IsChecked { + _, _ = w.WriteString("[x] ") + } else { + _, _ = w.WriteString("[ ] ") + } + } + return ast.WalkSkipChildren, nil +} + +// goldmarkRenderList is the default list rendering from goldmark. +func (r *ConfluenceTaskListRenderer) goldmarkRenderList(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { + n := node.(*ast.List) + tag := "ul" + if n.IsOrdered() { + tag = "ol" + } + if entering { + _ = w.WriteByte('<') + _, _ = w.WriteString(tag) + if n.IsOrdered() && n.Start != 1 { + _, _ = fmt.Fprintf(w, " start=\"%d\"", n.Start) + } + if n.Attributes() != nil { + html.RenderAttributes(w, n, html.ListAttributeFilter) + } + _, _ = w.WriteString(">\n") + } else { + _, _ = w.WriteString("\n") + } + return ast.WalkContinue, nil +} + +// goldmarkRenderListItem is the default list item rendering from goldmark. +func (r *ConfluenceTaskListRenderer) goldmarkRenderListItem(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { + if entering { + if node.Attributes() != nil { + _, _ = w.WriteString("') + } else { + _, _ = w.WriteString("
      1. ") + } + fc := node.FirstChild() + if fc != nil { + if _, ok := fc.(*ast.TextBlock); !ok { + _ = w.WriteByte('\n') + } + } + } else { + _, _ = w.WriteString("
      2. \n") + } + return ast.WalkContinue, nil +} diff --git a/testdata/tasklists-mixed.html b/testdata/tasklists-mixed.html new file mode 100644 index 0000000..908a908 --- /dev/null +++ b/testdata/tasklists-mixed.html @@ -0,0 +1,5 @@ +
          +
        • [x] task item
        • +
        • regular item
        • +
        • [ ] another task item
        • +
        diff --git a/testdata/tasklists-mixed.md b/testdata/tasklists-mixed.md new file mode 100644 index 0000000..4838f5a --- /dev/null +++ b/testdata/tasklists-mixed.md @@ -0,0 +1,3 @@ +- [x] task item +- regular item +- [ ] another task item diff --git a/testdata/tasklists.html b/testdata/tasklists.html new file mode 100644 index 0000000..2afeaa9 --- /dev/null +++ b/testdata/tasklists.html @@ -0,0 +1,17 @@ + + +1 +complete +#739 + + +2 +incomplete +https://github.com/octo-org/octo-repo/issues/740 + + +3 +incomplete +Add delight to the experience when all tasks are complete :tada: + + diff --git a/testdata/tasklists.md b/testdata/tasklists.md new file mode 100644 index 0000000..9ca49d2 --- /dev/null +++ b/testdata/tasklists.md @@ -0,0 +1,3 @@ +- [x] #739 +- [ ] https://github.com/octo-org/octo-repo/issues/740 +- [ ] Add delight to the experience when all tasks are complete :tada: