@asciidoctor/core 4.0.0 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@asciidoctor/core",
3
- "version": "4.0.0",
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",
@@ -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",
@@ -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
@@ -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,
@@ -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
@@ -587,13 +588,21 @@ export class Document extends AbstractBlock {
587
588
  }
588
589
 
589
590
  // Pre-compute all async text values (titles, list item text, cell text, reftexts)
590
- // so that synchronous getters work correctly during conversion.
591
- await this._resolveAllTexts(this)
592
- // Reset the footnote counter so that body-content footnotes (processed during conversion)
593
- // start numbering from 1, reproducing Ruby's "out of sequence" quirk: title footnotes are
594
- // numbered during parsing via apply_title_subs, then the counter restarts for body content.
595
- delete this.attributes['footnote-number']
596
- delete this._counters['footnote-number']
591
+ // so that synchronous getters work correctly during conversion. _resolveAllTexts
592
+ // replays attribute entries in document order (mirroring conversion) so body-level
593
+ // attribute (re)assignments are in scope; snapshot and restore the document
594
+ // attributes so the downstream steps and conversion still start from the restored
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.
602
+ const attributesSnapshot = { ...this.attributes }
603
+ // Pass 1: titles + reftexts (no block content text yet).
604
+ await this._resolveAllTexts(this, false)
605
+ this._restoreAttributeSnapshot(attributesSnapshot)
597
606
  // Pre-compute reftext for all registered inline anchor nodes.
