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