diff --git a/VERSION b/VERSION index 867e524..f0bb29e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.2.0 \ No newline at end of file +1.3.0 diff --git a/adf_test.go b/adf_test.go index fb19bc1..dfacc24 100644 --- a/adf_test.go +++ b/adf_test.go @@ -288,6 +288,151 @@ func TestConvertWithGFM_Strikethrough(t *testing.T) { } } +func TestConvert_CodeMarkExcludesIncompatibleMarks(t *testing.T) { + output, err := Convert([]byte("**`roboduck-toil`**")) + if err != nil { + t.Fatalf("Convert failed: %v", err) + } + if err := adfschema.Validate(output); err != nil { + t.Fatalf("Invalid ADF output: %v\nOutput: %s", err, output) + } + + var doc Document + if err := json.Unmarshal(output, &doc); err != nil { + t.Fatalf("Failed to parse output: %v", err) + } + marks := doc.Content[0].Content[0].Marks + if len(marks) != 1 || marks[0].Type != "code" { + t.Errorf("Expected only code mark, got %+v", marks) + } +} + +func TestConvert_CodeMarkRetainsLink(t *testing.T) { + output, err := Convert([]byte("[`roboduck-toil`](https://example.com)")) + if err != nil { + t.Fatalf("Convert failed: %v", err) + } + if err := adfschema.Validate(output); err != nil { + t.Fatalf("Invalid ADF output: %v\nOutput: %s", err, output) + } + + var doc Document + if err := json.Unmarshal(output, &doc); err != nil { + t.Fatalf("Failed to parse output: %v", err) + } + marks := doc.Content[0].Content[0].Marks + if len(marks) != 2 || marks[0].Type != "link" || marks[1].Type != "code" { + t.Errorf("Expected link and code marks, got %+v", marks) + } +} + +func TestConvertWithGFM_TaskList(t *testing.T) { + output, err := ConvertWithGFM([]byte("- [ ] First task\n- [x] **Done** task")) + if err != nil { + t.Fatalf("Convert failed: %v", err) + } + if err := adfschema.Validate(output); err != nil { + t.Fatalf("Invalid ADF output: %v\nOutput: %s", err, output) + } + + var doc Document + if err := json.Unmarshal(output, &doc); err != nil { + t.Fatalf("Failed to parse output: %v", err) + } + taskList := doc.Content[0] + if taskList.Type != "taskList" { + t.Fatalf("Expected taskList, got %s", taskList.Type) + } + if taskList.Attrs["localId"] != "" { + t.Errorf("Expected empty task list localId, got %v", taskList.Attrs["localId"]) + } + if len(taskList.Content) != 2 { + t.Fatalf("Expected 2 task items, got %d", len(taskList.Content)) + } + if taskList.Content[0].Type != "taskItem" || taskList.Content[0].Attrs["state"] != "TODO" { + t.Errorf("Expected TODO taskItem, got %+v", taskList.Content[0]) + } + if taskList.Content[1].Type != "taskItem" || taskList.Content[1].Attrs["state"] != "DONE" { + t.Errorf("Expected DONE taskItem, got %+v", taskList.Content[1]) + } + if len(taskList.Content[0].Content) == 0 || taskList.Content[0].Content[0].Type != "text" { + t.Errorf("Expected task item to contain inline text directly, got %+v", taskList.Content[0].Content) + } + if len(taskList.Content[1].Content[0].Marks) != 1 || taskList.Content[1].Content[0].Marks[0].Type != "strong" { + t.Errorf("Expected strong mark in completed task, got %+v", taskList.Content[1].Content) + } +} + +func TestConvertWithGFM_NestedTaskList(t *testing.T) { + input := []byte("- [ ] Parent\n - [x] Child\n- [ ] Next") + output, err := ConvertWithGFM(input) + if err != nil { + t.Fatalf("Convert failed: %v", err) + } + if err := adfschema.Validate(output); err != nil { + t.Fatalf("Invalid ADF output: %v\nOutput: %s", err, output) + } + + var doc Document + if err := json.Unmarshal(output, &doc); err != nil { + t.Fatalf("Failed to parse output: %v", err) + } + taskList := doc.Content[0] + if taskList.Type != "taskList" { + t.Fatalf("Expected taskList, got %s", taskList.Type) + } + if len(taskList.Content) != 3 { + t.Fatalf("Expected parent task, nested list, and next task; got %+v", taskList.Content) + } + if taskList.Content[0].Type != "taskItem" || taskList.Content[1].Type != "taskList" || taskList.Content[2].Type != "taskItem" { + t.Errorf("Expected taskItem, taskList, taskItem; got %+v", taskList.Content) + } + if taskList.Content[1].Content[0].Attrs["state"] != "DONE" { + t.Errorf("Expected nested task to be DONE, got %+v", taskList.Content[1].Content[0]) + } +} + +func TestConvertWithGFM_TaskListExternalMediaFallsBackToLink(t *testing.T) { + input := []byte("- [ ] ![Diagram](https://example.com/diagram.png)") + var buf bytes.Buffer + if err := NewWithGFM(WithExternalMedia(true)).Convert(input, &buf); err != nil { + t.Fatalf("Convert failed: %v", err) + } + if err := adfschema.Validate(buf.Bytes()); err != nil { + t.Fatalf("Invalid ADF output: %v\nOutput: %s", err, buf.Bytes()) + } + + var doc Document + if err := json.Unmarshal(buf.Bytes(), &doc); err != nil { + t.Fatalf("Failed to parse output: %v", err) + } + taskItem := doc.Content[0].Content[0] + if len(taskItem.Content) != 1 || taskItem.Content[0].Type != "text" { + t.Fatalf("Expected linked text inside taskItem, got %+v", taskItem.Content) + } + if len(taskItem.Content[0].Marks) != 1 || taskItem.Content[0].Marks[0].Type != "link" { + t.Errorf("Expected link mark inside taskItem, got %+v", taskItem.Content[0].Marks) + } +} + +func TestConvertWithGFM_MixedTaskListFallsBackToBulletList(t *testing.T) { + output, err := ConvertWithGFM([]byte("- [ ] Task\n- Regular item")) + if err != nil { + t.Fatalf("Convert failed: %v", err) + } + if err := adfschema.Validate(output); err != nil { + t.Fatalf("Invalid ADF output: %v\nOutput: %s", err, output) + } + + var doc Document + if err := json.Unmarshal(output, &doc); err != nil { + t.Fatalf("Failed to parse output: %v", err) + } + if doc.Content[0].Type != "bulletList" { + t.Errorf("Expected mixed list to remain a bulletList, got %s", doc.Content[0].Type) + } +} + func TestNew_ReusableInstance(t *testing.T) { md := New() diff --git a/renderer.go b/renderer.go index bafefba..e08a512 100644 --- a/renderer.go +++ b/renderer.go @@ -27,9 +27,10 @@ type Renderer struct { config Config // State for rendering - document *Document - nodeStack []*Node - markStack []Mark + document *Document + nodeStack []*Node + markStack []Mark + openTaskItems map[ast.Node]bool } // NewRenderer creates a new ADF renderer with the given options. @@ -96,6 +97,7 @@ func (r *Renderer) reset() { r.document = NewDocument() r.nodeStack = []*Node{} r.markStack = []Mark{} + r.openTaskItems = map[ast.Node]bool{} } // currentNode returns the current node being built, or nil if at document level. @@ -163,7 +165,31 @@ func (r *Renderer) currentMarks() []Mark { } marks := make([]Mark, len(r.markStack)) copy(marks, r.markStack) - return marks + return normalizeMarks(marks) +} + +// normalizeMarks enforces ADF mark-combination constraints. In particular, +// code may only be combined with link, so code takes precedence over all other +// formatting marks when Markdown nests them together. +func normalizeMarks(marks []Mark) []Mark { + hasCode := false + for _, mark := range marks { + if mark.Type == "code" { + hasCode = true + break + } + } + if !hasCode { + return marks + } + + normalized := make([]Mark, 0, 2) + for _, mark := range marks { + if mark.Type == "code" || mark.Type == "link" { + normalized = append(normalized, mark) + } + } + return normalized } // Block node renderers @@ -253,12 +279,19 @@ func (r *Renderer) renderHTMLBlock(w util.BufWriter, source []byte, node ast.Nod } func (r *Renderer) renderList(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { + list := node.(*ast.List) if entering { - n := node.(*ast.List) - if isTaskList(n) { + if isTaskList(list) { + // ADF taskItem nodes can only contain inline content. Goldmark nests a + // child list beneath its parent ListItem, so close the parent task item + // before emitting the child task list as its sibling. + if parentItem, ok := list.Parent().(*ast.ListItem); ok && r.openTaskItems[parentItem] { + r.popNode() + r.openTaskItems[parentItem] = false + } r.pushNode(NewTaskList()) - } else if n.IsOrdered() { - r.pushNode(NewOrderedList(n.Start)) + } else if list.IsOrdered() { + r.pushNode(NewOrderedList(list.Start)) } else { r.pushNode(NewBulletList()) } @@ -269,21 +302,24 @@ func (r *Renderer) renderList(w util.BufWriter, source []byte, node ast.Node, en } func (r *Renderer) renderListItem(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { - if entering { - // Check if parent is a task list - parent := node.Parent() - if parent != nil && parent.Kind() == ast.KindList { - list := parent.(*ast.List) - if isTaskList(list) { - // Determine checked state from the checkbox - state := "TODO" - if hasCheckedCheckBox(node) { - state = "DONE" - } - r.pushNode(NewTaskItem(state)) - return ast.WalkContinue, nil + item := node.(*ast.ListItem) + if list, ok := item.Parent().(*ast.List); ok && isTaskList(list) { + if entering { + checkBox, _ := taskCheckBox(item) + state := "TODO" + if checkBox.IsChecked { + state = "DONE" } + r.pushNode(NewTaskItem(state)) + r.openTaskItems[item] = true + } else if r.openTaskItems[item] { + r.popNode() + r.openTaskItems[item] = false } + return ast.WalkContinue, nil + } + + if entering { r.pushNode(NewListItem()) } else { r.popNode() @@ -291,21 +327,6 @@ func (r *Renderer) renderListItem(w util.BufWriter, source []byte, node ast.Node return ast.WalkContinue, nil } -// hasCheckedCheckBox checks if a list item has a checked task checkbox. -func hasCheckedCheckBox(item ast.Node) bool { - first := item.FirstChild() - if first == nil { - return false - } - for c := first.FirstChild(); c != nil; c = c.NextSibling() { - if c.Kind() == extast.KindTaskCheckBox { - return c.(*extast.TaskCheckBox).IsChecked - } - break - } - return false -} - func (r *Renderer) renderParagraph(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { if entering { r.pushNode(NewParagraph()) @@ -323,6 +344,12 @@ func (r *Renderer) renderParagraph(w util.BufWriter, source []byte, node ast.Nod } func (r *Renderer) renderTextBlock(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { + if parentItem, ok := node.Parent().(*ast.ListItem); ok { + if list, ok := parentItem.Parent().(*ast.List); ok && isTaskList(list) { + return ast.WalkContinue, nil + } + } + // TextBlock is a lightweight paragraph used in tight lists // In ADF, we still need to wrap content in a paragraph if entering { @@ -401,8 +428,9 @@ func (r *Renderer) renderImage(w util.BufWriter, source []byte, node ast.Node, e title = string(n.Title) } - // Check if external media is enabled - if r.config.ExternalMedia { + // taskItem content only permits inline nodes, so external media must fall + // back to linked text even when external media is otherwise enabled. + if r.config.ExternalMedia && (r.currentNode() == nil || r.currentNode().Type != "taskItem") { // Handle external media with paragraph splitting r.renderExternalMedia(dest, alt, title) } else { @@ -598,6 +626,15 @@ func (r *Renderer) renderTaskCheckBox(w util.BufWriter, source []byte, node ast. return ast.WalkContinue, nil } n := node.(*extast.TaskCheckBox) + if parentTextBlock, ok := n.Parent().(*ast.TextBlock); ok { + if parentItem, ok := parentTextBlock.Parent().(*ast.ListItem); ok { + if list, ok := parentItem.Parent().(*ast.List); ok && isTaskList(list) { + return ast.WalkContinue, nil + } + } + } + + // Render checkbox as text prefix var text string if n.IsChecked { text = "[x] " @@ -608,37 +645,57 @@ func (r *Renderer) renderTaskCheckBox(w util.BufWriter, source []byte, node ast. return ast.WalkContinue, nil } -// isTaskList checks if all items in a list have task checkboxes. +// taskCheckBox returns the leading task checkbox in a list item, if present. +func taskCheckBox(item *ast.ListItem) (*extast.TaskCheckBox, bool) { + textBlock, ok := item.FirstChild().(*ast.TextBlock) + if !ok { + return nil, false + } + checkBox, ok := textBlock.FirstChild().(*extast.TaskCheckBox) + return checkBox, ok +} + +// isTaskList reports whether every item in a list is a task item and every +// nested list can also be represented as an ADF task list. This avoids mixing +// listItem and taskItem nodes in a single ADF taskList. func isTaskList(list *ast.List) bool { - if list.IsOrdered() { + if !isTaskListItems(list) { return false } - for c := list.FirstChild(); c != nil; c = c.NextSibling() { - if c.Kind() != ast.KindListItem { - return false - } - if !hasTaskCheckBox(c) { - return false + + for item := list.FirstChild(); item != nil; item = item.NextSibling() { + for child := item.FirstChild(); child != nil; child = child.NextSibling() { + switch child := child.(type) { + case *ast.TextBlock: + // The leading checkbox and all task-item content are inline. + case *ast.List: + if isTaskList(child) { + continue + } + return false + default: + // taskItem cannot contain paragraphs or other block nodes. + return false + } } } - return list.HasChildren() + return true } -// hasTaskCheckBox checks if a list item's first paragraph starts with a TaskCheckBox. -func hasTaskCheckBox(item ast.Node) bool { - first := item.FirstChild() - if first == nil { +func isTaskListItems(list *ast.List) bool { + if list.FirstChild() == nil { return false } - // In goldmark, the task checkbox is a direct child of the list item's - // first child (paragraph or text block). - for c := first.FirstChild(); c != nil; c = c.NextSibling() { - if c.Kind() == extast.KindTaskCheckBox { - return true + for item := list.FirstChild(); item != nil; item = item.NextSibling() { + listItem, ok := item.(*ast.ListItem) + if !ok { + return false + } + if _, ok := taskCheckBox(listItem); !ok { + return false } - break // only check first child } - return false + return true } // ADF round-trip extension renderers (inline)