confluence-cli 1.31.1 → 1.32.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.
@@ -4,28 +4,8 @@ const https = require('https');
4
4
  const path = require('path');
5
5
  const FormData = require('form-data');
6
6
  const { convert } = require('html-to-text');
7
- const MarkdownIt = require('markdown-it');
8
-
9
- const NAMED_ENTITIES = {
10
- // uppercase variants
11
- aring: 'å', auml: 'ä', ouml: 'ö',
12
- eacute: 'é', egrave: 'è', ecirc: 'ê', euml: 'ë',
13
- aacute: 'á', agrave: 'à', acirc: 'â', atilde: 'ã',
14
- oacute: 'ó', ograve: 'ò', ocirc: 'ô', otilde: 'õ',
15
- uacute: 'ú', ugrave: 'ù', ucirc: 'û', uuml: 'ü',
16
- iacute: 'í', igrave: 'ì', icirc: 'î', iuml: 'ï',
17
- ntilde: 'ñ', ccedil: 'ç', szlig: 'ß', yuml: 'ÿ',
18
- eth: 'ð', thorn: 'þ',
19
- // uppercase variants
20
- Aring: 'Å', Auml: 'Ä', Ouml: 'Ö',
21
- Eacute: 'É', Egrave: 'È', Ecirc: 'Ê', Euml: 'Ë',
22
- Aacute: 'Á', Agrave: 'À', Acirc: 'Â', Atilde: 'Ã',
23
- Oacute: 'Ó', Ograve: 'Ò', Ocirc: 'Ô', Otilde: 'Õ',
24
- Uacute: 'Ú', Ugrave: 'Ù', Ucirc: 'Û', Uuml: 'Ü',
25
- Iacute: 'Í', Igrave: 'Ì', Icirc: 'Î', Iuml: 'Ï',
26
- Ntilde: 'Ñ', Ccedil: 'Ç', Szlig: 'SS', Yuml: 'Ÿ',
27
- Eth: 'Ð', Thorn: 'Þ'
28
- };
7
+ const MacroConverter = require('./macro-converter');
8
+ const { htmlToMarkdown, NAMED_ENTITIES } = require('./html-to-markdown');
29
9
 
