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>');