diff --git a/README.md b/README.md index e969e46..0be3157 100644 --- a/README.md +++ b/README.md @@ -929,12 +929,26 @@ The reverse direction emits the same `> **INFO**` / `> **WARNING**` / `> **NOTE* A blockquote without one of these markers stays a **plain blockquote** (`
`) — `> …` is treated as a quotation, not an alert. Use the markers above when you want a callout. -### `**TOC**` — Table of Contents +### `**TOC**` / `[[_TOC_]]` — Table of Contents -A paragraph containing only `**TOC**` becomes a Confluence Table of Contents macro using the macro's default heading levels: +A paragraph containing only `**TOC**`, `[[_TOC_]]`, ``, or `` becomes a Confluence Table of Contents macro using the macro's default heading levels: ```markdown **TOC** + +[[_TOC_]] + + +``` + +### `[[_LISTING_]]` — child page listing + +A paragraph containing only `[[_LISTING_]]`, ``, or `` becomes a Confluence Children Display macro: + +```markdown +[[_LISTING_]] + + ``` ### `**ANCHOR: id**` — anchor diff --git a/lib/html-to-storage.js b/lib/html-to-storage.js index 072980a..33a43f9 100644 --- a/lib/html-to-storage.js +++ b/lib/html-to-storage.js @@ -17,8 +17,8 @@ class HtmlDepthExceededError extends Error { this.maxDepth = maxDepth; } } -// Only `hr` is normalized to self-closing. `br` / `img` flow through in -// whatever shape the source had (markdown-it emits them without a slash). +// `hr` and `br` are normalized to self-closing XHTML for Confluence Server/DC. +// `img` flows through in whatever shape the source had. const VOID_TAGS = new Set(['hr']); const CALLOUT_MARKERS = ['info', 'warning', 'note']; // these tags are wrapped into Confluence HTML macro @@ -54,6 +54,17 @@ function meaningfulChildren(node) { return (node.children || []).filter((c) => !isWhitespaceOnly(c)); } +function detectMacroMarkerText(text, { allowPlain = false } = {}) { + const marker = (text || '').trim(); + if (marker === '[[_TOC_]]' || marker === '_TOC_' || (allowPlain && marker === 'TOC')) { + return { kind: 'toc' }; + } + if (marker === '[[_LISTING_]]' || marker === '_LISTING_' || (allowPlain && marker === 'LISTING')) { + return { kind: 'children' }; + } + return null; +} + // Detects `

TOC

` and `

ANCHOR: id

` // macro markers. The strict "p > one strong > one text" shape is intentional — // any embellishment must fall through to a plain paragraph. @@ -61,13 +72,17 @@ function detectParagraphMarker(node) { if (node.name !== 'p') return null; const kids = meaningfulChildren(node); if (kids.length !== 1) return null; + if (kids[0].type === 'text') { + return detectMacroMarkerText(kids[0].data); + } const strong = kids[0]; if (strong.type !== 'tag' || strong.name !== 'strong') return null; const strongKids = meaningfulChildren(strong); if (strongKids.length !== 1) return null; const text = strongKids[0]; if (text.type !== 'text') return null; - if (text.data === 'TOC') return { kind: 'toc' }; + const macro = detectMacroMarkerText(text.data, { allowPlain: true }); + if (macro) return macro; const anchor = text.data.match(/^ANCHOR: (.+)$/); if (anchor) return { kind: 'anchor', id: anchor[1] }; return null; @@ -356,6 +371,12 @@ function walkChildren(node, ctx) { function walkNode(node, ctx) { if (node.type === 'text') return node.data; + if (node.type === 'comment') { + const macro = detectMacroMarkerText(node.data); + if (macro && macro.kind === 'toc') return ''; + if (macro && macro.kind === 'children') return ''; + return ''; + } if (node.type !== 'tag') return ''; if (++ctx.depth > ctx.maxDepth) { ctx.depth--; @@ -372,7 +393,8 @@ function dispatchTag(node, ctx) { switch (node.name) { case 'p': { const marker = detectParagraphMarker(node); - if (marker && marker.kind === 'toc') return ''; + if (marker && marker.kind === 'toc') return ''; + if (marker && marker.kind === 'children') return ''; if (marker && marker.kind === 'anchor') { return `${marker.id}`; } @@ -390,7 +412,7 @@ function dispatchTag(node, ctx) { case 'hr': return '
'; case 'br': - return '
'; + return '
'; case 'img': return ``; case 'ul': diff --git a/lib/macro-converter.js b/lib/macro-converter.js index e2def3e..e97f1a4 100644 --- a/lib/macro-converter.js +++ b/lib/macro-converter.js @@ -94,9 +94,19 @@ class MacroConverter { _renderMarkdownToHtml(markdown) { const codeRanges = this._findCodeRanges(markdown); const htmlStash = []; + const replaceStandaloneMarker = (text, marker, replacement) => { + const line = `(^|\\n)([^\\S\\n]*)${marker}[^\\S\\n]*(?=\\n|$)`; + return text.replace(new RegExp(line, 'gi'), (match, prefix, indent) => `${prefix}${indent}${replacement}`); + }; + const replaceMacroPlaceholders = (text) => { + let result = replaceStandaloneMarker(text, '', '**TOC**'); + result = replaceStandaloneMarker(result, '', '**LISTING**'); + result = replaceStandaloneMarker(result, '\\[\\[_TOC_\\]\\]', '**TOC**'); + return replaceStandaloneMarker(result, '\\[\\[_LISTING_\\]\\]', '**LISTING**'); + }; 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) => { + let result = replaceMacroPlaceholders(text).replace(PASSTHROUGH_BLOCK_RE, (m) => { htmlStash.push(m); return `${STASH_DELIM}H${htmlStash.length - 1}${STASH_DELIM}`; }); diff --git a/tests/html-to-storage.test.js b/tests/html-to-storage.test.js index e94796f..36f9b7f 100644 --- a/tests/html-to-storage.test.js +++ b/tests/html-to-storage.test.js @@ -35,8 +35,8 @@ describe('htmlToStorage', () => { expect(htmlToStorage('
')).toBe('
'); }); - test('br stays in markdown-it form `
` (V1 does not transform it)', () => { - expect(htmlToStorage('
')).toBe('
'); + test('br is normalized to self-closing XHTML', () => { + expect(htmlToStorage('
')).toBe('
'); }); test('img stays in markdown-it form `` with attributes preserved', () => { @@ -229,7 +229,25 @@ describe('htmlToStorage', () => { describe('paragraph marker patterns', () => { test('`

