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
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,7 @@ The stats file (`stats.json`) follows the same resolution and lives alongside `c

**Custom domains on Confluence Cloud:**

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:
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:

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

**Link rendering on Cloud (`linkStyle`):**
**Link rendering (`linkStyle`):**

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:
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`:

```bash
export CONFLUENCE_LINK_STYLE=plain
Expand All @@ -305,7 +305,7 @@ Or per-profile:
}
```

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.
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.

**Read-only mode** (recommended for AI agents):
```bash
Expand Down
2 changes: 1 addition & 1 deletion lib/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ const AUTH_CHOICES = [

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

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

const normalizeLinkStyle = (rawValue, source) => {
if (rawValue === undefined || rawValue === null || rawValue === '') {
Expand Down
6 changes: 2 additions & 4 deletions lib/html-to-storage.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
// attributes preserved.

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

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

function htmlToStorage(html, options = {}) {
const isCloud = !!options.isCloud;
const linkStyle = VALID_LINK_STYLES.includes(options.linkStyle)
? options.linkStyle
: (isCloud ? 'smart' : 'wiki');
const linkStyle = resolveLinkStyle({ isCloud, linkStyle: options.linkStyle });
const ctx = {
linkStyle,
depth: 0,
Expand Down
10 changes: 10 additions & 0 deletions lib/link-style.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
const VALID_LINK_STYLES = ['smart', 'plain', 'wiki'];

function resolveLinkStyle({ isCloud = false, linkStyle = null } = {}) {
if (VALID_LINK_STYLES.includes(linkStyle)) {
return linkStyle;
}
return isCloud ? 'smart' : 'plain';
}

module.exports = { VALID_LINK_STYLES, resolveLinkStyle };
6 changes: 2 additions & 4 deletions lib/macro-converter.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
const MarkdownIt = require('markdown-it');
const { StorageWalker } = require('./storage-walker');
const { htmlToStorage } = require('./html-to-storage');
const { VALID_LINK_STYLES, resolveLinkStyle } = require('./link-style');

const VALID_LINK_STYLES = ['smart', 'plain', 'wiki'];
const CALLOUT_MARKERS = ['info', 'warning', 'note'];
// U+E000 (Unicode Private Use Area) is used as the stash placeholder
// delimiter. Declared as an explicit escape so the byte is visible in source
Expand Down Expand Up @@ -35,9 +35,7 @@ class MacroConverter {
this._isCloud = isCloud;
this.webUrlPrefix = webUrlPrefix;
this.buildUrl = buildUrl || ((pathOrUrl) => pathOrUrl);
this.linkStyle = VALID_LINK_STYLES.includes(linkStyle)
? linkStyle
: (isCloud ? 'smart' : 'wiki');
this.linkStyle = resolveLinkStyle({ isCloud, linkStyle });
this.markdown = new MarkdownIt();
this.setupConfluenceMarkdownExtensions();
}
Expand Down
4 changes: 2 additions & 2 deletions plugins/confluence/skills/confluence/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ confluence --version # verify install
| `CONFLUENCE_PROFILE` | Named profile to use (optional) | `staging` |
| `CONFLUENCE_READ_ONLY` | Block all write operations when `true` | `true` |
| `CONFLUENCE_FORCE_CLOUD` | Force Cloud link format for custom domains | `true` |
| `CONFLUENCE_LINK_STYLE` | Override link rendering: `smart`, `plain`, or `wiki` | `plain` |
| `CONFLUENCE_LINK_STYLE` | Override link rendering: `smart`, `plain`, or legacy `wiki` | `plain` |

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

Expand All @@ -55,7 +55,7 @@ confluence init \

**Cloud vs Server/DC:**
- Atlassian Cloud (`*.atlassian.net`): use `--api-path "/wiki/rest/api"`, auth type `basic` with email + API token
- 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.
- 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.
- 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).
- Self-hosted / Data Center: use `--api-path "/rest/api"`, auth type `bearer` with a personal access token (no email needed)

Expand Down
7 changes: 3 additions & 4 deletions tests/confluence-client.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -860,17 +860,16 @@ describe('ConfluenceClient', () => {
expect(result).not.toContain('<ac:link>');
});

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

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

Expand Down
16 changes: 14 additions & 2 deletions tests/html-to-storage.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -342,12 +342,24 @@ describe('htmlToStorage', () => {
.toBe('<ac:link><ri:url ri:value="https://example.com" /><ac:plain-text-link-body><![CDATA[link]]></ac:plain-text-link-body></ac:link>');
});

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

test('server default safely escapes quotes in external link attributes', () => {
const html = '<a href=\'https://example.com/?q="x"&a=1\'>quoted</a>';
expect(htmlToStorage(html, { isCloud: false })).toBe(
'<a href="https://example.com/?q=&quot;x&quot;&a=1">quoted</a>'
);
});

test('server default preserves rich external link bodies', () => {
const html = '<a href="https://example.com"><strong>bold</strong> text</a>';
expect(htmlToStorage(html, { isCloud: false })).toBe(html);
});

test('anchor link (`#id`) becomes ac:link with ac:anchor regardless of linkStyle', () => {
const html = '<a href="#section">jump</a>';
const expected = '<ac:link ac:anchor="section"><ac:plain-text-link-body><![CDATA[jump]]></ac:plain-text-link-body></ac:link>';
Expand Down
23 changes: 15 additions & 8 deletions tests/macro-converter.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ describe('MacroConverter', () => {
expect(converter.linkStyle).toBe('smart');
});

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

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

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

test('"wiki" emits the Server/DC ac:link + ri:url storage macro', () => {
test('explicit legacy "wiki" emits the ac:link + ri:url storage macro', () => {
const converter = new MacroConverter({ isCloud: false, linkStyle: 'wiki' });
const result = converter.markdownToStorage(markdown);
expect(result).toContain('<ac:link>');
expect(result).toContain('ri:value="https://example.com"');
expect(result).toContain('<![CDATA[Example]]>');
expect(result).not.toContain('data-card-appearance');
});

test('Server/DC default emits a standard anchor for external Markdown links', () => {
const converter = new MacroConverter({ isCloud: false });
const result = converter.markdownToStorage('**My external link:** [My Link Text](https://test.com)');
expect(result).toBe('<p><strong>My external link:</strong> <a href="https://test.com">My Link Text</a></p>\n');
expect(result).not.toContain('<ac:link>');
});
});

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