Skip to content

Commit 213b910

Browse files
authored
feat: add TOC, ANCHOR, and same-page anchor link conversions (#118)
- **TOC**: `**TOC**` paragraph → Confluence Table of Contents macro (uses macro defaults) - **ANCHOR**: `**ANCHOR: id**` paragraph → anchor macro - **Same-page anchor links**: `[text](#id)` → `ac:link` with `ac:anchor`, run before the linkStyle branch so it works under all three modes (smart / wiki / plain) QUOTE marker from the original PR has been deferred to #125.
1 parent fdca670 commit 213b910

2 files changed

Lines changed: 77 additions & 0 deletions

File tree

lib/macro-converter.js

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,12 @@ class MacroConverter {
8282

8383
storage = storage.replace(/<code>(.*?)<\/code>/g, '<code>$1</code>');
8484

85+
// **TOC** paragraph → Confluence Table of Contents macro (uses macro defaults)
86+
storage = storage.replace(
87+
/<p><strong>TOC<\/strong><\/p>/g,
88+
'<ac:structured-macro ac:name="toc" />'
89+
);
90+
8591
storage = storage.replace(/<blockquote>(.*?)<\/blockquote>/gs, (_, content) => {
8692
if (content.includes('<strong>INFO</strong>')) {
8793
const cleanContent = content.replace(/<p><strong>INFO<\/strong><\/p>\s*/, '');
@@ -112,6 +118,26 @@ class MacroConverter {
112118
storage = storage.replace(/<th>(.*?)<\/th>/g, '<th><p>$1</p></th>');
113119
storage = storage.replace(/<td>(.*?)<\/td>/g, '<td><p>$1</p></td>');
114120

121+
// **ANCHOR: id** paragraph → Confluence anchor macro
122+
storage = storage.replace(
123+
/<p><strong>ANCHOR: (.*?)<\/strong><\/p>/g,
124+
'<ac:structured-macro ac:name="anchor"><ac:parameter ac:name="">$1</ac:parameter></ac:structured-macro>'
125+
);
126+
127+
// Same-page anchor links (href="#id") → ac:link with ac:anchor. Must run
128+
// before the general link conversion below so the #id pattern is not
129+
// consumed by the generic <a href> replacement (and so it works under
130+
// all linkStyle modes, including "plain" which leaves <a> tags as-is).
131+
storage = storage.replace(/<a href="#(.*?)">(.*?)<\/a>/gs, (_, anchor, body) => {
132+
const text = body
133+
.replace(/&amp;/g, '&')
134+
.replace(/&lt;/g, '<')
135+
.replace(/&gt;/g, '>')
136+
.replace(/&quot;/g, '"')
137+
.replace(/&#39;/g, '\'');
138+
return `<ac:link ac:anchor="${anchor}"><ac:plain-text-link-body><![CDATA[${text}]]></ac:plain-text-link-body></ac:link>`;
139+
});
140+
115141
// Convert links based on linkStyle:
116142
// "smart" — Cloud smart links (<a data-card-appearance="inline">)
117143
// "plain" — simple <a href>; workaround for "Cannot handle: DefaultLink"

tests/macro-converter.test.js

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,3 +66,54 @@ describe('MacroConverter', () => {
6666
});
6767
});
6868
});
69+
70+
describe('MacroConverter markdownToStorage marker conventions', () => {
71+
// isCloud: true so link output matches the smart-link branch in mixed tests.
72+
const converter = new MacroConverter({ isCloud: true });
73+
74+
describe('TOC', () => {
75+
test('**TOC** becomes Table of Contents macro', () => {
76+
const result = converter.markdownToStorage('**TOC**');
77+
expect(result).toContain('<ac:structured-macro ac:name="toc" />');
78+
expect(result).not.toContain('**TOC**');
79+
});
80+
});
81+
82+
describe('ANCHOR', () => {
83+
test('**ANCHOR: id** becomes anchor macro with given id', () => {
84+
const result = converter.markdownToStorage('**ANCHOR: my-section**');
85+
expect(result).toContain('<ac:structured-macro ac:name="anchor">');
86+
expect(result).toContain('<ac:parameter ac:name="">my-section</ac:parameter>');
87+
expect(result).not.toContain('**ANCHOR');
88+
});
89+
});
90+
91+
describe('same-page anchor links', () => {
92+
test('[text](#id) becomes ac:link with ac:anchor', () => {
93+
const result = converter.markdownToStorage('[Jump](#my-section)');
94+
expect(result).toContain('<ac:link ac:anchor="my-section">');
95+
expect(result).toContain('<![CDATA[Jump]]>');
96+
});
97+
98+
test('anchor-link conversion runs before general link conversion on Cloud', () => {
99+
const result = converter.markdownToStorage(
100+
'[Jump](#my-section) and [External](https://example.com)'
101+
);
102+
expect(result).toContain('ac:anchor="my-section"');
103+
expect(result).toContain('data-card-appearance="inline"');
104+
});
105+
106+
test('anchor-link conversion runs before general link conversion on Server/DC', () => {
107+
const serverConverter = new MacroConverter({ isCloud: false });
108+
const result = serverConverter.markdownToStorage(
109+
'[Jump](#my-section) and [External](https://example.com)'
110+
);
111+
expect(result).toContain('ac:anchor="my-section"');
112+
// External link should get the ac:link + ri:url storage format, not be
113+
// double-wrapped by the anchor replacement.
114+
expect(result).toContain('ri:value="https://example.com"');
115+
expect(result).not.toContain('ac:anchor="https');
116+
});
117+
});
118+
119+
});

0 commit comments

Comments
 (0)