598
607
  for (const ref of Object.values(this.catalog.refs)) {
599
608
  if (ref && typeof ref.precomputeReftext === 'function') {
@@ -602,6 +611,15 @@ export class Document extends AbstractBlock {
602
611
  }
603
612
  // Build the reftext→id lookup map so that resolveId() is synchronous.
604
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']
605
623
 
606
624
  this._parsed = true
607
625
  return this
@@ -1656,12 +1674,39 @@ export class Document extends AbstractBlock {
1656
1674
  : ['attributes']
1657
1675
  }
1658
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
+
1659
1690
  /**
1660
1691
  * @private
1661
1692
  * Walk the block tree and pre-compute all async text values.
1662
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.
1663
1702
  */
1664
- async _resolveAllTexts(block) {
1703
+ async _resolveAllTexts(block, resolveContent) {
1704
+ // Replay this block's attribute entries (in document order, since the walk is
1705
+ // depth-first pre-order like conversion) so that body-level attribute assignments
1706
+ // and reassignments are in scope when the block's — and its descendants' and later
1707
+ // siblings' — title / list item / table cell / reftext values are substituted.
1708
+ // Mirrors AbstractBlock#convert, which calls playbackAttributes before converting.
1709
+ this.playbackAttributes(block.attributes)
1665
1710
  // The header section lives outside document.blocks; pre-compute its title here so
1666
1711
  // that doc.doctitle() returns the fully-substituted title (with replacements applied,
1667
1712
  // e.g. ' → &#8217;) rather than the header-subs-only fallback.
@@ -1681,12 +1726,12 @@ export class Document extends AbstractBlock {
1681
1726
  // dlist.blocks is an array of [[term, ...], item_or_null] pairs.
1682
1727
  for (const [terms, item] of block.blocks ?? []) {
1683
1728
  for (const term of terms ?? []) {
1684
- await term.precomputeText?.()
1685
- await this._resolveAllTexts(term)
1729
+ if (resolveContent) await term.precomputeText?.()
1730
+ await this._resolveAllTexts(term, resolveContent)
1686
1731
  }
1687
1732
  if (item) {
1688
- await item.precomputeText?.()
1689
- await this._resolveAllTexts(item)
1733
+ if (resolveContent) await item.precomputeText?.()
1734
+ await this._resolveAllTexts(item, resolveContent)
1690
1735
  }
1691
1736
  }
1692
1737
  } else if (ctx === 'table') {
@@ -1696,14 +1741,14 @@ export class Document extends AbstractBlock {
1696
1741
  ...(block.rows?.foot ?? []),
1697
1742
  ]) {
1698
1743
  for (const cell of row) {
1699
- await cell.precomputeText?.()
1744
+ if (resolveContent) await cell.precomputeText?.()
1700
1745
  await cell.precomputeReftext?.()
1701
1746
  }
1702
1747
  }
1703
1748
  } else {
1704
1749
  for (const child of block.blocks ?? []) {
1705
- await child.precomputeText?.()
1706
- await this._resolveAllTexts(child)
1750
+ if (resolveContent) await child.precomputeText?.()
1751
+ await this._resolveAllTexts(child, resolveContent)
1707
1752
  }
1708
1753
  }
1709
1754
  }
@@ -1995,14 +2040,19 @@ export class Document extends AbstractBlock {
1995
2040
  let newBasebackend, newFiletype
1996
2041
 
1997
2042
  if (converter && typeof converter._getBackendTraits === 'function') {
1998
- newBasebackend = converter.basebackend()
1999
- newFiletype = converter.filetype()
2000
- 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
2001
2051
  if (htmlsyntax) attrs.htmlsyntax = htmlsyntax
2002
2052
  if (init) {
2003
- attrs.outfilesuffix ??= converter.outfilesuffix()
2053
+ attrs.outfilesuffix ??= traits.outfilesuffix
2004
2054
  } else if (!this.isAttributeLocked('outfilesuffix')) {
2005
- attrs.outfilesuffix = converter.outfilesuffix()
2055
+ attrs.outfilesuffix = traits.outfilesuffix
2006
2056
  }
2007
2057
  } else if (converter) {
2008
2058
  const traits = deriveBackendTraits(newBackend)
package/src/index.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'
@@ -122,6 +128,7 @@ export {
122
128
  Reader,
123
129
  SyntaxHighlighterBase,
124
130
  LoggerManager,
131
+ LogMessage,
125
132
  MemoryLogger,
126
133
  NullLogger,
127
134
  HttpCache,
package/src/logging.js CHANGED
@@ -277,14 +277,23 @@ Logger.BasicFormatter = class {
277
277
 
278
278
  Logger.AutoFormattingMessage = {
279
279
  /**
280
- * Attach auto-formatting to any plain object carrying { text, source_location }.
281
- * @param {{text: string, source_location?: string}} obj
280
+ * Attach auto-formatting to any plain object carrying
281
+ * { text, source_location, include_location }.
282
+ *
283
+ * The location(s) are rendered only by inspect()/toString() (used when a
284
+ * stderr Logger formats the line); the structured `source_location` /
285
+ * `include_location` remain on the object so a MemoryLogger can record them
286
+ * on the resulting LogMessage without duplicating them inside `text`.
287
+ * @param {{text: string, source_location?: any, include_location?: any}} obj
282
288
  * @returns {typeof obj} The same object with inspect() and toString() added.
283
289
  */
284
290
  attach(obj) {
285
291
  obj.inspect = function () {
286
292
  const sloc = this.source_location
287
- return sloc ? `${sloc}: ${this.text}` : this.text
293
+ const iloc = this.include_location
294
+ let text = sloc ? `${sloc}: ${this.text}` : this.text
295
+ if (iloc) text += ` (${iloc})`
296
+ return text
288
297
  }
289
298
  obj.toString = obj.inspect
290
299
  return obj
@@ -294,28 +303,46 @@ Logger.AutoFormattingMessage = {
294
303
  // ── LogMessage ────────────────────────────────────────────────────────────────
295
304
 
296
305
  /** Wrapper stored by MemoryLogger; provides getSeverity/getText/getSourceLocation. */
297
- class LogMessage {
306
+ export class LogMessage {
307
+ /**
308
+ * @param {string} severity - Severity label, e.g. 'ERROR'.
309
+ * @param {string|{text: string, source_location?: import('./reader.js').Cursor}|null} message
310
+ */
298
311
  constructor(severity, message) {
299
312
  this.message = message
313
+ /** @type {string} */
300
314
  this.severity = severity // string label, e.g. 'ERROR'
301
315
  // AutoFormattingMessage objects carry { text, source_location }
302
316
  if (message !== null && typeof message === 'object' && 'text' in message) {
303
- this._text = message.text
304
- this._sourceLocation = message.source_location ?? null
317
+ /** @type {string} */
318
+ this.text = message.text
319
+ /** @type {import('./reader.js').Cursor|null} */
320
+ this.sourceLocation = message.source_location ?? null
305
321
  } else {
306
- this._text = message != null ? String(message) : ''
307
- this._sourceLocation = null
322
+ this.text = message != null ? String(message) : ''
323
+ this.sourceLocation = null
308
324
  }
309
325
  }
310
326
 
327
+ /**
328
+ * @returns {string} The severity label, e.g. 'ERROR'.
329
+ */
311
330
  getSeverity() {
312
331
  return this.severity
313
332
  }
333
+
334
+ /**
335
+ * @returns {string} The message text.
336
+ */
314
337
  getText() {
315
- return this._text
338
+ return this.text
316
339
  }
340
+
341
+ /**
342
+ * @returns {import('./reader.js').Cursor|undefined} The source location, if any.
343
+ */
317
344
  getSourceLocation() {
318
- return this._sourceLocation ?? undefined
345
+ return this.sourceLocation ?? undefined
319
346
  }
320
347
  }
321
348
 
@@ -328,6 +355,7 @@ export class MemoryLogger {
328
355
  // matching Ruby's MemoryLogger (level: UNKNOWN). The add() method stores all
329
356
  // messages unconditionally — level is only used by the isDebug() guard.
330
357
  this.level = Severity.UNKNOWN
358
+ /** @type {LogMessage[]} */
331
359
  this.messages = []
332
360
  }
333
361
 
@@ -335,6 +363,9 @@ export class MemoryLogger {
335
363
  return new MemoryLogger()
336
364
  }
337
365
 
366
+ /**
367
+ * @returns {LogMessage[]} The log messages recorded so far, in order.
368
+ */
338
369
  getMessages() {
339
370
  return this.messages
340
371
  }
package/src/parser.js CHANGED
@@ -598,7 +598,12 @@ export class Parser {
598
598
  }
599
599
  }
600
600
  ;(intro ?? section).blocks.push(newBlock)
601
- for (const key of Object.keys(attributes)) delete attributes[key]
601
+ // Reset the shared attributes object for the next block. Use Reflect.ownKeys
602
+ // (not Object.keys) so the Symbol-keyed attribute entries (ATTR_ENTRIES_KEY)
603
+ // are cleared too; otherwise the array of AttributeEntry objects leaks and
604
+ // accumulates across blocks, causing reassigned attributes (e.g. a body-level
605
+ // `:name:` redefined later) to all resolve to the final value at playback time.
606
+ for (const key of Reflect.ownKeys(attributes)) delete attributes[key]
602
607
  }
603
608
  }
604
609
 
@@ -1449,7 +1454,13 @@ export class Parser {
1449
1454
  }
1450
1455
  }
1451
1456
 
1452
- if (Object.keys(attributes).length > 0) block.updateAttributes(attributes)
1457
+ // Reflect.ownKeys (not Object.keys) so a block carrying only Symbol-keyed
1458
+ // attribute entries (ATTR_ENTRIES_KEY) — e.g. an `:attr:` entry immediately
1459
+ // preceding a list or table — still receives them, matching Ruby's
1460
+ // `attributes.empty?` where the `:attribute_entries` key is counted. Without this
1461
+ // the entries are dropped and the attribute is not played back for that block.
1462
+ if (Reflect.ownKeys(attributes).length > 0)
1463
+ block.updateAttributes(attributes)
1453
1464
  block.commitSubs()
1454
1465
 
1455
1466
  if (block.hasSub('callouts')) {