diff --git a/lib/html-to-storage.js b/lib/html-to-storage.js
index a3f88b9..ba44ae9 100644
--- a/lib/html-to-storage.js
+++ b/lib/html-to-storage.js
@@ -21,6 +21,8 @@ class HtmlDepthExceededError extends Error {
// whatever shape the source had (markdown-it emits them without a slash).
const VOID_TAGS = new Set(['hr']);
const CALLOUT_MARKERS = ['info', 'warning', 'note'];
+// these tags are wrapped into Confluence HTML macro
+const HTML_MACRO_TAGS = new Set(['svg', 'div']);
// Phrasing-content tags that trigger the `
` / `` / ` ` ``-wrap
// quirk: if an item contains only inline children and no text-node newline,
@@ -218,6 +220,36 @@ function convertBlockquote(node, ctx) {
`;
}
+// `` becomes expand macro. If no summary child is found,
+// falls through to plain HTML.
+function convertDetails(node, ctx) {
+ const children = node.children || [];
+ let summaryNode = null;
+ let bodyNodes = [];
+
+ for (const child of children) {
+ if (child.type === 'tag' && child.name === 'summary') {
+ summaryNode = child;
+ } else if (!isWhitespaceOnly(child)) {
+ bodyNodes.push(child);
+ }
+ }
+
+ if (!summaryNode) {
+ return `${walkChildren(node, ctx)} `;
+ }
+
+ const titleHtml = walkChildren(summaryNode, ctx);
+ const cleanTitle = titleHtml.replace(/<[^>]+>/g, '').trim();
+
+ const bodyHtml = bodyNodes
+ .map((c) => walkNode(c, ctx))
+ .join('')
+ .trim();
+
+ return `${cleanTitle} ${bodyHtml} `;
+}
+
// Strict `` adjacency only — `` with whitespace siblings or
// any other shape falls through as plain ``. The body needs manual
// entity decode because the parser keeps entities raw and CDATA is opaque
@@ -243,6 +275,20 @@ function convertCodeBlock(node, ctx) {
return `${language} `;
}
+// Wrap allowlisted HTML tags (svg, div) in Confluence HTML macro with CDATA.
+// Used for embedding custom HTML that Confluence doesn't natively support.
+function convertHtmlBlock(node, ctx) {
+ const { randomUUID } = require('crypto');
+ const inner = walkChildren(node, ctx);
+ const attrsStr = renderAttrs(node.attribs);
+ const openTag = `<${node.name}${attrsStr}>`;
+ const closeTag = `${node.name}>`;
+ const htmlContent = openTag + inner + closeTag;
+ const safeContent = htmlContent.replace(/]]>/g, ']]]]>');
+ const macroId = randomUUID();
+ return ` `;
+}
+
// Re-escape literal `"` inside attribute values. htmlparser2 with
// `decodeEntities: false` keeps source-escaped entities intact, but a
// single-quoted source attribute (``) lands a
@@ -359,6 +405,8 @@ function dispatchTag(node, ctx) {
return convertLink(node, ctx);
case 'blockquote':
return convertBlockquote(node, ctx);
+ case 'details':
+ return convertDetails(node, ctx);
case 'table':
case 'thead':
case 'tbody':
@@ -375,6 +423,9 @@ function dispatchTag(node, ctx) {
if (VOID_TAGS.has(node.name)) {
return `<${node.name}${renderAttrs(node.attribs)} />`;
}
+ if (HTML_MACRO_TAGS.has(node.name)) {
+ return convertHtmlBlock(node, ctx);
+ }
return `<${node.name}${renderAttrs(node.attribs)}>${walkChildren(node, ctx)}${node.name}>`;
}
}
diff --git a/lib/macro-converter.js b/lib/macro-converter.js
index cfe55c6..2dc2a25 100644
--- a/lib/macro-converter.js
+++ b/lib/macro-converter.js
@@ -21,7 +21,9 @@ const STASH_DELIM = '\uE000';
// The body alternation `"[^"]*"|'[^']*'|[^>]` makes the match quote-aware
// so a literal `>` inside a quoted attribute value (e.g.
// ``) does not terminate the tag prematurely.
-const PASSTHROUGH_TAG_RE = /<\/?(?:u|sub|sup|mark)(?=[\s/>])(?:"[^"]*"|'[^']*'|[^>])*>/gi;
+const PASSTHROUGH_TAG_RE = /<\/?(?:u|sub|sup|mark|details|summary)(?=[\s/>])(?:"[^"]*"|'[^']*'|[^>])*>/gi;
+// Block-level HTML elements that should pass through WITHOUT markdown processing of their content.
+const PASSTHROUGH_BLOCK_RE = /<(svg|div)(?:\s[^>]*)?>[\s\S]*?<\/\1>/gi;
// Single-backtick inline code spans. Block-level code (fenced + indented) is
// detected via MarkdownIt's tokenizer in `_findCodeRanges` because a regex
// can't reliably distinguish a 4-space-indented code block from a list-item
@@ -92,10 +94,19 @@ class MacroConverter {
_renderMarkdownToHtml(markdown) {
const codeRanges = this._findCodeRanges(markdown);
const htmlStash = [];
- const stashHtml = (text) => text.replace(PASSTHROUGH_TAG_RE, (m) => {
- htmlStash.push(m);
- return `${STASH_DELIM}H${htmlStash.length - 1}${STASH_DELIM}`;
- });
+ const stashHtml = (text) => {
+ // block-level HTML (svg, div with all content) must be stashed before inline tags to avoid matching the closing tag as inline HTML
+ let result = text.replace(PASSTHROUGH_BLOCK_RE, (m) => {
+ htmlStash.push(m);
+ return `${STASH_DELIM}H${htmlStash.length - 1}${STASH_DELIM}`;
+ });
+ // Then stash inline HTML tags
+ result = result.replace(PASSTHROUGH_TAG_RE, (m) => {
+ htmlStash.push(m);
+ return `${STASH_DELIM}H${htmlStash.length - 1}${STASH_DELIM}`;
+ });
+ return result;
+ };
let src = '';
let pos = 0;
diff --git a/tests/html-to-storage.test.js b/tests/html-to-storage.test.js
index a82a6af..e94796f 100644
--- a/tests/html-to-storage.test.js
+++ b/tests/html-to-storage.test.js
@@ -393,4 +393,116 @@ describe('htmlToStorage', () => {
expect(() => htmlToStorage(html)).not.toThrow();
});
});
+
+ describe('details/summary conversion', () => {
+ test('basic details with summary becomes expand macro', () => {
+ const html = 'Click me Hidden content
';
+ const out = htmlToStorage(html);
+ expect(out).toContain('');
+ expect(out).toContain('Click me ');
+ expect(out).toContain('Hidden content
');
+ expect(out).not.toContain('');
+ expect(out).not.toContain('');
+ });
+
+ test('summary with inline HTML has tags stripped from title', () => {
+ const html = 'View code body
';
+ const out = htmlToStorage(html);
+ expect(out).toContain('View code ');
+ expect(out).not.toContain('code ');
+ });
+
+ test('details with multiple paragraphs in body', () => {
+ const html = 'More First
Second
';
+ const out = htmlToStorage(html);
+ expect(out).toContain('First
Second
');
+ });
+
+ test('details with code block inside body', () => {
+ const html = 'Code console.log(); ';
+ const out = htmlToStorage(html);
+ expect(out).toContain('');
+ expect(out).toContain('');
+ expect(out).toContain('console.log();');
+ });
+
+ test('details without summary falls through as plain HTML', () => {
+ const html = 'No summary here
';
+ const out = htmlToStorage(html);
+ expect(out).toContain('');
+ expect(out).not.toContain('ac:structured-macro');
+ });
+
+ test('nested details inside info callout', () => {
+ const html = 'INFO
More Nested
';
+ const out = htmlToStorage(html);
+ expect(out).toContain('');
+ expect(out).toContain('');
+ expect(out).toContain('Nested');
+ });
+
+ test('details inside details (nested expand macros)', () => {
+ const html = 'Outer Inner Deep
';
+ const out = htmlToStorage(html);
+ const macroCount = (out.match(/ac:name="expand"/g) || []).length;
+ expect(macroCount).toBe(2);
+ expect(out).toContain('Deep');
+ });
+ });
+
+ describe('HTML macro wrapping', () => {
+ test('SVG block is wrapped in HTML macro with CDATA', () => {
+ const html = ' ';
+ const out = htmlToStorage(html);
+ expect(out).toContain('');
+ expect(out).toContain(']]>');
+ });
+
+ test('div block is wrapped in HTML macro', () => {
+ const html = '';
+ const out = htmlToStorage(html);
+ expect(out).toContain('');
+ });
+
+ test('CDATA end marker is escaped in HTML content', () => {
+ const html = 'test]]>end
';
+ const out = htmlToStorage(html);
+ expect(out).toContain('test]]]]>end');
+ expect(out).not.toContain('test]]>end');
+ });
+
+ test('each HTML block gets unique UUID', () => {
+ const html = ' ';
+ const out = htmlToStorage(html);
+ const ids = out.match(/ac:macro-id="([^"]+)"/g);
+ expect(ids).toHaveLength(2);
+ expect(ids[0]).not.toBe(ids[1]);
+ });
+
+ test('video tag is NOT wrapped (not in allowlist)', () => {
+ const html = ' ';
+ const out = htmlToStorage(html);
+ expect(out).toBe(' ');
+ expect(out).not.toContain('ac:structured-macro');
+ });
+
+ test('normal paragraph is NOT wrapped', () => {
+ const html = 'Regular paragraph
';
+ const out = htmlToStorage(html);
+ expect(out).toBe('Regular paragraph
');
+ expect(out).not.toContain('ac:structured-macro');
+ });
+
+ test('span is NOT wrapped (not in allowlist)', () => {
+ const html = 'inline ';
+ const out = htmlToStorage(html);
+ expect(out).toBe('inline ');
+ expect(out).not.toContain('ac:structured-macro');
+ });
+ });
});
diff --git a/tests/macro-converter.test.js b/tests/macro-converter.test.js
index 34e79be..29c860a 100644
--- a/tests/macro-converter.test.js
+++ b/tests/macro-converter.test.js
@@ -1316,3 +1316,48 @@ describe('MacroConverter integration smoke tests', () => {
expect(result).toContain('c
');
});
});
+
+describe('markdown with details/summary', () => {
+ const converter = new MacroConverter({ isCloud: true });
+
+ test('details in markdown converts to expand macro', () => {
+ const md = `
+Show more
+
+Hidden paragraph
+
+ `;
+ const out = converter.markdownToStorage(md);
+ expect(out).toContain('');
+ expect(out).toContain('Show more ');
+ expect(out).toContain('Hidden paragraph');
+ });
+
+ test('details with code block inside', () => {
+ const md = `
+View code
+
+\`\`\`javascript
+console.log("test");
+\`\`\`
+
+ `;
+ const out = converter.markdownToStorage(md);
+ expect(out).toContain('');
+ expect(out).toContain('');
+ expect(out).toContain('console.log');
+ });
+});
+
+describe('markdown with HTML blocks', () => {
+ const converter = new MacroConverter({ isCloud: true });
+
+ test('SVG in markdown wraps in HTML macro', () => {
+ const md = `
+
+ `;
+ const out = converter.markdownToStorage(md);
+ expect(out).toContain('