From e0934333dd678f537f2fe9937a517f7f62fc50c1 Mon Sep 17 00:00:00 2001 From: Evgeniy Sharapov Date: Wed, 6 May 2026 12:29:48 -0400 Subject: [PATCH] Add details/summary and HTML block support to accepted markdown. Changed markdown conversion features from custom plugin to current visitor architecture: - convert
/ HTML elements to Confluence expand macro - wrap and
blocks in Confluence HTML macros with CDATA - added necessary tests --- lib/html-to-storage.js | 51 ++++++++++++++++ lib/macro-converter.js | 21 +++++-- tests/html-to-storage.test.js | 112 ++++++++++++++++++++++++++++++++++ tests/macro-converter.test.js | 45 ++++++++++++++ 4 files changed, 224 insertions(+), 5 deletions(-) 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 = ``;
    +  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)}`;
       }
     }
    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 = '

    Content

    '; + 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('