confluence-cli 1.31.0 → 1.32.0

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,30 @@ 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');
9
+
10
+ function createSemaphore(limit) {
11
+ let active = 0;
12
+ const waiters = [];
13
+ return {
14
+ async acquire() {
15
+ if (active < limit) {
16
+ active++;
17
+ return;
18
+ }
19
+ await new Promise(resolve => waiters.push(resolve));
20
+ },
21
+ release() {
22
+ if (waiters.length > 0) {
23
+ const next = waiters.shift();
24
+ next();
25
+ } else {
26
+ active--;
27
+ }
28
+ }
29
+ };
30
+ }
29
31
 
30
32
  class ConfluenceClient {
31
33
  constructor(config) {
@@ -39,10 +41,14 @@ class ConfluenceClient {
39
41
  this.forceCloud = !!config.forceCloud;
40
42
  this.mtls = config.mtls;
41
43
  this.apiPath = this.sanitizeApiPath(config.apiPath);
42
- this.webUrlPrefix = this.apiPath.startsWith('/wiki/') ? '/wiki' : '';
44
+ this.webUrlPrefix = this.apiPath.includes('/wiki/') ? '/wiki' : '';
43
45
  this.baseURL = `${this.protocol}://${this.domain}${this.apiPath}`;
44
- this.markdown = new MarkdownIt();
45
- 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;
46
52
 
47
53
  const headers = {
48
54
  'Content-Type': 'application/json',
@@ -318,7 +324,7 @@ class ConfluenceClient {
318
324
  /**
319
325
  * Read a Confluence page content
320
326
  * @param {string} pageIdOrUrl - Page ID or URL
321
- * @param {string} format - Output format: 'text', 'html', or 'markdown'
327
+ * @param {string} format - Output format: 'text', 'html', 'storage', or 'markdown'
322
328
  * @param {object} options - Additional options
323
329
  * @param {boolean} options.resolveUsers - Whether to resolve userkeys to display names (default: true for markdown)
324
330
  * @param {boolean} options.extractReferencedAttachments - Whether to extract referenced attachments (default: false)
@@ -339,7 +345,7 @@ class ConfluenceClient {
339
345
  this._referencedAttachments = this.extractReferencedAttachments(htmlContent);
340
346
  }
341
347
 
342
- if (format === 'html') {
348
+ if (format === 'html' || format === 'storage') {
343
349
  return htmlContent;
344
350
  }
345
351
 
@@ -383,17 +389,11 @@ class ConfluenceClient {
383
389
 
384
390
  const response = await this.client.get(`/content/${pageId}`, {
385
391
  params: {
386
- expand: 'space'
392
+ expand: 'space,history,version,ancestors'
387
393
  }
388
394
  });
389
395
 
390
- return {
391
- title: response.data.title,
392
- id: response.data.id,
393
- type: response.data.type,
394
- status: response.data.status,
395
- space: response.data.space
396
- };
396
+ return this.normalizePage(response.data);
397
397
  }
398
398
 
399
399
  /**
@@ -540,7 +540,7 @@ class ConfluenceClient {
540
540
  const webui = page._links?.webui || '';
541
541
  return {
542
542
  title: page.title,
543
- url: webui ? this.buildUrl(`${this.webUrlPrefix}${webui}`) : ''
543
+ url: webui ? this.toAbsoluteUrl(webui, page._links?.base) : ''
544
544
  };
545
545
  }
546
546
  return null;
@@ -634,7 +634,7 @@ class ConfluenceClient {
634
634
  // Format: - [Page Title](URL)
635
635
  const childPagesList = childPages.map(page => {
636
636
  const webui = page._links?.webui || '';
637
- const url = webui ? this.buildUrl(`${this.webUrlPrefix}${webui}`) : '';
637
+ const url = webui ? this.toAbsoluteUrl(webui, page._links?.base) : '';
638
638
  if (url) {
639
639
  return `- [${page.title}](${url})`;
640
640
  } else {
@@ -1160,550 +1160,32 @@ class ConfluenceClient {
1160
1160
  return { pageId: String(pageId), key };
1161
1161
  }
1162
1162
 
1163
- /**
1164
- * Convert markdown to Confluence storage format
1165
- */
1166
1163
  markdownToStorage(markdown) {
1167
- // Convert markdown to HTML first
1168
- const html = this.markdown.render(markdown);
1169
-
1170
- // Convert HTML to native Confluence storage format elements
1171
- return this.htmlToConfluenceStorage(html);
1164
+ return this.converter.markdownToStorage(markdown);
1172
1165
  }
1173
1166
 
1174
- /**
1175
- * Convert HTML to native Confluence storage format
1176
- */
1177
1167
  htmlToConfluenceStorage(html) {
1178
- let storage = html;
1179
-
1180
- // Convert headings to native Confluence format
1181
- storage = storage.replace(/<h([1-6])>(.*?)<\/h[1-6]>/g, '<h$1>$2</h$1>');
1182
-
1183
- // Convert paragraphs
1184
- storage = storage.replace(/<p>(.*?)<\/p>/g, '<p>$1</p>');
1185
-
1186
- // Convert strong/bold text
1187
- storage = storage.replace(/<strong>(.*?)<\/strong>/g, '<strong>$1</strong>');
1188
-
1189
- // Convert emphasis/italic text
1190
- storage = storage.replace(/<em>(.*?)<\/em>/g, '<em>$1</em>');
1191
-
1192
- // Convert unordered lists
1193
- storage = storage.replace(/<ul>(.*?)<\/ul>/gs, '<ul>$1</ul>');
1194
- storage = storage.replace(/<li>(.*?)<\/li>/g, '<li><p>$1</p></li>');
1195
-
1196
- // Convert ordered lists
1197
- storage = storage.replace(/<ol>(.*?)<\/ol>/gs, '<ol>$1</ol>');
1198
-
1199
- // Convert code blocks to Confluence code macro
1200
- storage = storage.replace(/<pre><code(?:\s+class="language-(\w+)")?>(.*?)<\/code><\/pre>/gs, (_, lang, code) => {
1201
- const language = lang || 'text';
1202
- // Trim trailing newline added by markdown-it during HTML rendering,
1203
- // and decode HTML entities that markdown-it encodes inside <code> blocks
1204
- // so they appear as literal characters in the CDATA output
1205
- const decodedCode = code.replace(/\n$/, '')
1206
- .replace(/&quot;/g, '"')
1207
- .replace(/&lt;/g, '<')
1208
- .replace(/&gt;/g, '>')
1209
- .replace(/&amp;/g, '&'); // & last to avoid double-decoding
1210
- const safeCode = decodedCode.replace(/]]>/g, ']]]]><![CDATA[>');
1211
- 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>`;
1212
- });
1213
-
1214
- // Convert inline code
1215
- storage = storage.replace(/<code>(.*?)<\/code>/g, '<code>$1</code>');
1216
-
1217
- // Convert blockquotes to appropriate macros based on content
1218
- storage = storage.replace(/<blockquote>(.*?)<\/blockquote>/gs, (_, content) => {
1219
- // Check for admonition patterns
1220
- if (content.includes('<strong>INFO</strong>')) {
1221
- const cleanContent = content.replace(/<p><strong>INFO<\/strong><\/p>\s*/, '');
1222
- return `<ac:structured-macro ac:name="info">
1223
- <ac:rich-text-body>${cleanContent}</ac:rich-text-body>
1224
- </ac:structured-macro>`;
1225
- } else if (content.includes('<strong>WARNING</strong>')) {
1226
- const cleanContent = content.replace(/<p><strong>WARNING<\/strong><\/p>\s*/, '');
1227
- return `<ac:structured-macro ac:name="warning">
1228
- <ac:rich-text-body>${cleanContent}</ac:rich-text-body>
1229
- </ac:structured-macro>`;
1230
- } else if (content.includes('<strong>NOTE</strong>')) {
1231
- const cleanContent = content.replace(/<p><strong>NOTE<\/strong><\/p>\s*/, '');
1232
- return `<ac:structured-macro ac:name="note">
1233
- <ac:rich-text-body>${cleanContent}</ac:rich-text-body>
1234
- </ac:structured-macro>`;
1235
- } else {
1236
- // Default to info macro for regular blockquotes
1237
- return `<ac:structured-macro ac:name="info">
1238
- <ac:rich-text-body>${content}</ac:rich-text-body>
1239
- </ac:structured-macro>`;
1240
- }
1241
- });
1242
-
1243
- // Convert tables
1244
- storage = storage.replace(/<table>(.*?)<\/table>/gs, '<table>$1</table>');
1245
- storage = storage.replace(/<thead>(.*?)<\/thead>/gs, '<thead>$1</thead>');
1246
- storage = storage.replace(/<tbody>(.*?)<\/tbody>/gs, '<tbody>$1</tbody>');
1247
- storage = storage.replace(/<tr>(.*?)<\/tr>/gs, '<tr>$1</tr>');
1248
- storage = storage.replace(/<th>(.*?)<\/th>/g, '<th><p>$1</p></th>');
1249
- storage = storage.replace(/<td>(.*?)<\/td>/g, '<td><p>$1</p></td>');
1250
-
1251
- // Convert links
1252
- // Confluence Cloud does not render ac:link + ri:url; use smart links instead.
1253
- // Server/Data Center instances continue to use the ac:link storage format.
1254
- if (this.isCloud()) {
1255
- storage = storage.replace(/<a href="(.*?)">(.*?)<\/a>/g, '<a href="$1" data-card-appearance="inline">$2</a>');
1256
- } else {
1257
- 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>');
1258
- }
1259
-
1260
- // Convert horizontal rules
1261
- storage = storage.replace(/<hr\s*\/?>/g, '<hr />');
1262
-
1263
- // Note: Do NOT globally decode &lt; &gt; &amp; here. These represent literal
1264
- // characters in user content (e.g. <placeholder> in inline text) and
1265
- // Confluence storage format renders them correctly as-is. Code block
1266
- // entities are decoded separately above before CDATA insertion.
1267
-
1268
- return storage;
1168
+ return this.converter.htmlToConfluenceStorage(html);
1269
1169
  }
1270
1170
 
1271
- /**
1272
- * Convert markdown to Confluence storage format using native storage format
1273
- */
1274
1171
  markdownToNativeStorage(markdown) {
1275
- // Convert markdown to HTML first
1276
- const html = this.markdown.render(markdown);
1277
-
1278
- // Delegate to htmlToConfluenceStorage for proper conversion including code blocks
1279
- return this.htmlToConfluenceStorage(html);
1172
+ return this.converter.markdownToNativeStorage(markdown);
1280
1173
  }
1281
1174
 
1282
- /**
1283
- * Setup Confluence-specific markdown extensions
1284
- */
1285
1175
  setupConfluenceMarkdownExtensions() {
1286
- // Enable additional markdown-it features
1287
- this.markdown.enable(['table', 'strikethrough', 'linkify']);
1288
-
1289
- // Add custom rule for Confluence macros in markdown
1290
- this.markdown.core.ruler.before('normalize', 'confluence_macros', (state) => {
1291
- const src = state.src;
1292
-
1293
- // Convert [!info] admonitions to info macro
1294
- state.src = src.replace(/\[!info\]\s*([\s\S]*?)(?=\n\s*\n|\n\s*\[!|$)/g, (_, content) => {
1295
- return `> **INFO**\n> ${content.trim().replace(/\n/g, '\n> ')}`;
1296
- });
1297
-
1298
- // Convert [!warning] admonitions to warning macro
1299
- state.src = state.src.replace(/\[!warning\]\s*([\s\S]*?)(?=\n\s*\n|\n\s*\[!|$)/g, (_, content) => {
1300
- return `> **WARNING**\n> ${content.trim().replace(/\n/g, '\n> ')}`;
1301
- });
1302
-
1303
- // Convert [!note] admonitions to note macro
1304
- state.src = state.src.replace(/\[!note\]\s*([\s\S]*?)(?=\n\s*\n|\n\s*\[!|$)/g, (_, content) => {
1305
- return `> **NOTE**\n> ${content.trim().replace(/\n/g, '\n> ')}`;
1306
- });
1307
-
1308
- // Convert task lists to proper format
1309
- state.src = state.src.replace(/^(\s*)- \[([ x])\] (.+)$/gm, (_, indent, checked, text) => {
1310
- return `${indent}- [${checked}] ${text}`;
1311
- });
1312
- });
1176
+ this.converter.setupConfluenceMarkdownExtensions();
1313
1177
  }
1314
1178
 
1315
- /**
1316
- * Detect language from text content and return appropriate labels
1317
- * @param {string} text - Text content to analyze
1318
- * @returns {object} Object with language-specific labels
1319
- */
1320
1179
  detectLanguageLabels(text) {
1321
- const labels = {
1322
- includePage: 'Include Page',
1323
- sharedBlock: 'Shared Block',
1324
- includeSharedBlock: 'Include Shared Block',
1325
- fromPage: 'from page',
1326
- expandDetails: 'Expand Details'
1327
- };
1328
-
1329
- if (/[\u4e00-\u9fa5]/.test(text)) {
1330
- // Chinese
1331
- labels.includePage = '包含页面';
1332
- labels.sharedBlock = '共享块';
1333
- labels.includeSharedBlock = '包含共享块';
1334
- labels.fromPage = '来自页面';
1335
- labels.expandDetails = '展开详情';
1336
- } else if (/[\u3040-\u309f\u30a0-\u30ff]/.test(text)) {
1337
- // Japanese
1338
- labels.includePage = 'ページを含む';
1339
- labels.sharedBlock = '共有ブロック';
1340
- labels.includeSharedBlock = '共有ブロックを含む';
1341
- labels.fromPage = 'ページから';
1342
- labels.expandDetails = '詳細を表示';
1343
- } else if (/[\uac00-\ud7af]/.test(text)) {
1344
- // Korean
1345
- labels.includePage = '페이지 포함';
1346
- labels.sharedBlock = '공유 블록';
1347
- labels.includeSharedBlock = '공유 블록 포함';
1348
- labels.fromPage = '페이지에서';
1349
- labels.expandDetails = '상세 보기';
1350
- } else if (/[\u0400-\u04ff]/.test(text)) {
1351
- // Russian/Cyrillic
1352
- labels.includePage = 'Включить страницу';
1353
- labels.sharedBlock = 'Общий блок';
1354
- labels.includeSharedBlock = 'Включить общий блок';
1355
- labels.fromPage = 'со страницы';
1356
- labels.expandDetails = 'Подробнее';
1357
- } else if ((text.match(/[àâäéèêëïîôùûüÿœæç]/gi) || []).length >= 2) {
1358
- // French (requires at least 2 French-specific characters to avoid false positives)
1359
- labels.includePage = 'Inclure la page';
1360
- labels.sharedBlock = 'Bloc partagé';
1361
- labels.includeSharedBlock = 'Inclure le bloc partagé';
1362
- labels.fromPage = 'de la page';
1363
- labels.expandDetails = 'Détails';
1364
- } else if ((text.match(/[äöüß]/gi) || []).length >= 2) {
1365
- // German (requires at least 2 German-specific characters)
1366
- // Note: French is checked before German because French regex includes more characters
1367
- // that overlap with German (ä, ü). The threshold helps distinguish between them.
1368
- labels.includePage = 'Seite einbinden';
1369
- labels.sharedBlock = 'Gemeinsamer Block';
1370
- labels.includeSharedBlock = 'Gemeinsamen Block einbinden';
1371
- labels.fromPage = 'von Seite';
1372
- labels.expandDetails = 'Details';
1373
- } else if ((text.match(/[áéíóúñ¿¡]/gi) || []).length >= 2) {
1374
- // Spanish (requires at least 2 Spanish-specific characters)
1375
- labels.includePage = 'Incluir página';
1376
- labels.sharedBlock = 'Bloque compartido';
1377
- labels.includeSharedBlock = 'Incluir bloque compartido';
1378
- labels.fromPage = 'de la página';
1379
- labels.expandDetails = 'Detalles';
1380
- }
1381
-
1382
- return labels;
1180
+ return this.converter.detectLanguageLabels(text);
1383
1181
  }
1384
1182
 
1385
- /**
1386
- * Convert Confluence storage format to markdown
1387
- * @param {string} storage - Confluence storage format HTML
1388
- * @param {object} options - Conversion options
1389
- * @param {string} options.attachmentsDir - Directory name for attachments (default: 'attachments')
1390
- */
1391
1183
  storageToMarkdown(storage, options = {}) {
1392
- const attachmentsDir = options.attachmentsDir || 'attachments';
1393
- let markdown = storage;
1394
-
1395
- // Detect language from content
1396
- const labels = this.detectLanguageLabels(markdown);
1397
-
1398
- // Remove table of contents macro
1399
- markdown = markdown.replace(/<ac:structured-macro ac:name="toc"[^>]*\s*\/>/g, '');
1400
- markdown = markdown.replace(/<ac:structured-macro ac:name="toc"[^>]*>[\s\S]*?<\/ac:structured-macro>/g, '');
1401
-
1402
- // Remove floatmenu macro (floating table of contents)
1403
- markdown = markdown.replace(/<ac:structured-macro ac:name="floatmenu"[^>]*>[\s\S]*?<\/ac:structured-macro>/g, '');
1404
-
1405
- // Convert Confluence images to markdown images
1406
- // Format: <ac:image><ri:attachment ri:filename="image.png" /></ac:image>
1407
- markdown = markdown.replace(/<ac:image[^>]*>\s*<ri:attachment\s+ri:filename="([^"]+)"[^>]*\s*\/>\s*<\/ac:image>/g, (_, filename) => {
1408
- return `![${filename}](${attachmentsDir}/${filename})`;
1409
- });
1410
-
1411
- // Also handle self-closing ac:image with ri:attachment
1412
- markdown = markdown.replace(/<ac:image[^>]*><ri:attachment\s+ri:filename="([^"]+)"[^>]*><\/ri:attachment><\/ac:image>/g, (_, filename) => {
1413
- return `![${filename}](${attachmentsDir}/${filename})`;
1414
- });
1415
-
1416
- // Convert mermaid macro to mermaid code block
1417
- 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) => {
1418
- return `\n\`\`\`mermaid\n${code.trim()}\n\`\`\`\n`;
1419
- });
1420
-
1421
- // Convert expand macro - extract content from rich-text-body
1422
- 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) => {
1423
- return `\n<details>\n<summary>${labels.expandDetails}</summary>\n\n${content}\n\n</details>\n`;
1424
- });
1425
-
1426
- // Convert Confluence code macros to markdown
1427
- 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) => {
1428
- return `\n\`\`\`${lang}\n${code}\n\`\`\`\n`;
1429
- });
1430
-
1431
- // Convert code macros without language parameter
1432
- 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) => {
1433
- return `\n\`\`\`\n${code}\n\`\`\`\n`;
1434
- });
1435
-
1436
- // Convert info macro to admonition
1437
- 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) => {
1438
- const cleanContent = this.htmlToMarkdown(content);
1439
- return `[!info]\n${cleanContent}`;
1440
- });
1441
-
1442
- // Convert warning macro to admonition
1443
- 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) => {
1444
- const cleanContent = this.htmlToMarkdown(content);
1445
- return `[!warning]\n${cleanContent}`;
1446
- });
1447
-
1448
- // Convert note macro to admonition
1449
- 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) => {
1450
- const cleanContent = this.htmlToMarkdown(content);
1451
- return `[!note]\n${cleanContent}`;
1452
- });
1453
-
1454
- // Convert task list macros to markdown checkboxes
1455
- // Note: This is independent of user resolution - it only converts <ac:task> structure to "- [ ]" or "- [x]" format
1456
- markdown = markdown.replace(/<ac:task-list>([\s\S]*?)<\/ac:task-list>/g, (_, content) => {
1457
- const tasks = [];
1458
- // Match each task: <ac:task>...<ac:task-status>xxx</ac:task-status>...<ac:task-body>...</ac:task-body>...</ac:task>
1459
- 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;
1460
- let match;
1461
- while ((match = taskRegex.exec(content)) !== null) {
1462
- const status = match[1];
1463
- let taskBody = match[2];
1464
- // Clean up HTML from task body, but preserve @username
1465
- taskBody = taskBody.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim();
1466
- const checkbox = status === 'complete' ? '[x]' : '[ ]';
1467
- if (taskBody) {
1468
- tasks.push(`- ${checkbox} ${taskBody}`);
1469
- }
1470
- }
1471
- return tasks.length > 0 ? '\n' + tasks.join('\n') + '\n' : '';
1472
- });
1473
-
1474
- // Convert panel macro to markdown blockquote with title
1475
- 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) => {
1476
- const cleanContent = this.htmlToMarkdown(content);
1477
- return `\n> **${title}**\n>\n${cleanContent.split('\n').map(line => line ? `> ${line}` : '>').join('\n')}\n`;
1478
- });
1479
-
1480
- // Convert include macro - extract page link and convert to markdown link
1481
- // Handle both with and without parameter name
1482
- 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) => {
1483
- // Try to build a proper URL - if spaceKey starts with ~, it's a user space
1484
- if (spaceKey.startsWith('~')) {
1485
- const spacePath = `display/${spaceKey}/${encodeURIComponent(title)}`;
1486
- return `\n> 📄 **${labels.includePage}**: [${title}](${this.buildUrl(`${this.webUrlPrefix}/${spacePath}`)})\n`;
1487
- } else {
1488
- // For non-user spaces, we cannot construct a valid link without the page ID.
1489
- // Document that manual correction is required.
1490
- return `\n> 📄 **${labels.includePage}**: [${title}](${this.buildUrl(`${this.webUrlPrefix}/spaces/${spaceKey}/pages/[PAGE_ID_HERE]`)}) _(manual link correction required)_\n`;
1491
- }
1492
- });
1493
-
1494
- // Convert shared-block and include-shared-block macros - extract content
1495
- 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) => {
1496
- const cleanContent = this.htmlToMarkdown(content);
1497
- return `\n> **${labels.sharedBlock}: ${blockKey}**\n>\n${cleanContent.split('\n').map(line => line ? `> ${line}` : '>').join('\n')}\n`;
1498
- });
1499
-
1500
- // Convert include-shared-block with page parameter
1501
- 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) => {
1502
- // The page ID is not available, so we cannot generate a valid link.
1503
- // Instead, document that the link needs manual correction.
1504
- return `\n> 📄 **${labels.includeSharedBlock}**: ${blockKey} (${labels.fromPage}: ${pageTitle} [link needs manual correction])\n`;
1505
- });
1506
-
1507
- // Convert view-file macro to file link
1508
- // Handle both orders: name first or height first
1509
- 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) => {
1510
- return `\n📎 [${filename}](${attachmentsDir}/${filename})\n`;
1511
- });
1512
-
1513
- // Also handle view-file with height parameter (which might appear after name)
1514
- 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) => {
1515
- return `\n📎 [${filename}](${attachmentsDir}/${filename})\n`;
1516
- });
1517
-
1518
- // Remove layout macros but preserve content
1519
- markdown = markdown.replace(/<ac:layout>/g, '');
1520
- markdown = markdown.replace(/<\/ac:layout>/g, '');
1521
- markdown = markdown.replace(/<ac:layout-section[^>]*>/g, '');
1522
- markdown = markdown.replace(/<\/ac:layout-section>/g, '');
1523
- markdown = markdown.replace(/<ac:layout-cell[^>]*>/g, '');
1524
- markdown = markdown.replace(/<\/ac:layout-cell>/g, '');
1525
-
1526
- // Remove other unhandled macros (replace with empty string for now)
1527
- markdown = markdown.replace(/<ac:structured-macro[^>]*>[\s\S]*?<\/ac:structured-macro>/g, '');
1528
-
1529
- // Convert external URL links
1530
- 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)');
1531
-
1532
- // Convert internal page links - extract page title
1533
- // Format: <ac:link><ri:page ri:space-key="xxx" ri:content-title="Page Title" /></ac:link>
1534
- markdown = markdown.replace(/<ac:link>\s*<ri:page[^>]*ri:content-title="([^"]*)"[^>]*\/>\s*<\/ac:link>/g, '[$1]');
1535
- markdown = markdown.replace(/<ac:link>\s*<ri:page[^>]*ri:content-title="([^"]*)"[^>]*>\s*<\/ri:page>\s*<\/ac:link>/g, '[$1]');
1536
-
1537
- // Convert internal page links with custom display text (ac:link-body)
1538
- markdown = markdown.replace(/<ac:link[^>]*>[\s\S]*?<ac:link-body>([\s\S]*?)<\/ac:link-body>[\s\S]*?<\/ac:link>/g, '$1');
1539
-
1540
- // Remove any remaining ac:link tags that weren't matched (including those with attributes)
1541
- markdown = markdown.replace(/<ac:link[^>]*>[\s\S]*?<\/ac:link>/g, '');
1542
-
1543
- // Convert remaining HTML to markdown
1544
- markdown = this.htmlToMarkdown(markdown);
1545
-
1546
- return markdown;
1184
+ return this.converter.storageToMarkdown(storage, options);
1547
1185
  }