30
10
  function createSemaphore(limit) {
31
11
  let active = 0;
@@ -61,10 +41,14 @@ class ConfluenceClient {
61
41
  this.forceCloud = !!config.forceCloud;
62
42
  this.mtls = config.mtls;
63
43
  this.apiPath = this.sanitizeApiPath(config.apiPath);
64
- this.webUrlPrefix = this.apiPath.startsWith('/wiki/') ? '/wiki' : '';
44
+ this.webUrlPrefix = this.apiPath.includes('/wiki/') ? '/wiki' : '';
65
45
  this.baseURL = `${this.protocol}://${this.domain}${this.apiPath}`;
66
- this.markdown = new MarkdownIt();
67
- this.setupConfluenceMarkdownExtensions();
46
+ this.converter = new MacroConverter({
47
+ isCloud: this.isCloud(),
48
+ webUrlPrefix: this.webUrlPrefix,
49
+ buildUrl: (pathOrUrl) => this.buildUrl(pathOrUrl),
50
+ });
51
+ this.markdown = this.converter.markdown;
68
52
 
69
53
  const headers = {
70
54
  'Content-Type': 'application/json',
@@ -340,7 +324,7 @@ class ConfluenceClient {
340
324
  /**
341
325
  * Read a Confluence page content
342
326
  * @param {string} pageIdOrUrl - Page ID or URL
343
- * @param {string} format - Output format: 'text', 'html', or 'markdown'
327
+ * @param {string} format - Output format: 'text', 'html', 'storage', or 'markdown'
344
328
  * @param {object} options - Additional options
345
329
  * @param {boolean} options.resolveUsers - Whether to resolve userkeys to display names (default: true for markdown)
346
330
  * @param {boolean} options.extractReferencedAttachments - Whether to extract referenced attachments (default: false)
@@ -361,7 +345,7 @@ class ConfluenceClient {
361
345
  this._referencedAttachments = this.extractReferencedAttachments(htmlContent);
362
346
  }
363
347
 
364
- if (format === 'html') {
348
+ if (format === 'html' || format === 'storage') {
365
349
  return htmlContent;
366
350
  }
367
351
 
@@ -405,17 +389,11 @@ class ConfluenceClient {
405
389
 
406
390
  const response = await this.client.get(`/content/${pageId}`, {
407
391
  params: {
408
- expand: 'space'
392
+ expand: 'space,history,version,ancestors'
409
393
  }
410
394
  });
411
395
 
412
- return {
413
- title: response.data.title,
414
- id: response.data.id,
415
- type: response.data.type,
416
- status: response.data.status,
417
- space: response.data.space
418
- };
396
+ return this.normalizePage(response.data);
419
397
  }
420
398
 
421
399
  /**
@@ -562,7 +540,7 @@ class ConfluenceClient {
562
540
  const webui = page._links?.webui || '';
563
541
  return {
564
542
  title: page.title,
565
- url: webui ? this.buildUrl(`${this.webUrlPrefix}${webui}`) : ''
543
+ url: webui ? this.toAbsoluteUrl(webui, page._links?.base) : ''
566
544
  };
567
545
  }
568
546
  return null;
@@ -656,7 +634,7 @@ class ConfluenceClient {
656
634
  // Format: - [Page Title](URL)
657
635
  const childPagesList = childPages.map(page => {
658
636
  const webui = page._links?.webui || '';
659
- const url = webui ? this.buildUrl(`${this.webUrlPrefix}${webui}`) : '';
637
+ const url = webui ? this.toAbsoluteUrl(webui, page._links?.base) : '';
660
638
  if (url) {
661
639
  return `- [${page.title}](${url})`;
662
640
  } else {
@@ -1182,550 +1160,32 @@ class ConfluenceClient {
1182
1160
  return { pageId: String(pageId), key };
1183
1161
  }
1184
1162
 
1185
- /**
1186
- * Convert markdown to Confluence storage format
1187
- */
1188
1163
  markdownToStorage(markdown) {
1189
- // Convert markdown to HTML first
1190
- const html = this.markdown.render(markdown);
1191
-
1192
- // Convert HTML to native Confluence storage format elements
1193
- return this.htmlToConfluenceStorage(html);
1164
+ return this.converter.markdownToStorage(markdown);
1194
1165
  }
1195
1166
 
1196
- /**
1197
- * Convert HTML to native Confluence storage format
1198
- */
1199
1167
  htmlToConfluenceStorage(html) {
1200
- let storage = html;
1201
-
1202
- // Convert headings to native Confluence format
1203
- storage = storage.replace(/<h([1-6])>(.*?)<\/h[1-6]>/g, '<h$1>$2</h$1>');
1204
-
1205
- // Convert paragraphs
1206
- storage = storage.replace(/<p>(.*?)<\/p>/g, '<p>$1</p>');
1207
-
1208
- // Convert strong/bold text
1209
- storage = storage.replace(/<strong>(.*?)<\/strong>/g, '<strong>$1</strong>');
1210
-
1211
- // Convert emphasis/italic text
1212
- storage = storage.replace(/<em>(.*?)<\/em>/g, '<em>$1</em>');
1213
-
1214
- // Convert unordered lists
1215
- storage = storage.replace(/<ul>(.*?)<\/ul>/gs, '<ul>$1</ul>');
1216
- storage = storage.replace(/<li>(.*?)<\/li>/g, '<li><p>$1</p></li>');
1217
-
1218
- // Convert ordered lists
1219
- storage = storage.replace(/<ol>(.*?)<\/ol>/gs, '<ol>$1</ol>');
1220
-
1221
- // Convert code blocks to Confluence code macro
1222
- storage = storage.replace(/<pre><code(?:\s+class="language-(\w+)")?>(.*?)<\/code><\/pre>/gs, (_, lang, code) => {
1223
- const language = lang || 'text';
1224
- // Trim trailing newline added by markdown-it during HTML rendering,
1225
- // and decode HTML entities that markdown-it encodes inside <code> blocks
1226
- // so they appear as literal characters in the CDATA output
1227
- const decodedCode = code.replace(/\n$/, '')
1228
- .replace(/&quot;/g, '"')
1229
- .replace(/&lt;/g, '<')
1230
- .replace(/&gt;/g, '>')
1231
- .replace(/&amp;/g, '&'); // & last to avoid double-decoding
1232
- const safeCode = decodedCode.replace(/]]>/g, ']]]]><![CDATA[>');
1233
- return `<ac:structured-macro ac:name="code"><ac:parameter ac:name="language">${language}</ac:parameter><ac:plain-text-body><![CDATA[${safeCode}]]></ac:plain-text-body></ac:structured-macro>`;
1234
- });
1235
-
1236
- // Convert inline code
1237
- storage = storage.replace(/<code>(.*?)<\/code>/g, '<code>$1</code>');
1238
-
1239
- // Convert blockquotes to appropriate macros based on content
1240
- storage = storage.replace(/<blockquote>(.*?)<\/blockquote>/gs, (_, content) => {
1241
- // Check for admonition patterns
1242
- if (content.includes('<strong>INFO</strong>')) {
1243
- const cleanContent = content.replace(/<p><strong>INFO<\/strong><\/p>\s*/, '');
1244
- return `<ac:structured-macro ac:name="info">
1245
- <ac:rich-text-body>${cleanContent}</ac:rich-text-body>
1246
- </ac:structured-macro>`;
1247
- } else if (content.includes('<strong>WARNING</strong>')) {
1248
- const cleanContent = content.replace(/<p><strong>WARNING<\/strong><\/p>\s*/, '');
1249
- return `<ac:structured-macro ac:name="warning">
1250
- <ac:rich-text-body>${cleanContent}</ac:rich-text-body>
1251
- </ac:structured-macro>`;
1252
- } else if (content.includes('<strong>NOTE</strong>')) {
1253
- const cleanContent = content.replace(/<p><strong>NOTE<\/strong><\/p>\s*/, '');
1254
- return `<ac:structured-macro ac:name="note">
1255
- <ac:rich-text-body>${cleanContent}</ac:rich-text-body>
1256
- </ac:structured-macro>`;
1257
- } else {
1258
- // Default to info macro for regular blockquotes
1259
- return `<ac:structured-macro ac:name="info">
1260
- <ac:rich-text-body>${content}</ac:rich-text-body>
1261
- </ac:structured-macro>`;
1262
- }
1263
- });
1264
-
1265
- // Convert tables
1266
- storage = storage.replace(/<table>(.*?)<\/table>/gs, '<table>$1</table>');
1267
- storage = storage.replace(/<thead>(.*?)<\/thead>/gs, '<thead>$1</thead>');
1268
- storage = storage.replace(/<tbody>(.*?)<\/tbody>/gs, '<tbody>$1</tbody>');
1269
- storage = storage.replace(/<tr>(.*?)<\/tr>/gs, '<tr>$1</tr>');
1270
- storage = storage.replace(/<th>(.*?)<\/th>/g, '<th><p>$1</p></th>');
1271
- storage = storage.replace(/<td>(.*?)<\/td>/g, '<td><p>$1</p></td>');
1272
-
1273
- // Convert links
1274
- // Confluence Cloud does not render ac:link + ri:url; use smart links instead.
1275
- // Server/Data Center instances continue to use the ac:link storage format.
1276
- if (this.isCloud()) {
1277
- storage = storage.replace(/<a href="(.*?)">(.*?)<\/a>/g, '<a href="$1" data-card-appearance="inline">$2</a>');
1278
- } else {
1279
- storage = storage.replace(/<a href="(.*?)">(.*?)<\/a>/g, '<ac:link><ri:url ri:value="$1" /><ac:plain-text-link-body><![CDATA[$2]]></ac:plain-text-link-body></ac:link>');
1280
- }
1281
-
1282
- // Convert horizontal rules
1283
- storage = storage.replace(/<hr\s*\/?>/g, '<hr />');
1284
-
1285
- // Note: Do NOT globally decode &lt; &gt; &amp; here. These represent literal
1286
- // characters in user content (e.g. <placeholder> in inline text) and
1287
- // Confluence storage format renders them correctly as-is. Code block
1288
- // entities are decoded separately above before CDATA insertion.
1289
-
1290
- return storage;
1168
+ return this.converter.htmlToConfluenceStorage(html);
1291
1169
  }
1292
1170
 
1293
- /**
1294
- * Convert markdown to Confluence storage format using native storage format
1295
- */
1296
1171
  markdownToNativeStorage(markdown) {
1297
- // Convert markdown to HTML first
1298
- const html = this.markdown.render(markdown);
1299
-
1300
- // Delegate to htmlToConfluenceStorage for proper conversion including code blocks
1301
- return this.htmlToConfluenceStorage(html);
1172
+ return this.converter.markdownToNativeStorage(markdown);
1302
1173
  }
1303
1174
 
1304
- /**
1305
- * Setup Confluence-specific markdown extensions
1306
- */
1307
1175
  setupConfluenceMarkdownExtensions() {
1308
- // Enable additional markdown-it features
1309
- this.markdown.enable(['table', 'strikethrough', 'linkify']);
1310
-
1311
- // Add custom rule for Confluence macros in markdown
1312
- this.markdown.core.ruler.before('normalize', 'confluence_macros', (state) => {
1313
- const src = state.src;
1314
-
1315
- // Convert [!info] admonitions to info macro
1316
- state.src = src.replace(/\[!info\]\s*([\s\S]*?)(?=\n\s*\n|\n\s*\[!|$)/g, (_, content) => {
1317
- return `> **INFO**\n> ${content.trim().replace(/\n/g, '\n> ')}`;
1318
- });
1319
-
1320
- // Convert [!warning] admonitions to warning macro
1321
- state.src = state.src.replace(/\[!warning\]\s*([\s\S]*?)(?=\n\s*\n|\n\s*\[!|$)/g, (_, content) => {
1322
- return `> **WARNING**\n> ${content.trim().replace(/\n/g, '\n> ')}`;
1323
- });
1324
-
1325
- // Convert [!note] admonitions to note macro
1326
- state.src = state.src.replace(/\[!note\]\s*([\s\S]*?)(?=\n\s*\n|\n\s*\[!|$)/g, (_, content) => {
1327
- return `> **NOTE**\n> ${content.trim().replace(/\n/g, '\n> ')}`;
1328
- });
1329
-
1330
- // Convert task lists to proper format
1331
- state.src = state.src.replace(/^(\s*)- \[([ x])\] (.+)$/gm, (_, indent, checked, text) => {
1332
- return `${indent}- [${checked}] ${text}`;
1333
- });
1334
- });
1176
+ this.converter.setupConfluenceMarkdownExtensions();
1335
1177
  }
1336
1178
 
1337
- /**
1338
- * Detect language from text content and return appropriate labels
1339
- * @param {string} text - Text content to analyze
1340
- * @returns {object} Object with language-specific labels
1341
- */
1342
1179
  detectLanguageLabels(text) {
1343
- const labels = {
1344
- includePage: 'Include Page',
1345
- sharedBlock: 'Shared Block',
1346
- includeSharedBlock: 'Include Shared Block',
1347
- fromPage: 'from page',
1348
- expandDetails: 'Expand Details'
1349
- };
1350
-
1351
- if (/[\u4e00-\u9fa5]/.test(text)) {
1352
- // Chinese
1353
- labels.includePage = '包含页面';
1354
- labels.sharedBlock = '共享块';
1355
- labels.includeSharedBlock = '包含共享块';
1356
- labels.fromPage = '来自页面';
1357
- labels.expandDetails = '展开详情';
1358
- } else if (/[\u3040-\u309f\u30a0-\u30ff]/.test(text)) {
1359
- // Japanese
1360
- labels.includePage = 'ページを含む';
1361
- labels.sharedBlock = '共有ブロック';
1362
- labels.includeSharedBlock = '共有ブロックを含む';
1363
- labels.fromPage = 'ページから';
1364
- labels.expandDetails = '詳細を表示';
1365
- } else if (/[\uac00-\ud7af]/.test(text)) {
1366
- // Korean
1367
- labels.includePage = '페이지 포함';
1368
- labels.sharedBlock = '공유 블록';
1369
- labels.includeSharedBlock = '공유 블록 포함';
1370
- labels.fromPage = '페이지에서';
1371
- labels.expandDetails = '상세 보기';
1372
- } else if (/[\u0400-\u04ff]/.test(text)) {
1373
- // Russian/Cyrillic
1374
- labels.includePage = 'Включить страницу';
1375
- labels.sharedBlock = 'Общий блок';
1376
- labels.includeSharedBlock = 'Включить общий блок';
1377
- labels.fromPage = 'со страницы';
1378
- labels.expandDetails = 'Подробнее';
1379
- } else if ((text.match(/[àâäéèêëïîôùûüÿœæç]/gi) || []).length >= 2) {
1380
- // French (requires at least 2 French-specific characters to avoid false positives)
1381
- labels.includePage = 'Inclure la page';
1382
- labels.sharedBlock = 'Bloc partagé';
1383
- labels.includeSharedBlock = 'Inclure le bloc partagé';
1384
- labels.fromPage = 'de la page';
1385
- labels.expandDetails = 'Détails';
1386
- } else if ((text.match(/[äöüß]/gi) || []).length >= 2) {
1387
- // German (requires at least 2 German-specific characters)
1388
- // Note: French is checked before German because French regex includes more characters
1389
- // that overlap with German (ä, ü). The threshold helps distinguish between them.
1390
- labels.includePage = 'Seite einbinden';
1391
- labels.sharedBlock = 'Gemeinsamer Block';
1392
- labels.includeSharedBlock = 'Gemeinsamen Block einbinden';
1393
- labels.fromPage = 'von Seite';
1394
- labels.expandDetails = 'Details';
1395
- } else if ((text.match(/[áéíóúñ¿¡]/gi) || []).length >= 2) {
1396
- // Spanish (requires at least 2 Spanish-specific characters)
1397
- labels.includePage = 'Incluir página';
1398
- labels.sharedBlock = 'Bloque compartido';
1399
- labels.includeSharedBlock = 'Incluir bloque compartido';
1400
- labels.fromPage = 'de la página';
1401
- labels.expandDetails = 'Detalles';
1402
- }
1403
-
1404
- return labels;
1180
+ return this.converter.detectLanguageLabels(text);
1405
1181
  }
1406
1182
 
1407
- /**
1408
- * Convert Confluence storage format to markdown
1409
- * @param {string} storage - Confluence storage format HTML
1410
- * @param {object} options - Conversion options
1411
- * @param {string} options.attachmentsDir - Directory name for attachments (default: 'attachments')
1412
- */
1413
1183
  storageToMarkdown(storage, options = {}) {
1414
- const attachmentsDir = options.attachmentsDir || 'attachments';
1415
- let markdown = storage;
1416
-
1417
- // Detect language from content
1418
- const labels = this.detectLanguageLabels(markdown);
1419
-
1420
- // Remove table of contents macro
1421
- markdown = markdown.replace(/<ac:structured-macro ac:name="toc"[^>]*\s*\/>/g, '');
1422
- markdown = markdown.replace(/<ac:structured-macro ac:name="toc"[^>]*>[\s\S]*?<\/ac:structured-macro>/g, '');
1423
-
1424
- // Remove floatmenu macro (floating table of contents)
1425
- markdown = markdown.replace(/<ac:structured-macro ac:name="floatmenu"[^>]*>[\s\S]*?<\/ac:structured-macro>/g, '');
1426
-
1427
- // Convert Confluence images to markdown images
1428
- // Format: <ac:image><ri:attachment ri:filename="image.png" /></ac:image>
1429
- markdown = markdown.replace(/<ac:image[^>]*>\s*<ri:attachment\s+ri:filename="([^"]+)"[^>]*\s*\/>\s*<\/ac:image>/g, (_, filename) => {
1430
- return `![${filename}](${attachmentsDir}/${filename})`;
1431
- });
1432
-
1433
- // Also handle self-closing ac:image with ri:attachment
1434
- markdown = markdown.replace(/<ac:image[^>]*><ri:attachment\s+ri:filename="([^"]+)"[^>]*><\/ri:attachment><\/ac:image>/g, (_, filename) => {
1435
- return `![${filename}](${attachmentsDir}/${filename})`;
1436
- });
1437
-
1438
- // Convert mermaid macro to mermaid code block
1439
- 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) => {
1440
- return `\n\`\`\`mermaid\n${code.trim()}\n\`\`\`\n`;
1441
- });
1442
-
1443
- // Convert expand macro - extract content from rich-text-body
1444
- 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) => {
1445
- return `\n<details>\n<summary>${labels.expandDetails}</summary>\n\n${content}\n\n</details>\n`;
1446
- });
1447
-
1448
- // Convert Confluence code macros to markdown
1449
- 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) => {
1450
- return `\n\`\`\`${lang}\n${code}\n\`\`\`\n`;
1451
- });
1452
-
1453
- // Convert code macros without language parameter
1454
- markdown = markdown.replace(/<ac:structured-macro ac:name="code"[^>]*>[\s\S]*?<ac:plain-text-body><!\[CDATA\[([\s\S]*?)\]\]><\/ac:plain-text-body>[\s\S]*?<\/ac:structured-macro>/g, (_, code) => {
1455
- return `\n\`\`\`\n${code}\n\`\`\`\n`;
1456
- });
1457
-
1458
- // Convert info macro to admonition
1459
- markdown = markdown.replace(/<ac:structured-macro ac:name="info"[^>]*>[\s\S]*?<ac:rich-text-body>([\s\S]*?)<\/ac:rich-text-body>[\s\S]*?<\/ac:structured-macro>/g, (_, content) => {
1460
- const cleanContent = this.htmlToMarkdown(content);
1461
- return `[!info]\n${cleanContent}`;
1462
- });
1463
-
1464
- // Convert warning macro to admonition
1465
- markdown = markdown.replace(/<ac:structured-macro ac:name="warning"[^>]*>[\s\S]*?<ac:rich-text-body>([\s\S]*?)<\/ac:rich-text-body>[\s\S]*?<\/ac:structured-macro>/g, (_, content) => {
1466
- const cleanContent = this.htmlToMarkdown(content);
1467
- return `[!warning]\n${cleanContent}`;
1468
- });
1469
-
1470
- // Convert note macro to admonition
1471
- markdown = markdown.replace(/<ac:structured-macro ac:name="note"[^>]*>[\s\S]*?<ac:rich-text-body>([\s\S]*?)<\/ac:rich-text-body>[\s\S]*?<\/ac:structured-macro>/g, (_, content) => {
1472
- const cleanContent = this.htmlToMarkdown(content);
1473
- return `[!note]\n${cleanContent}`;
1474
- });
1475
-
1476
- // Convert task list macros to markdown checkboxes
1477
- // Note: This is independent of user resolution - it only converts <ac:task> structure to "- [ ]" or "- [x]" format
1478
- markdown = markdown.replace(/<ac:task-list>([\s\S]*?)<\/ac:task-list>/g, (_, content) => {
1479
- const tasks = [];
1480
- // Match each task: <ac:task>...<ac:task-status>xxx</ac:task-status>...<ac:task-body>...</ac:task-body>...</ac:task>
1481
- 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;
1482
- let match;
1483
- while ((match = taskRegex.exec(content)) !== null) {
1484
- const status = match[1];
1485
- let taskBody = match[2];
1486
- // Clean up HTML from task body, but preserve @username
1487
- taskBody = taskBody.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim();
1488
- const checkbox = status === 'complete' ? '[x]' : '[ ]';
1489
- if (taskBody) {
1490
- tasks.push(`- ${checkbox} ${taskBody}`);
1491
- }
1492
- }
1493
- return tasks.length > 0 ? '\n' + tasks.join('\n') + '\n' : '';
1494
- });
1495
-
1496
- // Convert panel macro to markdown blockquote with title
1497
- markdown = markdown.replace(/<ac:structured-macro ac:name="panel"[^>]*>[\s\S]*?<ac:parameter ac:name="title">([^<]*)<\/ac:parameter>[\s\S]*?<ac:rich-text-body>([\s\S]*?)<\/ac:rich-text-body>[\s\S]*?<\/ac:structured-macro>/g, (_, title, content) => {
1498
- const cleanContent = this.htmlToMarkdown(content);
1499
- return `\n> **${title}**\n>\n${cleanContent.split('\n').map(line => line ? `> ${line}` : '>').join('\n')}\n`;
1500
- });
1501
-
1502
- // Convert include macro - extract page link and convert to markdown link
1503
- // Handle both with and without parameter name
1504
- markdown = markdown.replace(/<ac:structured-macro ac:name="include"[^>]*>[\s\S]*?<ac:parameter ac:name="">[\s\S]*?<ac:link>[\s\S]*?<ri:page\s+ri:space-key="([^"]+)"\s+ri:content-title="([^"]+)"[^>]*\/>[\s\S]*?<\/ac:link>[\s\S]*?<\/ac:parameter>[\s\S]*?<\/ac:structured-macro>/g, (_, spaceKey, title) => {
1505
- // Try to build a proper URL - if spaceKey starts with ~, it's a user space
1506
- if (spaceKey.startsWith('~')) {
1507
- const spacePath = `display/${spaceKey}/${encodeURIComponent(title)}`;
1508
- return `\n> 📄 **${labels.includePage}**: [${title}](${this.buildUrl(`${this.webUrlPrefix}/${spacePath}`)})\n`;
1509
- } else {
1510
- // For non-user spaces, we cannot construct a valid link without the page ID.
1511
- // Document that manual correction is required.
1512
- return `\n> 📄 **${labels.includePage}**: [${title}](${this.buildUrl(`${this.webUrlPrefix}/spaces/${spaceKey}/pages/[PAGE_ID_HERE]`)}) _(manual link correction required)_\n`;
1513
- }
1514
- });
1515
-
1516
- // Convert shared-block and include-shared-block macros - extract content
1517
- markdown = markdown.replace(/<ac:structured-macro ac:name="(shared-block|include-shared-block)"[^>]*>[\s\S]*?<ac:parameter ac:name="shared-block-key">([^<]*)<\/ac:parameter>[\s\S]*?<ac:rich-text-body>([\s\S]*?)<\/ac:rich-text-body>[\s\S]*?<\/ac:structured-macro>/g, (_, macroType, blockKey, content) => {
1518
- const cleanContent = this.htmlToMarkdown(content);
1519
- return `\n> **${labels.sharedBlock}: ${blockKey}**\n>\n${cleanContent.split('\n').map(line => line ? `> ${line}` : '>').join('\n')}\n`;
1520
- });
1521
-
1522
- // Convert include-shared-block with page parameter
1523
- markdown = markdown.replace(/<ac:structured-macro ac:name="include-shared-block"[^>]*>[\s\S]*?<ac:parameter ac:name="shared-block-key">([^<]*)<\/ac:parameter>[\s\S]*?<ac:parameter ac:name="page">[\s\S]*?<ac:link>[\s\S]*?<ri:page\s+ri:space-key="([^"]+)"\s+ri:content-title="([^"]+)"[^>]*\/>[\s\S]*?<\/ac:link>[\s\S]*?<\/ac:parameter>[\s\S]*?<\/ac:structured-macro>/g, (_, blockKey, spaceKey, pageTitle) => {
1524
- // The page ID is not available, so we cannot generate a valid link.
1525
- // Instead, document that the link needs manual correction.
1526
- return `\n> 📄 **${labels.includeSharedBlock}**: ${blockKey} (${labels.fromPage}: ${pageTitle} [link needs manual correction])\n`;
1527
- });
1528
-
1529
- // Convert view-file macro to file link
1530
- // Handle both orders: name first or height first
1531
- markdown = markdown.replace(/<ac:structured-macro ac:name="view-file"[^>]*>[\s\S]*?<ac:parameter ac:name="name">[\s\S]*?<ri:attachment\s+ri:filename="([^"]+)"[^>]*\/>[\s\S]*?<\/ac:parameter>[\s\S]*?<\/ac:structured-macro>/g, (_, filename) => {
1532
- return `\n📎 [${filename}](${attachmentsDir}/${filename})\n`;
1533
- });
1534
-
1535
- // Also handle view-file with height parameter (which might appear after name)
1536
- markdown = markdown.replace(/<ac:structured-macro ac:name="view-file"[^>]*>[\s\S]*?<ac:parameter ac:name="name">[\s\S]*?<ri:attachment\s+ri:filename="([^"]+)"[^>]*\/>[\s\S]*?<\/ac:parameter>[\s\S]*?<ac:parameter ac:name="height">([^<]*)<\/ac:parameter>[\s\S]*?<\/ac:structured-macro>/g, (_, filename, _height) => {
1537
- return `\n📎 [${filename}](${attachmentsDir}/${filename})\n`;
1538
- });
1539
-
1540
- // Remove layout macros but preserve content
1541
- markdown = markdown.replace(/<ac:layout>/g, '');
1542
- markdown = markdown.replace(/<\/ac:layout>/g, '');
1543
- markdown = markdown.replace(/<ac:layout-section[^>]*>/g, '');
1544
- markdown = markdown.replace(/<\/ac:layout-section>/g, '');
1545
- markdown = markdown.replace(/<ac:layout-cell[^>]*>/g, '');
1546
- markdown = markdown.replace(/<\/ac:layout-cell>/g, '');
1547
-
1548
- // Remove other unhandled macros (replace with empty string for now)
1549
- markdown = markdown.replace(/<ac:structured-macro[^>]*>[\s\S]*?<\/ac:structured-macro>/g, '');
1550
-
1551
- // Convert external URL links
1552
- 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)');
1553
-
1554
- // Convert internal page links - extract page title
1555
- // Format: <ac:link><ri:page ri:space-key="xxx" ri:content-title="Page Title" /></ac:link>
1556
- markdown = markdown.replace(/<ac:link>\s*<ri:page[^>]*ri:content-title="([^"]*)"[^>]*\/>\s*<\/ac:link>/g, '[$1]');
1557
- markdown = markdown.replace(/<ac:link>\s*<ri:page[^>]*ri:content-title="([^"]*)"[^>]*>\s*<\/ri:page>\s*<\/ac:link>/g, '[$1]');
1558
-
1559
- // Convert internal page links with custom display text (ac:link-body)
1560
- markdown = markdown.replace(/<ac:link[^>]*>[\s\S]*?<ac:link-body>([\s\S]*?)<\/ac:link-body>[\s\S]*?<\/ac:link>/g, '$1');
1561
-
1562
- // Remove any remaining ac:link tags that weren't matched (including those with attributes)
1563
- markdown = markdown.replace(/<ac:link[^>]*>[\s\S]*?<\/ac:link>/g, '');
1564
-
1565
- // Convert remaining HTML to markdown
1566
- markdown = this.htmlToMarkdown(markdown);
1567
-
1568
- return markdown;
1184
+ return this.converter.storageToMarkdown(storage, options);
1569
1185
  }
