Skip to content

Commit 2fa54fb

Browse files
authored
fix: render external links as standard anchors (#214)
1 parent 7b7b2cc commit 2fa54fb

9 files changed

Lines changed: 53 additions & 29 deletions

File tree

README.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -264,7 +264,7 @@ The stats file (`stats.json`) follows the same resolution and lives alongside `c
264264

265265
**Custom domains on Confluence Cloud:**
266266

267-
If your Confluence Cloud instance uses a custom domain (e.g., `wiki.example.org` instead of `*.atlassian.net`), the CLI may misidentify it as a Server/Data Center instance and produce broken link formats. Set `CONFLUENCE_FORCE_CLOUD=true` to override the automatic detection:
267+
If your Confluence Cloud instance uses a custom domain (e.g., `wiki.example.org` instead of `*.atlassian.net`), the CLI may identify it as a Server/Data Center instance and use standard links instead of Cloud smart links. Set `CONFLUENCE_FORCE_CLOUD=true` to override the automatic detection:
268268

269269
```bash
270270
export CONFLUENCE_FORCE_CLOUD=true
@@ -283,9 +283,9 @@ Or add `"forceCloud": true` to your profile in the config file (see [Config file
283283
}
284284
```
285285

286-
**Link rendering on Cloud (`linkStyle`):**
286+
**Link rendering (`linkStyle`):**
287287

288-
Some Cloud instances — particularly custom-domain Cloud setups — fail to render smart links (`<a data-card-appearance="inline">`) and show "Cannot handle: DefaultLink" errors instead. If you hit this, set `linkStyle` to `plain` to emit simple `<a href>` tags, which render reliably everywhere:
288+
External links use Cloud smart links (`<a data-card-appearance="inline">`) on Cloud and standard `<a href>` tags on Server/Data Center and local conversions. Some Cloud instances — particularly custom-domain Cloud setups — fail to render smart links and show "Cannot handle: DefaultLink" errors instead. If you hit this, set `linkStyle` to `plain`:
289289

290290
```bash
291291
export CONFLUENCE_LINK_STYLE=plain
@@ -305,7 +305,7 @@ Or per-profile:
305305
}
306306
```
307307

308-
Valid values: `smart` (Cloud smart links), `plain` (simple `<a href>`), `wiki` (Server/DC `ac:link`). When unset, the CLI picks `smart` for Cloud and `wiki` for Server/DC — existing behavior is unchanged.
308+
Valid values: `smart` (Cloud smart links), `plain` (standard `<a href>`), and `wiki` (legacy `ac:link` + `ri:url` output for explicit backward compatibility). When unset, the CLI picks `smart` for Cloud and `plain` for Server/Data Center and local conversions. New configurations should not use `wiki` for external links.
309309

310310
**Read-only mode** (recommended for AI agents):
311311
```bash

lib/config.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ const AUTH_CHOICES = [
5050

5151
const AUTH_TYPES = ['basic', 'bearer', 'mtls', 'cookie', 'none'];
5252

53-
const { VALID_LINK_STYLES } = require('./macro-converter');
53+
const { VALID_LINK_STYLES } = require('./link-style');
5454

5555
const normalizeLinkStyle = (rawValue, source) => {
5656
if (rawValue === undefined || rawValue === null || rawValue === '') {

lib/html-to-storage.js

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@
44
// attributes preserved.
55

66
const { parseDocument } = require('htmlparser2');
7+
const { resolveLinkStyle } = require('./link-style');
78

8-
const VALID_LINK_STYLES = ['smart', 'plain', 'wiki'];
99
// Hard cap on input HTML nesting to keep the recursive walker off the JS
1010
// stack ceiling for pathological / malicious input.
1111
const DEFAULT_MAX_DEPTH = 256;
@@ -459,9 +459,7 @@ function dispatchTag(node, ctx) {
459459

460460
function htmlToStorage(html, options = {}) {
461461
const isCloud = !!options.isCloud;
462-
const linkStyle = VALID_LINK_STYLES.includes(options.linkStyle)
463-
? options.linkStyle
464-
: (isCloud ? 'smart' : 'wiki');
462+
const linkStyle = resolveLinkStyle({ isCloud, linkStyle: options.linkStyle });
465463
const ctx = {
466464
linkStyle,
467465
depth: 0,

lib/link-style.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
const VALID_LINK_STYLES = ['smart', 'plain', 'wiki'];
2+
3+
function resolveLinkStyle({ isCloud = false, linkStyle = null } = {}) {
4+
if (VALID_LINK_STYLES.includes(linkStyle)) {
5+
return linkStyle;
6+
}
7+
return isCloud ? 'smart' : 'plain';
8+
}
9+
10+
module.exports = { VALID_LINK_STYLES, resolveLinkStyle };

lib/macro-converter.js

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
const MarkdownIt = require('markdown-it');
22
const { StorageWalker } = require('./storage-walker');
33
const { htmlToStorage } = require('./html-to-storage');
4+
const { VALID_LINK_STYLES, resolveLinkStyle } = require('./link-style');
45

5-
const VALID_LINK_STYLES = ['smart', 'plain', 'wiki'];
66
const CALLOUT_MARKERS = ['info', 'warning', 'note'];
77
// U+E000 (Unicode Private Use Area) is used as the stash placeholder
88
// delimiter. Declared as an explicit escape so the byte is visible in source
@@ -35,9 +35,7 @@ class MacroConverter {
3535
this._isCloud = isCloud;
3636
this.webUrlPrefix = webUrlPrefix;
3737
this.buildUrl = buildUrl || ((pathOrUrl) => pathOrUrl);
38-
this.linkStyle = VALID_LINK_STYLES.includes(linkStyle)
39-
? linkStyle
40-
: (isCloud ? 'smart' : 'wiki');
38+
this.linkStyle = resolveLinkStyle({ isCloud, linkStyle });
4139
this.markdown = new MarkdownIt();
4240
this.setupConfluenceMarkdownExtensions();
4341
}

plugins/confluence/skills/confluence/SKILL.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ confluence --version # verify install
3030
| `CONFLUENCE_PROFILE` | Named profile to use (optional) | `staging` |
3131
| `CONFLUENCE_READ_ONLY` | Block all write operations when `true` | `true` |
3232
| `CONFLUENCE_FORCE_CLOUD` | Force Cloud link format for custom domains | `true` |
33-
| `CONFLUENCE_LINK_STYLE` | Override link rendering: `smart`, `plain`, or `wiki` | `plain` |
33+
| `CONFLUENCE_LINK_STYLE` | Override link rendering: `smart`, `plain`, or legacy `wiki` | `plain` |
3434

3535
**Global `--profile` flag (use a named profile for any command):**
3636

@@ -55,7 +55,7 @@ confluence init \
5555

5656
**Cloud vs Server/DC:**
5757
- Atlassian Cloud (`*.atlassian.net`): use `--api-path "/wiki/rest/api"`, auth type `basic` with email + API token
58-
- Atlassian Cloud (custom domain): if your Cloud instance uses a custom domain (e.g., `wiki.example.org`), set `CONFLUENCE_FORCE_CLOUD=true` or add `"forceCloud": true` to your profile in `~/.confluence-cli/config.json`. Without this, links will render incorrectly.
58+
- Atlassian Cloud (custom domain): if your Cloud instance uses a custom domain (e.g., `wiki.example.org`), set `CONFLUENCE_FORCE_CLOUD=true` or add `"forceCloud": true` to your profile in `~/.confluence-cli/config.json` to enable Cloud smart-link rendering.
5959
- Atlassian Cloud (scoped token): use `--domain "api.atlassian.com"`, `--api-path "/ex/confluence/<your-cloud-id>/wiki/rest/api"`, auth type `basic` with email + scoped token. Get your Cloud ID from `https://<your-site>.atlassian.net/_edge/tenant_info`. Recommended for agents (least privilege).
6060
- Self-hosted / Data Center: use `--api-path "/rest/api"`, auth type `bearer` with a personal access token (no email needed)
6161

tests/confluence-client.test.js

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -860,17 +860,16 @@ describe('ConfluenceClient', () => {
860860
expect(result).not.toContain('<ac:link>');
861861
});
862862

863-
test('should convert links to ac:link format on Server/Data Center instances', () => {
863+
test('should convert links to standard anchor format on Server/Data Center instances', () => {
864864
const serverClient = new ConfluenceClient({
865865
domain: 'confluence.example.com',
866866
token: 'test-token'
867867
});
868868
const markdown = '[Example Link](https://example.com)';
869869
const result = serverClient.markdownToStorage(markdown);
870870

871-
expect(result).toContain('<ac:link>');
872-
expect(result).toContain('ri:value="https://example.com"');
873-
expect(result).toContain('Example Link');
871+
expect(result).toContain('<a href="https://example.com">Example Link</a>');
872+
expect(result).not.toContain('<ac:link>');
874873
expect(result).not.toContain('data-card-appearance');
875874
});
876875

tests/html-to-storage.test.js

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -342,12 +342,24 @@ describe('htmlToStorage', () => {
342342
.toBe('<ac:link><ri:url ri:value="https://example.com" /><ac:plain-text-link-body><![CDATA[link]]></ac:plain-text-link-body></ac:link>');
343343
});
344344

345-
test('default linkStyle is `wiki` for server (isCloud:false) and `smart` for cloud', () => {
345+
test('default linkStyle is `plain` for server (isCloud:false) and `smart` for cloud', () => {
346346
const a = '<a href="x">y</a>';
347-
expect(htmlToStorage(a, { isCloud: false })).toContain('<ac:link>');
347+
expect(htmlToStorage(a, { isCloud: false })).toBe(a);
348348
expect(htmlToStorage(a, { isCloud: true })).toContain('data-card-appearance="inline"');
349349
});
350350

351+
test('server default safely escapes quotes in external link attributes', () => {
352+
const html = '<a href=\'https://example.com/?q="x"&a=1\'>quoted</a>';
353+
expect(htmlToStorage(html, { isCloud: false })).toBe(
354+
'<a href="https://example.com/?q=&quot;x&quot;&a=1">quoted</a>'
355+
);
356+
});
357+
358+
test('server default preserves rich external link bodies', () => {
359+
const html = '<a href="https://example.com"><strong>bold</strong> text</a>';
360+
expect(htmlToStorage(html, { isCloud: false })).toBe(html);
361+
});
362+
351363
test('anchor link (`#id`) becomes ac:link with ac:anchor regardless of linkStyle', () => {
352364
const html = '<a href="#section">jump</a>';
353365
const expected = '<ac:link ac:anchor="section"><ac:plain-text-link-body><![CDATA[jump]]></ac:plain-text-link-body></ac:link>';

tests/macro-converter.test.js

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,9 @@ describe('MacroConverter', () => {
77
expect(converter.linkStyle).toBe('smart');
88
});
99

10-
test('defaults to "wiki" when isCloud is false and no linkStyle is passed', () => {
10+
test('defaults to "plain" when isCloud is false and no linkStyle is passed', () => {
1111
const converter = new MacroConverter({ isCloud: false });
12-
expect(converter.linkStyle).toBe('wiki');
12+
expect(converter.linkStyle).toBe('plain');
1313
});
1414

1515
test('explicit "smart" is used even when isCloud is false', () => {
@@ -27,8 +27,10 @@ describe('MacroConverter', () => {
2727
test('invalid linkStyle silently falls back to the isCloud-based default', () => {
2828
// Config-level validation is the user-facing guardrail; the converter is
2929
// lenient so direct library consumers cannot break the pipeline.
30-
const converter = new MacroConverter({ isCloud: true, linkStyle: 'garbage' });
31-
expect(converter.linkStyle).toBe('smart');
30+
const cloudConverter = new MacroConverter({ isCloud: true, linkStyle: 'garbage' });
31+
const serverConverter = new MacroConverter({ isCloud: false, linkStyle: 'garbage' });
32+
expect(cloudConverter.linkStyle).toBe('smart');
33+
expect(serverConverter.linkStyle).toBe('plain');
3234
});
3335
});
3436

@@ -50,14 +52,21 @@ describe('MacroConverter', () => {
5052
expect(result).not.toContain('<ac:link>');
5153
});
5254

53-
test('"wiki" emits the Server/DC ac:link + ri:url storage macro', () => {
55+
test('explicit legacy "wiki" emits the ac:link + ri:url storage macro', () => {
5456
const converter = new MacroConverter({ isCloud: false, linkStyle: 'wiki' });
5557
const result = converter.markdownToStorage(markdown);
5658
expect(result).toContain('<ac:link>');
5759
expect(result).toContain('ri:value="https://example.com"');
5860
expect(result).toContain('<![CDATA[Example]]>');
5961
expect(result).not.toContain('data-card-appearance');
6062
});
63+
64+
test('Server/DC default emits a standard anchor for external Markdown links', () => {
65+
const converter = new MacroConverter({ isCloud: false });
66+
const result = converter.markdownToStorage('**My external link:** [My Link Text](https://test.com)');
67+
expect(result).toBe('<p><strong>My external link:</strong> <a href="https://test.com">My Link Text</a></p>\n');
68+
expect(result).not.toContain('<ac:link>');
69+
});
6170
});
6271

6372
describe('VALID_LINK_STYLES', () => {
@@ -220,9 +229,7 @@ describe('MacroConverter markdownToStorage marker conventions', () => {
220229
'[Jump](#my-section) and [External](https://example.com)'
221230
);
222231
expect(result).toContain('ac:anchor="my-section"');
223-
// External link should get the ac:link + ri:url storage format, not be
224-
// double-wrapped by the anchor replacement.
225-
expect(result).toContain('ri:value="https://example.com"');
232+
expect(result).toContain('<a href="https://example.com">External</a>');
226233
expect(result).not.toContain('ac:anchor="https');
227234
});
228235
});

0 commit comments

Comments
 (0)