Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -929,12 +929,26 @@ The reverse direction emits the same `> **INFO**` / `> **WARNING**` / `> **NOTE*

A blockquote without one of these markers stays a **plain blockquote** (`<blockquote>…</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_]]`, `<!-- _TOC_ -->`, or `<!-- [[_TOC_]] -->` becomes a Confluence Table of Contents macro using the macro's default heading levels:

```markdown
**TOC**

[[_TOC_]]

<!-- _TOC_ -->
```

### `[[_LISTING_]]` — child page listing

A paragraph containing only `[[_LISTING_]]`, `<!-- _LISTING_ -->`, or `<!-- [[_LISTING_]] -->` becomes a Confluence Children Display macro:

```markdown
[[_LISTING_]]

<!-- _LISTING_ -->
```

### `**ANCHOR: id**` — anchor
Expand Down
32 changes: 27 additions & 5 deletions lib/html-to-storage.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -54,20 +54,35 @@ 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 `<p><strong>TOC</strong></p>` and `<p><strong>ANCHOR: id</strong></p>`
// macro markers. The strict "p > one strong > one text" shape is intentional —
// any embellishment must fall through to a plain paragraph.
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;
Expand Down Expand Up @@ -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 '<ac:structured-macro ac:name="toc" ac:schema-version="1" />';
if (macro && macro.kind === 'children') return '<ac:structured-macro ac:name="children" ac:schema-version="2" />';
return '';
}
if (node.type !== 'tag') return '';
if (++ctx.depth > ctx.maxDepth) {
ctx.depth--;
Expand All @@ -372,7 +393,8 @@ function dispatchTag(node, ctx) {
switch (node.name) {
case 'p': {
const marker = detectParagraphMarker(node);
if (marker && marker.kind === 'toc') return '<ac:structured-macro ac:name="toc" />';
if (marker && marker.kind === 'toc') return '<ac:structured-macro ac:name="toc" ac:schema-version="1" />';
if (marker && marker.kind === 'children') return '<ac:structured-macro ac:name="children" ac:schema-version="2" />';
if (marker && marker.kind === 'anchor') {
return `<ac:structured-macro ac:name="anchor"><ac:parameter ac:name="">${marker.id}</ac:parameter></ac:structured-macro>`;
}
Expand All @@ -390,7 +412,7 @@ function dispatchTag(node, ctx) {
case 'hr':
return '<hr />';
case 'br':
return '<br>';
return '<br />';
case 'img':
return `<img${renderAttrs(node.attribs)}>`;
case 'ul':
Expand Down
12 changes: 11 additions & 1 deletion lib/macro-converter.js
Original file line number Diff line number Diff line change
Expand Up @@ -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, '<!--\\s*(?:\\[\\[)?_TOC_(?:\\]\\])?\\s*-->', '**TOC**');
result = replaceStandaloneMarker(result, '<!--\\s*(?:\\[\\[)?_LISTING_(?:\\]\\])?\\s*-->', '**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}`;
});
Expand Down
26 changes: 22 additions & 4 deletions tests/html-to-storage.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,8 @@ describe('htmlToStorage', () => {
expect(htmlToStorage('<hr>')).toBe('<hr />');
});

