@asciidoctor/core 4.0.0-alpha.2 → 4.0.0-alpha.3

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-alpha.2",
3
+ "version": "4.0.0-alpha.3",
4
4
  "description": "Asciidoctor.js: AsciiDoc in JavaScript powered by Asciidoctor",
5
5
  "type": "module",
6
6
  "main": "build/node/index.cjs",
@@ -28,6 +28,7 @@
28
28
  "build:types": "tsc && node scripts/strip-internal.js && node scripts/patch-jsdoc.js",
29
29
  "docs": "typedoc --tsconfig tsconfig.docs.json",
30
30
  "test": "node --test test/**.test.js",
31
+ "test:deno": "deno test --allow-env --no-check --allow-read --allow-sys --allow-write --allow-run --allow-net test/*.test.js",
31
32
  "test:coverage": "node --test --experimental-test-coverage --test-coverage-include='src/**' test/**.test.js",
32
33
  "test:browser": "vitest run --config vitest.browser.config.js",
33
34
  "lint": "biome lint src test",
@@ -65,7 +66,7 @@
65
66
  },
66
67
  "homepage": "https://github.com/asciidoctor/asciidoctor.js",
67
68
  "volta": {
68
- "node": "24.14.1"
69
+ "node": "24.15.0"
69
70
  },
