diff --git a/internal/api/markdown.go b/internal/api/markdown.go index 5f1e495..b808ce4 100644 --- a/internal/api/markdown.go +++ b/internal/api/markdown.go @@ -463,6 +463,31 @@ func countLeadingSpaces(line string) int { return count } +// hasCodeMark returns true if the content node has an inline code mark. +// In ADF, the code mark is exclusive and cannot be combined with other marks +// like strong, em, or strike. Jira will reject the document with INVALID_INPUT. +func hasCodeMark(c ADFContent) bool { + for _, m := range c.Marks { + if m.Type == "code" { + return true + } + } + return false +} + +// addMarkToContent prepends a mark to inner content nodes, skipping nodes that +// already have a code mark (since code is exclusive in ADF). +func addMarkToContent(innerContent []ADFContent, mark ADFMark) []ADFContent { + result := make([]ADFContent, 0, len(innerContent)) + for _, c := range innerContent { + if !hasCodeMark(c) { + c.Marks = append([]ADFMark{mark}, c.Marks...) + } + result = append(result, c) + } + return result +} + // parseInline parses inline markdown elements (bold, italic, code, links). func parseInline(text string) []ADFContent { if text == "" { @@ -519,20 +544,14 @@ func parseInline(text string) []ADFContent { if boldMatch := regexp.MustCompile(`^\*\*([^*]+)\*\*`).FindStringSubmatch(remaining); len(boldMatch) > 0 { // Parse inner content for nested formatting innerContent := parseInline(boldMatch[1]) - for _, c := range innerContent { - c.Marks = append([]ADFMark{{Type: "strong"}}, c.Marks...) - content = append(content, c) - } + content = append(content, addMarkToContent(innerContent, ADFMark{Type: "strong"})...) remaining = remaining[len(boldMatch[0]):] matched = true continue } if boldMatch := regexp.MustCompile(`^__([^_]+)__`).FindStringSubmatch(remaining); len(boldMatch) > 0 { innerContent := parseInline(boldMatch[1]) - for _, c := range innerContent { - c.Marks = append([]ADFMark{{Type: "strong"}}, c.Marks...) - content = append(content, c) - } + content = append(content, addMarkToContent(innerContent, ADFMark{Type: "strong"})...) remaining = remaining[len(boldMatch[0]):] matched = true continue @@ -541,10 +560,7 @@ func parseInline(text string) []ADFContent { // Strikethrough: ~~text~~ if strikeMatch := regexp.MustCompile(`^~~([^~]+)~~`).FindStringSubmatch(remaining); len(strikeMatch) > 0 { innerContent := parseInline(strikeMatch[1]) - for _, c := range innerContent { - c.Marks = append([]ADFMark{{Type: "strike"}}, c.Marks...) - content = append(content, c) - } + content = append(content, addMarkToContent(innerContent, ADFMark{Type: "strike"})...) remaining = remaining[len(strikeMatch[0]):] matched = true continue @@ -553,20 +569,14 @@ func parseInline(text string) []ADFContent { // Italic: *text* or _text_ (must not be followed by another * or _) if italicMatch := regexp.MustCompile(`^\*([^*]+)\*`).FindStringSubmatch(remaining); len(italicMatch) > 0 { innerContent := parseInline(italicMatch[1]) - for _, c := range innerContent { - c.Marks = append([]ADFMark{{Type: "em"}}, c.Marks...) - content = append(content, c) - } + content = append(content, addMarkToContent(innerContent, ADFMark{Type: "em"})...) remaining = remaining[len(italicMatch[0]):] matched = true continue } if italicMatch := regexp.MustCompile(`^_([^_]+)_`).FindStringSubmatch(remaining); len(italicMatch) > 0 { innerContent := parseInline(italicMatch[1]) - for _, c := range innerContent { - c.Marks = append([]ADFMark{{Type: "em"}}, c.Marks...) - content = append(content, c) - } + content = append(content, addMarkToContent(innerContent, ADFMark{Type: "em"})...) remaining = remaining[len(italicMatch[0]):] matched = true continue diff --git a/internal/api/markdown_code_mark_test.go b/internal/api/markdown_code_mark_test.go new file mode 100644 index 0000000..ebe0153 --- /dev/null +++ b/internal/api/markdown_code_mark_test.go @@ -0,0 +1,98 @@ +package api + +import ( + "encoding/json" + "testing" +) + +func TestMarkdownToADF_CodeInsideBold(t *testing.T) { + adf := MarkdownToADF("**Bold with `code` inside**") + + if len(adf.Content) != 1 { + t.Fatalf("expected 1 block, got %d", len(adf.Content)) + } + + para := adf.Content[0] + if para.Type != "paragraph" { + t.Fatalf("expected paragraph, got %s", para.Type) + } + + if len(para.Content) != 3 { + b, _ := json.MarshalIndent(adf, "", " ") + t.Fatalf("expected 3 inline nodes, got %d:\n%s", len(para.Content), b) + } + + // "Bold with " should have strong mark + if para.Content[0].Text != "Bold with " { + t.Errorf("expected 'Bold with ', got %q", para.Content[0].Text) + } + if len(para.Content[0].Marks) != 1 || para.Content[0].Marks[0].Type != "strong" { + t.Errorf("expected [strong] marks, got %v", para.Content[0].Marks) + } + + // "code" should have ONLY code mark (not strong+code) + if para.Content[1].Text != "code" { + t.Errorf("expected 'code', got %q", para.Content[1].Text) + } + if len(para.Content[1].Marks) != 1 || para.Content[1].Marks[0].Type != "code" { + t.Errorf("expected [code] marks only, got %v", para.Content[1].Marks) + } + + // " inside" should have strong mark + if para.Content[2].Text != " inside" { + t.Errorf("expected ' inside', got %q", para.Content[2].Text) + } + if len(para.Content[2].Marks) != 1 || para.Content[2].Marks[0].Type != "strong" { + t.Errorf("expected [strong] marks, got %v", para.Content[2].Marks) + } +} + +func TestMarkdownToADF_CodeInsideItalic(t *testing.T) { + adf := MarkdownToADF("*italic `code` here*") + + para := adf.Content[0] + for _, c := range para.Content { + if hasCodeMark(c) { + if len(c.Marks) != 1 { + t.Errorf("code node should have only code mark, got %v", c.Marks) + } + } + } +} + +func TestMarkdownToADF_CodeInsideStrikethrough(t *testing.T) { + adf := MarkdownToADF("~~deleted `code` here~~") + + para := adf.Content[0] + for _, c := range para.Content { + if hasCodeMark(c) { + if len(c.Marks) != 1 { + t.Errorf("code node should have only code mark, got %v", c.Marks) + } + } + } +} + +func TestMarkdownToADF_BoldWithCodeAndCodeBlock(t *testing.T) { + // This was the exact combination that caused INVALID_INPUT from Jira + md := "**Migration: Falsche `s_action` korrigieren**\n\n```sql\nSELECT 1\n```" + adf := MarkdownToADF(md) + + if len(adf.Content) != 2 { + b, _ := json.MarshalIndent(adf, "", " ") + t.Fatalf("expected 2 blocks (paragraph + codeBlock), got %d:\n%s", len(adf.Content), b) + } + + // Verify no code node has additional marks + para := adf.Content[0] + for _, c := range para.Content { + if hasCodeMark(c) && len(c.Marks) > 1 { + t.Errorf("code node should have only code mark, got %v", c.Marks) + } + } + + // Verify code block is present + if adf.Content[1].Type != "codeBlock" { + t.Errorf("expected codeBlock, got %s", adf.Content[1].Type) + } +}