TOC

` becomes the toc macro', () => { expect(htmlToStorage('

TOC

')) - .toBe(''); + .toBe(''); + }); + + test('`

[[_TOC_]]

` becomes the toc macro', () => { + expect(htmlToStorage('

[[_TOC_]]

')) + .toBe(''); + }); + + test('`

LISTING

` becomes the children macro', () => { + expect(htmlToStorage('

LISTING

')) + .toBe(''); + }); + + test('macro placeholders in HTML comments become storage macros', () => { + expect(htmlToStorage('')) + .toBe( + '' + + '' + ); }); test('`

ANCHOR: id

` becomes the anchor macro', () => { @@ -278,7 +296,7 @@ describe('htmlToStorage', () => { test('TOC marker detection tolerates trailing whitespace text nodes', () => { const html = '

TOC\n

'; - expect(htmlToStorage(html)).toBe(''); + expect(htmlToStorage(html)).toBe(''); }); }); diff --git a/tests/macro-converter.test.js b/tests/macro-converter.test.js index 1b498bc..d308cf6 100644 --- a/tests/macro-converter.test.js +++ b/tests/macro-converter.test.js @@ -74,9 +74,57 @@ describe('MacroConverter markdownToStorage marker conventions', () => { describe('TOC', () => { test('**TOC** becomes Table of Contents macro', () => { const result = converter.markdownToStorage('**TOC**'); - expect(result).toContain(''); + expect(result).toContain(''); expect(result).not.toContain('**TOC**'); }); + + test('[[_TOC_]] becomes Table of Contents macro', () => { + const result = converter.markdownToStorage('[[_TOC_]]'); + expect(result).toContain(''); + expect(result).not.toContain('[[_TOC_]]'); + }); + + test('HTML comment TOC placeholders become Table of Contents macro', () => { + expect(converter.markdownToStorage('')) + .toContain(''); + expect(converter.markdownToStorage('')) + .toContain(''); + }); + }); + + describe('LISTING', () => { + test('[[_LISTING_]] becomes Children Display macro', () => { + const result = converter.markdownToStorage('[[_LISTING_]]'); + expect(result).toContain(''); + expect(result).not.toContain('[[_LISTING_]]'); + }); + + test('HTML comment LISTING placeholders become Children Display macro', () => { + expect(converter.markdownToStorage('')) + .toContain(''); + expect(converter.markdownToStorage('')) + .toContain(''); + }); + + test('macro placeholders inside code stay literal', () => { + const inline = converter.markdownToStorage('`[[_TOC_]]`'); + const fenced = converter.markdownToStorage('```\n\n```'); + + expect(inline).toContain('[[_TOC_]]'); + expect(inline).not.toContain('ac:name="toc"'); + expect(fenced).toContain(''); + expect(fenced).not.toContain('ac:name="children"'); + }); + + test('macro placeholders inside prose do not become macros', () => { + const toc = converter.markdownToStorage('Use [[_TOC_]] as a marker.'); + const listing = converter.markdownToStorage('Keep in prose.'); + + expect(toc).toContain('TOC'); + expect(toc).not.toContain('ac:name="toc"'); + expect(listing).toContain('LISTING'); + expect(listing).not.toContain('ac:name="children"'); + }); }); describe('ANCHOR', () => { @@ -1053,19 +1101,34 @@ describe('MacroConverter
passthrough', () => { // markdown, since markdown table grammar allows only inline text per cell. const converter = new MacroConverter({ isCloud: true }); - test('
in a table cell passes through instead of being escaped', () => { + test('
in a table cell becomes self-closing storage XHTML instead of being escaped', () => { const md = '| 버전 | 변경 |\n| --- | --- |\n| v1 | 가

다 |'; const result = converter.markdownToStorage(md); - expect(result).toContain('



'); + expect(result).toContain('



'); expect(result).not.toContain('<br>'); }); - test('
and
variants pass through', () => { + test('
and
variants normalize to self-closing storage XHTML', () => { const result = converter.markdownToStorage('a
b and c
d'); - expect(result).toContain('a
b and c
d'); + expect(result).toContain('a
b and c
d'); expect(result).not.toContain('<br'); }); + test('markdown hard breaks normalize to self-closing storage XHTML', () => { + const markdown = [ + '# Lines paragraph', + '**Test Line 1:** line 1 ', + '**Test Line 2:** `line 2` ', + '**Test Line 3:** line 3' + ].join('\n'); + + const result = converter.markdownToStorage(markdown); + + expect(result).toContain('Test Line 1: line 1
'); + expect(result).toContain('Test Line 2: line 2
'); + expect(result).not.toContain('
'); + }); + test('literal
inside inline code stays escaped, not passed through', () => { const result = converter.markdownToStorage('`
`'); expect(result).toContain('<br>');