70
71
  "devDependencies": {
71
72
  "@biomejs/biome": "^2.4.13",
@@ -1,3 +1,19 @@
1
+ // Symbol key used to store attribute entries on a block-attributes object without
2
+ // polluting the public attributes (invisible to Object.keys / for-in / JSON.stringify).
3
+ // Spread ({ ...attrs }) copies Symbol-keyed properties, so the entry survives the
4
+ // shallow clone made in AbstractNode's constructor.
5
+ export const ATTR_ENTRIES_KEY = Symbol('attribute_entries')
6
+
7
+ /**
8
+ * Return the attribute entries stored for the given block attributes object,
9
+ * or undefined if none have been saved.
10
+ * @param {Object} blockAttributes
11
+ * @returns {AttributeEntry[]|undefined}
12
+ */
13
+ export function getAttributeEntries(blockAttributes) {
14
+ return blockAttributes[ATTR_ENTRIES_KEY]
15
+ }
16
+
1
17
  export class AttributeEntry {
2
18
  constructor(name, value, negate = null) {
3
19
  this.name = name
@@ -6,7 +22,7 @@ export class AttributeEntry {
6
22
  }
7
23
 
8
24
  saveTo(blockAttributes) {
9
- ;(blockAttributes.attribute_entries ??= []).push(this)
25
+ ;(blockAttributes[ATTR_ENTRIES_KEY] ??= []).push(this)
10
26
  return this
11
27
  }
12
28
  }
@@ -5,7 +5,7 @@
5
5
  //
6
6
  // This logic is specific to Asciidoctor.js and has no equivalent in the upstream Ruby asciidoctor
7
7
  // implementation. It handles the case where the document is loaded in a browser environment
8
- // (XMLHttpRequest / Fetch IO module) where paths can be file:// or http(s):// URIs.
8
+ // where paths can be file:// or http(s):// URIs.
9
9
  //
10
10
  // The key behavioural differences from the standard file-system resolver:
11
11
  // - Relative targets are resolved by string concatenation against a URI context dir,
@@ -55,7 +55,7 @@ function _linkReplacement(reader, target, attrlist) {
55
55
  * 1. target starts with file:// → inc_path = relpath = target
56
56
  * 2. target is a URI → must descend from baseDir or allow-uri-read; else → link
57
57
  * 3. target is an absolute OS path → prepend file:// (or file:///)
58
- * 4. baseDir == '.' → inc_path = relpath = target (resolved by XMLHttpRequest/fetch)
58
+ * 4. baseDir == '.' → inc_path = relpath = target (resolved by fetch)
59
59
  * 5. baseDir starts with file:// OR baseDir is not a URI → inc_path = baseDir/target; relpath = target
60
60
  * 6. baseDir is an absolute URL → inc_path = baseDir/target; relpath = target
61
61
  *
@@ -276,7 +276,7 @@ export class TemplateConverter extends ConverterBase {
276
276
 
277
277
  let entries
278
278
  try {
279
- entries = await fsp.readdir(templateDir)
279
+ entries = (await fsp.readdir(templateDir)).sort()
280
280
  } catch {
281
281
  return result
282
282
  }
package/src/document.js CHANGED
@@ -68,7 +68,11 @@ export class ImageReference {
68
68
  import { Footnote } from './footnote.js'
69
69
  export { Footnote }
70
70
 
71
- import { AttributeEntry } from './attribute_entry.js'
71
+ import {
72
+ AttributeEntry,
73
+ getAttributeEntries,
74
+ ATTR_ENTRIES_KEY,
75
+ } from './attribute_entry.js'
72
76
  export { AttributeEntry }
73
77
 
74
78
  /**
@@ -927,8 +931,9 @@ export class Document extends AbstractBlock {
927
931
  * @param {Object} blockAttributes
928
932
  */
929
933
  playbackAttributes(blockAttributes) {
930
- if (!('attribute_entries' in blockAttributes)) return
931
- for (const entry of blockAttributes.attribute_entries) {
934
+ const entries = getAttributeEntries(blockAttributes)
935
+ if (!entries) return
936
+ for (const entry of entries) {
932
937
  if (entry.negate) {
933
938
  delete this.attributes[entry.name]
934
939
  if (entry.name === 'compat-mode') this.compatMode = false
@@ -1657,6 +1662,12 @@ export class Document extends AbstractBlock {
1657
1662
  * Handles titles (AbstractBlock), list item text, table cell text, and reftexts.
1658
1663
  */
1659
1664
  async _resolveAllTexts(block) {
1665
+ // The header section lives outside document.blocks; pre-compute its title here so
1666
+ // that doc.doctitle() returns the fully-substituted title (with replacements applied,
1667
+ // e.g. ' → ’) rather than the header-subs-only fallback.
1668
+ if (block === this && this.header) {
1669
+ await this.header.precomputeTitle?.()
1670
+ }
1660
1671
  // Skip title pre-computation for blocks with an explicit empty id ([id=]).
1661
1672
  // In Ruby, apply_title_subs is lazy: it is never called during parsing for such
1662
1673
  // blocks because section.title is never accessed. An explicit empty id is
@@ -1740,7 +1751,7 @@ export class Document extends AbstractBlock {
1740
1751
  * @internal
1741
1752
  */
1742
1753
  _clearPlaybackAttributes(attributes) {
1743
- delete attributes.attribute_entries
1754
+ delete attributes[ATTR_ENTRIES_KEY]
1744
1755
  }
1745
1756
 
1746
1757
  /**
package/src/extensions.js CHANGED
@@ -27,6 +27,94 @@ import { basename } from './helpers.js'
27
27
  import { ATTR_REF_HEAD } from './constants.js'
28
28
  import { MacroNameRx, CC_ANY } from './rx.js'
29
29
 
30
+ // Type-only imports for JSDoc
31
+ /**
32
+ * @typedef {import('./document.js').Document} Document
33
+ * @typedef {import('./abstract_block.js').AbstractBlock} AbstractBlock
34
+ */
35
+
36
+ // ── DSL interface types ───────────────────────────────────────────────────────
37
+
38
+ /**
39
+ * DSL interface for configuring a {@link Processor} instance.
40
+ * Applied to a processor instance via `Object.assign(instance, DslMixin)`.
41
+ *
42
+ * The `process` property behaves as a setter when called with a single Function
43
+ * argument (stores the process block), or as a passthrough caller otherwise.
44
+ *
45
+ * @typedef {object} ProcessorDslInterface
46
+ * @property {(key: string, value: unknown) => void} option - Set a config option.
47
+ * @property {(fn: (...args: unknown[]) => unknown) => void} process - Register the process function.
48
+ * @property {() => boolean} processBlockGiven - Returns true if a process function has been registered.
49
+ */
50
+
51
+ /**
52
+ * DSL interface for document processors (Preprocessor, TreeProcessor, Postprocessor, DocinfoProcessor).
53
+ *
54
+ * @typedef {ProcessorDslInterface & { prefer(): void; prepend(): void }} DocumentProcessorDslInterface
55
+ */
56
+
57
+ /**
58
+ * DSL interface for syntax processors (BlockProcessor, BlockMacroProcessor, InlineMacroProcessor).
59
+ *
60
+ * @typedef {ProcessorDslInterface & {
61
+ * named(value: string): void;
62
+ * contentModel(value: string): void;
63
+ * parseContentAs(value: string): void;
64
+ * positionalAttributes(...value: string[]): void;
65
+ * namePositionalAttributes(...value: string[]): void;
66
+ * positionalAttrs(...value: string[]): void;
67
+ * defaultAttributes(value: Record<string, string>): void;
68
+ * defaultAttrs(value: Record<string, string>): void;
69
+ * resolveAttributes(...args: unknown[]): void;
70
+ * resolvesAttributes(...args: unknown[]): void;
71
+ * }} SyntaxProcessorDslInterface
72
+ */
73
+
74
+ /**
75
+ * DSL interface for include processors.
76
+ *
77
+ * @typedef {DocumentProcessorDslInterface & {
78
+ * handles(fn: (doc: Document, target: string) => boolean): void;
79
+ * }} IncludeProcessorDslInterface
80
+ */
81
+
82
+ /**
83
+ * DSL interface for docinfo processors.
84
+ *
85
+ * @typedef {DocumentProcessorDslInterface & {
86
+ * atLocation(value: string): void;
87
+ * }} DocinfoProcessorDslInterface
88
+ */
89
+
90
+ /**
91
+ * DSL interface for block processors.
92
+ *
93
+ * @typedef {SyntaxProcessorDslInterface & {
94
+ * contexts(...value: (string | string[])[]): void;
95
+ * onContexts(...value: (string | string[])[]): void;
96
+ * onContext(...value: (string | string[])[]): void;
97
+ * bindTo(...value: (string | string[])[]): void;
98
+ * }} BlockProcessorDslInterface
99
+ */
100
+
101
+ /**
102
+ * DSL interface for macro processors (block and inline macros).
103
+ *
104
+ * @typedef {SyntaxProcessorDslInterface} MacroProcessorDslInterface
105
+ */
106
+
107
+ /**
108
+ * DSL interface for inline macro processors.
109
+ *
110
+ * @typedef {MacroProcessorDslInterface & {
111
+ * format(value: string): void;
112
+ * matchFormat(value: string): void;
113
+ * usingFormat(value: string): void;
114
+ * match(value: RegExp): void;
115
+ * }} InlineMacroProcessorDslInterface
116
+ */
117
+
30
118
  // ── DSL Mixins ────────────────────────────────────────────────────────────────
31
119
 
32
120
  /**
@@ -625,7 +713,12 @@ export class Processor {
625
713
  * Extensions.register(function () { this.preprocessor(CommentStripPreprocessor) })
626
714
  */
627
715
  export class Preprocessor extends Processor {
628
- process(_document, _reader) {
716
+ /**
717
+ * @param {Document} document - The document being parsed.
718
+ * @param {Reader} reader - The reader positioned at the beginning of the source.
719
+ * @returns {Reader|undefined} The same or a substitute Reader, or undefined to use the original.
720
+ */
721
+ process(document, reader) {
629
722
  throw new Error(
630
723
  `${this.constructor.name} must implement the process method`
631
724
  )
@@ -650,7 +743,11 @@ Preprocessor.DSL = DocumentProcessorDsl
650
743
  * Extensions.register(function () { this.treeProcessor(ShoutTreeProcessor) })
651
744
  */
652
745
  export class TreeProcessor extends Processor {
653
- process(_document) {
746
+ /**
747
+ * @param {Document} document - The parsed document.
748
+ * @returns {void}
749
+ */
750
+ process(document) {
654
751
  throw new Error(
655
752
  `${this.constructor.name} must implement the process method`
656
753
  )
@@ -679,7 +776,12 @@ export const Treeprocessor = TreeProcessor
679
776
  * Extensions.register(function () { this.postprocessor(FooterPostprocessor) })
680
777
  */
681
778
  export class Postprocessor extends Processor {
682
- process(_document, _output) {
779
+ /**
780
+ * @param {Document} document - The converted document.
781
+ * @param {string} output - The converted output string.
782
+ * @returns {string} The (possibly modified) output string.
783
+ */
784
+ process(document, output) {
683
785
  throw new Error(
684
786
  `${this.constructor.name} must implement the process method`
685
787
  )
@@ -693,13 +795,25 @@ Postprocessor.DSL = DocumentProcessorDsl
693
795
  * Implementations must extend IncludeProcessor.
694
796
  */
695
797
  export class IncludeProcessor extends Processor {
696
- process(_document, _reader, _target, _attributes) {
798
+ /**
799
+ * @param {Document} document - The document being parsed.
800
+ * @param {Reader} reader - The reader for the including document.
801
+ * @param {string} target - The target of the include directive.
802
+ * @param {Record<string, string>} attributes - The parsed include attributes.
803
+ * @returns {void}
804
+ */
805
+ process(document, reader, target, attributes) {
697
806
  throw new Error(
698
807
  `${this.constructor.name} must implement the process method`
699
808
  )
700
809
  }
701
810
 
702
- handles(_doc, _target) {
811
+ /**
812
+ * @param {Document} doc - The document being parsed.
813
+ * @param {string} target - The target of the include directive.
814
+ * @returns {boolean} true if this processor handles the given target.
815
+ */
816
+ handles(doc, target) {
703
817
  return true
704
818
  }
705
819
  }
@@ -717,7 +831,11 @@ export class DocinfoProcessor extends Processor {
717
831
  this.config.location ??= 'head'
718
832
  }
719
833
 
720
- process(_document) {
834
+ /**
835
+ * @param {Document} document - The document being converted.
836
+ * @returns {string} The docinfo content to inject into the document.
837
+ */
838
+ process(document) {
721
839
  throw new Error(
722
840
  `${this.constructor.name} must implement the process method`
723
841
  )
@@ -773,7 +891,13 @@ export class BlockProcessor extends Processor {
773
891
  this.config.content_model ??= 'compound'
774
892
  }
775
893
 
776
- process(_parent, _reader, _attributes) {
894
+ /**
895
+ * @param {AbstractBlock} parent - The enclosing block.
896
+ * @param {Reader} reader - The reader positioned at the block content.
897
+ * @param {Record<string, unknown>} attributes - The parsed block attributes.
898
+ * @returns {Block|void} A block node, or void to let the parser handle it.
899
+ */
900
+ process(parent, reader, attributes) {
777
901
  throw new Error(
778
902
  `${this.constructor.name} must implement the process method`
779
903
  )
@@ -791,7 +915,13 @@ export class MacroProcessor extends Processor {
791
915
  this.config.content_model ??= 'attributes'
792
916
  }
793
917
 
794
- process(_parent, _target, _attributes) {
918
+ /**
919
+ * @param {AbstractBlock} parent - The enclosing block.
920
+ * @param {string} target - The macro target (text between `name:` and `[`).
921
+ * @param {Record<string, unknown>} attributes - The parsed macro attributes.
922
+ * @returns {Block|Inline|void}
923
+ */
924
+ process(parent, target, attributes) {
795
925
  throw new Error(
796
926
  `${this.constructor.name} must implement the process method`
797
927
  )
@@ -837,6 +967,16 @@ export class BlockMacroProcessor extends MacroProcessor {
837
967
  set name(value) {
838
968
  this._name = value
839
969
  }
970
+
971
+ /**
972
+ * @param {AbstractBlock} parent - The enclosing block.
973
+ * @param {string} target - The macro target.
974
+ * @param {Record<string, unknown>} attributes - The parsed macro attributes.
975
+ * @returns {Block} A block node created with one of the `createBlock` helpers.
976
+ */
977
+ process(parent, target, attributes) {
978
+ return super.process(parent, target, attributes)
979
+ }
840
980
  }
841
981
  BlockMacroProcessor.DSL = MacroProcessorDsl
842
982
 
@@ -880,6 +1020,16 @@ export class InlineMacroProcessor extends MacroProcessor {
880
1020
  ))
881
1021
  }
882
1022
 
1023
+ /**
1024
+ * @param {AbstractBlock} parent - The enclosing block.
1025
+ * @param {string} target - The macro target.
1026
+ * @param {Record<string, unknown>} attributes - The parsed macro attributes.
1027
+ * @returns {Inline} An Inline node created with `this.createInline(...)`.
1028
+ */
1029
+ process(parent, target, attributes) {
1030
+ return super.process(parent, target, attributes)
1031
+ }
1032
+
883
1033
  resolveRegexp(name, format) {
884
1034
  if (!MacroNameRx.test(name)) {
885
1035
  throw new Error(`invalid name for inline macro: ${name}`)
@@ -1263,6 +1413,15 @@ export class Registry {
1263
1413
  return !!this._block_extensions
1264
1414
  }
1265
1415
 
1416
+ /**
1417
+ * Retrieve all BlockProcessor Extension proxy objects.
1418
+ *
1419
+ * @returns {ProcessorExtension[]}
1420
+ */
1421
+ blocks() {
1422
+ return this._block_extensions ? Object.values(this._block_extensions) : []
1423
+ }
1424
+
1266
1425
  /**
1267
1426
  * Check whether a BlockProcessor is registered for the given name and context.
1268
1427
  *
@@ -1309,6 +1468,17 @@ export class Registry {
1309
1468
  return !!this._block_macro_extensions
1310
1469
  }
1311
1470
 
1471
+ /**
1472
+ * Retrieve all BlockMacroProcessor Extension proxy objects.
1473
+ *
1474
+ * @returns {ProcessorExtension[]}
1475
+ */
1476
+ blockMacros() {
1477
+ return this._block_macro_extensions
1478
+ ? Object.values(this._block_macro_extensions)
1479
+ : []
1480
+ }
1481
+
1312
1482
  /**
1313
1483
  * Check whether a BlockMacroProcessor is registered for the given name.
1314
1484
  *
@@ -1414,6 +1584,85 @@ export class Registry {
1414
1584
  return extension
1415
1585
  }
1416
1586
 
1587
+ // ── JavaScript-style accessors ───────────────────────────────────────────────
1588
+
1589
+ /** @returns {object} the plain Object that maps names to groups for this registry. */
1590
+ getGroups() {
1591
+ return this.groups
1592
+ }
1593
+
1594
+ /** Alias for {@link preprocessors}. */
1595
+ getPreprocessors() {
1596
+ return this.preprocessors()
1597
+ }
1598
+
1599
+ /** Alias for {@link treeProcessors}. */
1600
+ getTreeProcessors() {
1601
+ return this.treeProcessors()
1602
+ }
1603
+
1604
+ /** Alias for {@link includeProcessors}. */
1605
+ getIncludeProcessors() {
1606
+ return this.includeProcessors()
1607
+ }
1608
+
1609
+ /** Alias for {@link postprocessors}. */
1610
+ getPostprocessors() {
1611
+ return this.postprocessors()
1612
+ }
1613
+
1614
+ /**
1615
+ * Alias for {@link docinfoProcessors}.
1616
+ *
1617
+ * @param {string|null} [location=null]
1618
+ */
1619
+ getDocinfoProcessors(location = null) {
1620
+ return this.docinfoProcessors(location)
1621
+ }
1622
+
1623
+ /** Alias for {@link blocks}. */
1624
+ getBlocks() {
1625
+ return this.blocks()
1626
+ }
1627
+
1628
+ /** Alias for {@link blockMacros}. */
1629
+ getBlockMacros() {
1630
+ return this.blockMacros()
1631
+ }
1632
+
1633
+ /** Alias for {@link inlineMacros}. */
1634
+ getInlineMacros() {
1635
+ return this.inlineMacros()
1636
+ }
1637
+
1638
+ /**
1639
+ * Alias for {@link registeredForInlineMacro}.
1640
+ *
1641
+ * @param {string} name
1642
+ */
1643
+ getInlineMacroFor(name) {
1644
+ return this.registeredForInlineMacro(name)
1645
+ }
1646
+
1647
+ /**
1648
+ * Alias for {@link registeredForBlock}.
1649
+ *
1650
+ * @param {string} name
1651
+ * @param {string} context
1652
+ */
1653
+ getBlockFor(name, context) {
1654
+ return this.registeredForBlock(name, context)
1655
+ }
1656
+
1657
+ /**
1658
+ * Alias for {@link registeredForBlockMacro}.
1659
+ *
1660
+ * @param {string} name
1661
+ */
1662
+ getBlockMacroFor(name) {
1663
+ return this.registeredForBlockMacro(name)
1664
+ }
1665
+
1417
1666
  // ── Private helpers ──────────────────────────────────────────────────────────
1418
1667
 
1419
1668
  /** @internal */
@@ -1639,6 +1888,15 @@ export const Extensions = {
1639
1888
  return _groups
1640
1889
  },
1641
1890
 
1891
+ /**
1892
+ * Alias for {@link groups}.
1893
+ *
1894
+ * @returns {object}
1895
+ */
1896
+ getGroups() {
1897
+ return this.groups()
1898
+ },
1899
+
1642
1900
  /**
1643
1901
  * Create a new Registry, optionally pre-populated with a named block.
1644
1902
  *
package/src/helpers.js CHANGED
@@ -38,7 +38,16 @@ const rstrip = (line) => line.replace(/[ \t\r\n\f\v]+$/, '')
38
38
  */
39
39
  export function prepareSourceArray(data, trimEnd = true) {
40
40
  if (!data.length) return []
41
- if (data[0].startsWith(BOM)) data[0] = data[0].slice(1)
41
+ if (data[0].startsWith(BOM)) {
42
+ data[0] = data[0].slice(1)
43
+ } else if (
44
+ data[0].charCodeAt(0) === 0xef &&
45
+ data[0].charCodeAt(1) === 0xbb &&
46
+ data[0].charCodeAt(2) === 0xbf
47
+ ) {
48
+ // Strip UTF-8 BOM encoded as three raw characters ( / \xEF\xBB\xBF) if not already decoded to U+FEFF
49
+ data[0] = data[0].slice(3)
50
+ }
42
51
  // Strip trailing \r to normalize Windows CRLF line endings (lines were split on \n, leaving \r).
43
52
  return trimEnd
44
53
  ? data.map(rstrip)
@@ -58,7 +67,16 @@ export function prepareSourceArray(data, trimEnd = true) {
58
67
  */
59
68
  export function prepareSourceString(data, trimEnd = true) {
60
69
  if (!data) return []
61
- if (data.startsWith(BOM)) data = data.slice(1)
70
+ if (data.startsWith(BOM)) {
71
+ data = data.slice(1)
72
+ } else if (
73
+ data.charCodeAt(0) === 0xef &&
74
+ data.charCodeAt(1) === 0xbb &&
75
+ data.charCodeAt(2) === 0xbf
76
+ ) {
77
+ // Strip UTF-8 BOM encoded as three raw characters ( / \xEF\xBB\xBF) if not already decoded to U+FEFF
78
+ data = data.slice(3)
79
+ }
62
80
  // Normalize Windows CRLF to LF so that split('\n') does not leave trailing \r on each line.
63
81
  if (data.includes('\r'))
64
82
  data = data.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
package/src/index.js CHANGED
@@ -28,6 +28,18 @@ import {
28
28
  BlockMacroProcessor,
29
29
  Extensions,
30
30
  } from './extensions.js'
31
+
32
+ // Re-export DSL interface types for TypeScript consumers.
33
+ /**
34
+ * @typedef {import('./extensions.js').ProcessorDslInterface} ProcessorDslInterface
35
+ * @typedef {import('./extensions.js').DocumentProcessorDslInterface} DocumentProcessorDslInterface
36
+ * @typedef {import('./extensions.js').SyntaxProcessorDslInterface} SyntaxProcessorDslInterface
37
+ * @typedef {import('./extensions.js').IncludeProcessorDslInterface} IncludeProcessorDslInterface
38
+ * @typedef {import('./extensions.js').DocinfoProcessorDslInterface} DocinfoProcessorDslInterface
39
+ * @typedef {import('./extensions.js').BlockProcessorDslInterface} BlockProcessorDslInterface
40
+ * @typedef {import('./extensions.js').MacroProcessorDslInterface} MacroProcessorDslInterface
41
+ * @typedef {import('./extensions.js').InlineMacroProcessorDslInterface} InlineMacroProcessorDslInterface
42
+ */
31
43
  import {
32
44
  Converter,
33
45
  ConverterBase,
package/src/load.js CHANGED
@@ -17,7 +17,7 @@
17
17
 
18
18
  import { SpaceDelimiterRx, EscapedSpaceRx } from './rx.js'
19
19
  import { NULL, BACKEND_ALIASES } from './constants.js'
20
- import { LoggerManager, NullLogger } from './logging.js'
20
+ import { NullLogger, withLogger, getContextLogger } from './logging.js'
21
21
  import { basename, extname } from './helpers.js'
22
22
  import { Document } from './document.js'
23
23
  import { Converter } from './converter.js'
@@ -58,17 +58,23 @@ export async function load(input, options = {}) {
58
58
  // Shallow-copy options so we don't mutate the caller's object.
59
59
  options = Object.assign({}, options)
60
60
 
61
- const timings = options.timings ?? null
62
- if (timings) timings.start('read')
63
-
64
61
  // ── Logger override ───────────────────────────────────────────────────────
62
+ // When a logger option is supplied, run the conversion in an async-local
63
+ // context so the logger is scoped to this call only — no global mutation,
64
+ // which makes concurrent callers (e.g. parallel Deno tests) safe.
65
65
  if ('logger' in options) {
66
- const logger = options.logger
67
- if (logger !== LoggerManager.logger) {
68
- LoggerManager.logger = logger ?? new NullLogger()
69
- }
66
+ const newLogger = options.logger ?? new NullLogger()
67
+ delete options.logger
68
+ return withLogger(newLogger, () => _doLoad(input, options, newLogger))
70
69
  }
71
70
 
71
+ return _doLoad(input, options)
72
+ }
73
+
74
+ async function _doLoad(input, options, explicitLogger = null) {
75
+ const timings = options.timings ?? null
76
+ if (timings) timings.start('read')
77
+
72
78
  // ── Attributes normalisation ──────────────────────────────────────────────
73
79
  let attrs = options.attributes
74
80
  if (!attrs) {
@@ -100,11 +106,9 @@ export async function load(input, options = {}) {
100
106
  attrs.docname = basename(inputPath, docfilesuffix)
101
107
  }
102
108
  source = await _readStream(input)
103
- } else if (
104
- typeof input === 'object' &&
105
- input?.constructor?.name === 'Buffer'
106
- ) {
107
- source = input.toString('utf8')
109
+ } else if (input instanceof Uint8Array) {
110
+ // Covers both Node.js Buffer (a Uint8Array subclass) and browser Uint8Array shims.
111
+ source = new TextDecoder('utf-8').decode(input)
108
112
  } else if (typeof input === 'string') {
109
113
  source = input
110
114
  } else if (Array.isArray(input)) {
@@ -167,6 +171,19 @@ export async function load(input, options = {}) {
167
171
  }
168
172
 
169
173
  if (timings) timings.record('parse')
174
+
175
+ // Persist the logger on the Document instance so that doc.convert()
176
+ // (called by convert.js after the async-local context ends) still routes
177
+ // logging through the caller-supplied logger.
178
+ // ALS provides it in Node.js/Deno; the explicit parameter covers browser fallback.
179
+ const contextLogger = getContextLogger() ?? explicitLogger
180
+ if (contextLogger) {
181
+ Object.defineProperty(doc, 'logger', {
182
+ get: () => contextLogger,
183
+ configurable: true,
184
+ })
185
+ }
186
+
170
187
  return doc
171
188
  }
172
189