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 f6091f1..2ac73f1 100644 --- a/lib/confluence-client.js +++ b/lib/confluence-client.js @@ -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, '&') + .replace(//g, '>'); + +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) { + 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; @@ -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' } }); @@ -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 @@ -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: { @@ -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; @@ -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} - 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) { - // Extract all page links: - const pageLinkRegex = /\s*]*(?:\/>|><\/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 = `${body}`; + resolvedHtml = resolvedHtml.slice(0, link.startIndex) + + replacement + + resolvedHtml.slice(link.endIndex + 1); + }); return resolvedHtml; } diff --git a/lib/storage-walker.js b/lib/storage-walker.js index 3048cdd..b8bcead 100644 --- a/lib/storage-walker.js +++ b/lib/storage-walker.js @@ -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, @@ -131,7 +133,7 @@ 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.renderText(node.data || ''); case 'cdata': return this.walkNodes(node.children); case 'comment': @@ -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': @@ -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})`; } @@ -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) { 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. --- diff --git a/tests/confluence-client.test.js b/tests/confluence-client.test.js index 18ecabc..fad9200 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) => { @@ -478,6 +479,334 @@ 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 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, { + 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(); + }); + + 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 ' + + '*literal* [x] ' + + 'Read [this] styled

' + } + } + }); + 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 [\\*literal\\* `[x]` **Read \\[this\\]** *styled*](https://test.atlassian.net/spaces/ENG/pages/456/Custom)' + ); + + 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('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('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

' + + '

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; + 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 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\)\./; @@ -1046,6 +1375,24 @@ 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 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);