From a2cb81b23a26c40def80faeeb4b4c946ab706a6e Mon Sep 17 00:00:00 2001 From: "heecheol.park" Date: Fri, 10 Jul 2026 15:44:52 +0900 Subject: [PATCH] fix: render external links as standard anchors --- README.md | 8 +++---- lib/config.js | 2 +- lib/html-to-storage.js | 6 ++--- lib/link-style.js | 10 ++++++++ lib/macro-converter.js | 6 ++--- plugins/confluence/skills/confluence/SKILL.md | 4 ++-- tests/confluence-client.test.js | 7 +++--- tests/html-to-storage.test.js | 16 +++++++++++-- tests/macro-converter.test.js | 23 ++++++++++++------- 9 files changed, 53 insertions(+), 29 deletions(-) create mode 100644 lib/link-style.js diff --git a/README.md b/README.md index 0be3157..182d9d0 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 (``) and show "Cannot handle: DefaultLink" errors instead. If you hit this, set `linkStyle` to `plain` to emit simple `` tags, which render reliably everywhere: +External links use Cloud smart links (``) on Cloud and standard `` 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 @@ -305,7 +305,7 @@ Or per-profile: } ``` -Valid values: `smart` (Cloud smart links), `plain` (simple ``), `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 ``), 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 diff --git a/lib/config.js b/lib/config.js index dd329ee..d38b240 100644 --- a/lib/config.js +++ b/lib/config.js @@ -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 === '') { diff --git a/lib/html-to-storage.js b/lib/html-to-storage.js index 33a43f9..c223ead 100644 --- a/lib/html-to-storage.js +++ b/lib/html-to-storage.js @@ -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; @@ -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, diff --git a/lib/link-style.js b/lib/link-style.js new file mode 100644 index 0000000..9b3ce55 --- /dev/null +++ b/lib/link-style.js @@ -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 }; diff --git a/lib/macro-converter.js b/lib/macro-converter.js index e97f1a4..ab4c977 100644 --- a/lib/macro-converter.js +++ b/lib/macro-converter.js @@ -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 @@ -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(); } diff --git a/plugins/confluence/skills/confluence/SKILL.md b/plugins/confluence/skills/confluence/SKILL.md index 730545c..e08db22 100644 --- a/plugins/confluence/skills/confluence/SKILL.md +++ b/plugins/confluence/skills/confluence/SKILL.md @@ -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):** @@ -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//wiki/rest/api"`, auth type `basic` with email + scoped token. Get your Cloud ID from `https://.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) diff --git a/tests/confluence-client.test.js b/tests/confluence-client.test.js index 006df7e..18ecabc 100644 --- a/tests/confluence-client.test.js +++ b/tests/confluence-client.test.js @@ -860,7 +860,7 @@ describe('ConfluenceClient', () => { expect(result).not.toContain(''); }); - 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' @@ -868,9 +868,8 @@ describe('ConfluenceClient', () => { const markdown = '[Example Link](https://example.com)'; const result = serverClient.markdownToStorage(markdown); - expect(result).toContain(''); - expect(result).toContain('ri:value="https://example.com"'); - expect(result).toContain('Example Link'); + expect(result).toContain('Example Link'); + expect(result).not.toContain(''); expect(result).not.toContain('data-card-appearance'); }); diff --git a/tests/html-to-storage.test.js b/tests/html-to-storage.test.js index 36f9b7f..f820e38 100644 --- a/tests/html-to-storage.test.js +++ b/tests/html-to-storage.test.js @@ -342,12 +342,24 @@ describe('htmlToStorage', () => { .toBe(''); }); - 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 = 'y'; - expect(htmlToStorage(a, { isCloud: false })).toContain(''); + 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 = 'quoted'; + expect(htmlToStorage(html, { isCloud: false })).toBe( + 'quoted' + ); + }); + + test('server default preserves rich external link bodies', () => { + const html = 'bold text'; + expect(htmlToStorage(html, { isCloud: false })).toBe(html); + }); + test('anchor link (`#id`) becomes ac:link with ac:anchor regardless of linkStyle', () => { const html = 'jump'; const expected = ''; diff --git a/tests/macro-converter.test.js b/tests/macro-converter.test.js index d308cf6..527b9a8 100644 --- a/tests/macro-converter.test.js +++ b/tests/macro-converter.test.js @@ -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', () => { @@ -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'); }); }); @@ -50,7 +52,7 @@ describe('MacroConverter', () => { expect(result).not.toContain(''); }); - 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(''); @@ -58,6 +60,13 @@ describe('MacroConverter', () => { expect(result).toContain(''); 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('

My external link: My Link Text

\n'); + expect(result).not.toContain(''); + }); }); describe('VALID_LINK_STYLES', () => { @@ -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('External'); expect(result).not.toContain('ac:anchor="https'); }); });