test('br stays in markdown-it form `<br>` (V1 does not transform it)', () => {
expect(htmlToStorage('<br>')).toBe('<br>');
test('br is normalized to self-closing XHTML', () => {
expect(htmlToStorage('<br>')).toBe('<br />');
});

test('img stays in markdown-it form `<img …>` with attributes preserved', () => {
Expand Down Expand Up @@ -229,7 +229,25 @@ describe('htmlToStorage', () => {
describe('paragraph marker patterns', () => {
test('`<p><strong>TOC</strong></p>` becomes the toc macro', () => {
expect(htmlToStorage('<p><strong>TOC</strong></p>'))
.toBe('<ac:structured-macro ac:name="toc" />');
.toBe('<ac:structured-macro ac:name="toc" ac:schema-version="1" />');
});

test('`<p>[[_TOC_]]</p>` becomes the toc macro', () => {
expect(htmlToStorage('<p>[[_TOC_]]</p>'))
.toBe('<ac:structured-macro ac:name="toc" ac:schema-version="1" />');
});

test('`<p><strong>LISTING</strong></p>` becomes the children macro', () => {
expect(htmlToStorage('<p><strong>LISTING</strong></p>'))
.toBe('<ac:structured-macro ac:name="children" ac:schema-version="2" />');
});

test('macro placeholders in HTML comments become storage macros', () => {
expect(htmlToStorage('<!-- _TOC_ --><!-- [[_LISTING_]] -->'))
.toBe(
'<ac:structured-macro ac:name="toc" ac:schema-version="1" />' +
'<ac:structured-macro ac:name="children" ac:schema-version="2" />'
);
});

test('`<p><strong>ANCHOR: id</strong></p>` becomes the anchor macro', () => {
Expand Down Expand Up @@ -278,7 +296,7 @@ describe('htmlToStorage', () => {

test('TOC marker detection tolerates trailing whitespace text nodes', () => {
const html = '<p><strong>TOC</strong>\n</p>';
expect(htmlToStorage(html)).toBe('<ac:structured-macro ac:name="toc" />');
expect(htmlToStorage(html)).toBe('<ac:structured-macro ac:name="toc" ac:schema-version="1" />');
});
});

Expand Down
73 changes: 68 additions & 5 deletions tests/macro-converter.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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('<ac:structured-macro ac:name="toc" />');
expect(result).toContain('<ac:structured-macro ac:name="toc" ac:schema-version="1" />');
expect(result).not.toContain('**TOC**');
});

test('[[_TOC_]] becomes Table of Contents macro', () => {
const result = converter.markdownToStorage('[[_TOC_]]');
expect(result).toContain('<ac:structured-macro ac:name="toc" ac:schema-version="1" />');
expect(result).not.toContain('[[_TOC_]]');
});

test('HTML comment TOC placeholders become Table of Contents macro', () => {
expect(converter.markdownToStorage('<!-- _TOC_ -->'))
.toContain('<ac:structured-macro ac:name="toc" ac:schema-version="1" />');
expect(converter.markdownToStorage('<!-- [[_TOC_]] -->'))
.toContain('<ac:structured-macro ac:name="toc" ac:schema-version="1" />');
});
});

describe('LISTING', () => {
test('[[_LISTING_]] becomes Children Display macro', () => {
const result = converter.markdownToStorage('[[_LISTING_]]');
expect(result).toContain('<ac:structured-macro ac:name="children" ac:schema-version="2" />');
expect(result).not.toContain('[[_LISTING_]]');
});

test('HTML comment LISTING placeholders become Children Display macro', () => {
expect(converter.markdownToStorage('<!-- _LISTING_ -->'))
.toContain('<ac:structured-macro ac:name="children" ac:schema-version="2" />');
expect(converter.markdownToStorage('<!-- [[_LISTING_]] -->'))
.toContain('<ac:structured-macro ac:name="children" ac:schema-version="2" />');
});

test('macro placeholders inside code stay literal', () => {
const inline = converter.markdownToStorage('`[[_TOC_]]`');
const fenced = converter.markdownToStorage('```\n<!-- _LISTING_ -->\n```');

expect(inline).toContain('<code>[[_TOC_]]</code>');
expect(inline).not.toContain('ac:name="toc"');
expect(fenced).toContain('<!-- _LISTING_ -->');
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 <!-- _LISTING_ --> in prose.');

expect(toc).toContain('<em>TOC</em>');
expect(toc).not.toContain('ac:name="toc"');
expect(listing).toContain('<em>LISTING</em>');
expect(listing).not.toContain('ac:name="children"');
});
});

describe('ANCHOR', () => {
Expand Down Expand Up @@ -1053,19 +1101,34 @@ describe('MacroConverter <br> passthrough', () => {
// markdown, since markdown table grammar allows only inline text per cell.
const converter = new MacroConverter({ isCloud: true });

test('<br> in a table cell passes through instead of being escaped', () => {
test('<br> in a table cell becomes self-closing storage XHTML instead of being escaped', () => {
const md = '| 버전 | 변경 |\n| --- | --- |\n| v1 | 가<br>나<br>다 |';
const result = converter.markdownToStorage(md);
expect(result).toContain('<td><p>가<br>나<br>다</p></td>');
expect(result).toContain('<td><p>가<br />나<br />다</p></td>');
expect(result).not.toContain('&lt;br&gt;');
});

test('<br/> and <br /> variants pass through', () => {
test('<br/> and <br /> variants normalize to self-closing storage XHTML', () => {
const result = converter.markdownToStorage('a<br/>b and c<br />d');
expect(result).toContain('a<br>b and c<br>d');
expect(result).toContain('a<br />b and c<br />d');
expect(result).not.toContain('&lt;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('<strong>Test Line 1:</strong> line 1<br />');
expect(result).toContain('<strong>Test Line 2:</strong> <code>line 2</code><br />');
expect(result).not.toContain('<br>');
});

test('literal <br> inside inline code stays escaped, not passed through', () => {
const result = converter.markdownToStorage('`<br>`');
expect(result).toContain('&lt;br&gt;');
Expand Down