mirror of
https://github.com/kovetskiy/mark.git
synced 2026-03-14 14:17:37 +08:00
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>
This commit is contained in:
parent
529aa63d9f
commit
d2717f031d
@ -56,6 +56,7 @@ func (c *ConfluenceExtension) Extend(m goldmark.Markdown) {
|
|||||||
util.Prioritized(crenderer.NewConfluenceImageRenderer(c.Stdlib, c, c.Path), 100),
|
util.Prioritized(crenderer.NewConfluenceImageRenderer(c.Stdlib, c, c.Path), 100),
|
||||||
util.Prioritized(crenderer.NewConfluenceParagraphRenderer(), 100),
|
util.Prioritized(crenderer.NewConfluenceParagraphRenderer(), 100),
|
||||||
util.Prioritized(crenderer.NewConfluenceLinkRenderer(), 100),
|
util.Prioritized(crenderer.NewConfluenceLinkRenderer(), 100),
|
||||||
|
util.Prioritized(crenderer.NewConfluenceTaskListRenderer(), 100),
|
||||||
))
|
))
|
||||||
|
|
||||||
if slices.Contains(c.MarkConfig.Features, "mkdocsadmonitions") {
|
if slices.Contains(c.MarkConfig.Features, "mkdocsadmonitions") {
|
||||||
|
|||||||
170
renderer/tasklist.go
Normal file
170
renderer/tasklist.go
Normal file
@ -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 <ac:task-list> while falling back to <li> 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("<ac:task-list>\n")
|
||||||
|
} else {
|
||||||
|
_, _ = w.WriteString("</ac:task-list>\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, "<ac:task>\n<ac:task-id>%d</ac:task-id>\n<ac:task-status>%s</ac:task-status>\n<ac:task-body>", r.taskID, status)
|
||||||
|
} else {
|
||||||
|
_, _ = w.WriteString("</ac:task-body>\n</ac:task>\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 <ul>/<ol>), 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("</")
|
||||||
|
_, _ = w.WriteString(tag)
|
||||||
|
_, _ = 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("<li")
|
||||||
|
html.RenderAttributes(w, node, html.ListItemAttributeFilter)
|
||||||
|
_ = w.WriteByte('>')
|
||||||
|
} else {
|
||||||
|
_, _ = w.WriteString("<li>")
|
||||||
|
}
|
||||||
|
fc := node.FirstChild()
|
||||||
|
if fc != nil {
|
||||||
|
if _, ok := fc.(*ast.TextBlock); !ok {
|
||||||
|
_ = w.WriteByte('\n')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_, _ = w.WriteString("</li>\n")
|
||||||
|
}
|
||||||
|
return ast.WalkContinue, nil
|
||||||
|
}
|
||||||
5
testdata/tasklists-mixed.html
vendored
Normal file
5
testdata/tasklists-mixed.html
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<ul>
|
||||||
|
<li>[x] task item</li>
|
||||||
|
<li>regular item</li>
|
||||||
|
<li>[ ] another task item</li>
|
||||||
|
</ul>
|
||||||
3
testdata/tasklists-mixed.md
vendored
Normal file
3
testdata/tasklists-mixed.md
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
- [x] task item
|
||||||
|
- regular item
|
||||||
|
- [ ] another task item
|
||||||
17
testdata/tasklists.html
vendored
Normal file
17
testdata/tasklists.html
vendored
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
<ac:task-list>
|
||||||
|
<ac:task>
|
||||||
|
<ac:task-id>1</ac:task-id>
|
||||||
|
<ac:task-status>complete</ac:task-status>
|
||||||
|
<ac:task-body>#739</ac:task-body>
|
||||||
|
</ac:task>
|
||||||
|
<ac:task>
|
||||||
|
<ac:task-id>2</ac:task-id>
|
||||||
|
<ac:task-status>incomplete</ac:task-status>
|
||||||
|
<ac:task-body><a href="https://github.com/octo-org/octo-repo/issues/740">https://github.com/octo-org/octo-repo/issues/740</a></ac:task-body>
|
||||||
|
</ac:task>
|
||||||
|
<ac:task>
|
||||||
|
<ac:task-id>3</ac:task-id>
|
||||||
|
<ac:task-status>incomplete</ac:task-status>
|
||||||
|
<ac:task-body>Add delight to the experience when all tasks are complete :tada:</ac:task-body>
|
||||||
|
</ac:task>
|
||||||
|
</ac:task-list>
|
||||||
3
testdata/tasklists.md
vendored
Normal file
3
testdata/tasklists.md
vendored
Normal file
@ -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:
|
||||||
Loading…
x
Reference in New Issue
Block a user