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 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>')
@@ -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
  */
@@ -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
- return shouldWrapInP(node) ? `<li><p>${inner}</p></li>` : `<li>${inner}</li>`;
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
  }
@@ -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) => text.replace(PASSTHROUGH_TAG_RE, (m) => {
96
- htmlStash.push(m);
97
- return `${STASH_DELIM}H${htmlStash.length - 1}${STASH_DELIM}`;
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;
@@ -1,15 +1,15 @@
1
1
  {
2
2
  "name": "confluence-cli",
3
- "version": "2.4.0",
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.4.0",
9
+ "version": "2.6.0",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
- "axios": "^1.15.0",
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.0",
133
- "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz",
134
- "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==",
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.4.0",
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": "^1.15.0",
26
+ "axios": "~1.15.2",
27
27
  "chalk": "^4.1.2",
28
28
  "commander": "^11.1.0",
29
29
  "entities": "^4.5.0",