Skip to content
Merged
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 <id> has no readable body (it may be a folder or an unsupported content type).`; use `confluence info <id>` to inspect their metadata.

### Get Page Information
Expand Down
173 changes: 139 additions & 34 deletions lib/confluence-client.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,73 @@ const https = require('https');
const path = require('path');
const FormData = require('form-data');
const { convert } = require('html-to-text');
const { Parser, DomHandler } = 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 PAGE_LINK_LOOKUP_CONCURRENCY = 10;

const escapeXmlText = (value) => String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');

const escapeXmlAttribute = (value) => escapeXmlText(value).replace(/"/g, '&quot;');

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) {
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);
};

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;
Expand Down Expand Up @@ -351,15 +414,17 @@ 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
*/
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'
}
});

Expand All @@ -383,9 +448,12 @@ 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,
response.data._links?.base
);
}

// Resolve children macro to child pages list
Expand Down Expand Up @@ -596,9 +664,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: {
Expand All @@ -613,7 +682,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;
Expand All @@ -623,51 +692,87 @@ 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
* @returns {Promise<string>} - 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<string>} - HTML with resolvable page links replaced by anchors
*/
async resolvePageLinksInHtml(html) {
// Extract all page links: <ri:page ri:space-key="xxx" ri:content-title="yyy" />
const pageLinkRegex = /<ac:link>\s*<ri:page\s+ri:space-key="([^"]+)"\s+ri:content-title="([^"]+)"[^>]*(?:\/>|><\/ri:page>)\s*<\/ac:link>/g;
async resolvePageLinksInHtml(html, defaultSpaceKey, defaultBaseUrl) {
const { document, explicitlyClosedNodes } = parseDocumentWithExplicitClosures(html);
const pageLinks = [];
let match;

while ((match = pageLinkRegex.exec(html)) !== null) {
pageLinks.push({
fullMatch: match[0],
spaceKey: match[1],
title: match[2]
});

const nodesToVisit = [document];
while (nodesToVisit.length > 0) {
const node = nodesToVisit.pop();
if (
node.type === 'tag'
&& node.name === 'ac:link'
&& explicitlyClosedNodes.has(node)
&& !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 || '');

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)
});
continue;
}
}

const children = node.children || [];
for (let index = children.length - 1; index >= 0; index--) {
nodesToVisit.push(children[index]);
}
}

if (pageLinks.length === 0) {
return html;
}

// Fetch page info for all links in parallel
const pageLookups = new Map();
const semaphore = createSemaphore(PAGE_LINK_LOOKUP_CONCURRENCY);
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, (async () => {
await semaphore.acquire();
try {
return await this.findPageByTitleAndSpace(link.spaceKey, link.title, defaultBaseUrl);
} finally {
semaphore.release();
}
})());
}
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 = `<a href="${escapeXmlAttribute(link.pageInfo.url)}">${body}</a>`;
resolvedHtml = resolvedHtml.slice(0, link.startIndex)
+ replacement
+ resolvedHtml.slice(link.endIndex + 1);
});

return resolvedHtml;
}
Expand Down
48 changes: 39 additions & 9 deletions lib/storage-walker.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ class StorageWalker {

walk(storage) {
this._depth = 0;
this._markdownLinkLabelDepth = 0;
this._markdownCodeSpanDepth = 0;
this.warnings = [];

// htmlparser2 in xmlMode is lenient: malformed input (unclosed tags,
Expand Down Expand Up @@ -131,7 +133,7 @@ class StorageWalker {
// &lt; &gt; &quot; &apos;). Confluence storage prose still ships HTML
// named entities like &nbsp;, &eacute;, &ndash;, so decode them here
// before they reach markdown output.
return decodeEntities(node.data || '');
return this.renderText(node.data || '');
case 'cdata':
return this.walkNodes(node.children);
case 'comment':
Expand Down Expand Up @@ -173,20 +175,33 @@ 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.renderCodeSpan(this.walkNodes(node.children));
} finally {
this._markdownCodeSpanDepth--;
}
}
case 'br':
return '\n';
case 'hr':
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':
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':
Expand Down Expand Up @@ -421,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})`;
}
Expand Down Expand Up @@ -525,7 +540,22 @@ 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');
}

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);
const delimiter = '`'.repeat(longestRun + 1);
const padding = content.startsWith('`') || content.endsWith('`') ? ' ' : '';
return `${delimiter}${padding}${content}${padding}${delimiter}`;
}

_collectText(node) {
Expand Down
4 changes: 3 additions & 1 deletion plugins/confluence/skills/confluence/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down Expand Up @@ -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 <id> has no readable body (it may be a folder or an unsupported content type).`; use `confluence info <id>` to inspect their metadata.

---
Expand Down
Loading