diff --git a/internal/forge/jira/adf.go b/internal/forge/jira/adf.go index 0bfa5fea1..14b174d8b 100644 --- a/internal/forge/jira/adf.go +++ b/internal/forge/jira/adf.go @@ -2,6 +2,7 @@ package jira import ( "fmt" + "html" "net/url" "reflect" "regexp" @@ -94,12 +95,223 @@ func adfBlockContent(parent ast.Node, source []byte, depth int, restricted bool) if depth > maxADFWriteDepth { return content } - for c := parent.FirstChild(); c != nil; c = c.NextSibling() { + for c := parent.FirstChild(); c != nil; { + // In non-restricted context, attempt to convert
+ // HTML blocks into ADF expand nodes. tryDetailsExpand + // handles both single-block (no blank lines inside the + // markup) and multi-block (blank lines split the
+ // opening, body, and closing across several AST siblings) + // forms, since sticky.BuildUpdatedBody produces the latter + // while hand-written Markdown typically produces the former. + if !restricted { + if expand, next := tryDetailsExpand(c, source, depth); expand != nil { + content = append(content, expand) + c = next + continue + } + } content = append(content, convertBlockNode(c, source, depth, restricted)...) + c = c.NextSibling() } return content } +// summaryTagPattern matches ... in HTML block content +// for extracting the expand title from a
block. +var summaryTagPattern = regexp.MustCompile(`(?is)(.*?)`) + +// summaryInnerTagPattern matches HTML tags inside element content +// for stripping in extractSummary. Ensures the ADF expand title contains only +// plain text — nested HTML tags like , , \nbody\n
", + title: "alert(1)", + }, + { + name: "entity-encoded tags decoded then stripped", + input: "
<script>alert(1)</script>\nbody\n
", + title: "alert(1)", + }, + { + name: "mixed text and tags", + input: "
Hello world & friends\nbody\n
", + title: "Hello world & friends", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + doc := mustADF(t, tc.input) + content := asSlice(t, doc["content"]) + if len(content) != 1 { + t.Fatalf("doc content len = %d, want 1", len(content)) + } + expand := asMap(t, content[0]) + if expand["type"] != "expand" { + t.Fatalf("block type = %v, want %q", expand["type"], "expand") + } + attrs := asMap(t, expand["attrs"]) + if attrs["title"] != tc.title { + t.Errorf("expand attrs.title = %v, want %q", attrs["title"], tc.title) + } + }) + } +} + +// --------------------------------------------------------------------------- +// ADFToMarkdown — expand →
+// --------------------------------------------------------------------------- + +func TestADFToMarkdown_ExpandNodeEscapesTitle(t *testing.T) { + // A title containing HTML special characters (especially + // ) must be escaped to prevent breaking the output. + adf := map[string]any{ + "type": "doc", + "content": []any{ + map[string]any{ + "type": "expand", + "attrs": map[string]any{"title": "ab"}, + "content": []any{ + map[string]any{"type": "paragraph", "content": []any{map[string]any{"type": "text", "text": "body"}}}, + }, + }, + }, + } + got := ADFToMarkdown(adf) + want := "
a</summary>b\nbody\n
" + if got != want { + t.Errorf("ADFToMarkdown(expand with HTML title) = %q, want %q", got, want) + } +} + +func TestADFToMarkdown_ExpandNode(t *testing.T) { + // An ADF expand node should render as
+ // with the body content inside, providing round-trip fidelity with + // MarkdownToADF's
→ expand conversion. + adf := map[string]any{ + "type": "doc", + "content": []any{ + map[string]any{ + "type": "expand", + "attrs": map[string]any{"title": "Click to expand"}, + "content": []any{ + map[string]any{"type": "paragraph", "content": []any{map[string]any{"type": "text", "text": "hidden content"}}}, + }, + }, + }, + } + got := ADFToMarkdown(adf) + want := "
Click to expand\nhidden content\n
" + if got != want { + t.Errorf("ADFToMarkdown(expand) = %q, want %q", got, want) + } +} + +func TestADFToMarkdown_ExpandNodeWithoutTitle(t *testing.T) { + adf := map[string]any{ + "type": "doc", + "content": []any{ + map[string]any{ + "type": "expand", + "content": []any{ + map[string]any{"type": "paragraph", "content": []any{map[string]any{"type": "text", "text": "content"}}}, + }, + }, + }, + } + got := ADFToMarkdown(adf) + want := "
\ncontent\n
" + if got != want { + t.Errorf("ADFToMarkdown(expand without title) = %q, want %q", got, want) + } +} diff --git a/internal/jirapoll/discover.go b/internal/jirapoll/discover.go index ee45bd701..a9abab857 100644 --- a/internal/jirapoll/discover.go +++ b/internal/jirapoll/discover.go @@ -474,7 +474,8 @@ func walkADFNode(node map[string]any, sb *strings.Builder, depth int) { func isBlockType(nodeType string) bool { switch nodeType { case "doc", "paragraph", "heading", "blockquote", "codeBlock", - "bulletList", "orderedList", "listItem", "panel", "rule": + "bulletList", "orderedList", "listItem", "panel", "rule", + "expand": return true default: return false