From 075a93435561045d6fb4d44da525e86e86fda808 Mon Sep 17 00:00:00 2001 From: "heecheol.park" Date: Wed, 15 Jul 2026 21:18:30 +0900 Subject: [PATCH 1/9] fix: preserve same-space page links in markdown Resolve page links using the containing page's space when Confluence storage omits `ri:space-key`. Preserve custom link body formatting and add regression coverage. Fixes #215 --- lib/confluence-client.js | 112 +++++++++++++++++++++++--------- tests/confluence-client.test.js | 58 +++++++++++++++++ 2 files changed, 139 insertions(+), 31 deletions(-) diff --git a/lib/confluence-client.js b/lib/confluence-client.js index f6091f1..a244f33 100644 --- a/lib/confluence-client.js +++ b/lib/confluence-client.js @@ -4,11 +4,38 @@ const https = require('https'); const path = require('path'); const FormData = require('form-data'); const { convert } = require('html-to-text'); +const { parseDocument } = require('htmlparser2'); +const { decodeHTML } = require('entities'); const MacroConverter = require('./macro-converter'); const { htmlToMarkdown, NAMED_ENTITIES } = require('./html-to-markdown'); const WRITE_FORMATS = ['auto', 'storage', 'html', 'markdown']; +const escapeXmlText = (value) => String(value) + .replace(/&/g, '&') + .replace(//g, '>'); + +const escapeXmlAttribute = (value) => escapeXmlText(value).replace(/"/g, '"'); + +const findDirectChild = (node, name) => (node.children || []) + .find(child => child.type === 'tag' && child.name === name); + +const readElementContents = (source, node) => { + const children = node && node.children; + if (!children || children.length === 0) { + return ''; + } + + const first = children[0]; + const last = children[children.length - 1]; + if (!Number.isInteger(first.startIndex) || !Number.isInteger(last.endIndex)) { + return ''; + } + + return source.slice(first.startIndex, last.endIndex + 1); +}; + function createSemaphore(limit) { let active = 0; const waiters = []; @@ -356,10 +383,11 @@ class ConfluenceClient { */ async readPage(pageIdOrUrl, format = 'text', options = {}) { const pageId = await this.extractPageId(pageIdOrUrl); + const shouldResolvePageLinks = format === 'markdown' && options.resolvePageLinks !== false; const response = await this.client.get(`/content/${pageId}`, { params: { - expand: 'body.storage' + expand: shouldResolvePageLinks ? 'body.storage,space' : 'body.storage' } }); @@ -383,9 +411,8 @@ class ConfluenceClient { } // Resolve page links to full URLs - const resolvePageLinks = options.resolvePageLinks !== false; - if (resolvePageLinks) { - htmlContent = await this.resolvePageLinksInHtml(htmlContent); + if (shouldResolvePageLinks) { + htmlContent = await this.resolvePageLinksInHtml(htmlContent, response.data.space?.key); } // Resolve children macro to child pages list @@ -625,49 +652,72 @@ class ConfluenceClient { /** * Resolve all page links in HTML to full URLs * @param {string} html - HTML content with ri:page elements + * @param {string} defaultSpaceKey - Space key of the page containing the links * @returns {Promise} - HTML with resolved page links */ - async resolvePageLinksInHtml(html) { - // Extract all page links: - const pageLinkRegex = /\s*]*(?:\/>|><\/ri:page>)\s*<\/ac:link>/g; + async resolvePageLinksInHtml(html, defaultSpaceKey) { + const document = parseDocument(html, { + xmlMode: true, + decodeEntities: false, + withStartIndices: true, + withEndIndices: true + }); const pageLinks = []; - let match; - - while ((match = pageLinkRegex.exec(html)) !== null) { - pageLinks.push({ - fullMatch: match[0], - spaceKey: match[1], - title: match[2] - }); - } + + const collectPageLinks = (node) => { + if (node.type === 'tag' && node.name === 'ac:link' && !node.attribs?.['ac:anchor']) { + const page = findDirectChild(node, 'ri:page'); + const title = decodeHTML(page?.attribs?.['ri:content-title'] || ''); + const spaceKey = decodeHTML(page?.attribs?.['ri:space-key'] || defaultSpaceKey || ''); + + if (page && title && spaceKey && Number.isInteger(node.startIndex) && Number.isInteger(node.endIndex)) { + const body = findDirectChild(node, 'ac:plain-text-link-body') + || findDirectChild(node, 'ac:link-body'); + pageLinks.push({ + startIndex: node.startIndex, + endIndex: node.endIndex, + spaceKey, + title, + body: readElementContents(html, body) + }); + return; + } + } + + (node.children || []).forEach(collectPageLinks); + }; + + collectPageLinks(document); if (pageLinks.length === 0) { return html; } - // Fetch page info for all links in parallel + const pageLookups = new Map(); const pagePromises = pageLinks.map(async (link) => { - const pageInfo = await this.findPageByTitleAndSpace(link.spaceKey, link.title); + const lookupKey = `${link.spaceKey}\0${link.title}`; + if (!pageLookups.has(lookupKey)) { + pageLookups.set(lookupKey, this.findPageByTitleAndSpace(link.spaceKey, link.title)); + } return { ...link, - pageInfo + pageInfo: await pageLookups.get(lookupKey) }; }); const resolvedLinks = await Promise.all(pagePromises); - - // Replace page link references with markdown links let resolvedHtml = html; - resolvedLinks.forEach(({ fullMatch, title, pageInfo }) => { - let replacement; - if (pageInfo && pageInfo.url) { - replacement = `[${title}](${pageInfo.url})`; - } else { - // Fallback to just the title if page not found - replacement = `[${title}]`; - } - resolvedHtml = resolvedHtml.replace(fullMatch, replacement); - }); + + resolvedLinks + .filter(link => link.pageInfo?.url) + .sort((a, b) => b.startIndex - a.startIndex) + .forEach(link => { + const body = link.body || escapeXmlText(link.title); + const replacement = `${body}`; + resolvedHtml = resolvedHtml.slice(0, link.startIndex) + + replacement + + resolvedHtml.slice(link.endIndex + 1); + }); return resolvedHtml; } diff --git a/tests/confluence-client.test.js b/tests/confluence-client.test.js index 18ecabc..3033557 100644 --- a/tests/confluence-client.test.js +++ b/tests/confluence-client.test.js @@ -478,6 +478,64 @@ describe('ConfluenceClient', () => { mock.restore(); }); + test('readPage resolves same-space page links in markdown output', async () => { + const mock = new MockAdapter(client.client); + mock.onGet('/content/123').reply(config => { + expect(config.params.expand).toBe('body.storage,space'); + return [200, { + space: { key: 'ENG' }, + body: { + storage: { + value: '