1570
1186
 
1571
- /**
1572
- * Convert basic HTML to markdown
1573
- */
1574
1187
  htmlToMarkdown(html) {
1575
- let markdown = html;
1576
-
1577
- // Convert time elements to date text BEFORE removing attributes
1578
- // Format: <time datetime="2025-09-16" /> or <time datetime="2025-09-16"></time>
1579
- markdown = markdown.replace(/<time\s+datetime="([^"]+)"[^>]*(?:\/>|>\s*<\/time>)/g, '$1');
1580
-
1581
- // Convert strong/bold BEFORE removing HTML attributes
1582
- markdown = markdown.replace(/<strong[^>]*>(.*?)<\/strong>/g, '**$1**');
1583
-
1584
- // Convert emphasis/italic BEFORE removing HTML attributes
1585
- markdown = markdown.replace(/<em[^>]*>(.*?)<\/em>/g, '*$1*');
1586
-
1587
- // Convert code BEFORE removing HTML attributes
1588
- markdown = markdown.replace(/<code[^>]*>(.*?)<\/code>/g, '`$1`');
1589
-
1590
- // Remove HTML attributes from tags (but preserve content formatting)
1591
- markdown = markdown.replace(/<(\w+)[^>]*>/g, '<$1>');
1592
- markdown = markdown.replace(/<\/(\w+)[^>]*>/g, '</$1>');
1593
-
1594
- // Convert headings first (they don't contain other elements typically)
1595
- markdown = markdown.replace(/<h([1-6])>(.*?)<\/h[1-6]>/g, (_, level, text) => {
1596
- return '\n' + '#'.repeat(parseInt(level)) + ' ' + text.trim() + '\n';
1597
- });
1598
-
1599
- // Convert tables BEFORE paragraphs
1600
- markdown = markdown.replace(/<table>(.*?)<\/table>/gs, (_, content) => {
1601
- const rows = [];
1602
- let isHeader = true;
1603
-
1604
- // Extract table rows
1605
- const rowMatches = content.match(/<tr>(.*?)<\/tr>/gs);
1606
- if (rowMatches) {
1607
- rowMatches.forEach(rowMatch => {
1608
- const cells = [];
1609
- const cellContent = rowMatch.replace(/<tr>(.*?)<\/tr>/s, '$1');
1610
-
1611
- // Extract cells (th or td)
1612
- const cellMatches = cellContent.match(/<t[hd]>(.*?)<\/t[hd]>/gs);
1613
- if (cellMatches) {
1614
- cellMatches.forEach(cellMatch => {
1615
- let cellText = cellMatch.replace(/<t[hd]>(.*?)<\/t[hd]>/s, '$1');
1616
- // Clean up cell content - remove nested HTML but preserve text and some formatting
1617
- cellText = cellText.replace(/<p>/g, '').replace(/<\/p>/g, ' ');
1618
- cellText = cellText.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
1619
- cells.push(cellText || ' ');
1620
- });
1621
- }
1622
-
1623
- if (cells.length > 0) {
1624
- rows.push('| ' + cells.join(' | ') + ' |');
1625
-
1626
- if (isHeader) {
1627
- rows.push('| ' + cells.map(() => '---').join(' | ') + ' |');
1628
- isHeader = false;
1629
- }
1630
- }
1631
- });
1632
- }
1633
-
1634
- return rows.length > 0 ? '\n' + rows.join('\n') + '\n' : '';
1635
- });
1636
-
1637
- // Convert unordered lists BEFORE paragraphs
1638
- markdown = markdown.replace(/<ul>(.*?)<\/ul>/gs, (_, content) => {
1639
- let listItems = '';
1640
- const itemMatches = content.match(/<li>(.*?)<\/li>/gs);
1641
- if (itemMatches) {
1642
- itemMatches.forEach(itemMatch => {
1643
- let itemText = itemMatch.replace(/<li>(.*?)<\/li>/s, '$1');
1644
- // Clean up nested HTML but preserve some formatting
1645
- itemText = itemText.replace(/<p>/g, '').replace(/<\/p>/g, ' ');
1646
- itemText = itemText.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
1647
- if (itemText) {
1648
- listItems += '- ' + itemText + '\n';
1649
- }
1650
- });
1651
- }
1652
- return '\n' + listItems;
1653
- });
1654
-
1655
- // Convert ordered lists BEFORE paragraphs
1656
- markdown = markdown.replace(/<ol>(.*?)<\/ol>/gs, (_, content) => {
1657
- let listItems = '';
1658
- let counter = 1;
1659
- const itemMatches = content.match(/<li>(.*?)<\/li>/gs);
1660
- if (itemMatches) {
1661
- itemMatches.forEach(itemMatch => {
1662
- let itemText = itemMatch.replace(/<li>(.*?)<\/li>/s, '$1');
1663
- // Clean up nested HTML but preserve some formatting
1664
- itemText = itemText.replace(/<p>/g, '').replace(/<\/p>/g, ' ');
1665
- itemText = itemText.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
1666
- if (itemText) {
1667
- listItems += `${counter++}. ${itemText}\n`;
1668
- }
1669
- });
1670
- }
1671
- return '\n' + listItems;
1672
- });
1673
-
1674
- // Convert paragraphs (after lists and tables)
1675
- markdown = markdown.replace(/<p>(.*?)<\/p>/gs, (_, content) => {
1676
- return '\n' + content.trim() + '\n';
1677
- });
1678
-
1679
- // Convert line breaks
1680
- markdown = markdown.replace(/<br\s*\/?>/g, '\n');
1681
-
1682
- // Convert horizontal rules
1683
- markdown = markdown.replace(/<hr\s*\/?>/g, '\n---\n');
1684
-
1685
- // Remove any remaining HTML tags, but preserve <details> and <summary> for GFM compatibility
1686
- markdown = markdown.replace(/<(?!\/?(details|summary)\b)[^>]+>/g, ' ');
1687
-
1688
- // Clean up whitespace and HTML entities
1689
- markdown = markdown.replace(/&nbsp;/g, ' ');
1690
- markdown = markdown.replace(/&lt;/g, '<');
1691
- markdown = markdown.replace(/&gt;/g, '>');
1692
- markdown = markdown.replace(/&amp;/g, '&');
1693
- markdown = markdown.replace(/&quot;/g, '"');
1694
- markdown = markdown.replace(/&apos;/g, '\'');
1695
- // Smart quotes and special characters
1696
- markdown = markdown.replace(/&ldquo;/g, '"');
1697
- markdown = markdown.replace(/&rdquo;/g, '"');
1698
- markdown = markdown.replace(/&lsquo;/g, '\'');
1699
- markdown = markdown.replace(/&rsquo;/g, '\'');
1700
- markdown = markdown.replace(/&mdash;/g, '—');
1701
- markdown = markdown.replace(/&ndash;/g, '–');
1702
- markdown = markdown.replace(/&hellip;/g, '...');
1703
- markdown = markdown.replace(/&bull;/g, '•');
1704
- markdown = markdown.replace(/&copy;/g, '©');
1705
- markdown = markdown.replace(/&reg;/g, '®');
1706
- markdown = markdown.replace(/&trade;/g, '™');
1707
- // Numeric HTML entities
1708
- markdown = markdown.replace(/&#(\d+);/g, (_, code) => String.fromCharCode(parseInt(code, 10)));
1709
- markdown = markdown.replace(/&#x([0-9a-fA-F]+);/g, (_, code) => String.fromCharCode(parseInt(code, 16)));
1710
-
1711
- // Clean up nordic alphabets and other named entities
1712
- markdown = markdown.replace(/&([a-zA-Z]+);/g, (match, name) => NAMED_ENTITIES[name] || match);
1713
-
1714
- // Clean up extra whitespace for standard Markdown format
1715
- // Remove trailing spaces from each line
1716
- markdown = markdown.replace(/[ \t]+$/gm, '');
1717
- // Remove leading spaces from lines (except for code blocks, blockquotes, and list items)
1718
- markdown = markdown.replace(/^[ \t]+(?!([`>]|[*+-] |\d+[.)] ))/gm, '');
1719
- // Ensure proper spacing after headings (# Title should be followed by blank line or content)
1720
- markdown = markdown.replace(/^(#{1,6}[^\n]+)\n(?!\n)/gm, '$1\n\n');
1721
- // Normalize multiple blank lines to double newline
1722
- markdown = markdown.replace(/\n\s*\n\s*\n+/g, '\n\n');
1723
- // Collapse multiple spaces to single space (but preserve newlines)
1724
- markdown = markdown.replace(/[ \t]+/g, ' ');
1725
- // Final trim
1726
- markdown = markdown.trim();
1727
-
1728
- return markdown;
1188
+ return htmlToMarkdown(html);
1729
1189
  }
1730
1190
 
1731
1191
  /**
@@ -1958,34 +1418,35 @@ class ConfluenceClient {
1958
1418
  /**
1959
1419
  * Get child pages of a given page
1960
1420
  */
1961
- async getChildPages(pageId, limit = 500) {
1421
+ async getChildPages(pageId, limit = 500, options = {}) {
1422
+ const includeAncestors = Boolean(options.includeAncestors);
1962
1423
  const response = await this.client.get(`/content/${pageId}/child/page`, {
1963
1424
  params: {
1964
1425
  limit: limit,
1965
1426
  // Fetch lightweight payload; content fetched on-demand when copying
1966
- expand: 'space,version'
1427
+ expand: includeAncestors ? 'space,version,ancestors' : 'space,version'
1967
1428
  }
1968
1429
  });
1969
1430
 
1970
- return response.data.results.map(page => ({
1971
- id: page.id,
1972
- title: page.title,
1973
- type: page.type,
1974
- status: page.status,
1975
- space: page.space,
1976
- version: page.version?.number || 1
1431
+ return response.data.results.map(page => this.normalizePage(page, {
1432
+ parentId: pageId,
1433
+ depth: 1
1977
1434
  }));
1978
1435
  }
1979
1436
 
1980
1437
  /**
1981
1438
  * Get all descendant pages recursively
1982
1439
  */
1983
- async getAllDescendantPages(pageId, maxDepth = 10, currentDepth = 0) {
1440
+ async getAllDescendantPages(pageId, maxDepth = 10, currentDepth = 0, options = {}) {
1441
+ if (typeof currentDepth === 'object' && currentDepth !== null) {
1442
+ options = currentDepth;
1443
+ currentDepth = 0;
1444
+ }
1984
1445
  const semaphore = createSemaphore(10);
1985
- return this._collectDescendants(pageId, maxDepth, currentDepth, semaphore);
1446
+ return this._collectDescendants(pageId, maxDepth, currentDepth, semaphore, options);
1986
1447
  }
1987
1448
 
1988
- async _collectDescendants(pageId, maxDepth, currentDepth, semaphore) {
1449
+ async _collectDescendants(pageId, maxDepth, currentDepth, semaphore, options = {}) {
1989
1450
  if (currentDepth >= maxDepth) {
1990
1451
  return [];
1991
1452
  }
@@ -1993,21 +1454,25 @@ class ConfluenceClient {
1993
1454
  await semaphore.acquire();
1994
1455
  let children;
1995
1456
  try {
1996
- children = await this.getChildPages(pageId);
1457
+ children = await this.getChildPages(pageId, 500, options);
1997
1458
  } finally {
1998
1459
  semaphore.release();
1999
1460
  }
2000
1461
 
2001
- // Attach parentId so we can later reconstruct hierarchy if needed
2002
- const childrenWithParent = children.map(child => ({ ...child, parentId: pageId }));
1462
+ // Track depth for recursive JSON output while preserving direct parent linkage.
1463
+ const childrenWithDepth = children.map(child => ({
1464
+ ...child,
1465
+ parentId: child.parentId || String(pageId),
1466
+ depth: currentDepth + 1
1467
+ }));
2003
1468
 
2004
1469
  const grandChildrenLists = await Promise.all(
2005
1470
  children.map(child =>
2006
- this._collectDescendants(child.id, maxDepth, currentDepth + 1, semaphore)
1471
+ this._collectDescendants(child.id, maxDepth, currentDepth + 1, semaphore, options)
2007
1472
  )
2008
1473
  );
2009
1474
 
2010
- return childrenWithParent.concat(...grandChildrenLists);
1475
+ return childrenWithDepth.concat(...grandChildrenLists);
2011
1476
  }
2012
1477
 
2013
1478
  /**
@@ -2227,12 +1692,104 @@ class ConfluenceClient {
2227
1692
  };
2228
1693
  }
2229
1694
 
1695
+ normalizeUser(user) {
1696
+ if (!user) {
1697
+ return null;
1698
+ }
1699
+
1700
+ return {
1701
+ displayName: user.displayName || user.publicName || user.username || user.userKey || user.accountId || 'Unknown',
1702
+ accountId: user.accountId,
1703
+ userKey: user.userKey,
1704
+ username: user.username,
1705
+ email: user.email
1706
+ };
1707
+ }
1708
+
1709
+ normalizeAncestors(rawAncestors = []) {
1710
+ if (!Array.isArray(rawAncestors)) {
1711
+ return [];
1712
+ }
1713
+
1714
+ return rawAncestors.map((ancestor) => {
1715
+ const id = ancestor?.id ?? ancestor;
1716
+ return {
1717
+ id: id !== undefined && id !== null ? String(id) : null,
1718
+ type: ancestor?.type || null,
1719
+ title: ancestor?.title || null
1720
+ };
1721
+ }).filter((ancestor) => ancestor.id);
1722
+ }
1723
+
1724
+ normalizeSpace(space) {
1725
+ if (!space) {
1726
+ return null;
1727
+ }
1728
+
1729
+ return {
1730
+ key: space.key || null,
1731
+ name: space.name || null
1732
+ };
1733
+ }
1734
+
1735
+ getPageParentId(ancestors = []) {
1736
+ if (!Array.isArray(ancestors) || ancestors.length === 0) {
1737
+ return null;
1738
+ }
1739
+
1740
+ return ancestors[ancestors.length - 1].id || null;
1741
+ }
1742
+
1743
+ normalizePage(raw, overrides = {}) {
1744
+ const space = overrides.space === undefined
1745
+ ? this.normalizeSpace(raw?.space)
1746
+ : this.normalizeSpace(overrides.space);
1747
+ const ancestors = overrides.ancestors || this.normalizeAncestors(raw?.ancestors);
1748
+ const spaceKey = overrides.spaceKey || space?.key || null;
1749
+ const id = raw?.id !== undefined && raw?.id !== null ? String(raw.id) : null;
1750
+ const webui = raw?._links?.webui || null;
1751
+ const linksBase = raw?._links?.base || null;
1752
+ const fallbackUrl = (spaceKey && id)
1753
+ ? `${this.webUrlPrefix}/spaces/${spaceKey}/pages/${id}`
1754
+ : null;
1755
+
1756
+ return {
1757
+ id,
1758
+ title: raw?.title || '',
1759
+ type: raw?.type || null,
1760
+ status: raw?.status || null,
1761
+ space,
1762
+ spaceKey,
1763
+ parentId: overrides.parentId === undefined
1764
+ ? this.getPageParentId(ancestors)
1765
+ : (overrides.parentId === null ? null : String(overrides.parentId)),
1766
+ version: overrides.version !== undefined ? overrides.version : (raw?.version?.number || null),
1767
+ url: overrides.url || this.toAbsoluteUrl(webui, linksBase) || (fallbackUrl ? this.buildUrl(fallbackUrl) : null),
1768
+ ancestors,
1769
+ depth: overrides.depth,
1770
+ author: overrides.author !== undefined ? overrides.author : this.normalizeUser(raw?.history?.createdBy),
1771
+ lastUpdatedBy: overrides.lastUpdatedBy !== undefined ? overrides.lastUpdatedBy : this.normalizeUser(raw?.version?.by),
1772
+ createdAt: overrides.createdAt !== undefined ? overrides.createdAt : (raw?.history?.createdDate || null),
1773
+ updatedAt: overrides.updatedAt !== undefined ? overrides.updatedAt : (raw?.version?.when || null)
1774
+ };
1775
+ }
1776
+
2230
1777
  buildUrl(path) {
2231
1778
  const normalized = path && !path.startsWith('/') ? `/${path}` : (path || '');
2232
1779
  return `${this.protocol}://${this.domain}${normalized}`;
2233
1780
  }
2234
1781
 
2235
- toAbsoluteUrl(pathOrUrl) {
1782
+ joinBaseUrl(baseUrl, path) {
1783
+ if (!baseUrl) {
1784
+ return null;
1785
+ }
1786
+
1787
+ const normalizedBase = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
1788
+ const normalizedPath = path.startsWith('/') ? path : `/${path}`;
1789
+ return `${normalizedBase}${normalizedPath}`;
1790
+ }
1791
+
1792
+ toAbsoluteUrl(pathOrUrl, baseUrl = null) {
2236
1793
  if (!pathOrUrl) {
2237
1794
  return null;
2238
1795
  }
@@ -2241,6 +1798,10 @@ class ConfluenceClient {
2241
1798
  return pathOrUrl;
2242
1799
  }
2243
1800
 
1801
+ if (baseUrl) {
1802
+ return this.joinBaseUrl(baseUrl, pathOrUrl);
1803
+ }
1804
+
2244
1805
  const pathWithPrefix = this.webUrlPrefix && !pathOrUrl.startsWith(this.webUrlPrefix)
2245
1806
  ? `${this.webUrlPrefix}${pathOrUrl}`
2246
1807
  : pathOrUrl;
@@ -2272,8 +1833,8 @@ class ConfluenceClient {
2272
1833
 
2273
1834
  ConfluenceClient.createLocalConverter = function () {
2274
1835
  const instance = Object.create(ConfluenceClient.prototype);
2275
- instance.markdown = new MarkdownIt();
2276
- instance.setupConfluenceMarkdownExtensions();
1836
+ instance.converter = new MacroConverter();
1837
+ instance.markdown = instance.converter.markdown;
2277
1838
  return instance;
2278
1839
  };
2279
1840