1548
1186
 
1549
- /**
1550
- * Convert basic HTML to markdown
1551
- */
1552
1187
  htmlToMarkdown(html) {
1553
- let markdown = html;
1554
-
1555
- // Convert time elements to date text BEFORE removing attributes
1556
- // Format: <time datetime="2025-09-16" /> or <time datetime="2025-09-16"></time>
1557
- markdown = markdown.replace(/<time\s+datetime="([^"]+)"[^>]*(?:\/>|>\s*<\/time>)/g, '$1');
1558
-
1559
- // Convert strong/bold BEFORE removing HTML attributes
1560
- markdown = markdown.replace(/<strong[^>]*>(.*?)<\/strong>/g, '**$1**');
1561
-
1562
- // Convert emphasis/italic BEFORE removing HTML attributes
1563
- markdown = markdown.replace(/<em[^>]*>(.*?)<\/em>/g, '*$1*');
1564
-
1565
- // Convert code BEFORE removing HTML attributes
1566
- markdown = markdown.replace(/<code[^>]*>(.*?)<\/code>/g, '`$1`');
1567
-
1568
- // Remove HTML attributes from tags (but preserve content formatting)
1569
- markdown = markdown.replace(/<(\w+)[^>]*>/g, '<$1>');
1570
- markdown = markdown.replace(/<\/(\w+)[^>]*>/g, '</$1>');
1571
-
1572
- // Convert headings first (they don't contain other elements typically)
1573
- markdown = markdown.replace(/<h([1-6])>(.*?)<\/h[1-6]>/g, (_, level, text) => {
1574
- return '\n' + '#'.repeat(parseInt(level)) + ' ' + text.trim() + '\n';
1575
- });
1576
-
1577
- // Convert tables BEFORE paragraphs
1578
- markdown = markdown.replace(/<table>(.*?)<\/table>/gs, (_, content) => {
1579
- const rows = [];
1580
- let isHeader = true;
1581
-
1582
- // Extract table rows
1583
- const rowMatches = content.match(/<tr>(.*?)<\/tr>/gs);
1584
- if (rowMatches) {
1585
- rowMatches.forEach(rowMatch => {
1586
- const cells = [];
1587
- const cellContent = rowMatch.replace(/<tr>(.*?)<\/tr>/s, '$1');
1588
-
1589
- // Extract cells (th or td)
1590
- const cellMatches = cellContent.match(/<t[hd]>(.*?)<\/t[hd]>/gs);
1591
- if (cellMatches) {
1592
- cellMatches.forEach(cellMatch => {
1593
- let cellText = cellMatch.replace(/<t[hd]>(.*?)<\/t[hd]>/s, '$1');
1594
- // Clean up cell content - remove nested HTML but preserve text and some formatting
1595
- cellText = cellText.replace(/<p>/g, '').replace(/<\/p>/g, ' ');
1596
- cellText = cellText.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
1597
- cells.push(cellText || ' ');
1598
- });
1599
- }
1600
-
1601
- if (cells.length > 0) {
1602
- rows.push('| ' + cells.join(' | ') + ' |');
1603
-
1604
- if (isHeader) {
1605
- rows.push('| ' + cells.map(() => '---').join(' | ') + ' |');
1606
- isHeader = false;
1607
- }
1608
- }
1609
- });
1610
- }
1611
-
1612
- return rows.length > 0 ? '\n' + rows.join('\n') + '\n' : '';
1613
- });
1614
-
1615
- // Convert unordered lists BEFORE paragraphs
1616
- markdown = markdown.replace(/<ul>(.*?)<\/ul>/gs, (_, content) => {
1617
- let listItems = '';
1618
- const itemMatches = content.match(/<li>(.*?)<\/li>/gs);
1619
- if (itemMatches) {
1620
- itemMatches.forEach(itemMatch => {
1621
- let itemText = itemMatch.replace(/<li>(.*?)<\/li>/s, '$1');
1622
- // Clean up nested HTML but preserve some formatting
1623
- itemText = itemText.replace(/<p>/g, '').replace(/<\/p>/g, ' ');
1624
- itemText = itemText.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
1625
- if (itemText) {
1626
- listItems += '- ' + itemText + '\n';
1627
- }
1628
- });
1629
- }
1630
- return '\n' + listItems;
1631
- });
1632
-
1633
- // Convert ordered lists BEFORE paragraphs
1634
- markdown = markdown.replace(/<ol>(.*?)<\/ol>/gs, (_, content) => {
1635
- let listItems = '';
1636
- let counter = 1;
1637
- const itemMatches = content.match(/<li>(.*?)<\/li>/gs);
1638
- if (itemMatches) {
1639
- itemMatches.forEach(itemMatch => {
1640
- let itemText = itemMatch.replace(/<li>(.*?)<\/li>/s, '$1');
1641
- // Clean up nested HTML but preserve some formatting
1642
- itemText = itemText.replace(/<p>/g, '').replace(/<\/p>/g, ' ');
1643
- itemText = itemText.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
1644
- if (itemText) {
1645
- listItems += `${counter++}. ${itemText}\n`;
1646
- }
1647
- });
1648
- }
1649
- return '\n' + listItems;
1650
- });
1651
-
1652
- // Convert paragraphs (after lists and tables)
1653
- markdown = markdown.replace(/<p>(.*?)<\/p>/gs, (_, content) => {
1654
- return '\n' + content.trim() + '\n';
1655
- });
1656
-
1657
- // Convert line breaks
1658
- markdown = markdown.replace(/<br\s*\/?>/g, '\n');
1659
-
1660
- // Convert horizontal rules
1661
- markdown = markdown.replace(/<hr\s*\/?>/g, '\n---\n');
1662
-
1663
- // Remove any remaining HTML tags, but preserve <details> and <summary> for GFM compatibility
1664
- markdown = markdown.replace(/<(?!\/?(details|summary)\b)[^>]+>/g, ' ');
1665
-
1666
- // Clean up whitespace and HTML entities
1667
- markdown = markdown.replace(/&nbsp;/g, ' ');
1668
- markdown = markdown.replace(/&lt;/g, '<');
1669
- markdown = markdown.replace(/&gt;/g, '>');
1670
- markdown = markdown.replace(/&amp;/g, '&');
1671
- markdown = markdown.replace(/&quot;/g, '"');
1672
- markdown = markdown.replace(/&apos;/g, '\'');
1673
- // Smart quotes and special characters
1674
- markdown = markdown.replace(/&ldquo;/g, '"');
1675
- markdown = markdown.replace(/&rdquo;/g, '"');
1676
- markdown = markdown.replace(/&lsquo;/g, '\'');
1677
- markdown = markdown.replace(/&rsquo;/g, '\'');
1678
- markdown = markdown.replace(/&mdash;/g, '—');
1679
- markdown = markdown.replace(/&ndash;/g, '–');
1680
- markdown = markdown.replace(/&hellip;/g, '...');
1681
- markdown = markdown.replace(/&bull;/g, '•');
1682
- markdown = markdown.replace(/&copy;/g, '©');
1683
- markdown = markdown.replace(/&reg;/g, '®');
1684
- markdown = markdown.replace(/&trade;/g, '™');
1685
- // Numeric HTML entities
1686
- markdown = markdown.replace(/&#(\d+);/g, (_, code) => String.fromCharCode(parseInt(code, 10)));
1687
- markdown = markdown.replace(/&#x([0-9a-fA-F]+);/g, (_, code) => String.fromCharCode(parseInt(code, 16)));
1688
-
1689
- // Clean up nordic alphabets and other named entities
1690
- markdown = markdown.replace(/&([a-zA-Z]+);/g, (match, name) => NAMED_ENTITIES[name] || match);
1691
-
1692
- // Clean up extra whitespace for standard Markdown format
1693
- // Remove trailing spaces from each line
1694
- markdown = markdown.replace(/[ \t]+$/gm, '');
1695
- // Remove leading spaces from lines (except for code blocks, blockquotes, and list items)
1696
- markdown = markdown.replace(/^[ \t]+(?!([`>]|[*+-] |\d+[.)] ))/gm, '');
1697
- // Ensure proper spacing after headings (# Title should be followed by blank line or content)
1698
- markdown = markdown.replace(/^(#{1,6}[^\n]+)\n(?!\n)/gm, '$1\n\n');
1699
- // Normalize multiple blank lines to double newline
1700
- markdown = markdown.replace(/\n\s*\n\s*\n+/g, '\n\n');
1701
- // Collapse multiple spaces to single space (but preserve newlines)
1702
- markdown = markdown.replace(/[ \t]+/g, ' ');
1703
- // Final trim
1704
- markdown = markdown.trim();
1705
-
1706
- return markdown;
1188
+ return htmlToMarkdown(html);
1707
1189
  }
1708
1190
 
1709
1191
  /**
@@ -1936,48 +1418,61 @@ class ConfluenceClient {
1936
1418
  /**
1937
1419
  * Get child pages of a given page
1938
1420
  */
1939
- async getChildPages(pageId, limit = 500) {
1421
+ async getChildPages(pageId, limit = 500, options = {}) {
1422
+ const includeAncestors = Boolean(options.includeAncestors);
1940
1423
  const response = await this.client.get(`/content/${pageId}/child/page`, {
1941
1424
  params: {
1942
1425
  limit: limit,
1943
1426
  // Fetch lightweight payload; content fetched on-demand when copying
1944
- expand: 'space,version'
1427
+ expand: includeAncestors ? 'space,version,ancestors' : 'space,version'
1945
1428
  }
1946
1429
  });
1947
1430
 
1948
- return response.data.results.map(page => ({
1949
- id: page.id,
1950
- title: page.title,
1951
- type: page.type,
1952
- status: page.status,
1953
- space: page.space,
1954
- version: page.version?.number || 1
1431
+ return response.data.results.map(page => this.normalizePage(page, {
1432
+ parentId: pageId,
1433
+ depth: 1
1955
1434
  }));
1956
1435
  }
1957
1436
 
1958
1437
  /**
1959
1438
  * Get all descendant pages recursively
1960
1439
  */
1961
- 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
+ }
1445
+ const semaphore = createSemaphore(10);
1446
+ return this._collectDescendants(pageId, maxDepth, currentDepth, semaphore, options);
1447
+ }
1448
+
1449
+ async _collectDescendants(pageId, maxDepth, currentDepth, semaphore, options = {}) {
1962
1450
  if (currentDepth >= maxDepth) {
1963
1451
  return [];
1964
1452
  }
1965
1453
 
1966
- const children = await this.getChildPages(pageId);
1967
- // Attach parentId so we can later reconstruct hierarchy if needed
1968
- const childrenWithParent = children.map(child => ({ ...child, parentId: pageId }));
1969
- let allDescendants = [...childrenWithParent];
1970
-
1971
- for (const child of children) {
1972
- const grandChildren = await this.getAllDescendantPages(
1973
- child.id,
1974
- maxDepth,
1975
- currentDepth + 1
1976
- );
1977
- allDescendants = allDescendants.concat(grandChildren);
1454
+ await semaphore.acquire();
1455
+ let children;
1456
+ try {
1457
+ children = await this.getChildPages(pageId, 500, options);
1458
+ } finally {
1459
+ semaphore.release();
1978
1460
  }
1979
1461
 
1980
- return allDescendants;
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
+ }));
1468
+
1469
+ const grandChildrenLists = await Promise.all(
1470
+ children.map(child =>
1471
+ this._collectDescendants(child.id, maxDepth, currentDepth + 1, semaphore, options)
1472
+ )
1473
+ );
1474
+
1475
+ return childrenWithDepth.concat(...grandChildrenLists);
1981
1476
  }
1982
1477
 
1983
1478
  /**
@@ -2197,12 +1692,104 @@ class ConfluenceClient {
2197
1692
  };
2198
1693
  }
2199
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
+
2200
1777
  buildUrl(path) {
2201
1778
  const normalized = path && !path.startsWith('/') ? `/${path}` : (path || '');
2202
1779
  return `${this.protocol}://${this.domain}${normalized}`;
2203
1780
  }
2204
1781
 
2205
- 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) {
2206
1793
  if (!pathOrUrl) {
2207
1794
  return null;
2208
1795
  }
@@ -2211,6 +1798,10 @@ class ConfluenceClient {
2211
1798
  return pathOrUrl;
2212
1799
  }
2213
1800
 
1801
+ if (baseUrl) {
1802
+ return this.joinBaseUrl(baseUrl, pathOrUrl);
1803
+ }
1804
+
2214
1805
  const pathWithPrefix = this.webUrlPrefix && !pathOrUrl.startsWith(this.webUrlPrefix)
2215
1806
  ? `${this.webUrlPrefix}${pathOrUrl}`
2216
1807
  : pathOrUrl;
@@ -2242,8 +1833,8 @@ class ConfluenceClient {
2242
1833
 
2243
1834
  ConfluenceClient.createLocalConverter = function () {
2244
1835
  const instance = Object.create(ConfluenceClient.prototype);
2245
- instance.markdown = new MarkdownIt();
2246
- instance.setupConfluenceMarkdownExtensions();
1836
+ instance.converter = new MacroConverter();
1837
+ instance.markdown = instance.converter.markdown;
2247
1838
  return instance;
2248
1839
  };
2249
1840