See .

' + } + } + }]; + }); + mock.onGet('/content').reply(config => { + expect(config.params).toEqual({ + spaceKey: 'ENG', + title: 'Target page', + limit: 1 + }); + return [200, { + results: [{ + title: 'Target page', + _links: { webui: '/spaces/ENG/pages/456/Target-page' } + }] + }]; + }); + + await expect(client.readPage('123', 'markdown')).resolves.toBe( + 'See [Target page](https://test.atlassian.net/spaces/ENG/pages/456/Target-page).' + ); + + mock.restore(); + }); + + test('readPage preserves custom text for resolved same-space page links', async () => { + const mock = new MockAdapter(client.client); + mock.onGet('/content/123').reply(200, { + space: { key: 'ENG' }, + body: { + storage: { + value: '

Read this

' + } + } + }); + mock.onGet('/content').reply(200, { + results: [{ + title: 'Target page', + _links: { webui: '/spaces/ENG/pages/456/Target-page' } + }] + }); + + await expect(client.readPage('123', 'markdown')).resolves.toBe( + '[**Read this**](https://test.atlassian.net/spaces/ENG/pages/456/Target-page)' + ); + + mock.restore(); + }); + describe('bodyless content (folder) handling', () => { const NO_BODY_MESSAGE = /Page 123 has no readable body \(it may be a folder or an unsupported content type\)\./; From 365bc48a7bccfdd9841d69d011dc9d5fe6201bbb Mon Sep 17 00:00:00 2001 From: "heecheol.park" Date: Wed, 15 Jul 2026 21:32:01 +0900 Subject: [PATCH 2/9] no-mistakes(review): fix: harden same-space page link resolution --- lib/confluence-client.js | 31 +++++++++++- lib/storage-walker.js | 15 ++++-- tests/confluence-client.test.js | 85 +++++++++++++++++++++++++++++++++ 3 files changed, 126 insertions(+), 5 deletions(-) diff --git a/lib/confluence-client.js b/lib/confluence-client.js index a244f33..805815e 100644 --- a/lib/confluence-client.js +++ b/lib/confluence-client.js @@ -10,6 +10,7 @@ const MacroConverter = require('./macro-converter'); const { htmlToMarkdown, NAMED_ENTITIES } = require('./html-to-markdown'); const WRITE_FORMATS = ['auto', 'storage', 'html', 'markdown']; +const PAGE_LINK_LOOKUP_CONCURRENCY = 10; const escapeXmlText = (value) => String(value) .replace(/&/g, '&') @@ -21,6 +22,19 @@ const escapeXmlAttribute = (value) => escapeXmlText(value).replace(/"/g, '" const findDirectChild = (node, name) => (node.children || []) .find(child => child.type === 'tag' && child.name === name); +const isSemanticMacroPageReference = (node) => { + const parameter = node.parent; + const macro = parameter?.parent; + if (parameter?.name !== 'ac:parameter' || macro?.name !== 'ac:structured-macro') { + return false; + } + + const macroName = macro.attribs?.['ac:name']; + const parameterName = parameter.attribs?.['ac:name']; + return (macroName === 'include' && parameterName === '') + || (macroName === 'include-shared-block' && parameterName === 'page'); +}; + const readElementContents = (source, node) => { const children = node && node.children; if (!children || children.length === 0) { @@ -665,7 +679,12 @@ class ConfluenceClient { const pageLinks = []; const collectPageLinks = (node) => { - if (node.type === 'tag' && node.name === 'ac:link' && !node.attribs?.['ac:anchor']) { + if ( + node.type === 'tag' + && node.name === 'ac:link' + && !node.attribs?.['ac:anchor'] + && !isSemanticMacroPageReference(node) + ) { const page = findDirectChild(node, 'ri:page'); const title = decodeHTML(page?.attribs?.['ri:content-title'] || ''); const spaceKey = decodeHTML(page?.attribs?.['ri:space-key'] || defaultSpaceKey || ''); @@ -694,10 +713,18 @@ class ConfluenceClient { } const pageLookups = new Map(); + const semaphore = createSemaphore(PAGE_LINK_LOOKUP_CONCURRENCY); const pagePromises = pageLinks.map(async (link) => { const lookupKey = `${link.spaceKey}\0${link.title}`; if (!pageLookups.has(lookupKey)) { - pageLookups.set(lookupKey, this.findPageByTitleAndSpace(link.spaceKey, link.title)); + pageLookups.set(lookupKey, (async () => { + await semaphore.acquire(); + try { + return await this.findPageByTitleAndSpace(link.spaceKey, link.title); + } finally { + semaphore.release(); + } + })()); } return { ...link, diff --git a/lib/storage-walker.js b/lib/storage-walker.js index 3048cdd..0d5bb75 100644 --- a/lib/storage-walker.js +++ b/lib/storage-walker.js @@ -65,6 +65,7 @@ class StorageWalker { walk(storage) { this._depth = 0; + this._markdownLinkLabelDepth = 0; this.warnings = []; // htmlparser2 in xmlMode is lenient: malformed input (unclosed tags, @@ -131,7 +132,9 @@ class StorageWalker { // < > " '). Confluence storage prose still ships HTML // named entities like  , é, –, so decode them here // before they reach markdown output. - return decodeEntities(node.data || ''); + return this._markdownLinkLabelDepth > 0 + ? this.escapeMarkdownText(decodeEntities(node.data || '')) + : decodeEntities(node.data || ''); case 'cdata': return this.walkNodes(node.children); case 'comment': @@ -181,8 +184,14 @@ class StorageWalker { return '\n---\n'; case 'a': { const href = decodeEntities((node.attribs && node.attribs.href) || ''); - const inner = this.walkNodes(node.children); - if (!href) return inner; + if (!href) return this.walkNodes(node.children); + this._markdownLinkLabelDepth++; + let inner; + try { + inner = this.walkNodes(node.children); + } finally { + this._markdownLinkLabelDepth--; + } return `[${inner}](${href})`; } case 'time': diff --git a/tests/confluence-client.test.js b/tests/confluence-client.test.js index 3033557..624b149 100644 --- a/tests/confluence-client.test.js +++ b/tests/confluence-client.test.js @@ -536,6 +536,91 @@ describe('ConfluenceClient', () => { mock.restore(); }); + test('readPage preserves semantic macro page references while resolving body links', async () => { + const mock = new MockAdapter(client.client); + const lookedUpTitles = []; + mock.onGet('/content/123').reply(200, { + space: { key: 'ENG' }, + body: { + storage: { + value: '' + + 'block-1' + + '

' + } + } + }); + mock.onGet('/content').reply(config => { + lookedUpTitles.push(config.params.title); + return [200, { + results: [{ + title: config.params.title, + _links: { webui: '/spaces/ENG/pages/456/Body-target' } + }] + }]; + }); + + const result = await client.readPage('123', 'markdown'); + + expect(lookedUpTitles).toEqual(['Body target']); + expect(result).toContain('**Include Page**: [Included page]'); + expect(result).toContain('**Include Shared Block**: block-1 (from page: Shared source'); + expect(result).toContain('[Body target](https://test.atlassian.net/spaces/ENG/pages/456/Body-target)'); + + mock.restore(); + }); + + test('readPage escapes resolved page-link labels while preserving inline formatting', async () => { + const mock = new MockAdapter(client.client); + mock.onGet('/content/123').reply(200, { + space: { key: 'ENG' }, + body: { + storage: { + value: '

and ' + + 'Read [this]

' + } + } + }); + mock.onGet('/content').reply(config => [200, { + results: [{ + title: config.params.title, + _links: { + webui: config.params.title === 'Custom' + ? '/spaces/ENG/pages/456/Custom' + : '/spaces/ENG/pages/456/Fallback' + } + }] + }]); + + await expect(client.readPage('123', 'markdown')).resolves.toBe( + '[evil\\]\\(https://attacker\\) \\[x](https://test.atlassian.net/spaces/ENG/pages/456/Fallback)' + + ' and [**Read \\[this\\]**](https://test.atlassian.net/spaces/ENG/pages/456/Custom)' + ); + + mock.restore(); + }); + + test('resolvePageLinksInHtml caps concurrent unique page lookups', async () => { + let inFlight = 0; + let maxInFlight = 0; + const titles = Array.from({ length: 25 }, (_, index) => `Page ${index}`); + client.findPageByTitleAndSpace = jest.fn(async (_spaceKey, title) => { + inFlight++; + maxInFlight = Math.max(maxInFlight, inFlight); + await new Promise(resolve => setImmediate(resolve)); + inFlight--; + return { title, url: `https://example.com/${encodeURIComponent(title)}` }; + }); + const html = [...titles, titles[0]] + .map(title => ``) + .join(''); + + const result = await client.resolvePageLinksInHtml(html, 'ENG'); + + expect(client.findPageByTitleAndSpace).toHaveBeenCalledTimes(titles.length); + expect(maxInFlight).toBeLessThanOrEqual(10); + expect(result.match(/ { const NO_BODY_MESSAGE = /Page 123 has no readable body \(it may be a folder or an unsupported content type\)\./; From 11b44a74cda0506904e0997b1dc792ff9c9455a6 Mon Sep 17 00:00:00 2001 From: "heecheol.park" Date: Wed, 15 Jul 2026 21:38:50 +0900 Subject: [PATCH 3/9] no-mistakes(review): fix: harden deep page-link rendering --- lib/confluence-client.js | 15 +++++++++------ lib/storage-walker.js | 15 +++++++++++---- tests/confluence-client.test.js | 23 +++++++++++++++++++++-- 3 files changed, 41 insertions(+), 12 deletions(-) diff --git a/lib/confluence-client.js b/lib/confluence-client.js index 805815e..8e186ca 100644 --- a/lib/confluence-client.js +++ b/lib/confluence-client.js @@ -678,7 +678,9 @@ class ConfluenceClient { }); const pageLinks = []; - const collectPageLinks = (node) => { + const nodesToVisit = [document]; + while (nodesToVisit.length > 0) { + const node = nodesToVisit.pop(); if ( node.type === 'tag' && node.name === 'ac:link' @@ -699,14 +701,15 @@ class ConfluenceClient { title, body: readElementContents(html, body) }); - return; + continue; } } - (node.children || []).forEach(collectPageLinks); - }; - - collectPageLinks(document); + const children = node.children || []; + for (let index = children.length - 1; index >= 0; index--) { + nodesToVisit.push(children[index]); + } + } if (pageLinks.length === 0) { return html; diff --git a/lib/storage-walker.js b/lib/storage-walker.js index 0d5bb75..951c8ad 100644 --- a/lib/storage-walker.js +++ b/lib/storage-walker.js @@ -66,6 +66,7 @@ class StorageWalker { walk(storage) { this._depth = 0; this._markdownLinkLabelDepth = 0; + this._markdownCodeSpanDepth = 0; this.warnings = []; // htmlparser2 in xmlMode is lenient: malformed input (unclosed tags, @@ -132,7 +133,7 @@ class StorageWalker { // < > " '). Confluence storage prose still ships HTML // named entities like  , é, –, so decode them here // before they reach markdown output. - return this._markdownLinkLabelDepth > 0 + return this._markdownLinkLabelDepth > 0 && this._markdownCodeSpanDepth === 0 ? this.escapeMarkdownText(decodeEntities(node.data || '')) : decodeEntities(node.data || ''); case 'cdata': @@ -176,8 +177,14 @@ class StorageWalker { return '*' + this.walkNodes(node.children) + '*'; case 's': case 'del': return '~~' + this.walkNodes(node.children) + '~~'; - case 'code': - return '`' + this.walkNodes(node.children) + '`'; + case 'code': { + this._markdownCodeSpanDepth++; + try { + return '`' + this.walkNodes(node.children) + '`'; + } finally { + this._markdownCodeSpanDepth--; + } + } case 'br': return '\n'; case 'hr': @@ -534,7 +541,7 @@ class StorageWalker { // an existing `\` in a title isn't reinterpreted as a markdown escape. escapeMarkdownText(s) { if (!s) return ''; - return s.replace(/([\\[\]()])/g, '\\$1'); + return s.replace(/([\\`*_[\]()~|<>])/g, '\\$1'); } _collectText(node) { diff --git a/tests/confluence-client.test.js b/tests/confluence-client.test.js index 624b149..065b243 100644 --- a/tests/confluence-client.test.js +++ b/tests/confluence-client.test.js @@ -4,6 +4,7 @@ const path = require('path'); const FormData = require('form-data'); const axios = require('axios'); const ConfluenceClient = require('../lib/confluence-client'); +const { StorageDepthExceededError } = require('../lib/storage-walker'); const MockAdapter = require('axios-mock-adapter'); const removeDirRecursive = (dir) => { @@ -576,7 +577,8 @@ describe('ConfluenceClient', () => { body: { storage: { value: '

and ' - + 'Read [this]

' + + '*literal* [x] ' + + 'Read [this] styled

' } } }); @@ -593,7 +595,7 @@ describe('ConfluenceClient', () => { await expect(client.readPage('123', 'markdown')).resolves.toBe( '[evil\\]\\(https://attacker\\) \\[x](https://test.atlassian.net/spaces/ENG/pages/456/Fallback)' - + ' and [**Read \\[this\\]**](https://test.atlassian.net/spaces/ENG/pages/456/Custom)' + + ' and [\\*literal\\* `[x]` **Read \\[this\\]** *styled*](https://test.atlassian.net/spaces/ENG/pages/456/Custom)' ); mock.restore(); @@ -621,6 +623,23 @@ describe('ConfluenceClient', () => { expect(result.match(/
{ + const mock = new MockAdapter(client.client); + const nesting = 20000; + mock.onGet('/content/123').reply(200, { + space: { key: 'ENG' }, + body: { + storage: { + value: '

'.repeat(nesting) + 'content' + '

'.repeat(nesting) + } + } + }); + + await expect(client.readPage('123', 'markdown')).rejects.toThrow(StorageDepthExceededError); + + mock.restore(); + }); + describe('bodyless content (folder) handling', () => { const NO_BODY_MESSAGE = /Page 123 has no readable body \(it may be a folder or an unsupported content type\)\./; From 31828c58cccad9cd4f32acc27b5a66946f8fa0e7 Mon Sep 17 00:00:00 2001 From: "heecheol.park" Date: Wed, 15 Jul 2026 21:44:19 +0900 Subject: [PATCH 4/9] no-mistakes(review): Harden inline code spans against Markdown link injection --- lib/storage-walker.js | 10 +++++++++- tests/confluence-client.test.js | 27 +++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/lib/storage-walker.js b/lib/storage-walker.js index 951c8ad..d909cc8 100644 --- a/lib/storage-walker.js +++ b/lib/storage-walker.js @@ -180,7 +180,7 @@ class StorageWalker { case 'code': { this._markdownCodeSpanDepth++; try { - return '`' + this.walkNodes(node.children) + '`'; + return this.renderCodeSpan(this.walkNodes(node.children)); } finally { this._markdownCodeSpanDepth--; } @@ -544,6 +544,14 @@ class StorageWalker { return s.replace(/([\\`*_[\]()~|<>])/g, '\\$1'); } + renderCodeSpan(content) { + const backtickRuns = content.match(/`+/g) || []; + const longestRun = backtickRuns.reduce((max, run) => Math.max(max, run.length), 0); + const delimiter = '`'.repeat(longestRun + 1); + const padding = content.startsWith('`') || content.endsWith('`') ? ' ' : ''; + return `${delimiter}${padding}${content}${padding}${delimiter}`; + } + _collectText(node) { if (!node) return ''; if (node.type === 'text') return node.data || ''; diff --git a/tests/confluence-client.test.js b/tests/confluence-client.test.js index 065b243..384f20d 100644 --- a/tests/confluence-client.test.js +++ b/tests/confluence-client.test.js @@ -601,6 +601,33 @@ describe('ConfluenceClient', () => { mock.restore(); }); + test('readPage uses collision-safe code spans in resolved page-link labels', async () => { + const mock = new MockAdapter(client.client); + mock.onGet('/content/123').reply(200, { + space: { key: 'ENG' }, + body: { + storage: { + value: '

' + + 'safe`](https://attacker.example) [x' + + '

' + } + } + }); + mock.onGet('/content').reply(200, { + results: [{ + title: 'Custom', + _links: { webui: '/spaces/ENG/pages/456/Custom' } + }] + }); + + await expect(client.readPage('123', 'markdown')).resolves.toBe( + '[``safe`](https://attacker.example) [x``]' + + '(https://test.atlassian.net/spaces/ENG/pages/456/Custom)' + ); + + mock.restore(); + }); + test('resolvePageLinksInHtml caps concurrent unique page lookups', async () => { let inFlight = 0; let maxInFlight = 0; From 9bb50dd7c10f18963426a76a8f451ae039028811 Mon Sep 17 00:00:00 2001 From: "heecheol.park" Date: Wed, 15 Jul 2026 21:50:06 +0900 Subject: [PATCH 5/9] no-mistakes(review): Preserve malformed page links without dropping trailing content --- lib/confluence-client.js | 32 +++++++++++++++++++++++++------- tests/confluence-client.test.js | 20 ++++++++++++++++++++ 2 files changed, 45 insertions(+), 7 deletions(-) diff --git a/lib/confluence-client.js b/lib/confluence-client.js index 8e186ca..be82afb 100644 --- a/lib/confluence-client.js +++ b/lib/confluence-client.js @@ -4,7 +4,7 @@ const https = require('https'); const path = require('path'); const FormData = require('form-data'); const { convert } = require('html-to-text'); -const { parseDocument } = require('htmlparser2'); +const { Parser, DomHandler } = require('htmlparser2'); const { decodeHTML } = require('entities'); const MacroConverter = require('./macro-converter'); const { htmlToMarkdown, NAMED_ENTITIES } = require('./html-to-markdown'); @@ -50,6 +50,28 @@ const readElementContents = (source, node) => { return source.slice(first.startIndex, last.endIndex + 1); }; +const parseDocumentWithExplicitClosures = (source) => { + const options = { + xmlMode: true, + decodeEntities: false, + recognizeSelfClosing: true, + withStartIndices: true, + withEndIndices: true + }; + const explicitlyClosedNodes = new WeakSet(); + const handler = new DomHandler(undefined, options); + const closeTag = handler.onclosetag.bind(handler); + handler.onclosetag = (name, isImplied) => { + const node = handler.tagStack[handler.tagStack.length - 1]; + if (!isImplied) { + explicitlyClosedNodes.add(node); + } + closeTag(name, isImplied); + }; + new Parser(handler, options).end(source); + return { document: handler.root, explicitlyClosedNodes }; +}; + function createSemaphore(limit) { let active = 0; const waiters = []; @@ -670,12 +692,7 @@ class ConfluenceClient { * @returns {Promise} - HTML with resolved page links */ async resolvePageLinksInHtml(html, defaultSpaceKey) { - const document = parseDocument(html, { - xmlMode: true, - decodeEntities: false, - withStartIndices: true, - withEndIndices: true - }); + const { document, explicitlyClosedNodes } = parseDocumentWithExplicitClosures(html); const pageLinks = []; const nodesToVisit = [document]; @@ -684,6 +701,7 @@ class ConfluenceClient { if ( node.type === 'tag' && node.name === 'ac:link' + && explicitlyClosedNodes.has(node) && !node.attribs?.['ac:anchor'] && !isSemanticMacroPageReference(node) ) { diff --git a/tests/confluence-client.test.js b/tests/confluence-client.test.js index 384f20d..5b41723 100644 --- a/tests/confluence-client.test.js +++ b/tests/confluence-client.test.js @@ -628,6 +628,26 @@ describe('ConfluenceClient', () => { mock.restore(); }); + test('resolvePageLinksInHtml preserves implicitly closed links and trailing content', async () => { + client.findPageByTitleAndSpace = jest.fn(); + const storage = '

Before

' + + '

Trailing content

'; + + const result = await client.resolvePageLinksInHtml(storage, 'ENG'); + const warnings = []; + const markdown = client.storageToMarkdown(result, { + onWarnings: emitted => warnings.push(...emitted) + }); + + expect(result).toBe(storage); + expect(client.findPageByTitleAndSpace).not.toHaveBeenCalled(); + expect(markdown).toContain('Trailing content'); + expect(warnings).toContainEqual(expect.objectContaining({ + type: 'implicit-close', + tag: 'ac:link' + })); + }); + test('resolvePageLinksInHtml caps concurrent unique page lookups', async () => { let inFlight = 0; let maxInFlight = 0; From db3ab46a21ebb685aa56fb4c09ee3e7d2af6c95b Mon Sep 17 00:00:00 2001 From: "heecheol.park" Date: Wed, 15 Jul 2026 21:54:59 +0900 Subject: [PATCH 6/9] no-mistakes(review): Escape datetime content in Markdown link labels --- lib/storage-walker.js | 14 ++++++++++---- tests/confluence-client.test.js | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/lib/storage-walker.js b/lib/storage-walker.js index d909cc8..26a1dc2 100644 --- a/lib/storage-walker.js +++ b/lib/storage-walker.js @@ -133,9 +133,7 @@ class StorageWalker { // < > " '). Confluence storage prose still ships HTML // named entities like  , é, –, so decode them here // before they reach markdown output. - return this._markdownLinkLabelDepth > 0 && this._markdownCodeSpanDepth === 0 - ? this.escapeMarkdownText(decodeEntities(node.data || '')) - : decodeEntities(node.data || ''); + return this.renderText(node.data || ''); case 'cdata': return this.walkNodes(node.children); case 'comment': @@ -202,7 +200,8 @@ class StorageWalker { return `[${inner}](${href})`; } case 'time': - return decodeEntities((node.attribs && node.attribs.datetime) || '') || this.walkNodes(node.children); + return this.renderText((node.attribs && node.attribs.datetime) || '') + || this.walkNodes(node.children); case 'ul': return this.handleList(node, false); case 'ol': @@ -544,6 +543,13 @@ class StorageWalker { return s.replace(/([\\`*_[\]()~|<>])/g, '\\$1'); } + renderText(text) { + const decodedText = decodeEntities(text); + return this._markdownLinkLabelDepth > 0 && this._markdownCodeSpanDepth === 0 + ? this.escapeMarkdownText(decodedText) + : decodedText; + } + renderCodeSpan(content) { const backtickRuns = content.match(/`+/g) || []; const longestRun = backtickRuns.reduce((max, run) => Math.max(max, run.length), 0); diff --git a/tests/confluence-client.test.js b/tests/confluence-client.test.js index 5b41723..7d99b31 100644 --- a/tests/confluence-client.test.js +++ b/tests/confluence-client.test.js @@ -628,6 +628,33 @@ describe('ConfluenceClient', () => { mock.restore(); }); + test('readPage escapes datetime attributes in resolved page-link labels', async () => { + const mock = new MockAdapter(client.client); + mock.onGet('/content/123').reply(200, { + space: { key: 'ENG' }, + body: { + storage: { + value: '

' + + '' + + '

' + } + } + }); + mock.onGet('/content').reply(200, { + results: [{ + title: 'Custom', + _links: { webui: '/spaces/ENG/pages/456/Custom' } + }] + }); + + await expect(client.readPage('123', 'markdown')).resolves.toBe( + '[x\\]\\(https://attacker.example\\) \\[y]' + + '(https://test.atlassian.net/spaces/ENG/pages/456/Custom)' + ); + + mock.restore(); + }); + test('resolvePageLinksInHtml preserves implicitly closed links and trailing content', async () => { client.findPageByTitleAndSpace = jest.fn(); const storage = '

Before

' @@ -1255,6 +1282,12 @@ describe('ConfluenceClient', () => { expect(result).toContain('Short Name'); }); + test('should preserve datetime attributes outside markdown link labels', () => { + const storage = '

at

'; + + expect(client.storageToMarkdown(storage)).toBe('at x](https://example.com) [y'); + }); + test('should remove ac:link tags with attributes', () => { const storage = '

Before

After

'; const result = client.storageToMarkdown(storage); From db874975568cf15113990c74522a8cc0e943660c Mon Sep 17 00:00:00 2001 From: "heecheol.park" Date: Wed, 15 Jul 2026 22:00:37 +0900 Subject: [PATCH 7/9] fix: preserve tenant origin for resolved page links Pass the containing page base through same-space title lookups so scoped API clients produce browser URLs on the tenant origin while preserving a result's own base URL when supplied. --- lib/confluence-client.js | 16 +++++++++----- tests/confluence-client.test.js | 38 +++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 5 deletions(-) diff --git a/lib/confluence-client.js b/lib/confluence-client.js index be82afb..e3dc358 100644 --- a/lib/confluence-client.js +++ b/lib/confluence-client.js @@ -448,7 +448,11 @@ class ConfluenceClient { // Resolve page links to full URLs if (shouldResolvePageLinks) { - htmlContent = await this.resolvePageLinksInHtml(htmlContent, response.data.space?.key); + htmlContent = await this.resolvePageLinksInHtml( + htmlContent, + response.data.space?.key, + response.data._links?.base + ); } // Resolve children macro to child pages list @@ -659,9 +663,10 @@ class ConfluenceClient { * Find a page by title and space key, return page info with URL * @param {string} spaceKey - Space key (e.g., "~huotui" or "TECH") * @param {string} title - Page title + * @param {string} defaultBaseUrl - Base URL to use when the result omits one * @returns {Promise<{title: string, url: string} | null>} */ - async findPageByTitleAndSpace(spaceKey, title) { + async findPageByTitleAndSpace(spaceKey, title, defaultBaseUrl) { try { const response = await this.client.get('/content', { params: { @@ -676,7 +681,7 @@ class ConfluenceClient { const webui = page._links?.webui || ''; return { title: page.title, - url: webui ? this.toAbsoluteUrl(webui, page._links?.base) : '' + url: webui ? this.toAbsoluteUrl(webui, page._links?.base || defaultBaseUrl) : '' }; } return null; @@ -689,9 +694,10 @@ class ConfluenceClient { * Resolve all page links in HTML to full URLs * @param {string} html - HTML content with ri:page elements * @param {string} defaultSpaceKey - Space key of the page containing the links + * @param {string} defaultBaseUrl - Base URL of the page containing the links * @returns {Promise} - HTML with resolved page links */ - async resolvePageLinksInHtml(html, defaultSpaceKey) { + async resolvePageLinksInHtml(html, defaultSpaceKey, defaultBaseUrl) { const { document, explicitlyClosedNodes } = parseDocumentWithExplicitClosures(html); const pageLinks = []; @@ -741,7 +747,7 @@ class ConfluenceClient { pageLookups.set(lookupKey, (async () => { await semaphore.acquire(); try { - return await this.findPageByTitleAndSpace(link.spaceKey, link.title); + return await this.findPageByTitleAndSpace(link.spaceKey, link.title, defaultBaseUrl); } finally { semaphore.release(); } diff --git a/tests/confluence-client.test.js b/tests/confluence-client.test.js index 7d99b31..0af69cd 100644 --- a/tests/confluence-client.test.js +++ b/tests/confluence-client.test.js @@ -513,6 +513,44 @@ describe('ConfluenceClient', () => { mock.restore(); }); + test('readPage uses the containing page base when scoped lookup results omit it', async () => { + const scopedClient = new ConfluenceClient({ + domain: 'api.atlassian.com', + email: 'user@example.com', + token: 'scoped-token', + apiPath: '/ex/confluence/cloud-id/wiki/rest/api' + }); + const mock = new MockAdapter(scopedClient.client); + mock.onGet('/content/123').reply(200, { + space: { key: 'ENG' }, + body: { + storage: { + value: '

and ' + + '

' + } + }, + _links: { base: 'https://tenant.atlassian.net/wiki' } + }); + mock.onGet('/content').reply(config => [200, { + results: [{ + title: config.params.title, + _links: { + ...(config.params.title === 'Result base' + ? { base: 'https://result-tenant.atlassian.net/wiki' } + : {}), + webui: `/spaces/ENG/pages/456/${config.params.title.replace(' ', '-')}` + } + }] + }]); + + await expect(scopedClient.readPage('123', 'markdown')).resolves.toBe( + '[Fallback base](https://tenant.atlassian.net/wiki/spaces/ENG/pages/456/Fallback-base)' + + ' and [Result base](https://result-tenant.atlassian.net/wiki/spaces/ENG/pages/456/Result-base)' + ); + + mock.restore(); + }); + test('readPage preserves custom text for resolved same-space page links', async () => { const mock = new MockAdapter(client.client); mock.onGet('/content/123').reply(200, { From 24d07c2be5781f75896b7b07da6662a8cf1882e0 Mon Sep 17 00:00:00 2001 From: "heecheol.park" Date: Wed, 15 Jul 2026 22:08:38 +0900 Subject: [PATCH 8/9] no-mistakes(review): Escape images in resolved Markdown link labels --- lib/storage-walker.js | 4 +- tests/confluence-client.test.js | 67 +++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/lib/storage-walker.js b/lib/storage-walker.js index 26a1dc2..b8bcead 100644 --- a/lib/storage-walker.js +++ b/lib/storage-walker.js @@ -436,12 +436,12 @@ class StorageWalker { handleImage(node) { const riAttachment = this.findChildByName(node, 'ri:attachment'); if (riAttachment) { - const filename = decodeEntities(riAttachment.attribs['ri:filename'] || ''); + const filename = this.renderText(riAttachment.attribs['ri:filename'] || ''); return `![${filename}](${this.attachmentsDir}/${filename})`; } const riUrl = this.findChildByName(node, 'ri:url'); if (riUrl) { - const url = decodeEntities(riUrl.attribs['ri:value'] || ''); + const url = this.renderText(riUrl.attribs['ri:value'] || ''); if (!url) return ''; return `![](${url})`; } diff --git a/tests/confluence-client.test.js b/tests/confluence-client.test.js index 0af69cd..fad9200 100644 --- a/tests/confluence-client.test.js +++ b/tests/confluence-client.test.js @@ -693,6 +693,61 @@ describe('ConfluenceClient', () => { mock.restore(); }); + test('readPage escapes attachment images in resolved page-link labels', async () => { + const mock = new MockAdapter(client.client); + mock.onGet('/content/123').reply(200, { + space: { key: 'ENG' }, + body: { + storage: { + value: '' + + '' + + '' + } + } + }); + mock.onGet('/content').reply(200, { + results: [{ + title: 'Custom', + _links: { webui: '/spaces/ENG/pages/456/Custom' } + }] + }); + + await expect(client.readPage('123', 'markdown')).resolves.toBe( + '[![plot\\]\\(https://attacker.example\\) \\[x.png]' + + '(attachments/plot\\]\\(https://attacker.example\\) \\[x.png)]' + + '(https://test.atlassian.net/spaces/ENG/pages/456/Custom)' + ); + + mock.restore(); + }); + + test('readPage escapes external images in resolved page-link labels', async () => { + const mock = new MockAdapter(client.client); + mock.onGet('/content/123').reply(200, { + space: { key: 'ENG' }, + body: { + storage: { + value: '' + + '' + + '' + } + } + }); + mock.onGet('/content').reply(200, { + results: [{ + title: 'Custom', + _links: { webui: '/spaces/ENG/pages/456/Custom' } + }] + }); + + await expect(client.readPage('123', 'markdown')).resolves.toBe( + '[![](https://images.example/x.png\\)\\]\\(https://attacker.example\\) \\[x)]' + + '(https://test.atlassian.net/spaces/ENG/pages/456/Custom)' + ); + + mock.restore(); + }); + test('resolvePageLinksInHtml preserves implicitly closed links and trailing content', async () => { client.findPageByTitleAndSpace = jest.fn(); const storage = '

Before

' @@ -1326,6 +1381,18 @@ describe('ConfluenceClient', () => { expect(client.storageToMarkdown(storage)).toBe('at x](https://example.com) [y'); }); + test('should preserve image attributes outside markdown link labels', () => { + const attachment = ''; + const external = ''; + + expect(client.storageToMarkdown(attachment)).toBe( + '![plot](draft).png](attachments/plot](draft).png)' + ); + expect(client.storageToMarkdown(external)).toBe( + '![](https://images.example/x](draft).png)' + ); + }); + test('should remove ac:link tags with attributes', () => { const storage = '

Before

After

'; const result = client.storageToMarkdown(storage); From 5c786cd2631e3ea2c6d3216851abc02b5c1d2c40 Mon Sep 17 00:00:00 2001 From: "heecheol.park" Date: Wed, 15 Jul 2026 22:16:37 +0900 Subject: [PATCH 9/9] no-mistakes(document): Document same-space Markdown page-link resolution --- README.md | 4 +++- lib/confluence-client.js | 11 ++++++----- plugins/confluence/skills/confluence/SKILL.md | 4 +++- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 182d9d0..d6e4b90 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ A powerful command-line interface for Atlassian Confluence that allows you to re ## Features -- 📖 **Read pages** - Get page content in text or HTML format +- 📖 **Read pages** - Get page content in text, HTML, storage, or Markdown format - 🔍 **Search** - Find pages using Confluence's powerful search - â„šī¸ **Page info** - Get detailed information about pages - 🏠 **List spaces** - View available Confluence spaces @@ -391,6 +391,8 @@ confluence read "https://your-domain.atlassian.net/wiki/viewpage.action?pageId=1 Use `--format storage` when you need Confluence's native storage representation, especially for macros and other Confluence-specific markup. +Markdown reads resolve accessible Confluence page links, including links that omit a space key because they target the same space, to absolute URLs. Custom link text and inline formatting are preserved. Markdown exports use the same link resolution. + Reading requires content with a storage body. Folders and other bodyless content return `Page has no readable body (it may be a folder or an unsupported content type).`; use `confluence info ` to inspect their metadata. ### Get Page Information diff --git a/lib/confluence-client.js b/lib/confluence-client.js index e3dc358..2ac73f1 100644 --- a/lib/confluence-client.js +++ b/lib/confluence-client.js @@ -414,6 +414,7 @@ class ConfluenceClient { * @param {string} format - Output format: 'text', 'html', 'storage', or 'markdown' * @param {object} options - Additional options * @param {boolean} options.resolveUsers - Whether to resolve userkeys to display names (default: true for markdown) + * @param {boolean} options.resolvePageLinks - Whether to replace resolvable Confluence page links with absolute URLs (default: true for markdown) * @param {boolean} options.extractReferencedAttachments - Whether to extract referenced attachments (default: false) * @throws {Error} When the target content has no storage body */ @@ -663,7 +664,7 @@ class ConfluenceClient { * Find a page by title and space key, return page info with URL * @param {string} spaceKey - Space key (e.g., "~huotui" or "TECH") * @param {string} title - Page title - * @param {string} defaultBaseUrl - Base URL to use when the result omits one + * @param {string} [defaultBaseUrl] - Base URL to use when the result omits one * @returns {Promise<{title: string, url: string} | null>} */ async findPageByTitleAndSpace(spaceKey, title, defaultBaseUrl) { @@ -691,11 +692,11 @@ class ConfluenceClient { } /** - * Resolve all page links in HTML to full URLs + * Resolve ordinary Confluence page links in storage HTML to absolute URLs * @param {string} html - HTML content with ri:page elements - * @param {string} defaultSpaceKey - Space key of the page containing the links - * @param {string} defaultBaseUrl - Base URL of the page containing the links - * @returns {Promise} - HTML with resolved page links + * @param {string} [defaultSpaceKey] - Space key of the page containing the links + * @param {string} [defaultBaseUrl] - Base URL of the page containing the links + * @returns {Promise} - HTML with resolvable page links replaced by anchors */ async resolvePageLinksInHtml(html, defaultSpaceKey, defaultBaseUrl) { const { document, explicitlyClosedNodes } = parseDocumentWithExplicitClosures(html); diff --git a/plugins/confluence/skills/confluence/SKILL.md b/plugins/confluence/skills/confluence/SKILL.md index e08db22..6add357 100644 --- a/plugins/confluence/skills/confluence/SKILL.md +++ b/plugins/confluence/skills/confluence/SKILL.md @@ -120,7 +120,7 @@ confluence read "https://company.atlassian.net/wiki/spaces/MYSPACE/pages/1234567 | Format | Notes | |---|---| -| `markdown` | Recommended for agent-generated content. Automatically converted by the API. | +| `markdown` | Recommended for agent-generated content. Automatically converted by the CLI. | | `storage` | Confluence XML storage format (default for create/update). Use for programmatic round-trips. | | `html` | Raw HTML. | | `text` | Plain text — for read/export output only, not for creation. | @@ -163,6 +163,8 @@ confluence read 123456789 --format storage confluence read 123456789 --format markdown ``` +Markdown output resolves accessible Confluence page links, including links to pages in the same space, to absolute URLs while preserving custom link text and inline formatting. + Requires content with a storage body. Folders and other bodyless content return `Page has no readable body (it may be a folder or an unsupported content type).`; use `confluence info ` to inspect their metadata. ---