confluence-cli 1.10.0 → 1.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/bin/confluence.js +4 -2
- package/lib/confluence-client.js +300 -12
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
## [1.10.1](https://github.com/pchuri/confluence-cli/compare/v1.10.0...v1.10.1) (2025-12-08)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Bug Fixes
|
|
5
|
+
|
|
6
|
+
* improve markdown export and attachment download ([#19](https://github.com/pchuri/confluence-cli/issues/19)) ([978275d](https://github.com/pchuri/confluence-cli/commit/978275dbe71eea83138bbd537ce7d4edda8180f8))
|
|
7
|
+
|
|
1
8
|
# [1.10.0](https://github.com/pchuri/confluence-cli/compare/v1.9.0...v1.10.0) (2025-12-05)
|
|
2
9
|
|
|
3
10
|
|
package/bin/confluence.js
CHANGED
|
@@ -404,7 +404,8 @@ program
|
|
|
404
404
|
let downloaded = 0;
|
|
405
405
|
for (const attachment of filtered) {
|
|
406
406
|
const targetPath = uniquePathFor(destDir, attachment.title);
|
|
407
|
-
|
|
407
|
+
// Pass the full attachment object so downloadAttachment can use downloadLink directly
|
|
408
|
+
const dataStream = await client.downloadAttachment(pageId, attachment);
|
|
408
409
|
await writeStream(dataStream, targetPath);
|
|
409
410
|
downloaded += 1;
|
|
410
411
|
console.log(`⬇️ ${chalk.green(attachment.title)} -> ${chalk.gray(targetPath)}`);
|
|
@@ -495,7 +496,8 @@ program
|
|
|
495
496
|
let downloaded = 0;
|
|
496
497
|
for (const attachment of filtered) {
|
|
497
498
|
const targetPath = uniquePathFor(attachmentsDir, attachment.title);
|
|
498
|
-
|
|
499
|
+
// Pass the full attachment object so downloadAttachment can use downloadLink directly
|
|
500
|
+
const dataStream = await client.downloadAttachment(pageId, attachment);
|
|
499
501
|
await writeStream(dataStream, targetPath);
|
|
500
502
|
downloaded += 1;
|
|
501
503
|
console.log(`⬇️ ${chalk.green(attachment.title)} -> ${chalk.gray(targetPath)}`);
|
package/lib/confluence-client.js
CHANGED
|
@@ -74,8 +74,12 @@ class ConfluenceClient {
|
|
|
74
74
|
|
|
75
75
|
/**
|
|
76
76
|
* Read a Confluence page content
|
|
77
|
+
* @param {string} pageIdOrUrl - Page ID or URL
|
|
78
|
+
* @param {string} format - Output format: 'text', 'html', or 'markdown'
|
|
79
|
+
* @param {object} options - Additional options
|
|
80
|
+
* @param {boolean} options.resolveUsers - Whether to resolve userkeys to display names (default: true for markdown)
|
|
77
81
|
*/
|
|
78
|
-
async readPage(pageIdOrUrl, format = 'text') {
|
|
82
|
+
async readPage(pageIdOrUrl, format = 'text', options = {}) {
|
|
79
83
|
const pageId = this.extractPageId(pageIdOrUrl);
|
|
80
84
|
|
|
81
85
|
const response = await this.client.get(`/content/${pageId}`, {
|
|
@@ -84,13 +88,26 @@ class ConfluenceClient {
|
|
|
84
88
|
}
|
|
85
89
|
});
|
|
86
90
|
|
|
87
|
-
|
|
91
|
+
let htmlContent = response.data.body.storage.value;
|
|
88
92
|
|
|
89
93
|
if (format === 'html') {
|
|
90
94
|
return htmlContent;
|
|
91
95
|
}
|
|
92
96
|
|
|
93
97
|
if (format === 'markdown') {
|
|
98
|
+
// Resolve userkeys to display names before converting to markdown
|
|
99
|
+
const resolveUsers = options.resolveUsers !== false;
|
|
100
|
+
if (resolveUsers) {
|
|
101
|
+
const { html: resolvedHtml } = await this.resolveUserKeysInHtml(htmlContent);
|
|
102
|
+
htmlContent = resolvedHtml;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Resolve page links to full URLs
|
|
106
|
+
const resolvePageLinks = options.resolvePageLinks !== false;
|
|
107
|
+
if (resolvePageLinks) {
|
|
108
|
+
htmlContent = await this.resolvePageLinksInHtml(htmlContent);
|
|
109
|
+
}
|
|
110
|
+
|
|
94
111
|
return this.storageToMarkdown(htmlContent);
|
|
95
112
|
}
|
|
96
113
|
|
|
@@ -167,6 +184,153 @@ class ConfluenceClient {
|
|
|
167
184
|
}));
|
|
168
185
|
}
|
|
169
186
|
|
|
187
|
+
/**
|
|
188
|
+
* Get user information by userkey
|
|
189
|
+
* @param {string} userKey - The user key (e.g., "8ad05c43962471ed0196c26107d7000c")
|
|
190
|
+
* @returns {Promise<{key: string, displayName: string, username: string}>}
|
|
191
|
+
*/
|
|
192
|
+
async getUserByKey(userKey) {
|
|
193
|
+
try {
|
|
194
|
+
const response = await this.client.get('/user', {
|
|
195
|
+
params: { key: userKey }
|
|
196
|
+
});
|
|
197
|
+
return {
|
|
198
|
+
key: userKey,
|
|
199
|
+
displayName: response.data.displayName || response.data.username || userKey,
|
|
200
|
+
username: response.data.username || ''
|
|
201
|
+
};
|
|
202
|
+
} catch (error) {
|
|
203
|
+
// Return full userkey as fallback if user not found
|
|
204
|
+
return {
|
|
205
|
+
key: userKey,
|
|
206
|
+
displayName: userKey,
|
|
207
|
+
username: ''
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Resolve all userkeys in HTML to display names
|
|
214
|
+
* @param {string} html - HTML content with ri:user elements
|
|
215
|
+
* @returns {Promise<{html: string, userMap: Map<string, string>}>}
|
|
216
|
+
*/
|
|
217
|
+
async resolveUserKeysInHtml(html) {
|
|
218
|
+
// Extract all unique userkeys
|
|
219
|
+
const userKeyRegex = /ri:userkey="([^"]+)"/g;
|
|
220
|
+
const userKeys = new Set();
|
|
221
|
+
let match;
|
|
222
|
+
while ((match = userKeyRegex.exec(html)) !== null) {
|
|
223
|
+
userKeys.add(match[1]);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (userKeys.size === 0) {
|
|
227
|
+
return { html, userMap: new Map() };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Fetch user info for all keys in parallel
|
|
231
|
+
const userPromises = Array.from(userKeys).map(key => this.getUserByKey(key));
|
|
232
|
+
const users = await Promise.all(userPromises);
|
|
233
|
+
|
|
234
|
+
// Build userkey -> displayName map
|
|
235
|
+
const userMap = new Map();
|
|
236
|
+
users.forEach(user => {
|
|
237
|
+
userMap.set(user.key, user.displayName);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
// Replace userkey references with display names in HTML
|
|
241
|
+
let resolvedHtml = html;
|
|
242
|
+
userMap.forEach((displayName, userKey) => {
|
|
243
|
+
// Replace <ac:link><ri:user ri:userkey="xxx" /></ac:link> with @displayName
|
|
244
|
+
const userLinkRegex = new RegExp(
|
|
245
|
+
`<ac:link>\\s*<ri:user\\s+ri:userkey="${userKey}"\\s*/>\\s*</ac:link>`,
|
|
246
|
+
'g'
|
|
247
|
+
);
|
|
248
|
+
resolvedHtml = resolvedHtml.replace(userLinkRegex, `@${displayName}`);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
return { html: resolvedHtml, userMap };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Find a page by title and space key, return page info with URL
|
|
256
|
+
* @param {string} spaceKey - Space key (e.g., "~huotui" or "TECH")
|
|
257
|
+
* @param {string} title - Page title
|
|
258
|
+
* @returns {Promise<{title: string, url: string} | null>}
|
|
259
|
+
*/
|
|
260
|
+
async findPageByTitleAndSpace(spaceKey, title) {
|
|
261
|
+
try {
|
|
262
|
+
const response = await this.client.get('/content', {
|
|
263
|
+
params: {
|
|
264
|
+
spaceKey: spaceKey,
|
|
265
|
+
title: title,
|
|
266
|
+
limit: 1
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
if (response.data.results && response.data.results.length > 0) {
|
|
271
|
+
const page = response.data.results[0];
|
|
272
|
+
const webui = page._links?.webui || '';
|
|
273
|
+
return {
|
|
274
|
+
title: page.title,
|
|
275
|
+
url: webui ? `https://${this.domain}/wiki${webui}` : ''
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
return null;
|
|
279
|
+
} catch (error) {
|
|
280
|
+
return null;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Resolve all page links in HTML to full URLs
|
|
286
|
+
* @param {string} html - HTML content with ri:page elements
|
|
287
|
+
* @returns {Promise<string>} - HTML with resolved page links
|
|
288
|
+
*/
|
|
289
|
+
async resolvePageLinksInHtml(html) {
|
|
290
|
+
// Extract all page links: <ri:page ri:space-key="xxx" ri:content-title="yyy" />
|
|
291
|
+
const pageLinkRegex = /<ac:link>\s*<ri:page\s+ri:space-key="([^"]+)"\s+ri:content-title="([^"]+)"[^>]*(?:\/>|><\/ri:page>)\s*<\/ac:link>/g;
|
|
292
|
+
const pageLinks = [];
|
|
293
|
+
let match;
|
|
294
|
+
|
|
295
|
+
while ((match = pageLinkRegex.exec(html)) !== null) {
|
|
296
|
+
pageLinks.push({
|
|
297
|
+
fullMatch: match[0],
|
|
298
|
+
spaceKey: match[1],
|
|
299
|
+
title: match[2]
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (pageLinks.length === 0) {
|
|
304
|
+
return html;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Fetch page info for all links in parallel
|
|
308
|
+
const pagePromises = pageLinks.map(async (link) => {
|
|
309
|
+
const pageInfo = await this.findPageByTitleAndSpace(link.spaceKey, link.title);
|
|
310
|
+
return {
|
|
311
|
+
...link,
|
|
312
|
+
pageInfo
|
|
313
|
+
};
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
const resolvedLinks = await Promise.all(pagePromises);
|
|
317
|
+
|
|
318
|
+
// Replace page link references with markdown links
|
|
319
|
+
let resolvedHtml = html;
|
|
320
|
+
resolvedLinks.forEach(({ fullMatch, title, pageInfo }) => {
|
|
321
|
+
let replacement;
|
|
322
|
+
if (pageInfo && pageInfo.url) {
|
|
323
|
+
replacement = `[${title}](${pageInfo.url})`;
|
|
324
|
+
} else {
|
|
325
|
+
// Fallback to just the title if page not found
|
|
326
|
+
replacement = `[${title}]`;
|
|
327
|
+
}
|
|
328
|
+
resolvedHtml = resolvedHtml.replace(fullMatch, replacement);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
return resolvedHtml;
|
|
332
|
+
}
|
|
333
|
+
|
|
170
334
|
/**
|
|
171
335
|
* List attachments for a page with pagination support
|
|
172
336
|
*/
|
|
@@ -228,13 +392,40 @@ class ConfluenceClient {
|
|
|
228
392
|
|
|
229
393
|
/**
|
|
230
394
|
* Download an attachment's data stream
|
|
395
|
+
* Now uses the download link from attachment metadata instead of the broken REST API endpoint
|
|
231
396
|
*/
|
|
232
|
-
async downloadAttachment(pageIdOrUrl,
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
397
|
+
async downloadAttachment(pageIdOrUrl, attachmentIdOrAttachment, options = {}) {
|
|
398
|
+
let downloadUrl;
|
|
399
|
+
|
|
400
|
+
// If the second argument is an attachment object with downloadLink, use it directly
|
|
401
|
+
if (typeof attachmentIdOrAttachment === 'object' && attachmentIdOrAttachment.downloadLink) {
|
|
402
|
+
downloadUrl = attachmentIdOrAttachment.downloadLink;
|
|
403
|
+
} else {
|
|
404
|
+
// Otherwise, fetch attachment info to get the download link
|
|
405
|
+
const pageId = this.extractPageId(pageIdOrUrl);
|
|
406
|
+
const attachmentId = attachmentIdOrAttachment;
|
|
407
|
+
const response = await this.client.get(`/content/${pageId}/child/attachment`, {
|
|
408
|
+
params: { limit: 500 }
|
|
409
|
+
});
|
|
410
|
+
const attachment = response.data.results.find(att => att.id === String(attachmentId));
|
|
411
|
+
if (!attachment) {
|
|
412
|
+
throw new Error(`Attachment with ID ${attachmentId} not found on page ${pageId}`);
|
|
413
|
+
}
|
|
414
|
+
downloadUrl = this.toAbsoluteUrl(attachment._links?.download);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
if (!downloadUrl) {
|
|
418
|
+
throw new Error('Unable to determine download URL for attachment');
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Download directly using axios with the same auth headers
|
|
422
|
+
const downloadResponse = await axios.get(downloadUrl, {
|
|
423
|
+
responseType: options.responseType || 'stream',
|
|
424
|
+
headers: {
|
|
425
|
+
'Authorization': this.authType === 'basic' ? this.buildBasicAuthHeader() : `Bearer ${this.token}`
|
|
426
|
+
}
|
|
236
427
|
});
|
|
237
|
-
return
|
|
428
|
+
return downloadResponse.data;
|
|
238
429
|
}
|
|
239
430
|
|
|
240
431
|
/**
|
|
@@ -402,14 +593,54 @@ class ConfluenceClient {
|
|
|
402
593
|
|
|
403
594
|
/**
|
|
404
595
|
* Convert Confluence storage format to markdown
|
|
596
|
+
* @param {string} storage - Confluence storage format HTML
|
|
597
|
+
* @param {object} options - Conversion options
|
|
598
|
+
* @param {string} options.attachmentsDir - Directory name for attachments (default: 'attachments')
|
|
405
599
|
*/
|
|
406
|
-
storageToMarkdown(storage) {
|
|
600
|
+
storageToMarkdown(storage, options = {}) {
|
|
601
|
+
const attachmentsDir = options.attachmentsDir || 'attachments';
|
|
407
602
|
let markdown = storage;
|
|
408
603
|
|
|
409
604
|
// Remove table of contents macro
|
|
410
605
|
markdown = markdown.replace(/<ac:structured-macro ac:name="toc"[^>]*\s*\/>/g, '');
|
|
411
606
|
markdown = markdown.replace(/<ac:structured-macro ac:name="toc"[^>]*>[\s\S]*?<\/ac:structured-macro>/g, '');
|
|
412
607
|
|
|
608
|
+
// Remove floatmenu macro (floating table of contents)
|
|
609
|
+
markdown = markdown.replace(/<ac:structured-macro ac:name="floatmenu"[^>]*>[\s\S]*?<\/ac:structured-macro>/g, '');
|
|
610
|
+
|
|
611
|
+
// Convert Confluence images to markdown images
|
|
612
|
+
// Format: <ac:image><ri:attachment ri:filename="image.png" /></ac:image>
|
|
613
|
+
markdown = markdown.replace(/<ac:image[^>]*>\s*<ri:attachment\s+ri:filename="([^"]+)"[^>]*\s*\/>\s*<\/ac:image>/g, (_, filename) => {
|
|
614
|
+
return ``;
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
// Also handle self-closing ac:image with ri:attachment
|
|
618
|
+
markdown = markdown.replace(/<ac:image[^>]*><ri:attachment\s+ri:filename="([^"]+)"[^>]*><\/ri:attachment><\/ac:image>/g, (_, filename) => {
|
|
619
|
+
return ``;
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
// Convert mermaid macro to mermaid code block
|
|
623
|
+
markdown = markdown.replace(/<ac:structured-macro ac:name="mermaid-macro"[^>]*>[\s\S]*?<ac:plain-text-body><!\[CDATA\[([\s\S]*?)\]\]><\/ac:plain-text-body>[\s\S]*?<\/ac:structured-macro>/g, (_, code) => {
|
|
624
|
+
return `\n\`\`\`mermaid\n${code.trim()}\n\`\`\`\n`;
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
// Convert expand macro - extract content from rich-text-body
|
|
628
|
+
// Detect language based on content for the expand summary text
|
|
629
|
+
const detectExpandSummary = (text) => {
|
|
630
|
+
if (/[\u4e00-\u9fa5]/.test(text)) return '展开详情'; // Chinese
|
|
631
|
+
if (/[\u3040-\u309f\u30a0-\u30ff]/.test(text)) return '詳細を表示'; // Japanese
|
|
632
|
+
if (/[\uac00-\ud7af]/.test(text)) return '상세 보기'; // Korean
|
|
633
|
+
if (/[\u0400-\u04ff]/.test(text)) return 'Подробнее'; // Russian/Cyrillic
|
|
634
|
+
if (/[àâäéèêëïîôùûüÿœæç]/i.test(text)) return 'Détails'; // French
|
|
635
|
+
if (/[äöüß]/i.test(text)) return 'Details'; // German
|
|
636
|
+
if (/[áéíóúñ¿¡]/i.test(text)) return 'Detalles'; // Spanish
|
|
637
|
+
return 'Expand Details'; // Default: English
|
|
638
|
+
};
|
|
639
|
+
const expandSummary = detectExpandSummary(markdown);
|
|
640
|
+
markdown = markdown.replace(/<ac:structured-macro ac:name="expand"[^>]*>[\s\S]*?<ac:rich-text-body>([\s\S]*?)<\/ac:rich-text-body>[\s\S]*?<\/ac:structured-macro>/g, (_, content) => {
|
|
641
|
+
return `\n<details>\n<summary>${expandSummary}</summary>\n\n${content}\n\n</details>\n`;
|
|
642
|
+
});
|
|
643
|
+
|
|
413
644
|
// Convert Confluence code macros to markdown
|
|
414
645
|
markdown = markdown.replace(/<ac:structured-macro ac:name="code"[^>]*>[\s\S]*?<ac:parameter ac:name="language">([^<]*)<\/ac:parameter>[\s\S]*?<ac:plain-text-body><!\[CDATA\[([\s\S]*?)\]\]><\/ac:plain-text-body>[\s\S]*?<\/ac:structured-macro>/g, (_, lang, code) => {
|
|
415
646
|
return `\`\`\`${lang}\n${code}\n\`\`\``;
|
|
@@ -438,12 +669,40 @@ class ConfluenceClient {
|
|
|
438
669
|
return `[!note]\n${cleanContent}`;
|
|
439
670
|
});
|
|
440
671
|
|
|
672
|
+
// Convert task list macros to markdown checkboxes
|
|
673
|
+
// Note: This is independent of user resolution - it only converts <ac:task> structure to "- [ ]" or "- [x]" format
|
|
674
|
+
markdown = markdown.replace(/<ac:task-list>([\s\S]*?)<\/ac:task-list>/g, (_, content) => {
|
|
675
|
+
const tasks = [];
|
|
676
|
+
// Match each task: <ac:task>...<ac:task-status>xxx</ac:task-status>...<ac:task-body>...</ac:task-body>...</ac:task>
|
|
677
|
+
const taskRegex = /<ac:task>[\s\S]*?<ac:task-status>([^<]*)<\/ac:task-status>[\s\S]*?<ac:task-body>([\s\S]*?)<\/ac:task-body>[\s\S]*?<\/ac:task>/g;
|
|
678
|
+
let match;
|
|
679
|
+
while ((match = taskRegex.exec(content)) !== null) {
|
|
680
|
+
const status = match[1];
|
|
681
|
+
let taskBody = match[2];
|
|
682
|
+
// Clean up HTML from task body, but preserve @username
|
|
683
|
+
taskBody = taskBody.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim();
|
|
684
|
+
const checkbox = status === 'complete' ? '[x]' : '[ ]';
|
|
685
|
+
if (taskBody) {
|
|
686
|
+
tasks.push(`- ${checkbox} ${taskBody}`);
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
return tasks.length > 0 ? '\n' + tasks.join('\n') + '\n' : '';
|
|
690
|
+
});
|
|
691
|
+
|
|
441
692
|
// Remove other unhandled macros (replace with empty string for now)
|
|
442
693
|
markdown = markdown.replace(/<ac:structured-macro[^>]*>[\s\S]*?<\/ac:structured-macro>/g, '');
|
|
443
694
|
|
|
444
|
-
// Convert links
|
|
695
|
+
// Convert external URL links
|
|
445
696
|
markdown = markdown.replace(/<ac:link><ri:url ri:value="([^"]*)" \/><ac:plain-text-link-body><!\[CDATA\[([^\]]*)\]\]><\/ac:plain-text-link-body><\/ac:link>/g, '[$2]($1)');
|
|
446
697
|
|
|
698
|
+
// Convert internal page links - extract page title
|
|
699
|
+
// Format: <ac:link><ri:page ri:space-key="xxx" ri:content-title="Page Title" /></ac:link>
|
|
700
|
+
markdown = markdown.replace(/<ac:link>\s*<ri:page[^>]*ri:content-title="([^"]*)"[^>]*\/>\s*<\/ac:link>/g, '[$1]');
|
|
701
|
+
markdown = markdown.replace(/<ac:link>\s*<ri:page[^>]*ri:content-title="([^"]*)"[^>]*>\s*<\/ri:page>\s*<\/ac:link>/g, '[$1]');
|
|
702
|
+
|
|
703
|
+
// Remove any remaining ac:link tags that weren't matched
|
|
704
|
+
markdown = markdown.replace(/<ac:link>[\s\S]*?<\/ac:link>/g, '');
|
|
705
|
+
|
|
447
706
|
// Convert remaining HTML to markdown
|
|
448
707
|
markdown = this.htmlToMarkdown(markdown);
|
|
449
708
|
|
|
@@ -456,6 +715,10 @@ class ConfluenceClient {
|
|
|
456
715
|
htmlToMarkdown(html) {
|
|
457
716
|
let markdown = html;
|
|
458
717
|
|
|
718
|
+
// Convert time elements to date text BEFORE removing attributes
|
|
719
|
+
// Format: <time datetime="2025-09-16" /> or <time datetime="2025-09-16"></time>
|
|
720
|
+
markdown = markdown.replace(/<time\s+datetime="([^"]+)"[^>]*(?:\/>|>\s*<\/time>)/g, '$1');
|
|
721
|
+
|
|
459
722
|
// Convert strong/bold BEFORE removing HTML attributes
|
|
460
723
|
markdown = markdown.replace(/<strong[^>]*>(.*?)<\/strong>/g, '**$1**');
|
|
461
724
|
|
|
@@ -560,8 +823,8 @@ class ConfluenceClient {
|
|
|
560
823
|
// Convert horizontal rules
|
|
561
824
|
markdown = markdown.replace(/<hr\s*\/?>/g, '\n---\n');
|
|
562
825
|
|
|
563
|
-
// Remove any remaining HTML tags
|
|
564
|
-
markdown = markdown.replace(/<[^>]+>/g, ' ');
|
|
826
|
+
// Remove any remaining HTML tags, but preserve <details> and <summary> for GFM compatibility
|
|
827
|
+
markdown = markdown.replace(/<(?!\/?(details|summary)\b)[^>]+>/g, ' ');
|
|
565
828
|
|
|
566
829
|
// Clean up whitespace and HTML entities
|
|
567
830
|
markdown = markdown.replace(/ /g, ' ');
|
|
@@ -569,10 +832,35 @@ class ConfluenceClient {
|
|
|
569
832
|
markdown = markdown.replace(/>/g, '>');
|
|
570
833
|
markdown = markdown.replace(/&/g, '&');
|
|
571
834
|
markdown = markdown.replace(/"/g, '"');
|
|
835
|
+
markdown = markdown.replace(/'/g, '\'');
|
|
836
|
+
// Smart quotes and special characters
|
|
837
|
+
markdown = markdown.replace(/“/g, '"');
|
|
838
|
+
markdown = markdown.replace(/”/g, '"');
|
|
839
|
+
markdown = markdown.replace(/‘/g, '\'');
|
|
840
|
+
markdown = markdown.replace(/’/g, '\'');
|
|
841
|
+
markdown = markdown.replace(/—/g, '—');
|
|
842
|
+
markdown = markdown.replace(/–/g, '–');
|
|
843
|
+
markdown = markdown.replace(/…/g, '...');
|
|
844
|
+
markdown = markdown.replace(/•/g, '•');
|
|
845
|
+
markdown = markdown.replace(/©/g, '©');
|
|
846
|
+
markdown = markdown.replace(/®/g, '®');
|
|
847
|
+
markdown = markdown.replace(/™/g, '™');
|
|
848
|
+
// Numeric HTML entities
|
|
849
|
+
markdown = markdown.replace(/&#(\d+);/g, (_, code) => String.fromCharCode(parseInt(code, 10)));
|
|
850
|
+
markdown = markdown.replace(/&#x([0-9a-fA-F]+);/g, (_, code) => String.fromCharCode(parseInt(code, 16)));
|
|
572
851
|
|
|
573
|
-
// Clean up extra whitespace
|
|
852
|
+
// Clean up extra whitespace for standard Markdown format
|
|
853
|
+
// Remove trailing spaces from each line
|
|
854
|
+
markdown = markdown.replace(/[ \t]+$/gm, '');
|
|
855
|
+
// Remove leading spaces from lines (except for code blocks, blockquotes, and list items)
|
|
856
|
+
markdown = markdown.replace(/^[ \t]+(?!([`>]|[*+-] |\d+[.)] ))/gm, '');
|
|
857
|
+
// Ensure proper spacing after headings (# Title should be followed by blank line or content)
|
|
858
|
+
markdown = markdown.replace(/^(#{1,6}[^\n]+)\n(?!\n)/gm, '$1\n\n');
|
|
859
|
+
// Normalize multiple blank lines to double newline
|
|
574
860
|
markdown = markdown.replace(/\n\s*\n\s*\n+/g, '\n\n');
|
|
861
|
+
// Collapse multiple spaces to single space (but preserve newlines)
|
|
575
862
|
markdown = markdown.replace(/[ \t]+/g, ' ');
|
|
863
|
+
// Final trim
|
|
576
864
|
markdown = markdown.trim();
|
|
577
865
|
|
|
578
866
|
return markdown;
|