From d79e4d20aa7f046bff34571c0e8f6dc2e8b87e08 Mon Sep 17 00:00:00 2001 From: Hinne Stolzenberg Date: Tue, 17 Feb 2026 12:39:01 +0100 Subject: [PATCH] fix: treat code mark as exclusive in ADF inline formatting Jira rejects ADF documents where the code mark is combined with other marks (strong, em, strike) on the same text node, returning 400 INVALID_INPUT. This happened when using inline code inside bold, italic, or strikethrough (e.g. **bold `code` text**). The code mark is exclusive in the ADF spec and must not overlap with other marks. When nesting inline code inside formatted text, the outer mark is now skipped for code nodes. --- internal/api/markdown.go | 50 ++++++++----- internal/api/markdown_code_mark_test.go | 98 +++++++++++++++++++++++++ 2 files changed, 128 insertions(+), 20 deletions(-) create mode 100644 internal/api/markdown_code_mark_test.go 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) + } +}