@asciidoctor/core 4.0.1 → 4.0.2

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.
@@ -1,4 +1,4 @@
1
- var version = "4.0.1";
1
+ var version = "4.0.2";
2
2
  const packageJson = {
3
3
  version: version};
4
4
 
@@ -3032,9 +3032,10 @@ class AbstractNode {
3032
3032
  * Construct a URI reference or data URI to the target image.
3033
3033
  *
3034
3034
  * If the target image is already a URI it is left untouched (unless data-uri
3035
- * conversion is requested). The image is resolved relative to the directory
3036
- * named by assetDirKey. When data-uri is enabled and the safe level permits,
3037
- * the image is embedded as a Base64 data URI.
3035
+ * conversion is requested). If the target image is a data URI, then it is
3036
+ * already an embedded image, so it is returned as-is. The image is resolved
3037
+ * relative to the directory named by assetDirKey. When data-uri is enabled and
3038
+ * the safe level permits, the image is embedded as a Base64 data URI.
3038
3039
  *
3039
3040
  * NOTE: When the document has both 'data-uri' and 'allow-uri-read' enabled
3040
3041
  * and the resolved image URL is a remote URI, this method returns a Promise
@@ -3045,6 +3046,8 @@ class AbstractNode {
3045
3046
  * @returns {Promise<string>} a Promise resolving to a String reference or data URI.
3046
3047
  */
3047
3048
  async imageUri(targetImage, assetDirKey = 'imagesdir') {
3049
+ // A data URI is already an embedded image, so use it as-is rather than reading or re-encoding it.
3050
+ if (targetImage.startsWith('data:')) return targetImage
3048
3051
  const doc = this.document;
3049
3052
  if (doc.safe < SafeMode.SECURE && doc.hasAttribute('data-uri')) {
3050
3053
  let imagesBase;
@@ -5452,7 +5455,18 @@ class SecurityError extends Error {
5452
5455
  function applyBackendTraits(instance) {
5453
5456
  instance._backendTraits = null;
5454
5457
 
5455
- instance.basebackend = function (value = null) {
5458
+ // Install a Ruby-style trait accessor method, but never clobber a flat string
5459
+ // property the converter already declared (convention #2). Overwriting e.g.
5460
+ // `converter.outfilesuffix = '.html'` with a method would silently turn the
5461
+ // author's string into a function; the backend traits stay reachable through
5462
+ // `_getBackendTraits()` instead.
5463
+ const defineTraitAccessor = (name, fn) => {
5464
+ const existing = Object.getOwnPropertyDescriptor(instance, name);
5465
+ if (existing && typeof existing.value !== 'function') return
5466
+ instance[name] = fn;
5467
+ };
5468
+
5469
+ defineTraitAccessor('basebackend', function (value = null) {
5456
5470
  if (value) {
5457
5471
  const traits = (this._backendTraits ??= {});
5458
5472
  traits.basebackend = value;
@@ -5466,19 +5480,19 @@ function applyBackendTraits(instance) {
5466
5480
  return value
5467
5481
  }
5468
5482
  return this._getBackendTraits().basebackend
5469
- };
5470
- instance.filetype = function (value = null) {
5483
+ });
5484
+ defineTraitAccessor('filetype', function (value = null) {
5471
5485
  if (value) return (this._getBackendTraits().filetype = value)
5472
5486
  return this._getBackendTraits().filetype
5473
- };
5474
- instance.htmlsyntax = function (value = null) {
5487
+ });
5488
+ defineTraitAccessor('htmlsyntax', function (value = null) {
5475
5489
  if (value) return (this._getBackendTraits().htmlsyntax = value)
5476
5490
  return this._getBackendTraits().htmlsyntax
5477
- };
5478
- instance.outfilesuffix = function (value = null) {
5491
+ });
5492
+ defineTraitAccessor('outfilesuffix', function (value = null) {
5479
5493
  if (value) return (this._getBackendTraits().outfilesuffix = value)
5480
5494
  return this._getBackendTraits().outfilesuffix
5481
- };
5495
+ });
5482
5496
  instance.supportsTemplates = function (value = true) {
5483
5497
  this._getBackendTraits().supportsTemplates = value;
5484
5498
  };
@@ -5562,7 +5576,9 @@ function normalizeConverter(converter, backend) {
5562
5576
  }
5563
5577
  }
5564
5578
 
5565
- // Apply the BackendTraits mixin so Document can call the standard accessor methods.
5579
+ // Apply the BackendTraits mixin so Document can read traits via
5580
+ // _getBackendTraits(). Flat string properties (convention #2) are preserved:
5581
+ // applyBackendTraits does not overwrite an existing same-named data property.
5566
5582
  applyBackendTraits(converter);
5567
5583
  if (traits) {
5568
5584
  converter._backendTraits = traits;
@@ -16091,7 +16107,7 @@ class Document extends AbstractBlock {
16091
16107
 
16092
16108
  const attrOverrides = this._attributeOverrides;
16093
16109
  attrOverrides.asciidoctor = '';
16094
- attrOverrides['asciidoctor-version'] = '3.0.0.dev'; // matches Ruby VERSION
16110
+ attrOverrides['asciidoctor-version'] = packageJson.version;
16095
16111
 
16096
16112
  const safeModeName = SafeMode.nameForValue(this.safe);
16097
16113
  attrOverrides['safe-mode-name'] = safeModeName;
@@ -16283,16 +16299,16 @@ class Document extends AbstractBlock {
16283
16299
  // attribute (re)assignments are in scope; snapshot and restore the document
16284
16300
  // attributes so the downstream steps and conversion still start from the restored
16285
16301
  // (header) state, matching Ruby's restore_attributes-before-convert invariant.
16302
+ //
16303
+ // This runs in two passes because list item / table cell / dlist text may contain
16304
+ // natural cross-references (e.g. <<Some section title>>) that resolve against the
16305
+ // reftext→id map. Pass 1 substitutes titles and reftexts only, so every section
16306
+ // reftext is known; the map is then built; pass 2 substitutes the block content
16307
+ // text, where resolveId() can now match those natural references.
16286
16308
  const attributesSnapshot = { ...this.attributes };
16287
- await this._resolveAllTexts(this);
16288
- for (const key of Reflect.ownKeys(this.attributes))
16289
- delete this.attributes[key];
16290
- Object.assign(this.attributes, attributesSnapshot);
16291
- // Reset the footnote counter so that body-content footnotes (processed during conversion)
16292
- // start numbering from 1, reproducing Ruby's "out of sequence" quirk: title footnotes are
16293
- // numbered during parsing via apply_title_subs, then the counter restarts for body content.
16294
- delete this.attributes['footnote-number'];
16295
- delete this._counters['footnote-number'];
16309
+ // Pass 1: titles + reftexts (no block content text yet).
16310
+ await this._resolveAllTexts(this, false);
16311
+ this._restoreAttributeSnapshot(attributesSnapshot);
16296
16312
  // Pre-compute reftext for all registered inline anchor nodes.
16297
16313
  for (const ref of Object.values(this.catalog.refs)) {
16298
16314
  if (ref && typeof ref.precomputeReftext === 'function') {
@@ -16301,6 +16317,15 @@ class Document extends AbstractBlock {
16301
16317
  }
16302
16318
  // Build the reftext→id lookup map so that resolveId() is synchronous.
16303
16319
  await this._buildReftextsMap();
16320
+ // Pass 2: list item / table cell / dlist text, now that natural cross-references
16321
+ // can be resolved against the reftext→id map.
16322
+ await this._resolveAllTexts(this, true);
16323
+ this._restoreAttributeSnapshot(attributesSnapshot);
16324
+ // Reset the footnote counter so that body-content footnotes (processed during conversion)
16325
+ // start numbering from 1, reproducing Ruby's "out of sequence" quirk: title footnotes are
16326
+ // numbered during parsing via apply_title_subs, then the counter restarts for body content.
16327
+ delete this.attributes['footnote-number'];
16328
+ delete this._counters['footnote-number'];
16304
16329
 
16305
16330
  this._parsed = true;
16306
16331
  return this
@@ -17355,12 +17380,33 @@ class Document extends AbstractBlock {
17355
17380
  : ['attributes']
17356
17381
  }
17357
17382
 
17383
+ /**
17384
+ * @private
17385
+ * Restore the document attributes to a previously captured snapshot, discarding any
17386
+ * body-level (re)assignments replayed while pre-computing text. Mirrors Ruby's
17387
+ * restore_attributes-before-convert invariant.
17388
+ * @param {Object} snapshot - The attributes snapshot to restore.
17389
+ */
17390
+ _restoreAttributeSnapshot(snapshot) {
17391
+ for (const key of Reflect.ownKeys(this.attributes))
17392
+ delete this.attributes[key];
17393
+ Object.assign(this.attributes, snapshot);
17394
+ }
17395
+
17358
17396
  /**
17359
17397
  * @private
17360
17398
  * Walk the block tree and pre-compute all async text values.
17361
17399
  * Handles titles (AbstractBlock), list item text, table cell text, and reftexts.
17362
- */
17363
- async _resolveAllTexts(block) {
17400
+ *
17401
+ * Runs in two passes (see {@link parse}): with `resolveContent` false only titles and
17402
+ * reftexts are substituted (so the reftext→id map can be built); with `resolveContent`
17403
+ * true the list item / table cell / dlist text is substituted, resolving any natural
17404
+ * cross-references against the now-complete map. Title/reftext pre-computation is
17405
+ * idempotent (results are cached), so running it in both passes is a no-op the second time.
17406
+ * @param {AbstractBlock} block - The block to resolve.
17407
+ * @param {boolean} resolveContent - Whether to substitute list item / cell / dlist text.
17408
+ */
17409
+ async _resolveAllTexts(block, resolveContent) {
17364
17410
  // Replay this block's attribute entries (in document order, since the walk is
17365
17411
  // depth-first pre-order like conversion) so that body-level attribute assignments
17366
17412
  // and reassignments are in scope when the block's — and its descendants' and later
@@ -17386,12 +17432,12 @@ class Document extends AbstractBlock {
17386
17432
  // dlist.blocks is an array of [[term, ...], item_or_null] pairs.
17387
17433
  for (const [terms, item] of block.blocks ?? []) {
17388
17434
  for (const term of terms ?? []) {
17389
- await term.precomputeText?.();
17390
- await this._resolveAllTexts(term);
17435
+ if (resolveContent) await term.precomputeText?.();
17436
+ await this._resolveAllTexts(term, resolveContent);
17391
17437
  }
17392
17438
  if (item) {
17393
- await item.precomputeText?.();
17394
- await this._resolveAllTexts(item);
17439
+ if (resolveContent) await item.precomputeText?.();
17440
+ await this._resolveAllTexts(item, resolveContent);
17395
17441
  }
17396
17442
  }
17397
17443
  } else if (ctx === 'table') {
@@ -17401,14 +17447,14 @@ class Document extends AbstractBlock {
17401
17447
  ...(block.rows?.foot ?? []),
17402
17448
  ]) {
17403
17449
  for (const cell of row) {
17404
- await cell.precomputeText?.();
17450
+ if (resolveContent) await cell.precomputeText?.();
17405
17451
  await cell.precomputeReftext?.();
17406
17452
  }
17407
17453
  }
17408
17454
  } else {
17409
17455
  for (const child of block.blocks ?? []) {
17410
- await child.precomputeText?.();
17411
- await this._resolveAllTexts(child);
17456
+ if (resolveContent) await child.precomputeText?.();
17457
+ await this._resolveAllTexts(child, resolveContent);
17412
17458
  }
17413
17459
  }
17414
17460
  }
@@ -17700,14 +17746,19 @@ class Document extends AbstractBlock {
17700
17746
  let newBasebackend, newFiletype;
17701
17747
 
17702
17748
  if (converter && typeof converter._getBackendTraits === 'function') {
17703
- newBasebackend = converter.basebackend();
17704
- newFiletype = converter.filetype();
17705
- const htmlsyntax = converter.htmlsyntax();
17749
+ // Read the traits object directly rather than the same-named accessor
17750
+ // methods. A user converter that declares flat string properties
17751
+ // (`converter.outfilesuffix = '.html'`, convention #2) keeps them intact,
17752
+ // so callers reading `converter.outfilesuffix` still see the string.
17753
+ const traits = converter._getBackendTraits();
17754
+ newBasebackend = traits.basebackend;
17755
+ newFiletype = traits.filetype;
17756
+ const htmlsyntax = traits.htmlsyntax;
17706
17757
  if (htmlsyntax) attrs.htmlsyntax = htmlsyntax;
17707
17758
  if (init) {
17708
- attrs.outfilesuffix ??= converter.outfilesuffix();
17759
+ attrs.outfilesuffix ??= traits.outfilesuffix;
17709
17760
  } else if (!this.isAttributeLocked('outfilesuffix')) {
17710
- attrs.outfilesuffix = converter.outfilesuffix();
17761
+ attrs.outfilesuffix = traits.outfilesuffix;
17711
17762
  }
17712
17763
  } else if (converter) {
17713
17764
  const traits = deriveBackendTraits(newBackend);
@@ -20954,7 +21005,7 @@ class Html5Converter extends ConverterBase {
20954
21005
  let reproducible;
20955
21006
  if (!(reproducible = node.hasAttribute('reproducible'))) {
20956
21007
  result.push(
20957
- `<meta name="generator" content="Asciidoctor ${node.getAttribute('asciidoctor-version')}"${slash}>`
21008
+ `<meta name="generator" content="Asciidoctor.js ${node.getAttribute('asciidoctor-version')}"${slash}>`
20958
21009
  );
20959
21010
  }
20960
21011
  if (node.hasAttribute('app-name')) {
@@ -21671,7 +21722,9 @@ ${await node.content()}
21671
21722
  const slash = this._voidSlash;
21672
21723
  let img, src;
21673
21724
  if (
21674
- (node.hasAttribute('format', 'svg') || target.includes('.svg')) &&
21725
+ (node.hasAttribute('format', 'svg') ||
21726
+ target.includes('.svg') ||
21727
+ target.startsWith('data:image/svg+xml')) &&
21675
21728
  node.document.safe < SafeMode.SECURE
21676
21729
  ) {
21677
21730
  if (node.hasOption('inline')) {
@@ -22512,7 +22565,9 @@ Your browser does not support the video tag.
22512
22565
  if (node.hasAttribute('title'))
22513
22566
  attrs += ` title="${node.getAttribute('title')}"`;
22514
22567
  if (
22515
- (node.hasAttribute('format', 'svg') || target.includes('.svg')) &&
22568
+ (node.hasAttribute('format', 'svg') ||
22569
+ target.includes('.svg') ||
22570
+ target.startsWith('data:image/svg+xml')) &&
22516
22571
  node.document.safe < SafeMode.SECURE
22517
22572
  ) {
22518
22573
  if (node.hasOption('inline')) {
@@ -22605,12 +22660,17 @@ Your browser does not support the video tag.
22605
22660
 
22606
22661
  // NOTE expose readSvgContents for Bespoke converter
22607
22662
  async readSvgContents(node, target) {
22608
- let svg = await node.readContents(target, {
22609
- start: node.document.getAttribute('imagesdir'),
22610
- normalize: true,
22611
- label: 'SVG',
22612
- warnIfEmpty: true,
22613
- });
22663
+ // A data-URI carries the SVG in the target itself (e.g. an embedded diagram
22664
+ // produced with `:data-uri:`), so decode it directly rather than trying to
22665
+ // read it as a file or remote URI.
22666
+ let svg = target.startsWith('data:')
22667
+ ? this._decodeDataUri(target)
22668
+ : await node.readContents(target, {
22669
+ start: node.document.getAttribute('imagesdir'),
22670
+ normalize: true,
22671
+ label: 'SVG',
22672
+ warnIfEmpty: true,
22673
+ });
22614
22674
  if (!svg) return null
22615
22675
  if (!svg.startsWith('<svg')) svg = svg.replace(SvgPreambleRx, '');
22616
22676
  // Fix incomplete SVG start tag (missing closing >) by inserting > before the first child element.
@@ -22642,6 +22702,27 @@ Your browser does not support the video tag.
22642
22702
 
22643
22703
  // ── Private helpers ─────────────────────────────────────────────────────────
22644
22704
 
22705
+ /**
22706
+ * Decode an inline `data:` URI to its text contents (e.g. an SVG document) so
22707
+ * an image whose target is a data-URI can be embedded inline. Supports both
22708
+ * Base64 (`;base64,`) and percent-encoded payloads. Returns null when the
22709
+ * payload is missing.
22710
+ *
22711
+ * @internal
22712
+ * @private
22713
+ */
22714
+ _decodeDataUri(target) {
22715
+ const comma = target.indexOf(',');
22716
+ if (comma === -1) return null
22717
+ const meta = target.slice('data:'.length, comma);
22718
+ const data = target.slice(comma + 1);
22719
+ if (/;base64\b/i.test(meta)) {
22720
+ const bytes = Uint8Array.from(atob(data), (c) => c.charCodeAt(0));
22721
+ return new TextDecoder('utf-8').decode(bytes)
22722
+ }
22723
+ return decodeURIComponent(data)
22724
+ }
22725
+
22645
22726
  /**
22646
22727
  * @internal
22647
22728
  * @private
@@ -24006,7 +24087,7 @@ class ManPageConverter extends ConverterBase {
24006
24087
  `'\\" t
24007
24088
  .\\" Title: ${mantitle}
24008
24089
  .\\" Author: ${node.hasAttribute('authors') ? node.getAttribute('authors') : '[see the "AUTHOR(S)" section]'}
24009
- .\\" Generator: Asciidoctor ${node.getAttribute('asciidoctor-version')}`,
24090
+ .\\" Generator: Asciidoctor.js ${node.getAttribute('asciidoctor-version')}`,
24010
24091
  ];
24011
24092
 
24012
24093
  if (docdate) result.push(`.\\" Date: ${docdate}`);
@@ -1,11 +1,12 @@
1
1
  'use strict';
2
2
 
3
3
  const node_module = require('node:module');
4
+ const node_url = require('node:url');
4
5
  const node_fs = require('node:fs');
5
6
  const path = require('node:path');
6
7
 
7
8
  var _documentCurrentScript = typeof document !== 'undefined' ? document.currentScript : null;
8
- var version = "4.0.1";
9
+ var version = "4.0.2";
9
10
  const packageJson = {
10
11
  version: version};
11
12
 
@@ -3039,9 +3040,10 @@ class AbstractNode {
3039
3040
  * Construct a URI reference or data URI to the target image.
3040
3041
  *
3041
3042
  * If the target image is already a URI it is left untouched (unless data-uri
3042
- * conversion is requested). The image is resolved relative to the directory
3043
- * named by assetDirKey. When data-uri is enabled and the safe level permits,
3044
- * the image is embedded as a Base64 data URI.
3043
+ * conversion is requested). If the target image is a data URI, then it is
3044
+ * already an embedded image, so it is returned as-is. The image is resolved
3045
+ * relative to the directory named by assetDirKey. When data-uri is enabled and
3046
+ * the safe level permits, the image is embedded as a Base64 data URI.
3045
3047
  *
3046
3048
  * NOTE: When the document has both 'data-uri' and 'allow-uri-read' enabled
3047
3049
  * and the resolved image URL is a remote URI, this method returns a Promise
@@ -3052,6 +3054,8 @@ class AbstractNode {
3052
3054
  * @returns {Promise<string>} a Promise resolving to a String reference or data URI.
3053
3055
  */
3054
3056
  async imageUri(targetImage, assetDirKey = 'imagesdir') {
3057
+ // A data URI is already an embedded image, so use it as-is rather than reading or re-encoding it.
3058
+ if (targetImage.startsWith('data:')) return targetImage
3055
3059
  const doc = this.document;
3056
3060
  if (doc.safe < SafeMode.SECURE && doc.hasAttribute('data-uri')) {
3057
3061
  let imagesBase;
@@ -5459,7 +5463,18 @@ class SecurityError extends Error {
5459
5463
  function applyBackendTraits(instance) {
5460
5464
  instance._backendTraits = null;
5461
5465
 
5462
- instance.basebackend = function (value = null) {
5466
+ // Install a Ruby-style trait accessor method, but never clobber a flat string
5467
+ // property the converter already declared (convention #2). Overwriting e.g.
5468
+ // `converter.outfilesuffix = '.html'` with a method would silently turn the
5469
+ // author's string into a function; the backend traits stay reachable through
5470
+ // `_getBackendTraits()` instead.
5471
+ const defineTraitAccessor = (name, fn) => {
5472
+ const existing = Object.getOwnPropertyDescriptor(instance, name);
5473
+ if (existing && typeof existing.value !== 'function') return
5474
+ instance[name] = fn;
5475
+ };
5476
+
5477
+ defineTraitAccessor('basebackend', function (value = null) {
5463
5478
  if (value) {
5464
5479
  const traits = (this._backendTraits ??= {});
5465
5480
  traits.basebackend = value;
@@ -5473,19 +5488,19 @@ function applyBackendTraits(instance) {
5473
5488
  return value
5474
5489
  }
5475
5490
  return this._getBackendTraits().basebackend
5476
- };
5477
- instance.filetype = function (value = null) {
5491
+ });
5492
+ defineTraitAccessor('filetype', function (value = null) {
5478
5493
  if (value) return (this._getBackendTraits().filetype = value)
5479
5494
  return this._getBackendTraits().filetype
5480
- };
5481
- instance.htmlsyntax = function (value = null) {
5495
+ });
5496
+ defineTraitAccessor('htmlsyntax', function (value = null) {
5482
5497
  if (value) return (this._getBackendTraits().htmlsyntax = value)
5483
5498
  return this._getBackendTraits().htmlsyntax
5484
- };
5485
- instance.outfilesuffix = function (value = null) {
5499
+ });
5500
+ defineTraitAccessor('outfilesuffix', function (value = null) {
5486
5501
  if (value) return (this._getBackendTraits().outfilesuffix = value)
5487
5502
  return this._getBackendTraits().outfilesuffix
5488
- };
5503
+ });
5489
5504
  instance.supportsTemplates = function (value = true) {
5490
5505
  this._getBackendTraits().supportsTemplates = value;
5491
5506
  };
@@ -5569,7 +5584,9 @@ function normalizeConverter(converter, backend) {
5569
5584
  }
5570
5585
  }
5571
5586
 
5572
- // Apply the BackendTraits mixin so Document can call the standard accessor methods.
5587
+ // Apply the BackendTraits mixin so Document can read traits via
5588
+ // _getBackendTraits(). Flat string properties (convention #2) are preserved:
5589
+ // applyBackendTraits does not overwrite an existing same-named data property.
5573
5590
  applyBackendTraits(converter);
5574
5591
  if (traits) {
5575
5592
  converter._backendTraits = traits;
@@ -16098,7 +16115,7 @@ class Document extends AbstractBlock {
16098
16115
 
16099
16116
  const attrOverrides = this._attributeOverrides;
16100
16117
  attrOverrides.asciidoctor = '';
16101
- attrOverrides['asciidoctor-version'] = '3.0.0.dev'; // matches Ruby VERSION
16118
+ attrOverrides['asciidoctor-version'] = packageJson.version;
16102
16119
 
16103
16120
  const safeModeName = SafeMode.nameForValue(this.safe);
16104
16121
  attrOverrides['safe-mode-name'] = safeModeName;
@@ -16290,16 +16307,16 @@ class Document extends AbstractBlock {
16290
16307
  // attribute (re)assignments are in scope; snapshot and restore the document
16291
16308
  // attributes so the downstream steps and conversion still start from the restored
16292
16309
  // (header) state, matching Ruby's restore_attributes-before-convert invariant.
16310
+ //
16311
+ // This runs in two passes because list item / table cell / dlist text may contain
16312
+ // natural cross-references (e.g. <<Some section title>>) that resolve against the
16313
+ // reftext→id map. Pass 1 substitutes titles and reftexts only, so every section
16314
+ // reftext is known; the map is then built; pass 2 substitutes the block content
16315
+ // text, where resolveId() can now match those natural references.
16293
16316
  const attributesSnapshot = { ...this.attributes };
16294
- await this._resolveAllTexts(this);
16295
- for (const key of Reflect.ownKeys(this.attributes))
16296
- delete this.attributes[key];
16297
- Object.assign(this.attributes, attributesSnapshot);
16298
- // Reset the footnote counter so that body-content footnotes (processed during conversion)
16299
- // start numbering from 1, reproducing Ruby's "out of sequence" quirk: title footnotes are
16300
- // numbered during parsing via apply_title_subs, then the counter restarts for body content.
16301
- delete this.attributes['footnote-number'];
16302
- delete this._counters['footnote-number'];
16317
+ // Pass 1: titles + reftexts (no block content text yet).
16318
+ await this._resolveAllTexts(this, false);
16319
+ this._restoreAttributeSnapshot(attributesSnapshot);
16303
16320
  // Pre-compute reftext for all registered inline anchor nodes.
16304
16321
  for (const ref of Object.values(this.catalog.refs)) {
16305
16322
  if (ref && typeof ref.precomputeReftext === 'function') {
@@ -16308,6 +16325,15 @@ class Document extends AbstractBlock {
16308
16325
  }
16309
16326
  // Build the reftext→id lookup map so that resolveId() is synchronous.
16310
16327
  await this._buildReftextsMap();
16328
+ // Pass 2: list item / table cell / dlist text, now that natural cross-references
16329
+ // can be resolved against the reftext→id map.
16330
+ await this._resolveAllTexts(this, true);
16331
+ this._restoreAttributeSnapshot(attributesSnapshot);
16332
+ // Reset the footnote counter so that body-content footnotes (processed during conversion)
16333
+ // start numbering from 1, reproducing Ruby's "out of sequence" quirk: title footnotes are
16334
+ // numbered during parsing via apply_title_subs, then the counter restarts for body content.
16335
+ delete this.attributes['footnote-number'];
16336
+ delete this._counters['footnote-number'];
16311
16337
 
16312
16338
  this._parsed = true;
16313
16339
  return this
@@ -17362,12 +17388,33 @@ class Document extends AbstractBlock {
17362
17388
  : ['attributes']
17363
17389
  }
17364
17390
 
17391
+ /**
17392
+ * @private
17393
+ * Restore the document attributes to a previously captured snapshot, discarding any
17394
+ * body-level (re)assignments replayed while pre-computing text. Mirrors Ruby's
17395
+ * restore_attributes-before-convert invariant.
17396
+ * @param {Object} snapshot - The attributes snapshot to restore.
17397
+ */
17398
+ _restoreAttributeSnapshot(snapshot) {
17399
+ for (const key of Reflect.ownKeys(this.attributes))
17400
+ delete this.attributes[key];
17401
+ Object.assign(this.attributes, snapshot);
17402
+ }
17403
+
17365
17404
  /**
17366
17405
  * @private
17367
17406
  * Walk the block tree and pre-compute all async text values.
17368
17407
  * Handles titles (AbstractBlock), list item text, table cell text, and reftexts.
17369
- */
17370
- async _resolveAllTexts(block) {
17408
+ *
17409
+ * Runs in two passes (see {@link parse}): with `resolveContent` false only titles and
17410
+ * reftexts are substituted (so the reftext→id map can be built); with `resolveContent`
17411
+ * true the list item / table cell / dlist text is substituted, resolving any natural
17412
+ * cross-references against the now-complete map. Title/reftext pre-computation is
17413
+ * idempotent (results are cached), so running it in both passes is a no-op the second time.
17414
+ * @param {AbstractBlock} block - The block to resolve.
17415
+ * @param {boolean} resolveContent - Whether to substitute list item / cell / dlist text.
17416
+ */
17417
+ async _resolveAllTexts(block, resolveContent) {
17371
17418
  // Replay this block's attribute entries (in document order, since the walk is
17372
17419
  // depth-first pre-order like conversion) so that body-level attribute assignments
17373
17420
  // and reassignments are in scope when the block's — and its descendants' and later
@@ -17393,12 +17440,12 @@ class Document extends AbstractBlock {
17393
17440
  // dlist.blocks is an array of [[term, ...], item_or_null] pairs.
17394
17441
  for (const [terms, item] of block.blocks ?? []) {
17395
17442
  for (const term of terms ?? []) {
17396
- await term.precomputeText?.();
17397
- await this._resolveAllTexts(term);
17443
+ if (resolveContent) await term.precomputeText?.();
17444
+ await this._resolveAllTexts(term, resolveContent);
17398
17445
  }
17399
17446
  if (item) {
17400
- await item.precomputeText?.();
17401
- await this._resolveAllTexts(item);
17447
+ if (resolveContent) await item.precomputeText?.();
17448
+ await this._resolveAllTexts(item, resolveContent);
17402
17449
  }
17403
17450
  }
17404
17451
  } else if (ctx === 'table') {
@@ -17408,14 +17455,14 @@ class Document extends AbstractBlock {
17408
17455
  ...(block.rows?.foot ?? []),
17409
17456
  ]) {
17410
17457
  for (const cell of row) {
17411
- await cell.precomputeText?.();
17458
+ if (resolveContent) await cell.precomputeText?.();
17412
17459
  await cell.precomputeReftext?.();
17413
17460
  }
17414
17461
  }
17415
17462
  } else {
17416
17463
  for (const child of block.blocks ?? []) {
17417
- await child.precomputeText?.();
17418
- await this._resolveAllTexts(child);
17464
+ if (resolveContent) await child.precomputeText?.();
17465
+ await this._resolveAllTexts(child, resolveContent);
17419
17466
  }
17420
17467
  }
17421
17468
  }
@@ -17707,14 +17754,19 @@ class Document extends AbstractBlock {
17707
17754
  let newBasebackend, newFiletype;
17708
17755
 
17709
17756
  if (converter && typeof converter._getBackendTraits === 'function') {
17710
- newBasebackend = converter.basebackend();
17711
- newFiletype = converter.filetype();
17712
- const htmlsyntax = converter.htmlsyntax();
17757
+ // Read the traits object directly rather than the same-named accessor
17758
+ // methods. A user converter that declares flat string properties
17759
+ // (`converter.outfilesuffix = '.html'`, convention #2) keeps them intact,
17760
+ // so callers reading `converter.outfilesuffix` still see the string.
17761
+ const traits = converter._getBackendTraits();
17762
+ newBasebackend = traits.basebackend;
17763
+ newFiletype = traits.filetype;
17764
+ const htmlsyntax = traits.htmlsyntax;
17713
17765
  if (htmlsyntax) attrs.htmlsyntax = htmlsyntax;
17714
17766
  if (init) {
17715
- attrs.outfilesuffix ??= converter.outfilesuffix();
17767
+ attrs.outfilesuffix ??= traits.outfilesuffix;
17716
17768
  } else if (!this.isAttributeLocked('outfilesuffix')) {
17717
- attrs.outfilesuffix = converter.outfilesuffix();
17769
+ attrs.outfilesuffix = traits.outfilesuffix;
17718
17770
  }
17719
17771
  } else if (converter) {
17720
17772
  const traits = deriveBackendTraits(newBackend);
@@ -21025,7 +21077,7 @@ class Html5Converter extends ConverterBase {
21025
21077
  let reproducible;
21026
21078
  if (!(reproducible = node.hasAttribute('reproducible'))) {
21027
21079
  result.push(
21028
- `<meta name="generator" content="Asciidoctor ${node.getAttribute('asciidoctor-version')}"${slash}>`
21080
+ `<meta name="generator" content="Asciidoctor.js ${node.getAttribute('asciidoctor-version')}"${slash}>`
21029
21081
  );
21030
21082
  }
21031
21083
  if (node.hasAttribute('app-name')) {
@@ -21742,7 +21794,9 @@ ${await node.content()}
21742
21794
  const slash = this._voidSlash;
21743
21795
  let img, src;
21744
21796
  if (
21745
- (node.hasAttribute('format', 'svg') || target.includes('.svg')) &&
21797
+ (node.hasAttribute('format', 'svg') ||
21798
+ target.includes('.svg') ||
21799
+ target.startsWith('data:image/svg+xml')) &&
21746
21800
  node.document.safe < SafeMode.SECURE
21747
21801
  ) {
21748
21802
  if (node.hasOption('inline')) {
@@ -22583,7 +22637,9 @@ Your browser does not support the video tag.
22583
22637
  if (node.hasAttribute('title'))
22584
22638
  attrs += ` title="${node.getAttribute('title')}"`;
22585
22639
  if (
22586
- (node.hasAttribute('format', 'svg') || target.includes('.svg')) &&
22640
+ (node.hasAttribute('format', 'svg') ||
22641
+ target.includes('.svg') ||
22642
+ target.startsWith('data:image/svg+xml')) &&
22587
22643
  node.document.safe < SafeMode.SECURE
22588
22644
  ) {
22589
22645
  if (node.hasOption('inline')) {
@@ -22676,12 +22732,17 @@ Your browser does not support the video tag.
22676
22732
 
22677
22733
  // NOTE expose readSvgContents for Bespoke converter
22678
22734
  async readSvgContents(node, target) {
22679
- let svg = await node.readContents(target, {
22680
- start: node.document.getAttribute('imagesdir'),
22681
- normalize: true,
22682
- label: 'SVG',
22683
- warnIfEmpty: true,
22684
- });
22735
+ // A data-URI carries the SVG in the target itself (e.g. an embedded diagram
22736
+ // produced with `:data-uri:`), so decode it directly rather than trying to
22737
+ // read it as a file or remote URI.
22738
+ let svg = target.startsWith('data:')
22739
+ ? this._decodeDataUri(target)
22740
+ : await node.readContents(target, {
22741
+ start: node.document.getAttribute('imagesdir'),
22742
+ normalize: true,
22743
+ label: 'SVG',
22744
+ warnIfEmpty: true,
22745
+ });
22685
22746
  if (!svg) return null
22686
22747
  if (!svg.startsWith('<svg')) svg = svg.replace(SvgPreambleRx, '');
22687
22748
  // Fix incomplete SVG start tag (missing closing >) by inserting > before the first child element.
@@ -22713,6 +22774,27 @@ Your browser does not support the video tag.
22713
22774
 
22714
22775
  // ── Private helpers ─────────────────────────────────────────────────────────
22715
22776
 
22777
+ /**
22778
+ * Decode an inline `data:` URI to its text contents (e.g. an SVG document) so
22779
+ * an image whose target is a data-URI can be embedded inline. Supports both
22780
+ * Base64 (`;base64,`) and percent-encoded payloads. Returns null when the
22781
+ * payload is missing.
22782
+ *
22783
+ * @internal
22784
+ * @private
22785
+ */
22786
+ _decodeDataUri(target) {
22787
+ const comma = target.indexOf(',');
22788
+ if (comma === -1) return null
22789
+ const meta = target.slice('data:'.length, comma);
22790
+ const data = target.slice(comma + 1);
22791
+ if (/;base64\b/i.test(meta)) {
22792
+ const bytes = Uint8Array.from(atob(data), (c) => c.charCodeAt(0));
22793
+ return new TextDecoder('utf-8').decode(bytes)
22794
+ }
22795
+ return decodeURIComponent(data)
22796
+ }
22797
+
22716
22798
  /**
22717
22799
  * @internal
22718
22800
  * @private
@@ -22955,8 +23037,7 @@ const composite = /*#__PURE__*/Object.freeze({
22955
23037
  // - Ruby File.directory?/File.file? → fsp.stat().isDirectory()/isFile() (async).
22956
23038
  // - Ruby File.basename / File.expand_path → node:path basename / resolve.
22957
23039
  // - PathResolver.system_path → pathResolver.systemPath().
22958
- // - template.render(node, opts) → template.render({node, opts, helpers}).
22959
- // - Helpers loaded from helpers.js/helpers.cjs; can export configure(enginesContext).
23040
+ // - template.render(node, opts) → template.render({node, opts, helpers}).// - Helpers loaded from helpers.js/helpers.cjs/helpers.mjs; can export configure(enginesContext).
22960
23041
  // - Custom engines registered via TemplateConverter.TemplateEngine.register(ext, adapter).
22961
23042
  // - Thread safety / Mutex → not needed (single-threaded JS).
22962
23043
  // - load_eruby / eRuby support → not applicable in JS environment.
@@ -23230,7 +23311,11 @@ class TemplateConverter extends ConverterBase {
23230
23311
  if (!(await _isFile(file))) continue
23231
23312
 
23232
23313
  // Collect helpers separately; process after all templates.
23233
- if (basename === 'helpers.js' || basename === 'helpers.cjs') {
23314
+ if (
23315
+ basename === 'helpers.js' ||
23316
+ basename === 'helpers.cjs' ||
23317
+ basename === 'helpers.mjs'
23318
+ ) {
23234
23319
  helpersFile = file;
23235
23320
  continue
23236
23321
  }
@@ -23298,8 +23383,8 @@ class TemplateConverter extends ConverterBase {
23298
23383
  const pugOpts = { ...(this.engineOptions.pug ?? {}), filename: file };
23299
23384
  const renderFn = pug.compileFile(file, pugOpts);
23300
23385
  template = { render: renderFn, file };
23301
- } else if (ext === 'js' || ext === 'cjs') {
23302
- const renderFn = _require(file);
23386
+ } else if (ext === 'js' || ext === 'cjs' || ext === 'mjs') {
23387
+ const renderFn = await _loadModule(file, ext);
23303
23388
  template = { render: renderFn, file };
23304
23389
  } else {
23305
23390
  // Fall back to custom TemplateEngine registry.
@@ -23314,16 +23399,20 @@ class TemplateConverter extends ConverterBase {
23314
23399
  result[name] = template;
23315
23400
  }
23316
23401
 
23317
- // Load helpers if found (or if a helpers.js exists at the top of the dir).
23318
- const fallbackHelpers = path.join(templateDir, 'helpers.js');
23319
- const fallbackHelpersCjs = path.join(templateDir, 'helpers.cjs');
23320
- if (!helpersFile && (await _isFile(fallbackHelpers)))
23321
- helpersFile = fallbackHelpers;
23322
- if (!helpersFile && (await _isFile(fallbackHelpersCjs)))
23323
- helpersFile = fallbackHelpersCjs;
23402
+ // Load helpers if found (or if a helpers file exists at the top of the dir).
23403
+ if (!helpersFile) {
23404
+ for (const ext of ['js', 'cjs', 'mjs']) {
23405
+ const fallback = path.join(templateDir, `helpers.${ext}`);
23406
+ if (await _isFile(fallback)) {
23407
+ helpersFile = fallback;
23408
+ break
23409
+ }
23410
+ }
23411
+ }
23324
23412
 
23325
23413
  if (helpersFile) {
23326
- const ctx = _require(helpersFile);
23414
+ const helpersExt = helpersFile.slice(helpersFile.lastIndexOf('.') + 1);
23415
+ const ctx = await _loadModule(helpersFile, helpersExt);
23327
23416
  if (typeof ctx.configure === 'function') ctx.configure(enginesCtx);
23328
23417
  result['helpers.js'] = { file: helpersFile, ctx };
23329
23418
  }
@@ -23351,6 +23440,32 @@ class TemplateConverter extends ConverterBase {
23351
23440
 
23352
23441
  // ── Helpers ───────────────────────────────────────────────────────────────────
23353
23442
 
23443
+ /**
23444
+ * Load a JavaScript module (template render function or helpers object) by
23445
+ * file extension.
23446
+ *
23447
+ * - `.cjs` files are always CommonJS: require() returns `module.exports`.
23448
+ * - `.js` and `.mjs` files are loaded with a dynamic `import()`, which Node
23449
+ * resolves as either ES modules or CommonJS. The export is taken from the
23450
+ * module's default export (`export default …` in ESM, `module.exports = …`
23451
+ * in CommonJS via interop); when there is no default export the module
23452
+ * namespace is returned instead, so ESM helpers exposing named exports
23453
+ * (`export function configure() {}`) work too.
23454
+ * @param {string} file - absolute path to the module file
23455
+ * @param {string} ext - file extension without the leading dot ('js' | 'cjs' | 'mjs')
23456
+ * @returns {Promise<*>} the module's default export, or its namespace
23457
+ * @internal
23458
+ * @private
23459
+ */
23460
+ async function _loadModule(file, ext) {
23461
+ if (ext === 'cjs') {
23462
+ const mod = _require(file);
23463
+ return mod?.default ? mod.default : mod
23464
+ }
23465
+ const mod = await import(node_url.pathToFileURL(file).href);
23466
+ return mod.default ?? mod
23467
+ }
23468
+
23354
23469
  /**
23355
23470
  * @internal
23356
23471
  * @private
@@ -24539,7 +24654,7 @@ class ManPageConverter extends ConverterBase {
24539
24654
  `'\\" t
24540
24655
  .\\" Title: ${mantitle}
24541
24656
  .\\" Author: ${node.hasAttribute('authors') ? node.getAttribute('authors') : '[see the "AUTHOR(S)" section]'}
24542
- .\\" Generator: Asciidoctor ${node.getAttribute('asciidoctor-version')}`,
24657
+ .\\" Generator: Asciidoctor.js ${node.getAttribute('asciidoctor-version')}`,
24543
24658
  ];
24544
24659
 
24545
24660
  if (docdate) result.push(`.\\" Date: ${docdate}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@asciidoctor/core",
3
- "version": "4.0.1",
3
+ "version": "4.0.2",
4
4
  "description": "Asciidoctor.js: AsciiDoc in JavaScript powered by Asciidoctor",
5
5
  "type": "module",
6
6
  "main": "build/node/index.cjs",
@@ -461,9 +461,10 @@ export class AbstractNode {
461
461
  * Construct a URI reference or data URI to the target image.
462
462
  *
463
463
  * If the target image is already a URI it is left untouched (unless data-uri
464
- * conversion is requested). The image is resolved relative to the directory
465
- * named by assetDirKey. When data-uri is enabled and the safe level permits,
466
- * the image is embedded as a Base64 data URI.
464
+ * conversion is requested). If the target image is a data URI, then it is
465
+ * already an embedded image, so it is returned as-is. The image is resolved
466
+ * relative to the directory named by assetDirKey. When data-uri is enabled and
467
+ * the safe level permits, the image is embedded as a Base64 data URI.
467
468
  *
468
469
  * NOTE: When the document has both 'data-uri' and 'allow-uri-read' enabled
469
470
  * and the resolved image URL is a remote URI, this method returns a Promise
@@ -474,6 +475,8 @@ export class AbstractNode {
474
475
  * @returns {Promise<string>} a Promise resolving to a String reference or data URI.
475
476
  */
476
477
  async imageUri(targetImage, assetDirKey = 'imagesdir') {
478
+ // A data URI is already an embedded image, so use it as-is rather than reading or re-encoding it.
479
+ if (targetImage.startsWith('data:')) return targetImage
477
480
  const doc = this.document
478
481
  if (doc.safe < SafeMode.SECURE && doc.hasAttribute('data-uri')) {
479
482
  let imagesBase
@@ -116,7 +116,7 @@ export default class Html5Converter extends ConverterBase {
116
116
  let reproducible
117
117
  if (!(reproducible = node.hasAttribute('reproducible'))) {
118
118
  result.push(
119
- `<meta name="generator" content="Asciidoctor ${node.getAttribute('asciidoctor-version')}"${slash}>`
119
+ `<meta name="generator" content="Asciidoctor.js ${node.getAttribute('asciidoctor-version')}"${slash}>`
120
120
  )
121
121
  }
122
122
  if (node.hasAttribute('app-name')) {
@@ -833,7 +833,9 @@ ${await node.content()}
833
833
  const slash = this._voidSlash
834
834
  let img, src
835
835
  if (
836
- (node.hasAttribute('format', 'svg') || target.includes('.svg')) &&
836
+ (node.hasAttribute('format', 'svg') ||
837
+ target.includes('.svg') ||
838
+ target.startsWith('data:image/svg+xml')) &&
837
839
  node.document.safe < SafeMode.SECURE
838
840
  ) {
839
841
  if (node.hasOption('inline')) {
@@ -1674,7 +1676,9 @@ Your browser does not support the video tag.
1674
1676
  if (node.hasAttribute('title'))
1675
1677
  attrs += ` title="${node.getAttribute('title')}"`
1676
1678
  if (
1677
- (node.hasAttribute('format', 'svg') || target.includes('.svg')) &&
1679
+ (node.hasAttribute('format', 'svg') ||
1680
+ target.includes('.svg') ||
1681
+ target.startsWith('data:image/svg+xml')) &&
1678
1682
  node.document.safe < SafeMode.SECURE
1679
1683
  ) {
1680
1684
  if (node.hasOption('inline')) {
@@ -1767,12 +1771,17 @@ Your browser does not support the video tag.
1767
1771
 
1768
1772
  // NOTE expose readSvgContents for Bespoke converter
1769
1773
  async readSvgContents(node, target) {
1770
- let svg = await node.readContents(target, {
1771
- start: node.document.getAttribute('imagesdir'),
1772
- normalize: true,
1773
- label: 'SVG',
1774
- warnIfEmpty: true,
1775
- })
1774
+ // A data-URI carries the SVG in the target itself (e.g. an embedded diagram
1775
+ // produced with `:data-uri:`), so decode it directly rather than trying to
1776
+ // read it as a file or remote URI.
1777
+ let svg = target.startsWith('data:')
1778
+ ? this._decodeDataUri(target)
1779
+ : await node.readContents(target, {
1780
+ start: node.document.getAttribute('imagesdir'),
1781
+ normalize: true,
1782
+ label: 'SVG',
1783
+ warnIfEmpty: true,
1784
+ })
1776
1785
  if (!svg) return null
1777
1786
  if (!svg.startsWith('<svg')) svg = svg.replace(SvgPreambleRx, '')
1778
1787
  // Fix incomplete SVG start tag (missing closing >) by inserting > before the first child element.
@@ -1804,6 +1813,27 @@ Your browser does not support the video tag.
1804
1813
 
1805
1814
  // ── Private helpers ─────────────────────────────────────────────────────────
1806
1815
 
1816
+ /**
1817
+ * Decode an inline `data:` URI to its text contents (e.g. an SVG document) so
1818
+ * an image whose target is a data-URI can be embedded inline. Supports both
1819
+ * Base64 (`;base64,`) and percent-encoded payloads. Returns null when the
1820
+ * payload is missing.
1821
+ *
1822
+ * @internal
1823
+ * @private
1824
+ */
1825
+ _decodeDataUri(target) {
1826
+ const comma = target.indexOf(',')
1827
+ if (comma === -1) return null
1828
+ const meta = target.slice('data:'.length, comma)
1829
+ const data = target.slice(comma + 1)
1830
+ if (/;base64\b/i.test(meta)) {
1831
+ const bytes = Uint8Array.from(atob(data), (c) => c.charCodeAt(0))
1832
+ return new TextDecoder('utf-8').decode(bytes)
1833
+ }
1834
+ return decodeURIComponent(data)
1835
+ }
1836
+
1807
1837
  /**
1808
1838
  * @internal
1809
1839
  * @private
@@ -108,7 +108,7 @@ export default class ManPageConverter extends ConverterBase {
108
108
  `'\\" t
109
109
  .\\" Title: ${mantitle}
110
110
  .\\" Author: ${node.hasAttribute('authors') ? node.getAttribute('authors') : '[see the "AUTHOR(S)" section]'}
111
- .\\" Generator: Asciidoctor ${node.getAttribute('asciidoctor-version')}`,
111
+ .\\" Generator: Asciidoctor.js ${node.getAttribute('asciidoctor-version')}`,
112
112
  ]
113
113
 
114
114
  if (docdate) result.push(`.\\" Date: ${docdate}`)
@@ -8,8 +8,7 @@
8
8
  // - Ruby File.directory?/File.file? → fsp.stat().isDirectory()/isFile() (async).
9
9
  // - Ruby File.basename / File.expand_path → node:path basename / resolve.
10
10
  // - PathResolver.system_path → pathResolver.systemPath().
11
- // - template.render(node, opts) → template.render({node, opts, helpers}).
12
- // - Helpers loaded from helpers.js/helpers.cjs; can export configure(enginesContext).
11
+ // - template.render(node, opts) → template.render({node, opts, helpers}).// - Helpers loaded from helpers.js/helpers.cjs/helpers.mjs; can export configure(enginesContext).
13
12
  // - Custom engines registered via TemplateConverter.TemplateEngine.register(ext, adapter).
14
13
  // - Thread safety / Mutex → not needed (single-threaded JS).
15
14
  // - load_eruby / eRuby support → not applicable in JS environment.
@@ -19,6 +18,7 @@
19
18
  import { ConverterBase } from '../converter.js'
20
19
  import { PathResolver } from '../path_resolver.js'
21
20
  import { createRequire } from 'node:module'
21
+ import { pathToFileURL } from 'node:url'
22
22
  import { promises as fsp } from 'node:fs'
23
23
  import path from 'node:path'
24
24
 
@@ -288,7 +288,11 @@ export class TemplateConverter extends ConverterBase {
288
288
  if (!(await _isFile(file))) continue
289
289
 
290
290
  // Collect helpers separately; process after all templates.
291
- if (basename === 'helpers.js' || basename === 'helpers.cjs') {
291
+ if (
292
+ basename === 'helpers.js' ||
293
+ basename === 'helpers.cjs' ||
294
+ basename === 'helpers.mjs'
295
+ ) {
292
296
  helpersFile = file
293
297
  continue
294
298
  }
@@ -356,8 +360,8 @@ export class TemplateConverter extends ConverterBase {
356
360
  const pugOpts = { ...(this.engineOptions.pug ?? {}), filename: file }
357
361
  const renderFn = pug.compileFile(file, pugOpts)
358
362
  template = { render: renderFn, file }
359
- } else if (ext === 'js' || ext === 'cjs') {
360
- const renderFn = _require(file)
363
+ } else if (ext === 'js' || ext === 'cjs' || ext === 'mjs') {
364
+ const renderFn = await _loadModule(file, ext)
361
365
  template = { render: renderFn, file }
362
366
  } else {
363
367
  // Fall back to custom TemplateEngine registry.
@@ -372,16 +376,20 @@ export class TemplateConverter extends ConverterBase {
372
376
  result[name] = template
373
377
  }
374
378
 
375
- // Load helpers if found (or if a helpers.js exists at the top of the dir).
376
- const fallbackHelpers = path.join(templateDir, 'helpers.js')
377
- const fallbackHelpersCjs = path.join(templateDir, 'helpers.cjs')
378
- if (!helpersFile && (await _isFile(fallbackHelpers)))
379
- helpersFile = fallbackHelpers
380
- if (!helpersFile && (await _isFile(fallbackHelpersCjs)))
381
- helpersFile = fallbackHelpersCjs
379
+ // Load helpers if found (or if a helpers file exists at the top of the dir).
380
+ if (!helpersFile) {
381
+ for (const ext of ['js', 'cjs', 'mjs']) {
382
+ const fallback = path.join(templateDir, `helpers.${ext}`)
383
+ if (await _isFile(fallback)) {
384
+ helpersFile = fallback
385
+ break
386
+ }
387
+ }
388
+ }
382
389
 
383
390
  if (helpersFile) {
384
- const ctx = _require(helpersFile)
391
+ const helpersExt = helpersFile.slice(helpersFile.lastIndexOf('.') + 1)
392
+ const ctx = await _loadModule(helpersFile, helpersExt)
385
393
  if (typeof ctx.configure === 'function') ctx.configure(enginesCtx)
386
394
  result['helpers.js'] = { file: helpersFile, ctx }
387
395
  }
@@ -409,6 +417,32 @@ export class TemplateConverter extends ConverterBase {
409
417
 
410
418
  // ── Helpers ───────────────────────────────────────────────────────────────────
411
419
 
420
+ /**
421
+ * Load a JavaScript module (template render function or helpers object) by
422
+ * file extension.
423
+ *
424
+ * - `.cjs` files are always CommonJS: require() returns `module.exports`.
425
+ * - `.js` and `.mjs` files are loaded with a dynamic `import()`, which Node
426
+ * resolves as either ES modules or CommonJS. The export is taken from the
427
+ * module's default export (`export default …` in ESM, `module.exports = …`
428
+ * in CommonJS via interop); when there is no default export the module
429
+ * namespace is returned instead, so ESM helpers exposing named exports
430
+ * (`export function configure() {}`) work too.
431
+ * @param {string} file - absolute path to the module file
432
+ * @param {string} ext - file extension without the leading dot ('js' | 'cjs' | 'mjs')
433
+ * @returns {Promise<*>} the module's default export, or its namespace
434
+ * @internal
435
+ * @private
436
+ */
437
+ async function _loadModule(file, ext) {
438
+ if (ext === 'cjs') {
439
+ const mod = _require(file)
440
+ return mod?.default ? mod.default : mod
441
+ }
442
+ const mod = await import(pathToFileURL(file).href)
443
+ return mod.default ?? mod
444
+ }
445
+
412
446
  /**
413
447
  * @internal
414
448
  * @private
package/src/converter.js CHANGED
@@ -26,7 +26,18 @@ import { TrailingDigitsRx } from './rx.js'
26
26
  export function applyBackendTraits(instance) {
27
27
  instance._backendTraits = null
28
28
 
29
- instance.basebackend = function (value = null) {
29
+ // Install a Ruby-style trait accessor method, but never clobber a flat string
30
+ // property the converter already declared (convention #2). Overwriting e.g.
31
+ // `converter.outfilesuffix = '.html'` with a method would silently turn the
32
+ // author's string into a function; the backend traits stay reachable through
33
+ // `_getBackendTraits()` instead.
34
+ const defineTraitAccessor = (name, fn) => {
35
+ const existing = Object.getOwnPropertyDescriptor(instance, name)
36
+ if (existing && typeof existing.value !== 'function') return
37
+ instance[name] = fn
38
+ }
39
+
40
+ defineTraitAccessor('basebackend', function (value = null) {
30
41
  if (value) {
31
42
  const traits = (this._backendTraits ??= {})
32
43
  traits.basebackend = value
@@ -40,19 +51,19 @@ export function applyBackendTraits(instance) {
40
51
  return value
41
52
  }
42
53
  return this._getBackendTraits().basebackend
43
- }
44
- instance.filetype = function (value = null) {
54
+ })
55
+ defineTraitAccessor('filetype', function (value = null) {
45
56
  if (value) return (this._getBackendTraits().filetype = value)
46
57
  return this._getBackendTraits().filetype
47
- }
48
- instance.htmlsyntax = function (value = null) {
58
+ })
59
+ defineTraitAccessor('htmlsyntax', function (value = null) {
49
60
  if (value) return (this._getBackendTraits().htmlsyntax = value)
50
61
  return this._getBackendTraits().htmlsyntax
51
- }
52
- instance.outfilesuffix = function (value = null) {
62
+ })
63
+ defineTraitAccessor('outfilesuffix', function (value = null) {
53
64
  if (value) return (this._getBackendTraits().outfilesuffix = value)
54
65
  return this._getBackendTraits().outfilesuffix
55
- }
66
+ })
56
67
  instance.supportsTemplates = function (value = true) {
57
68
  this._getBackendTraits().supportsTemplates = value
58
69
  }
@@ -136,7 +147,9 @@ export function normalizeConverter(converter, backend) {
136
147
  }
137
148
  }
138
149
 
139
- // Apply the BackendTraits mixin so Document can call the standard accessor methods.
150
+ // Apply the BackendTraits mixin so Document can read traits via
151
+ // _getBackendTraits(). Flat string properties (convention #2) are preserved:
152
+ // applyBackendTraits does not overwrite an existing same-named data property.
140
153
  applyBackendTraits(converter)
141
154
  if (traits) {
142
155
  converter._backendTraits = traits
package/src/document.js CHANGED
@@ -12,6 +12,7 @@
12
12
  // - Mutex / thread-safety not applicable in single-threaded JS.
13
13
  // - `instance_variable_get :@attribute_overrides` → direct property access.
14
14
 
15
+ import packageJson from '../package.json' with { type: 'json' }
15
16
  import { AbstractBlock } from './abstract_block.js'
16
17
  import { Section } from './section.js'
17
18
  import { Inline } from './inline.js'
@@ -400,7 +401,7 @@ export class Document extends AbstractBlock {
400
401
 
401
402
  const attrOverrides = this._attributeOverrides
402
403
  attrOverrides.asciidoctor = ''
403
- attrOverrides['asciidoctor-version'] = '3.0.0.dev' // matches Ruby VERSION
404
+ attrOverrides['asciidoctor-version'] = packageJson.version
404
405
 
405
406
  const safeModeName = SafeMode.nameForValue(this.safe)
406
407
  attrOverrides['safe-mode-name'] = safeModeName
@@ -592,16 +593,16 @@ export class Document extends AbstractBlock {
592
593
  // attribute (re)assignments are in scope; snapshot and restore the document
593
594
  // attributes so the downstream steps and conversion still start from the restored
594
595
  // (header) state, matching Ruby's restore_attributes-before-convert invariant.
596
+ //
597
+ // This runs in two passes because list item / table cell / dlist text may contain
598
+ // natural cross-references (e.g. <<Some section title>>) that resolve against the
599
+ // reftext→id map. Pass 1 substitutes titles and reftexts only, so every section
600
+ // reftext is known; the map is then built; pass 2 substitutes the block content
601
+ // text, where resolveId() can now match those natural references.
595
602
  const attributesSnapshot = { ...this.attributes }
596
- await this._resolveAllTexts(this)
597
- for (const key of Reflect.ownKeys(this.attributes))
598
- delete this.attributes[key]
599
- Object.assign(this.attributes, attributesSnapshot)
600
- // Reset the footnote counter so that body-content footnotes (processed during conversion)
601
- // start numbering from 1, reproducing Ruby's "out of sequence" quirk: title footnotes are
602
- // numbered during parsing via apply_title_subs, then the counter restarts for body content.
603
- delete this.attributes['footnote-number']
604
- delete this._counters['footnote-number']
603
+ // Pass 1: titles + reftexts (no block content text yet).
604
+ await this._resolveAllTexts(this, false)
605
+ this._restoreAttributeSnapshot(attributesSnapshot)
605
606
  // Pre-compute reftext for all registered inline anchor nodes.
606
607
  for (const ref of Object.values(this.catalog.refs)) {
607
608
  if (ref && typeof ref.precomputeReftext === 'function') {
@@ -610,6 +611,15 @@ export class Document extends AbstractBlock {
610
611
  }
611
612
  // Build the reftext→id lookup map so that resolveId() is synchronous.
612
613
  await this._buildReftextsMap()
614
+ // Pass 2: list item / table cell / dlist text, now that natural cross-references
615
+ // can be resolved against the reftext→id map.
616
+ await this._resolveAllTexts(this, true)
617
+ this._restoreAttributeSnapshot(attributesSnapshot)
618
+ // Reset the footnote counter so that body-content footnotes (processed during conversion)
619
+ // start numbering from 1, reproducing Ruby's "out of sequence" quirk: title footnotes are
620
+ // numbered during parsing via apply_title_subs, then the counter restarts for body content.
621
+ delete this.attributes['footnote-number']
622
+ delete this._counters['footnote-number']
613
623
 
614
624
  this._parsed = true
615
625
  return this
@@ -1664,12 +1674,33 @@ export class Document extends AbstractBlock {
1664
1674
  : ['attributes']
1665
1675
  }
1666
1676
 
1677
+ /**
1678
+ * @private
1679
+ * Restore the document attributes to a previously captured snapshot, discarding any
1680
+ * body-level (re)assignments replayed while pre-computing text. Mirrors Ruby's
1681
+ * restore_attributes-before-convert invariant.
1682
+ * @param {Object} snapshot - The attributes snapshot to restore.
1683
+ */
1684
+ _restoreAttributeSnapshot(snapshot) {
1685
+ for (const key of Reflect.ownKeys(this.attributes))
1686
+ delete this.attributes[key]
1687
+ Object.assign(this.attributes, snapshot)
1688
+ }
1689
+
1667
1690
  /**
1668
1691
  * @private
1669
1692
  * Walk the block tree and pre-compute all async text values.
1670
1693
  * Handles titles (AbstractBlock), list item text, table cell text, and reftexts.
1694
+ *
1695
+ * Runs in two passes (see {@link parse}): with `resolveContent` false only titles and
1696
+ * reftexts are substituted (so the reftext→id map can be built); with `resolveContent`
1697
+ * true the list item / table cell / dlist text is substituted, resolving any natural
1698
+ * cross-references against the now-complete map. Title/reftext pre-computation is
1699
+ * idempotent (results are cached), so running it in both passes is a no-op the second time.
1700
+ * @param {AbstractBlock} block - The block to resolve.
1701
+ * @param {boolean} resolveContent - Whether to substitute list item / cell / dlist text.
1671
1702
  */
1672
- async _resolveAllTexts(block) {
1703
+ async _resolveAllTexts(block, resolveContent) {
1673
1704
  // Replay this block's attribute entries (in document order, since the walk is
1674
1705
  // depth-first pre-order like conversion) so that body-level attribute assignments
1675
1706
  // and reassignments are in scope when the block's — and its descendants' and later
@@ -1695,12 +1726,12 @@ export class Document extends AbstractBlock {
1695
1726
  // dlist.blocks is an array of [[term, ...], item_or_null] pairs.
1696
1727
  for (const [terms, item] of block.blocks ?? []) {
1697
1728
  for (const term of terms ?? []) {
1698
- await term.precomputeText?.()
1699
- await this._resolveAllTexts(term)
1729
+ if (resolveContent) await term.precomputeText?.()
1730
+ await this._resolveAllTexts(term, resolveContent)
1700
1731
  }
1701
1732
  if (item) {
1702
- await item.precomputeText?.()
1703
- await this._resolveAllTexts(item)
1733
+ if (resolveContent) await item.precomputeText?.()
1734
+ await this._resolveAllTexts(item, resolveContent)
1704
1735
  }
1705
1736
  }
1706
1737
  } else if (ctx === 'table') {
@@ -1710,14 +1741,14 @@ export class Document extends AbstractBlock {
1710
1741
  ...(block.rows?.foot ?? []),
1711
1742
  ]) {
1712
1743
  for (const cell of row) {
1713
- await cell.precomputeText?.()
1744
+ if (resolveContent) await cell.precomputeText?.()
1714
1745
  await cell.precomputeReftext?.()
1715
1746
  }
1716
1747
  }
1717
1748
  } else {
1718
1749
  for (const child of block.blocks ?? []) {
1719
- await child.precomputeText?.()
1720
- await this._resolveAllTexts(child)
1750
+ if (resolveContent) await child.precomputeText?.()
1751
+ await this._resolveAllTexts(child, resolveContent)
1721
1752
  }
1722
1753
  }
1723
1754
  }
@@ -2009,14 +2040,19 @@ export class Document extends AbstractBlock {
2009
2040
  let newBasebackend, newFiletype
2010
2041
 
2011
2042
  if (converter && typeof converter._getBackendTraits === 'function') {
2012
- newBasebackend = converter.basebackend()
2013
- newFiletype = converter.filetype()
2014
- const htmlsyntax = converter.htmlsyntax()
2043
+ // Read the traits object directly rather than the same-named accessor
2044
+ // methods. A user converter that declares flat string properties
2045
+ // (`converter.outfilesuffix = '.html'`, convention #2) keeps them intact,
2046
+ // so callers reading `converter.outfilesuffix` still see the string.
2047
+ const traits = converter._getBackendTraits()
2048
+ newBasebackend = traits.basebackend
2049
+ newFiletype = traits.filetype
2050
+ const htmlsyntax = traits.htmlsyntax
2015
2051
  if (htmlsyntax) attrs.htmlsyntax = htmlsyntax
2016
2052
  if (init) {
2017
- attrs.outfilesuffix ??= converter.outfilesuffix()
2053
+ attrs.outfilesuffix ??= traits.outfilesuffix
2018
2054
  } else if (!this.isAttributeLocked('outfilesuffix')) {
2019
- attrs.outfilesuffix = converter.outfilesuffix()
2055
+ attrs.outfilesuffix = traits.outfilesuffix
2020
2056
  }
2021
2057
  } else if (converter) {
2022
2058
  const traits = deriveBackendTraits(newBackend)
@@ -234,9 +234,10 @@ export abstract class AbstractNode {
234
234
  * Construct a URI reference or data URI to the target image.
235
235
  *
236
236
  * If the target image is already a URI it is left untouched (unless data-uri
237
- * conversion is requested). The image is resolved relative to the directory
238
- * named by assetDirKey. When data-uri is enabled and the safe level permits,
239
- * the image is embedded as a Base64 data URI.
237
+ * conversion is requested). If the target image is a data URI, then it is
238
+ * already an embedded image, so it is returned as-is. The image is resolved
239
+ * relative to the directory named by assetDirKey. When data-uri is enabled and
240
+ * the safe level permits, the image is embedded as a Base64 data URI.
240
241
  *
241
242
  * NOTE: When the document has both 'data-uri' and 'allow-uri-read' enabled
242
243
  * and the resolved image URL is a remote URI, this method returns a Promise
@@ -469,10 +469,26 @@ export class Document extends AbstractBlock<string> {
469
469
  * @returns {string[]} The list of substitutions to apply.
470
470
  */
471
471
  private _resolveDocinfoSubs;
472
+ /**
473
+ * @private
474
+ * Restore the document attributes to a previously captured snapshot, discarding any
475
+ * body-level (re)assignments replayed while pre-computing text. Mirrors Ruby's
476
+ * restore_attributes-before-convert invariant.
477
+ * @param {Object} snapshot - The attributes snapshot to restore.
478
+ */
479
+ private _restoreAttributeSnapshot;
472
480
  /**
473
481
  * @private
474
482
  * Walk the block tree and pre-compute all async text values.
475
483
  * Handles titles (AbstractBlock), list item text, table cell text, and reftexts.
484
+ *
485
+ * Runs in two passes (see {@link parse}): with `resolveContent` false only titles and
486
+ * reftexts are substituted (so the reftext→id map can be built); with `resolveContent`
487
+ * true the list item / table cell / dlist text is substituted, resolving any natural
488
+ * cross-references against the now-complete map. Title/reftext pre-computation is
489
+ * idempotent (results are cached), so running it in both passes is a no-op the second time.
490
+ * @param {AbstractBlock} block - The block to resolve.
491
+ * @param {boolean} resolveContent - Whether to substitute list item / cell / dlist text.
476
492
  */
477
493
  private _resolveAllTexts;
478
494
  /**