confluence-cli 2.4.0 → 2.6.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.
- package/README.md +3 -0
- package/bin/confluence.js +141 -0
- package/lib/confluence-client.js +142 -0
- package/lib/html-to-storage.js +54 -2
- package/lib/macro-converter.js +16 -5
- package/npm-shrinkwrap.json +6 -6
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -707,6 +707,9 @@ confluence stats
|
|
|
707
707
|
| `update <pageId>` | Update a page's title or content | `--title <string>`, `--content <string>`, `--file <path>`, `--format <storage\|html\|markdown>` |
|
|
708
708
|
| `move <pageId_or_url> <newParentId_or_url>` | Move a page to a new parent location | `--title <string>` |
|
|
709
709
|
| `delete <pageId_or_url>` | Delete a page by ID or URL | `--yes` |
|
|
710
|
+
| `versions <pageId_or_url>` | List historical versions of a page | `--format <text\|json>` |
|
|
711
|
+
| `version-delete <pageId_or_url> <versionNumber>` | Delete a single non-current version of a page | `--yes` |
|
|
712
|
+
| `versions-purge <pageId_or_url>` | Delete every non-current historical version of a page | `--yes`, `--throttle <seconds>` |
|
|
710
713
|
| `edit <pageId>` | Export page content for editing | `--output <file>` |
|
|
711
714
|
| `attachments <pageId_or_url>` | List or download attachments for a page | `--limit <number>`, `--pattern <glob>`, `--download`, `--dest <directory>` |
|
|
712
715
|
| `attachment-upload <pageId_or_url>` | Upload attachments to a page | `--file <path>`, `--comment <text>`, `--replace`, `--minor-edit` |
|
package/bin/confluence.js
CHANGED
|
@@ -453,6 +453,147 @@ program
|
|
|
453
453
|
}
|
|
454
454
|
});
|
|
455
455
|
|
|
456
|
+
// List historical versions of a page
|
|
457
|
+
program
|
|
458
|
+
.command('versions <pageId>')
|
|
459
|
+
.description('List historical versions of a Confluence page')
|
|
460
|
+
.option('--format <format>', 'Output format: text or json (default: text)', 'text')
|
|
461
|
+
.action(async (pageId, options) => {
|
|
462
|
+
const analytics = new Analytics();
|
|
463
|
+
try {
|
|
464
|
+
const config = getConfig(getProfileName());
|
|
465
|
+
const client = new ConfluenceClient(config);
|
|
466
|
+
const resolvedId = String(await client.extractPageId(pageId));
|
|
467
|
+
const versions = await client.listVersions(resolvedId);
|
|
468
|
+
|
|
469
|
+
if (options.format === 'json') {
|
|
470
|
+
console.log(JSON.stringify({ pageId: resolvedId, versions }, null, 2));
|
|
471
|
+
} else {
|
|
472
|
+
const max = versions.length ? Math.max(...versions.map(v => v.number)) : 0;
|
|
473
|
+
console.log(chalk.blue(`Versions for page ${resolvedId} (${versions.length} total):`));
|
|
474
|
+
if (versions.length === 0) {
|
|
475
|
+
console.log(chalk.yellow(' (no versions returned)'));
|
|
476
|
+
}
|
|
477
|
+
for (const v of versions) {
|
|
478
|
+
const tag = v.number === max ? chalk.green(' [current]') : '';
|
|
479
|
+
const author = v.by || 'unknown';
|
|
480
|
+
const note = v.message ? ` — ${v.message}` : '';
|
|
481
|
+
console.log(` v${v.number}${tag} ${v.when} ${author}${note}`);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
analytics.track('versions', true);
|
|
485
|
+
} catch (error) {
|
|
486
|
+
handleCommandError(analytics, 'versions', error);
|
|
487
|
+
}
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
// Delete a single historical version of a page
|
|
491
|
+
program
|
|
492
|
+
.command('version-delete <pageId> <versionNumber>')
|
|
493
|
+
.description('Delete a single historical version of a page (cannot delete the current version)')
|
|
494
|
+
.option('-y, --yes', 'Skip confirmation prompt')
|
|
495
|
+
.action(async (pageId, versionNumber, options) => {
|
|
496
|
+
const analytics = new Analytics();
|
|
497
|
+
try {
|
|
498
|
+
const config = getConfig(getProfileName());
|
|
499
|
+
assertWritable(config);
|
|
500
|
+
const client = new ConfluenceClient(config);
|
|
501
|
+
const resolvedId = String(await client.extractPageId(pageId));
|
|
502
|
+
const n = Number(versionNumber);
|
|
503
|
+
|
|
504
|
+
if (!options.yes) {
|
|
505
|
+
const { confirmed } = await inquirer.prompt([{
|
|
506
|
+
type: 'confirm',
|
|
507
|
+
name: 'confirmed',
|
|
508
|
+
default: false,
|
|
509
|
+
message: `Delete v${n} of page ${resolvedId}? This cannot be undone.`
|
|
510
|
+
}]);
|
|
511
|
+
if (!confirmed) {
|
|
512
|
+
console.log(chalk.yellow('Cancelled.'));
|
|
513
|
+
analytics.track('version_delete_cancel', true);
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
const result = await client.deleteVersion(resolvedId, n);
|
|
519
|
+
const note = result.viaExperimental ? chalk.yellow(' (via experimental endpoint)') : '';
|
|
520
|
+
console.log(chalk.green(`✅ Deleted v${result.versionNumber} of page ${result.id}${note}`));
|
|
521
|
+
analytics.track('version_delete', true);
|
|
522
|
+
} catch (error) {
|
|
523
|
+
handleCommandError(analytics, 'version_delete', error);
|
|
524
|
+
}
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
// Convenience: delete every non-current historical version of a page,
|
|
528
|
+
// keeping only the current one.
|
|
529
|
+
program
|
|
530
|
+
.command('versions-purge <pageId>')
|
|
531
|
+
.description('Delete every non-current historical version of a page (keeps only current)')
|
|
532
|
+
.option('-y, --yes', 'Skip confirmation prompt')
|
|
533
|
+
.option('--throttle <seconds>', 'Sleep between version-delete calls', '0')
|
|
534
|
+
.action(async (pageId, options) => {
|
|
535
|
+
const analytics = new Analytics();
|
|
536
|
+
try {
|
|
537
|
+
const config = getConfig(getProfileName());
|
|
538
|
+
assertWritable(config);
|
|
539
|
+
const client = new ConfluenceClient(config);
|
|
540
|
+
const resolvedId = String(await client.extractPageId(pageId));
|
|
541
|
+
const versions = await client.listVersions(resolvedId);
|
|
542
|
+
|
|
543
|
+
if (versions.length === 0) {
|
|
544
|
+
console.log(chalk.yellow(`No versions returned for page ${resolvedId}.`));
|
|
545
|
+
analytics.track('versions_purge', true);
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
const max = Math.max(...versions.map(v => v.number));
|
|
549
|
+
const historicalCount = versions.filter(v => v.number !== max).length;
|
|
550
|
+
if (historicalCount === 0) {
|
|
551
|
+
console.log(chalk.yellow(`Only current version v${max} exists for page ${resolvedId}; nothing to purge.`));
|
|
552
|
+
analytics.track('versions_purge', true);
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
if (!options.yes) {
|
|
557
|
+
const { confirmed } = await inquirer.prompt([{
|
|
558
|
+
type: 'confirm',
|
|
559
|
+
name: 'confirmed',
|
|
560
|
+
default: false,
|
|
561
|
+
message: `Delete ${historicalCount} historical version(s) of page ${resolvedId}? Current version (v${max}) will be kept.`
|
|
562
|
+
}]);
|
|
563
|
+
if (!confirmed) {
|
|
564
|
+
console.log(chalk.yellow('Cancelled.'));
|
|
565
|
+
analytics.track('versions_purge_cancel', true);
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
const throttleMs = Math.max(0, parseFloat(options.throttle || '0')) * 1000;
|
|
571
|
+
const result = await client.purgeNonCurrentVersions(resolvedId, {
|
|
572
|
+
onProgress: async (event) => {
|
|
573
|
+
if (event.kind === 'deleted') {
|
|
574
|
+
const note = event.viaExperimental ? chalk.yellow(' (experimental)') : '';
|
|
575
|
+
console.log(chalk.green(` ✓ deleted v${event.versionNumber}${note}`));
|
|
576
|
+
} else if (event.kind === 'failed') {
|
|
577
|
+
console.log(chalk.red(` ✗ v${event.versionNumber}: ${event.message}`));
|
|
578
|
+
}
|
|
579
|
+
if (throttleMs > 0) {
|
|
580
|
+
await new Promise(r => setTimeout(r, throttleMs));
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
console.log('');
|
|
586
|
+
console.log(chalk.green(`✅ Purge complete for page ${result.id}: ` +
|
|
587
|
+
`${result.deleted} deleted, ${result.failed} failed, kept v${result.kept}.`));
|
|
588
|
+
analytics.track('versions_purge', result.failed === 0);
|
|
589
|
+
if (result.failed > 0) {
|
|
590
|
+
process.exitCode = 1;
|
|
591
|
+
}
|
|
592
|
+
} catch (error) {
|
|
593
|
+
handleCommandError(analytics, 'versions_purge', error);
|
|
594
|
+
}
|
|
595
|
+
});
|
|
596
|
+
|
|
456
597
|
// Edit command - opens page content for editing
|
|
457
598
|
program
|
|
458
599
|
.command('edit <pageId>')
|
package/lib/confluence-client.js
CHANGED
|
@@ -1443,6 +1443,148 @@ class ConfluenceClient {
|
|
|
1443
1443
|
return { id: String(pageId) };
|
|
1444
1444
|
}
|
|
1445
1445
|
|
|
1446
|
+
/**
|
|
1447
|
+
* Build the absolute URL for the experimental version endpoint.
|
|
1448
|
+
* Confluence Server/Data Center exposes content versions only at
|
|
1449
|
+
* /rest/experimental/ (the modern /rest/api/.../version path 404s
|
|
1450
|
+
* there). Cloud accepts both. We use this as a fallback when the
|
|
1451
|
+
* configured apiPath returns 404/405.
|
|
1452
|
+
*/
|
|
1453
|
+
experimentalVersionUrl(pageId, versionNumber = null) {
|
|
1454
|
+
const base = `${this.protocol}://${this.domain}${this.webUrlPrefix}/rest/experimental/content/${pageId}/version`;
|
|
1455
|
+
return versionNumber == null ? base : `${base}/${versionNumber}`;
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
/**
|
|
1459
|
+
* List historical versions of a page. Returns an array sorted by
|
|
1460
|
+
* version number ascending. Each entry has: number, when, by,
|
|
1461
|
+
* minorEdit, message.
|
|
1462
|
+
*
|
|
1463
|
+
* Pages with many versions (e.g. heavily edited or repeatedly
|
|
1464
|
+
* uploaded) may exceed a single page of results, so this paginates
|
|
1465
|
+
* via start/limit until all versions are collected.
|
|
1466
|
+
*
|
|
1467
|
+
* Path strategy: try the configured /rest/api/ path first; on 404
|
|
1468
|
+
* or 405 (typical of Server/DC where the version endpoints live
|
|
1469
|
+
* under /rest/experimental/), fall back transparently. Subsequent
|
|
1470
|
+
* pages use whichever path succeeded so we don't double the
|
|
1471
|
+
* round-trip count on long histories.
|
|
1472
|
+
*/
|
|
1473
|
+
async listVersions(pageIdOrUrl) {
|
|
1474
|
+
const pageId = await this.extractPageId(pageIdOrUrl);
|
|
1475
|
+
const limit = 200;
|
|
1476
|
+
let start = 0;
|
|
1477
|
+
let useExperimental = false;
|
|
1478
|
+
const all = [];
|
|
1479
|
+
while (true) {
|
|
1480
|
+
let response;
|
|
1481
|
+
try {
|
|
1482
|
+
const url = useExperimental
|
|
1483
|
+
? this.experimentalVersionUrl(pageId)
|
|
1484
|
+
: `/content/${pageId}/version`;
|
|
1485
|
+
response = await this.client.get(url, { params: { start, limit } });
|
|
1486
|
+
} catch (error) {
|
|
1487
|
+
const status = error.response && error.response.status;
|
|
1488
|
+
if (!useExperimental && (status === 404 || status === 405)) {
|
|
1489
|
+
useExperimental = true;
|
|
1490
|
+
continue;
|
|
1491
|
+
}
|
|
1492
|
+
throw error;
|
|
1493
|
+
}
|
|
1494
|
+
const results = response.data.results || [];
|
|
1495
|
+
all.push(...results);
|
|
1496
|
+
if (results.length < limit) {
|
|
1497
|
+
break;
|
|
1498
|
+
}
|
|
1499
|
+
start += limit;
|
|
1500
|
+
}
|
|
1501
|
+
return all.map(v => ({
|
|
1502
|
+
number: v.number,
|
|
1503
|
+
when: v.when,
|
|
1504
|
+
by: v.by ? (v.by.displayName || v.by.publicName || v.by.email || null) : null,
|
|
1505
|
+
minorEdit: v.minorEdit === true,
|
|
1506
|
+
message: v.message || ''
|
|
1507
|
+
})).sort((a, b) => a.number - b.number);
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
/**
|
|
1511
|
+
* Delete a single historical version of a page.
|
|
1512
|
+
*
|
|
1513
|
+
* Confluence refuses to delete the current version (returns 400).
|
|
1514
|
+
* The "rolled up into the next version" wording in Atlassian's
|
|
1515
|
+
* docs refers to the diff representation; the deleted version's
|
|
1516
|
+
* snapshot is removed from page history.
|
|
1517
|
+
*
|
|
1518
|
+
* Server/DC instances expose this only at /rest/experimental/, so
|
|
1519
|
+
* a 404 or 405 on the configured /rest/api/ path triggers a
|
|
1520
|
+
* one-shot retry against the experimental URL.
|
|
1521
|
+
*/
|
|
1522
|
+
async deleteVersion(pageIdOrUrl, versionNumber) {
|
|
1523
|
+
const pageId = await this.extractPageId(pageIdOrUrl);
|
|
1524
|
+
const n = Number(versionNumber);
|
|
1525
|
+
if (!Number.isInteger(n) || n < 1) {
|
|
1526
|
+
throw new Error(`versionNumber must be a positive integer, got: ${versionNumber}`);
|
|
1527
|
+
}
|
|
1528
|
+
try {
|
|
1529
|
+
await this.client.delete(`/content/${pageId}/version/${n}`);
|
|
1530
|
+
return { id: String(pageId), versionNumber: n, viaExperimental: false };
|
|
1531
|
+
} catch (error) {
|
|
1532
|
+
const status = error.response && error.response.status;
|
|
1533
|
+
if (status !== 404 && status !== 405) {
|
|
1534
|
+
throw error;
|
|
1535
|
+
}
|
|
1536
|
+
await this.client.delete(this.experimentalVersionUrl(pageId, n));
|
|
1537
|
+
return { id: String(pageId), versionNumber: n, viaExperimental: true };
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
/**
|
|
1542
|
+
* Delete every non-current version of a page. Used to purge the
|
|
1543
|
+
* historical content snapshots that may still hold a leaked secret
|
|
1544
|
+
* even after the current version has been overwritten with a
|
|
1545
|
+
* redacted copy.
|
|
1546
|
+
*
|
|
1547
|
+
* Returns { id, kept, deleted, failed } where `kept` is the version
|
|
1548
|
+
* number that survived (the current one) and `deleted`/`failed` are
|
|
1549
|
+
* the counts of historical versions removed/errored.
|
|
1550
|
+
*/
|
|
1551
|
+
async purgeNonCurrentVersions(pageIdOrUrl, options = {}) {
|
|
1552
|
+
const onProgress = typeof options.onProgress === 'function' ? options.onProgress : () => {};
|
|
1553
|
+
const pageId = String(await this.extractPageId(pageIdOrUrl));
|
|
1554
|
+
const versions = await this.listVersions(pageId);
|
|
1555
|
+
|
|
1556
|
+
if (versions.length === 0) {
|
|
1557
|
+
return { id: pageId, kept: null, deleted: 0, failed: 0, errors: [] };
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
const max = Math.max(...versions.map(v => v.number));
|
|
1561
|
+
// Delete in descending order. Some Confluence builds renumber
|
|
1562
|
+
// historical versions when an earlier one is removed; deleting
|
|
1563
|
+
// newest-first sidesteps the resulting off-by-one drift.
|
|
1564
|
+
const toDelete = versions
|
|
1565
|
+
.filter(v => v.number !== max)
|
|
1566
|
+
.map(v => v.number)
|
|
1567
|
+
.sort((a, b) => b - a);
|
|
1568
|
+
|
|
1569
|
+
let deleted = 0;
|
|
1570
|
+
const errors = [];
|
|
1571
|
+
for (const n of toDelete) {
|
|
1572
|
+
try {
|
|
1573
|
+
const res = await this.deleteVersion(pageId, n);
|
|
1574
|
+
deleted += 1;
|
|
1575
|
+
onProgress({ kind: 'deleted', versionNumber: n, viaExperimental: res.viaExperimental });
|
|
1576
|
+
} catch (error) {
|
|
1577
|
+
const detail = error.response
|
|
1578
|
+
? `HTTP ${error.response.status} ${error.response.statusText || ''}`.trim()
|
|
1579
|
+
: error.message;
|
|
1580
|
+
errors.push({ versionNumber: n, message: detail });
|
|
1581
|
+
onProgress({ kind: 'failed', versionNumber: n, message: detail });
|
|
1582
|
+
}
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
return { id: pageId, kept: max, deleted, failed: errors.length, errors };
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1446
1588
|
/**
|
|
1447
1589
|
* Search for a page by title and space
|
|
1448
1590
|
*/
|
package/lib/html-to-storage.js
CHANGED
|
@@ -21,6 +21,8 @@ class HtmlDepthExceededError extends Error {
|
|
|
21
21
|
// whatever shape the source had (markdown-it emits them without a slash).
|
|
22
22
|
const VOID_TAGS = new Set(['hr']);
|
|
23
23
|
const CALLOUT_MARKERS = ['info', 'warning', 'note'];
|
|
24
|
+
// these tags are wrapped into Confluence HTML macro
|
|
25
|
+
const HTML_MACRO_TAGS = new Set(['svg', 'div']);
|
|
24
26
|
|
|
25
27
|
// Phrasing-content tags that trigger the `<li>` / `<th>` / `<td>` `<p>`-wrap
|
|
26
28
|
// quirk: if an item contains only inline children and no text-node newline,
|
|
@@ -218,6 +220,36 @@ function convertBlockquote(node, ctx) {
|
|
|
218
220
|
</ac:structured-macro>`;
|
|
219
221
|
}
|
|
220
222
|
|
|
223
|
+
// `<details><summary>` becomes expand macro. If no summary child is found,
|
|
224
|
+
// falls through to plain HTML.
|
|
225
|
+
function convertDetails(node, ctx) {
|
|
226
|
+
const children = node.children || [];
|
|
227
|
+
let summaryNode = null;
|
|
228
|
+
let bodyNodes = [];
|
|
229
|
+
|
|
230
|
+
for (const child of children) {
|
|
231
|
+
if (child.type === 'tag' && child.name === 'summary') {
|
|
232
|
+
summaryNode = child;
|
|
233
|
+
} else if (!isWhitespaceOnly(child)) {
|
|
234
|
+
bodyNodes.push(child);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (!summaryNode) {
|
|
239
|
+
return `<details${renderAttrs(node.attribs)}>${walkChildren(node, ctx)}</details>`;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const titleHtml = walkChildren(summaryNode, ctx);
|
|
243
|
+
const cleanTitle = titleHtml.replace(/<[^>]+>/g, '').trim();
|
|
244
|
+
|
|
245
|
+
const bodyHtml = bodyNodes
|
|
246
|
+
.map((c) => walkNode(c, ctx))
|
|
247
|
+
.join('')
|
|
248
|
+
.trim();
|
|
249
|
+
|
|
250
|
+
return `<ac:structured-macro ac:name="expand"><ac:parameter ac:name="title">${cleanTitle}</ac:parameter><ac:rich-text-body>${bodyHtml}</ac:rich-text-body></ac:structured-macro>`;
|
|
251
|
+
}
|
|
252
|
+
|
|
221
253
|
// Strict `<pre><code>` adjacency only — `<pre>` with whitespace siblings or
|
|
222
254
|
// any other shape falls through as plain `<pre>`. The body needs manual
|
|
223
255
|
// entity decode because the parser keeps entities raw and CDATA is opaque
|
|
@@ -243,6 +275,20 @@ function convertCodeBlock(node, ctx) {
|
|
|
243
275
|
return `<ac:structured-macro ac:name="code"><ac:parameter ac:name="language">${language}</ac:parameter><ac:plain-text-body><![CDATA[${body}]]></ac:plain-text-body></ac:structured-macro>`;
|
|
244
276
|
}
|
|
245
277
|
|
|
278
|
+
// Wrap allowlisted HTML tags (svg, div) in Confluence HTML macro with CDATA.
|
|
279
|
+
// Used for embedding custom HTML that Confluence doesn't natively support.
|
|
280
|
+
function convertHtmlBlock(node, ctx) {
|
|
281
|
+
const { randomUUID } = require('crypto');
|
|
282
|
+
const inner = walkChildren(node, ctx);
|
|
283
|
+
const attrsStr = renderAttrs(node.attribs);
|
|
284
|
+
const openTag = `<${node.name}${attrsStr}>`;
|
|
285
|
+
const closeTag = `</${node.name}>`;
|
|
286
|
+
const htmlContent = openTag + inner + closeTag;
|
|
287
|
+
const safeContent = htmlContent.replace(/]]>/g, ']]]]><![CDATA[>');
|
|
288
|
+
const macroId = randomUUID();
|
|
289
|
+
return `<ac:structured-macro ac:name="html" ac:schema-version="1" ac:macro-id="${macroId}"><ac:plain-text-body><![CDATA[${safeContent}]]></ac:plain-text-body></ac:structured-macro>`;
|
|
290
|
+
}
|
|
291
|
+
|
|
246
292
|
// Re-escape literal `"` inside attribute values. htmlparser2 with
|
|
247
293
|
// `decodeEntities: false` keeps source-escaped entities intact, but a
|
|
248
294
|
// single-quoted source attribute (`<a title='he said "hi"'>`) lands a
|
|
@@ -344,10 +390,11 @@ function dispatchTag(node, ctx) {
|
|
|
344
390
|
return `<img${renderAttrs(node.attribs)}>`;
|
|
345
391
|
case 'ul':
|
|
346
392
|
case 'ol':
|
|
347
|
-
return `<${node.name}>${walkChildren(node, ctx)}</${node.name}>`;
|
|
393
|
+
return `<${node.name}${renderAttrs(node.attribs)}>${walkChildren(node, ctx)}</${node.name}>`;
|
|
348
394
|
case 'li': {
|
|
349
395
|
const inner = walkChildren(node, ctx);
|
|
350
|
-
|
|
396
|
+
const open = `<li${renderAttrs(node.attribs)}>`;
|
|
397
|
+
return shouldWrapInP(node) ? `${open}<p>${inner}</p></li>` : `${open}${inner}</li>`;
|
|
351
398
|
}
|
|
352
399
|
case 'pre':
|
|
353
400
|
return convertCodeBlock(node, ctx);
|
|
@@ -358,6 +405,8 @@ function dispatchTag(node, ctx) {
|
|
|
358
405
|
return convertLink(node, ctx);
|
|
359
406
|
case 'blockquote':
|
|
360
407
|
return convertBlockquote(node, ctx);
|
|
408
|
+
case 'details':
|
|
409
|
+
return convertDetails(node, ctx);
|
|
361
410
|
case 'table':
|
|
362
411
|
case 'thead':
|
|
363
412
|
case 'tbody':
|
|
@@ -374,6 +423,9 @@ function dispatchTag(node, ctx) {
|
|
|
374
423
|
if (VOID_TAGS.has(node.name)) {
|
|
375
424
|
return `<${node.name}${renderAttrs(node.attribs)} />`;
|
|
376
425
|
}
|
|
426
|
+
if (HTML_MACRO_TAGS.has(node.name)) {
|
|
427
|
+
return convertHtmlBlock(node, ctx);
|
|
428
|
+
}
|
|
377
429
|
return `<${node.name}${renderAttrs(node.attribs)}>${walkChildren(node, ctx)}</${node.name}>`;
|
|
378
430
|
}
|
|
379
431
|
}
|
package/lib/macro-converter.js
CHANGED
|
@@ -21,7 +21,9 @@ const STASH_DELIM = '\uE000';
|
|
|
21
21
|
// The body alternation `"[^"]*"|'[^']*'|[^>]` makes the match quote-aware
|
|
22
22
|
// so a literal `>` inside a quoted attribute value (e.g.
|
|
23
23
|
// `<mark title="1>0">`) does not terminate the tag prematurely.
|
|
24
|
-
const PASSTHROUGH_TAG_RE = /<\/?(?:u|sub|sup|mark)(?=[\s/>])(?:"[^"]*"|'[^']*'|[^>])*>/gi;
|
|
24
|
+
const PASSTHROUGH_TAG_RE = /<\/?(?:u|sub|sup|mark|details|summary)(?=[\s/>])(?:"[^"]*"|'[^']*'|[^>])*>/gi;
|
|
25
|
+
// Block-level HTML elements that should pass through WITHOUT markdown processing of their content.
|
|
26
|
+
const PASSTHROUGH_BLOCK_RE = /<(svg|div)(?:\s[^>]*)?>[\s\S]*?<\/\1>/gi;
|
|
25
27
|
// Single-backtick inline code spans. Block-level code (fenced + indented) is
|
|
26
28
|
// detected via MarkdownIt's tokenizer in `_findCodeRanges` because a regex
|
|
27
29
|
// can't reliably distinguish a 4-space-indented code block from a list-item
|
|
@@ -92,10 +94,19 @@ class MacroConverter {
|
|
|
92
94
|
_renderMarkdownToHtml(markdown) {
|
|
93
95
|
const codeRanges = this._findCodeRanges(markdown);
|
|
94
96
|
const htmlStash = [];
|
|
95
|
-
const stashHtml = (text) =>
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
97
|
+
const stashHtml = (text) => {
|
|
98
|
+
// block-level HTML (svg, div with all content) must be stashed before inline tags to avoid matching the closing tag as inline HTML
|
|
99
|
+
let result = text.replace(PASSTHROUGH_BLOCK_RE, (m) => {
|
|
100
|
+
htmlStash.push(m);
|
|
101
|
+
return `${STASH_DELIM}H${htmlStash.length - 1}${STASH_DELIM}`;
|
|
102
|
+
});
|
|
103
|
+
// Then stash inline HTML tags
|
|
104
|
+
result = result.replace(PASSTHROUGH_TAG_RE, (m) => {
|
|
105
|
+
htmlStash.push(m);
|
|
106
|
+
return `${STASH_DELIM}H${htmlStash.length - 1}${STASH_DELIM}`;
|
|
107
|
+
});
|
|
108
|
+
return result;
|
|
109
|
+
};
|
|
99
110
|
|
|
100
111
|
let src = '';
|
|
101
112
|
let pos = 0;
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "confluence-cli",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.6.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "confluence-cli",
|
|
9
|
-
"version": "2.
|
|
9
|
+
"version": "2.6.0",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
|
-
"axios": "
|
|
12
|
+
"axios": "~1.15.2",
|
|
13
13
|
"chalk": "^4.1.2",
|
|
14
14
|
"commander": "^11.1.0",
|
|
15
15
|
"entities": "^4.5.0",
|
|
@@ -129,9 +129,9 @@
|
|
|
129
129
|
"license": "MIT"
|
|
130
130
|
},
|
|
131
131
|
"node_modules/axios": {
|
|
132
|
-
"version": "1.15.
|
|
133
|
-
"resolved": "https://registry.npmjs.org/axios/-/axios-1.15.
|
|
134
|
-
"integrity": "sha512-
|
|
132
|
+
"version": "1.15.2",
|
|
133
|
+
"resolved": "https://registry.npmjs.org/axios/-/axios-1.15.2.tgz",
|
|
134
|
+
"integrity": "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==",
|
|
135
135
|
"license": "MIT",
|
|
136
136
|
"dependencies": {
|
|
137
137
|
"follow-redirects": "^1.15.11",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "confluence-cli",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.6.0",
|
|
4
4
|
"description": "A command-line interface for Atlassian Confluence with page creation and editing capabilities",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
"author": "pchuri",
|
|
24
24
|
"license": "MIT",
|
|
25
25
|
"dependencies": {
|
|
26
|
-
"axios": "
|
|
26
|
+
"axios": "~1.15.2",
|
|
27
27
|
"chalk": "^4.1.2",
|
|
28
28
|
"commander": "^11.1.0",
|
|
29
29
|
"entities": "^4.5.0",
|