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 ``;
}
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 ``;
}
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://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(
+ '.png](attachments/plot](draft).png)'
+ );
+ expect(client.storageToMarkdown(external)).toBe(
+ '.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.
---