@asciidoctor/core 4.0.0-alpha.6 → 4.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,7 +5,7 @@ const node_fs = require('node:fs');
5
5
  const path = require('node:path');
6
6
 
7
7
  var _documentCurrentScript = typeof document !== 'undefined' ? document.currentScript : null;
8
- var version = "4.0.0-alpha.6";
8
+ var version = "4.0.1";
9
9
  const packageJson = {
10
10
  version: version};
11
11
 
@@ -1752,14 +1752,23 @@ Logger.BasicFormatter = class {
1752
1752
 
1753
1753
  Logger.AutoFormattingMessage = {
1754
1754
  /**
1755
- * Attach auto-formatting to any plain object carrying { text, source_location }.
1756
- * @param {{text: string, source_location?: string}} obj
1755
+ * Attach auto-formatting to any plain object carrying
1756
+ * { text, source_location, include_location }.
1757
+ *
1758
+ * The location(s) are rendered only by inspect()/toString() (used when a
1759
+ * stderr Logger formats the line); the structured `source_location` /
1760
+ * `include_location` remain on the object so a MemoryLogger can record them
1761
+ * on the resulting LogMessage without duplicating them inside `text`.
1762
+ * @param {{text: string, source_location?: any, include_location?: any}} obj
1757
1763
  * @returns {typeof obj} The same object with inspect() and toString() added.
1758
1764
  */
1759
1765
  attach(obj) {
1760
1766
  obj.inspect = function () {
1761
1767
  const sloc = this.source_location;
1762
- return sloc ? `${sloc}: ${this.text}` : this.text
1768
+ const iloc = this.include_location;
1769
+ let text = sloc ? `${sloc}: ${this.text}` : this.text;
1770
+ if (iloc) text += ` (${iloc})`;
1771
+ return text
1763
1772
  };
1764
1773
  obj.toString = obj.inspect;
1765
1774
  return obj
@@ -1770,27 +1779,45 @@ Logger.AutoFormattingMessage = {
1770
1779
 
1771
1780
  /** Wrapper stored by MemoryLogger; provides getSeverity/getText/getSourceLocation. */
1772
1781
  class LogMessage {
1782
+ /**
1783
+ * @param {string} severity - Severity label, e.g. 'ERROR'.
1784
+ * @param {string|{text: string, source_location?: import('./reader.js').Cursor}|null} message
1785
+ */
1773
1786
  constructor(severity, message) {
1774
1787
  this.message = message;
1788
+ /** @type {string} */
1775
1789
  this.severity = severity; // string label, e.g. 'ERROR'
1776
1790
  // AutoFormattingMessage objects carry { text, source_location }
1777
1791
  if (message !== null && typeof message === 'object' && 'text' in message) {
1778
- this._text = message.text;
1779
- this._sourceLocation = message.source_location ?? null;
1792
+ /** @type {string} */
1793
+ this.text = message.text;
1794
+ /** @type {import('./reader.js').Cursor|null} */
1795
+ this.sourceLocation = message.source_location ?? null;
1780
1796
  } else {
1781
- this._text = message != null ? String(message) : '';
1782
- this._sourceLocation = null;
1797
+ this.text = message != null ? String(message) : '';
1798
+ this.sourceLocation = null;
1783
1799
  }
1784
1800
  }
1785
1801
 
1802
+ /**
1803
+ * @returns {string} The severity label, e.g. 'ERROR'.
1804
+ */
1786
1805
  getSeverity() {
1787
1806
  return this.severity
1788
1807
  }
1808
+
1809
+ /**
1810
+ * @returns {string} The message text.
1811
+ */
1789
1812
  getText() {
1790
- return this._text
1813
+ return this.text
1791
1814
  }
1815
+
1816
+ /**
1817
+ * @returns {import('./reader.js').Cursor|undefined} The source location, if any.
1818
+ */
1792
1819
  getSourceLocation() {
1793
- return this._sourceLocation ?? undefined
1820
+ return this.sourceLocation ?? undefined
1794
1821
  }
1795
1822
  }
1796
1823
 
@@ -1803,6 +1830,7 @@ class MemoryLogger {
1803
1830
  // matching Ruby's MemoryLogger (level: UNKNOWN). The add() method stores all
1804
1831
  // messages unconditionally — level is only used by the isDebug() guard.
1805
1832
  this.level = Severity.UNKNOWN;
1833
+ /** @type {LogMessage[]} */
1806
1834
  this.messages = [];
1807
1835
  }
1808
1836
 
@@ -1810,6 +1838,9 @@ class MemoryLogger {
1810
1838
  return new MemoryLogger()
1811
1839
  }
1812
1840
 
1841
+ /**
1842
+ * @returns {LogMessage[]} The log messages recorded so far, in order.
1843
+ */
1813
1844
  getMessages() {
1814
1845
  return this.messages
1815
1846
  }
@@ -6275,7 +6306,7 @@ function resolveBrowserIncludePath(reader, target, attrlist) {
6275
6306
  // ── Rule 2: target is an absolute URL (http:// / https:// / …) ───────────
6276
6307
  } else if (isUriish(pTarget)) {
6277
6308
  const descends = pathResolver.descendsFrom(pTarget, baseDir);
6278
- if (descends === false && !doc.getAttribute('allow-uri-read')) {
6309
+ if (descends === false && !doc.hasAttribute('allow-uri-read')) {
6279
6310
  return reader.replaceNextLine(_linkReplacement(reader, target, attrlist))
6280
6311
  }
6281
6312
  incPath = relpath = pTarget;
@@ -6309,7 +6340,7 @@ function resolveBrowserIncludePath(reader, target, attrlist) {
6309
6340
  } else {
6310
6341
  // Nested include: context dir is an absolute URL.
6311
6342
  const ctxDescends = pathResolver.descendsFrom(ctxDir, baseDir);
6312
- if (ctxDescends !== false || doc.getAttribute('allow-uri-read')) {
6343
+ if (ctxDescends !== false || doc.hasAttribute('allow-uri-read')) {
6313
6344
  incPath = `${ctxDir}/${pTarget}`;
6314
6345
  relpath = ctxDescends !== false ? incPath.slice(ctxDescends) : pTarget;
6315
6346
  } else {
@@ -6973,22 +7004,34 @@ class Reader {
6973
7004
  }
6974
7005
 
6975
7006
  /** @param {string} msg @param {{ sourceLocation?: any, includeLocation?: any }} [opts] */
6976
- _logWarn(msg, { sourceLocation, includeLocation } = {}) {
6977
- let text = sourceLocation ? `${sourceLocation.lineInfo}: ${msg}` : msg;
6978
- if (includeLocation) text += ` (${includeLocation.lineInfo})`;
6979
- this.logger.warn(text);
7007
+ _logWarn(msg, opts = {}) {
7008
+ this.logger.warn(this._messageWithContext(msg, opts));
6980
7009
  }
6981
7010
  _logError(msg, opts = {}) {
6982
- let text = opts.sourceLocation
6983
- ? `${opts.sourceLocation.lineInfo}: ${msg}`
6984
- : msg;
6985
- if (opts.includeLocation) text += ` (${opts.includeLocation.lineInfo})`;
6986
- this.logger.error(text);
7011
+ this.logger.error(this._messageWithContext(msg, opts));
6987
7012
  }
6988
7013
  /** @param {string} msg @param {{ sourceLocation?: any }} [opts] */
6989
- _logInfo(msg, { sourceLocation } = {}) {
6990
- const text = sourceLocation ? `${sourceLocation.lineInfo}: ${msg}` : msg;
6991
- this.logger.info(text);
7014
+ _logInfo(msg, opts = {}) {
7015
+ this.logger.info(this._messageWithContext(msg, opts));
7016
+ }
7017
+
7018
+ /**
7019
+ * Build an auto-formatting message that keeps the cursor as a structured
7020
+ * source_location (rather than baking it into the text). When displayed by a
7021
+ * stderr Logger the location is rendered as a "<path>: line <N>: " prefix, but
7022
+ * a MemoryLogger records it separately on the LogMessage so consumers can call
7023
+ * getSourceLocation(). Mirrors Ruby's Logging#message_with_context.
7024
+ * @param {string} msg
7025
+ * @param {{ sourceLocation?: any, includeLocation?: any }} [opts]
7026
+ * @internal
7027
+ */
7028
+ _messageWithContext(msg, { sourceLocation, includeLocation } = {}) {
7029
+ if (!sourceLocation && !includeLocation) return msg
7030
+ return Logger.AutoFormattingMessage.attach({
7031
+ text: msg,
7032
+ source_location: sourceLocation ?? null,
7033
+ include_location: includeLocation ?? null,
7034
+ })
6992
7035
  }
6993
7036
  }
6994
7037
 
@@ -7746,7 +7789,7 @@ class PreprocessorReader extends Reader {
7746
7789
  }
7747
7790
 
7748
7791
  if (isUriish(target) || typeof this._dir !== 'string') {
7749
- if (!doc.getAttribute('allow-uri-read')) {
7792
+ if (!doc.hasAttribute('allow-uri-read')) {
7750
7793
  this._logWarn(
7751
7794
  `cannot include contents of URI: ${target} (allow-uri-read attribute not enabled)`,
7752
7795
  { sourceLocation: this.cursor }
@@ -10356,7 +10399,12 @@ class Parser {
10356
10399
  }
10357
10400
  }
10358
10401
  (intro ?? section).blocks.push(newBlock);
10359
- for (const key of Object.keys(attributes)) delete attributes[key];
10402
+ // Reset the shared attributes object for the next block. Use Reflect.ownKeys
10403
+ // (not Object.keys) so the Symbol-keyed attribute entries (ATTR_ENTRIES_KEY)
10404
+ // are cleared too; otherwise the array of AttributeEntry objects leaks and
10405
+ // accumulates across blocks, causing reassigned attributes (e.g. a body-level
10406
+ // `:name:` redefined later) to all resolve to the final value at playback time.
10407
+ for (const key of Reflect.ownKeys(attributes)) delete attributes[key];
10360
10408
  }
10361
10409
  }
10362
10410
 
@@ -11203,7 +11251,13 @@ class Parser {
11203
11251
  }
11204
11252
  }
11205
11253
 
11206
- if (Object.keys(attributes).length > 0) block.updateAttributes(attributes);
11254
+ // Reflect.ownKeys (not Object.keys) so a block carrying only Symbol-keyed
11255
+ // attribute entries (ATTR_ENTRIES_KEY) — e.g. an `:attr:` entry immediately
11256
+ // preceding a list or table — still receives them, matching Ruby's
11257
+ // `attributes.empty?` where the `:attribute_entries` key is counted. Without this
11258
+ // the entries are dropped and the attribute is not played back for that block.
11259
+ if (Reflect.ownKeys(attributes).length > 0)
11260
+ block.updateAttributes(attributes);
11207
11261
  block.commitSubs();
11208
11262
 
11209
11263
  if (block.hasSub('callouts')) {
@@ -13473,6 +13527,7 @@ function _uniform(str, chr, len) {
13473
13527
  * DSL interface for include processors.
13474
13528
  *
13475
13529
  * @typedef {DocumentProcessorDslInterface & {
13530
+ * handles(fn: (target: string) => boolean): void;
13476
13531
  * handles(fn: (doc: Document, target: string) => boolean): void;
13477
13532
  * }} IncludeProcessorDslInterface
13478
13533
  */
@@ -13523,7 +13578,7 @@ function _uniform(str, chr, len) {
13523
13578
  * - Called with a single Function argument → stores it as the process block.
13524
13579
  * - Called with non-Function arguments → invokes the stored process block.
13525
13580
  *
13526
- * The this context inside a stored process function is bound to the processor
13581
+ * The `this` context inside a stored process function is bound to the processor
13527
13582
  * instance at definition time.
13528
13583
  */
13529
13584
  const ProcessorDsl = {
@@ -13697,6 +13752,35 @@ const SyntaxProcessorDsl = {
13697
13752
  const IncludeProcessorDsl = {
13698
13753
  ...DocumentProcessorDsl,
13699
13754
 
13755
+ /**
13756
+ * @overload
13757
+ * @param {(target: string) => boolean} fn - Predicate that receives only the include target.
13758
+ * @returns {void}
13759
+ */
13760
+ /**
13761
+ * @overload
13762
+ * @param {(doc: Document, target: string) => boolean} fn - Predicate that receives the document and the include target.
13763
+ * @returns {void}
13764
+ */
13765
+ /**
13766
+ * @overload
13767
+ * @param {Document} doc - The document being parsed.
13768
+ * @param {string} target - The include target.
13769
+ * @returns {boolean}
13770
+ */
13771
+ /**
13772
+ * Register a function that decides whether this include processor handles a given target,
13773
+ * or invoke the registered handler.
13774
+ *
13775
+ * **Setter form** — register a predicate. The callback may accept either just the target
13776
+ * string (arity 1) or both the document and the target string (arity 2).
13777
+ *
13778
+ * **Invoker form** — call the registered predicate with `(doc, target)` and return its
13779
+ * result, or return `true` if no predicate has been registered.
13780
+ *
13781
+ * @param {...*} args
13782
+ * @returns {boolean | void}
13783
+ */
13700
13784
  handles(...args) {
13701
13785
  if (args.length === 1 && typeof args[0] === 'function') {
13702
13786
  const fn = args[0];
@@ -14464,6 +14548,8 @@ class ProcessorExtension extends Extension {
14464
14548
  super(kind, instance, instance.config);
14465
14549
  this.processMethod =
14466
14550
  processMethod || ((...args) => instance.process(...args));
14551
+ /** @internal */
14552
+ this._direct = false;
14467
14553
  }
14468
14554
  }
14469
14555
 
@@ -14491,10 +14577,24 @@ const SYNTAX_PROCESSOR_CLASSES = {
14491
14577
  * Registry holds the extensions which have been registered and activated, has
14492
14578
  * methods for registering or defining a processor and looks up extensions
14493
14579
  * stored in the registry during parsing.
14580
+ *
14581
+ * A registry can be reused across multiple conversions. Extensions registered
14582
+ * via a group block (passed to {@link Extensions.create} or
14583
+ * {@link Extensions.register}) are re-executed on every activation. Extensions
14584
+ * registered directly on the registry instance (e.g. `registry.preprocessor(fn)`)
14585
+ * are preserved across activations.
14586
+ *
14587
+ * @example
14588
+ * const registry = Extensions.create('my-ext', function () {
14589
+ * this.preprocessor(function () { ... })
14590
+ * })
14591
+ * // registry can be passed to multiple conversions safely
14494
14592
  */
14495
14593
  class Registry {
14496
14594
  constructor(groups = {}) {
14497
14595
  this.groups = groups;
14596
+ /** @internal */
14597
+ this._activating = false;
14498
14598
  this._reset();
14499
14599
  }
14500
14600
 
@@ -14511,18 +14611,26 @@ class Registry {
14511
14611
  ...Object.values(Extensions.groups()),
14512
14612
  ...Object.values(this.groups),
14513
14613
  ];
14514
- for (const group of extGroups) {
14515
- if (typeof group === 'function') {
14516
- // Check if it is a class (constructor) with an activate prototype method.
14517
- if (group.prototype && typeof group.prototype.activate === 'function') {
14518
- new group().activate(this);
14519
- } else {
14520
- // Plain function — call in the context of this registry (like instance_exec).
14521
- group.length === 0 ? group.call(this) : group(this);
14614
+ this._activating = true;
14615
+ try {
14616
+ for (const group of extGroups) {
14617
+ if (typeof group === 'function') {
14618
+ // Check if it is a class (constructor) with an activate prototype method.
14619
+ if (
14620
+ group.prototype &&
14621
+ typeof group.prototype.activate === 'function'
14622
+ ) {
14623
+ new group().activate(this);
14624
+ } else {
14625
+ // Plain function — call in the context of this registry (like instance_exec).
14626
+ group.length === 0 ? group.call(this) : group(this);
14627
+ }
14628
+ } else if (group && typeof group.activate === 'function') {
14629
+ group.activate(this);
14522
14630
  }
14523
- } else if (group && typeof group.activate === 'function') {
14524
- group.activate(this);
14525
14631
  }
14632
+ } finally {
14633
+ this._activating = false;
14526
14634
  }
14527
14635
  return this
14528
14636
  }
@@ -15098,6 +15206,7 @@ class Registry {
15098
15206
  }
15099
15207
 
15100
15208
  const extension = new ProcessorExtension(kind, processorInstance);
15209
+ extension._direct = !this._activating;
15101
15210
  extension.config.position === '>>'
15102
15211
  ? store.unshift(extension)
15103
15212
  : store.push(extension);
@@ -15169,27 +15278,44 @@ class Registry {
15169
15278
  }
15170
15279
 
15171
15280
  store[name] = new ProcessorExtension(kind, processorInstance);
15281
+ store[name]._direct = !this._activating;
15172
15282
  return store[name]
15173
15283
  }
15174
15284
 
15175
15285
  /** @internal */
15176
15286
  _reset() {
15287
+ // Keep extensions registered directly (outside a group); only clear group-registered ones.
15288
+ // Extensions tagged with _direct=true survive across activations.
15289
+ const keepArr = (arr) => {
15290
+ const kept = arr?.filter((e) => e._direct) ?? [];
15291
+ return kept.length ? kept : null
15292
+ };
15293
+ const keepMap = (map) => {
15294
+ if (!map) return null
15295
+ const kept = Object.create(null);
15296
+ for (const [k, v] of Object.entries(map)) if (v._direct) kept[k] = v;
15297
+ return Object.keys(kept).length ? kept : null
15298
+ };
15177
15299
  /** @internal */
15178
- this._preprocessor_extensions = null;
15300
+ this._preprocessor_extensions = keepArr(this._preprocessor_extensions);
15179
15301
  /** @internal */
15180
- this._tree_processor_extensions = null;
15302
+ this._tree_processor_extensions = keepArr(this._tree_processor_extensions);
15181
15303
  /** @internal */
15182
- this._postprocessor_extensions = null;
15304
+ this._postprocessor_extensions = keepArr(this._postprocessor_extensions);
15183
15305
  /** @internal */
15184
- this._include_processor_extensions = null;
15306
+ this._include_processor_extensions = keepArr(
15307
+ this._include_processor_extensions
15308
+ );
15185
15309
  /** @internal */
15186
- this._docinfo_processor_extensions = null;
15310
+ this._docinfo_processor_extensions = keepArr(
15311
+ this._docinfo_processor_extensions
15312
+ );
15187
15313
  /** @internal */
15188
- this._block_extensions = null;
15314
+ this._block_extensions = keepMap(this._block_extensions);
15189
15315
  /** @internal */
15190
- this._block_macro_extensions = null;
15316
+ this._block_macro_extensions = keepMap(this._block_macro_extensions);
15191
15317
  /** @internal */
15192
- this._inline_macro_extensions = null;
15318
+ this._inline_macro_extensions = keepMap(this._inline_macro_extensions);
15193
15319
  this.document = null;
15194
15320
  }
15195
15321
 
@@ -15272,6 +15398,13 @@ const Extensions = {
15272
15398
  /**
15273
15399
  * Create a new Registry, optionally pre-populated with a named block.
15274
15400
  *
15401
+ * When a `block` is provided it is stored as a group and re-executed on every
15402
+ * activation, making the registry safe to reuse across multiple conversions.
15403
+ * Without a `block`, any extensions registered directly on the returned registry
15404
+ * (e.g. `registry.preprocessor(fn)`) are stored in transient state that is
15405
+ * cleared on every activation — those registrations will be lost from the second
15406
+ * conversion onwards. Prefer the block form when the registry may be reused.
15407
+ *
15275
15408
  * @param {string|null} [name=null] - Optional name for the group; auto-generated if omitted.
15276
15409
  * @param {Function|null} [block=null] - Optional function to register as the group.
15277
15410
  * @returns {Registry}
@@ -16152,8 +16285,16 @@ class Document extends AbstractBlock {
16152
16285
  }
16153
16286
 
16154
16287
  // Pre-compute all async text values (titles, list item text, cell text, reftexts)
16155
- // so that synchronous getters work correctly during conversion.
16288
+ // so that synchronous getters work correctly during conversion. _resolveAllTexts
16289
+ // replays attribute entries in document order (mirroring conversion) so body-level
16290
+ // attribute (re)assignments are in scope; snapshot and restore the document
16291
+ // attributes so the downstream steps and conversion still start from the restored
16292
+ // (header) state, matching Ruby's restore_attributes-before-convert invariant.
16293
+ const attributesSnapshot = { ...this.attributes };
16156
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);
16157
16298
  // Reset the footnote counter so that body-content footnotes (processed during conversion)
16158
16299
  // start numbering from 1, reproducing Ruby's "out of sequence" quirk: title footnotes are
16159
16300
  // numbered during parsing via apply_title_subs, then the counter restarts for body content.
@@ -17227,6 +17368,12 @@ class Document extends AbstractBlock {
17227
17368
  * Handles titles (AbstractBlock), list item text, table cell text, and reftexts.
17228
17369
  */
17229
17370
  async _resolveAllTexts(block) {
17371
+ // Replay this block's attribute entries (in document order, since the walk is
17372
+ // depth-first pre-order like conversion) so that body-level attribute assignments
17373
+ // and reassignments are in scope when the block's — and its descendants' and later
17374
+ // siblings' — title / list item / table cell / reftext values are substituted.
17375
+ // Mirrors AbstractBlock#convert, which calls playbackAttributes before converting.
17376
+ this.playbackAttributes(block.attributes);
17230
17377
  // The header section lives outside document.blocks; pre-compute its title here so
17231
17378
  // that doc.doctitle() returns the fully-substituted title (with replacements applied,
17232
17379
  // e.g. ' → &#8217;) rather than the header-subs-only fallback.
@@ -17955,7 +18102,7 @@ const Substitutors = {
17955
18102
  *
17956
18103
  * @param {string|string[]} text - The text to process; must not be null.
17957
18104
  * @param {string[]} [subs=NORMAL_SUBS] - The substitutions to perform.
17958
- * @returns {string|string[]} Text with substitutions applied.
18105
+ * @returns {Promise<string|string[]>} Text with substitutions applied.
17959
18106
  */
17960
18107
  async applySubs(text, subs = NORMAL_SUBS) {
17961
18108
  const isEmpty = Array.isArray(text) ? text.length === 0 : text.length === 0;
@@ -18069,7 +18216,7 @@ const Substitutors = {
18069
18216
  * Substitute quoted text (emphasis, strong, monospaced, etc.)
18070
18217
  *
18071
18218
  * @param {string} text
18072
- * @returns {string}
18219
+ * @returns {Promise<string>}
18073
18220
  */
18074
18221
  async subQuotes(text) {
18075
18222
  const compat = this.document.compatMode;
@@ -18241,7 +18388,7 @@ const Substitutors = {
18241
18388
  * Substitute inline macros (links, images, etc.)
18242
18389
  *
18243
18390
  * @param {string} text
18244
- * @returns {string}
18391
+ * @returns {Promise<string>}
18245
18392
  */
18246
18393
  async subMacros(text) {
18247
18394
  const foundSquareBracket = text.includes('[');
@@ -19136,7 +19283,7 @@ const Substitutors = {
19136
19283
  * Substitute post replacements (hard line breaks).
19137
19284
  *
19138
19285
  * @param {string} text
19139
- * @returns {string}
19286
+ * @returns {Promise<string>}
19140
19287
  */
19141
19288
  async subPostReplacements(text) {
19142
19289
  if (
@@ -19174,7 +19321,7 @@ const Substitutors = {
19174
19321
  *
19175
19322
  * @param {string} source
19176
19323
  * @param {boolean} processCallouts
19177
- * @returns {string}
19324
+ * @returns {Promise<string>}
19178
19325
  */
19179
19326
  async subSource(source, processCallouts) {
19180
19327
  return processCallouts
@@ -19186,7 +19333,7 @@ const Substitutors = {
19186
19333
  * Substitute callout source references.
19187
19334
  *
19188
19335
  * @param {string} text
19189
- * @returns {string}
19336
+ * @returns {Promise<string>}
19190
19337
  */
19191
19338
  async subCallouts(text) {
19192
19339
  const calloutRx = this.hasAttribute('line-comment')
@@ -19215,7 +19362,7 @@ const Substitutors = {
19215
19362
  *
19216
19363
  * @param {string} source
19217
19364
  * @param {boolean} processCallouts
19218
- * @returns {string}
19365
+ * @returns {Promise<string>}
19219
19366
  */
19220
19367
  async highlightSource(source, processCallouts) {
19221
19368
  const syntaxHl = this.document.syntaxHighlighter;
@@ -19561,7 +19708,7 @@ const Substitutors = {
19561
19708
  * Restore passthrough text by reinserting into placeholder positions.
19562
19709
  *
19563
19710
  * @param {string} text
19564
- * @returns {string}
19711
+ * @returns {Promise<string>}
19565
19712
  */
19566
19713
  async restorePassthroughs(text) {
19567
19714
  if (!text.includes(PASS_START)) return text
@@ -19753,7 +19900,7 @@ const Substitutors = {
19753
19900
  * @param {string} attrlist
19754
19901
  * @param {string[]} [posattrs=[]]
19755
19902
  * @param {Object} [opts={}]
19756
- * @returns {Object}
19903
+ * @returns {Promise<Object>}
19757
19904
  */
19758
19905
  async parseAttributes(attrlist, posattrs = [], opts = {}) {
19759
19906
  if (!attrlist || attrlist.length === 0) return {}
@@ -25250,6 +25397,7 @@ exports.Inline = Inline;
25250
25397
  exports.InlineMacroProcessor = InlineMacroProcessor;
25251
25398
  exports.List = List;
25252
25399
  exports.ListItem = ListItem;
25400
+ exports.LogMessage = LogMessage;
25253
25401
  exports.Logger = Logger;
25254
25402
  exports.LoggerManager = LoggerManager;
25255
25403
  exports.MemoryHttpCache = MemoryHttpCache;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@asciidoctor/core",
3
- "version": "4.0.0-alpha.6",
3
+ "version": "4.0.1",
4
4
  "description": "Asciidoctor.js: AsciiDoc in JavaScript powered by Asciidoctor",
5
5
  "type": "module",
6
6
  "main": "build/node/index.cjs",
@@ -66,7 +66,7 @@
66
66
  },
67
67
  "homepage": "https://github.com/asciidoctor/asciidoctor.js",
68
68
  "volta": {
69
- "node": "24.16.0"
69
+ "node": "24.18.0"
70
70
  },
71
71
  "devDependencies": {
72
72
  "@biomejs/biome": "^2.4.13",
@@ -92,7 +92,7 @@ export function resolveBrowserIncludePath(reader, target, attrlist) {
92
92
  // ── Rule 2: target is an absolute URL (http:// / https:// / …) ───────────
93
93
  } else if (isUriish(pTarget)) {
94
94
  const descends = pathResolver.descendsFrom(pTarget, baseDir)
95
- if (descends === false && !doc.getAttribute('allow-uri-read')) {
95
+ if (descends === false && !doc.hasAttribute('allow-uri-read')) {
96
96
  return reader.replaceNextLine(_linkReplacement(reader, target, attrlist))
97
97
  }
98
98
  incPath = relpath = pTarget
@@ -126,7 +126,7 @@ export function resolveBrowserIncludePath(reader, target, attrlist) {
126
126
  } else {
127
127
  // Nested include: context dir is an absolute URL.
128
128
  const ctxDescends = pathResolver.descendsFrom(ctxDir, baseDir)
129
- if (ctxDescends !== false || doc.getAttribute('allow-uri-read')) {
129
+ if (ctxDescends !== false || doc.hasAttribute('allow-uri-read')) {
130
130
  incPath = `${ctxDir}/${pTarget}`
131
131
  relpath = ctxDescends !== false ? incPath.slice(ctxDescends) : pTarget
132
132
  } else {
package/src/browser.js CHANGED
@@ -9,7 +9,13 @@ import {
9
9
  ImageReference,
10
10
  RevisionInfo,
11
11
  } from './document.js'
12
- import { Logger, LoggerManager, MemoryLogger, NullLogger } from './logging.js'
12
+ import {
13
+ Logger,
14
+ LoggerManager,
15
+ LogMessage,
16
+ MemoryLogger,
17
+ NullLogger,
18
+ } from './logging.js'
13
19
  import { HttpCache, MemoryHttpCache, HttpCacheManager } from './http_cache.js'
14
20
  import { SafeMode, ContentModel } from './constants.js'
15
21
  import { Timings } from './timings.js'
@@ -110,6 +116,7 @@ export {
110
116
  Reader,
111
117
  SyntaxHighlighterBase,
112
118
  LoggerManager,
119
+ LogMessage,
113
120
  MemoryLogger,
114
121
  NullLogger,
115
122
  HttpCache,
package/src/document.js CHANGED
@@ -587,8 +587,16 @@ export class Document extends AbstractBlock {
587
587
  }
588
588
 
589
589
  // Pre-compute all async text values (titles, list item text, cell text, reftexts)
590
- // so that synchronous getters work correctly during conversion.
590
+ // so that synchronous getters work correctly during conversion. _resolveAllTexts
591
+ // replays attribute entries in document order (mirroring conversion) so body-level
592
+ // attribute (re)assignments are in scope; snapshot and restore the document
593
+ // attributes so the downstream steps and conversion still start from the restored
594
+ // (header) state, matching Ruby's restore_attributes-before-convert invariant.
595
+ const attributesSnapshot = { ...this.attributes }
591
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)
592
600
  // Reset the footnote counter so that body-content footnotes (processed during conversion)
593
601
  // start numbering from 1, reproducing Ruby's "out of sequence" quirk: title footnotes are
594
602
  // numbered during parsing via apply_title_subs, then the counter restarts for body content.
@@ -1662,6 +1670,12 @@ export class Document extends AbstractBlock {
1662
1670
  * Handles titles (AbstractBlock), list item text, table cell text, and reftexts.
1663
1671
  */
1664
1672
  async _resolveAllTexts(block) {
1673
+ // Replay this block's attribute entries (in document order, since the walk is
1674
+ // depth-first pre-order like conversion) so that body-level attribute assignments
1675
+ // and reassignments are in scope when the block's — and its descendants' and later
1676
+ // siblings' — title / list item / table cell / reftext values are substituted.
1677
+ // Mirrors AbstractBlock#convert, which calls playbackAttributes before converting.
1678
+ this.playbackAttributes(block.attributes)
1665
1679
  // The header section lives outside document.blocks; pre-compute its title here so
1666
1680
  // that doc.doctitle() returns the fully-substituted title (with replacements applied,
1667
1681
  // e.g. ' → &#8217;) rather than the header-subs-only fallback.