@asciidoctor/core 3.0.4 → 4.0.0-alpha.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.
Files changed (92) hide show
  1. package/README.md +42 -10
  2. package/build/browser/index.js +24156 -0
  3. package/build/node/index.cjs +24737 -0
  4. package/{dist/css/asciidoctor.css → data/asciidoctor-default.css} +54 -53
  5. package/package.json +55 -97
  6. package/src/abstract_block.js +857 -0
  7. package/src/abstract_node.js +954 -0
  8. package/src/attribute_entry.js +12 -0
  9. package/src/attribute_list.js +380 -0
  10. package/src/block.js +168 -0
  11. package/src/browser/asset.js +22 -0
  12. package/src/browser/reader.js +138 -0
  13. package/src/browser.js +121 -0
  14. package/src/callouts.js +85 -0
  15. package/src/compliance.js +54 -0
  16. package/src/constants.js +665 -0
  17. package/src/convert.js +370 -0
  18. package/src/converter/composite.js +83 -0
  19. package/src/converter/docbook5.js +1031 -0
  20. package/src/converter/html5.js +1893 -0
  21. package/src/converter/manpage.js +935 -0
  22. package/src/converter/template.js +459 -0
  23. package/src/converter.js +478 -0
  24. package/src/data/stylesheet-data.js +2 -0
  25. package/src/document.js +2134 -0
  26. package/src/extensions.js +1952 -0
  27. package/src/footnote.js +28 -0
  28. package/src/helpers.js +355 -0
  29. package/src/index.js +138 -0
  30. package/src/inline.js +158 -0
  31. package/src/list.js +240 -0
  32. package/src/load.js +276 -0
  33. package/src/logging.js +526 -0
  34. package/src/parser.js +3661 -0
  35. package/src/path_resolver.js +472 -0
  36. package/src/reader.js +1755 -0
  37. package/src/rx.js +829 -0
  38. package/src/section.js +354 -0
  39. package/src/stylesheets.js +30 -0
  40. package/src/substitutors.js +2241 -0
  41. package/src/syntaxHighlighter/highlightjs.js +90 -0
  42. package/src/syntaxHighlighter/html_pipeline.js +33 -0
  43. package/src/syntax_highlighter.js +304 -0
  44. package/src/table.js +952 -0
  45. package/src/timings.js +78 -0
  46. package/types/abstract_block.d.ts +346 -0
  47. package/types/abstract_node.d.ts +471 -0
  48. package/types/attribute_entry.d.ts +7 -0
  49. package/types/attribute_list.d.ts +52 -0
  50. package/types/block.d.ts +55 -0
  51. package/types/browser/asset.d.ts +7 -0
  52. package/types/browser/reader.d.ts +29 -0
  53. package/types/callouts.d.ts +36 -0
  54. package/types/compliance.d.ts +23 -0
  55. package/types/constants.d.ts +268 -0
  56. package/types/convert.d.ts +34 -0
  57. package/types/converter/composite.d.ts +20 -0
  58. package/types/converter/docbook5.d.ts +41 -0
  59. package/types/converter/html5.d.ts +51 -0
  60. package/types/converter/manpage.d.ts +59 -0
  61. package/types/converter/template.d.ts +83 -0
  62. package/types/converter.d.ts +150 -0
  63. package/types/data/stylesheet-data.d.ts +2 -0
  64. package/types/document.d.ts +495 -0
  65. package/types/extensions.d.ts +876 -0
  66. package/types/footnote.d.ts +18 -0
  67. package/types/helpers.d.ts +146 -0
  68. package/types/index.d.cts +75 -0
  69. package/types/index.d.ts +73 -3731
  70. package/types/inline.d.ts +69 -0
  71. package/types/list.d.ts +114 -0
  72. package/types/load.d.ts +39 -0
  73. package/types/logging.d.ts +187 -0
  74. package/types/parser.d.ts +114 -0
  75. package/types/path_resolver.d.ts +103 -0
  76. package/types/reader.d.ts +184 -0
  77. package/types/rx.d.ts +513 -0
  78. package/types/section.d.ts +122 -0
  79. package/types/stylesheets.d.ts +10 -0
  80. package/types/substitutors.d.ts +208 -0
  81. package/types/syntaxHighlighter/highlightjs.d.ts +33 -0
  82. package/types/syntaxHighlighter/html_pipeline.d.ts +16 -0
  83. package/types/syntax_highlighter.d.ts +167 -0
  84. package/types/table.d.ts +231 -0
  85. package/types/timings.d.ts +25 -0
  86. package/types/tsconfig.json +9 -0
  87. package/LICENSE +0 -21
  88. package/dist/browser/asciidoctor.js +0 -47654
  89. package/dist/browser/asciidoctor.min.js +0 -1452
  90. package/dist/graalvm/asciidoctor.js +0 -47402
  91. package/dist/node/asciidoctor.cjs +0 -21567
  92. package/dist/node/asciidoctor.js +0 -23037
@@ -0,0 +1,2134 @@
1
+ /** @import { Reader } from './reader.js' */
2
+
3
+ // ESM conversion of document.rb
4
+ //
5
+ // Ruby-to-JavaScript notes:
6
+ // - Document extends AbstractBlock with super(self, 'document').
7
+ // - Ruby Struct → plain class with named properties.
8
+ // - Ruby `parse unless @parsed` → synchronous call since JS parse is synchronous.
9
+ // - Extensions / SyntaxHighlighter are optional; Extensions is statically imported,
10
+ // SyntaxHighlighter is loaded lazily by the converter pipeline.
11
+ // - File.write / process.env / Time.now have Node.js equivalents.
12
+ // - Mutex / thread-safety not applicable in single-threaded JS.
13
+ // - `instance_variable_get :@attribute_overrides` → direct property access.
14
+
15
+ import { AbstractBlock } from './abstract_block.js'
16
+ import { Section } from './section.js'
17
+ import { Inline } from './inline.js'
18
+ import { Callouts } from './callouts.js'
19
+ import { PathResolver } from './path_resolver.js'
20
+ import { Compliance } from './compliance.js'
21
+ import { extname, nextval } from './helpers.js'
22
+ import {
23
+ SafeMode,
24
+ DEFAULT_ATTRIBUTES,
25
+ DEFAULT_BACKEND,
26
+ DEFAULT_DOCTYPE,
27
+ BACKEND_ALIASES,
28
+ DEFAULT_PAGE_WIDTHS,
29
+ FLEXIBLE_ATTRIBUTES,
30
+ USER_HOME,
31
+ } from './constants.js'
32
+ import { Converter, CustomFactory, deriveBackendTraits } from './converter.js'
33
+ import { XmlSanitizeRx, AttributeEntryPassMacroRx } from './rx.js'
34
+ import { LF } from './constants.js'
35
+ import { applyLogging } from './logging.js'
36
+ import { SyntaxHighlighter, DefaultFactoryProxy } from './syntax_highlighter.js'
37
+ import { PreprocessorReader, Cursor } from './reader.js'
38
+ import { Parser } from './parser.js'
39
+ import { Extensions, Registry } from './extensions.js'
40
+
41
+ // ── Helper structs ────────────────────────────────────────────────────────────
42
+
43
+ export class ImageReference {
44
+ constructor(target, imagesdir) {
45
+ this.target = target
46
+ this.imagesdir = imagesdir
47
+ }
48
+
49
+ /**
50
+ * @returns {string} the target image path or URI.
51
+ */
52
+ getTarget() {
53
+ return this.target
54
+ }
55
+
56
+ /**
57
+ * @returns {string} the images directory.
58
+ */
59
+ getImagesDirectory() {
60
+ return this.imagesdir
61
+ }
62
+
63
+ toString() {
64
+ return this.target
65
+ }
66
+ }
67
+
68
+ import { Footnote } from './footnote.js'
69
+ export { Footnote }
70
+
71
+ import { AttributeEntry } from './attribute_entry.js'
72
+ export { AttributeEntry }
73
+
74
+ /**
75
+ * Parsed and stores a partitioned title (title & subtitle).
76
+ */
77
+ export class DocumentTitle {
78
+ constructor(val, opts = {}) {
79
+ this._sanitized = !!(opts.sanitize && val.includes('<'))
80
+ if (this._sanitized) {
81
+ val = val.replace(XmlSanitizeRx, '').replace(/ {2,}/g, ' ').trim()
82
+ }
83
+ const sep = opts.separator ?? ':'
84
+ const sepStr = sep ? `${sep} ` : null
85
+ if (!sepStr || !val.includes(sepStr)) {
86
+ this.main = val
87
+ this.subtitle = null
88
+ } else {
89
+ const idx = val.lastIndexOf(sepStr)
90
+ this.main = val.slice(0, idx)
91
+ this.subtitle = val.slice(idx + sepStr.length)
92
+ }
93
+ this.combined = val
94
+ }
95
+
96
+ get title() {
97
+ return this.main
98
+ }
99
+
100
+ isSanitized() {
101
+ return this._sanitized
102
+ }
103
+ hasSubtitle() {
104
+ return this.subtitle != null
105
+ }
106
+ getMain() {
107
+ return this.main
108
+ }
109
+ getCombined() {
110
+ return this.combined
111
+ }
112
+ getSubtitle() {
113
+ return this.subtitle
114
+ }
115
+ toString() {
116
+ return this.combined
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Represents an Author parsed from document attributes.
122
+ */
123
+ export class Author {
124
+ constructor(name, firstname, middlename, lastname, initials, email) {
125
+ this.name = name
126
+ this.firstname = firstname
127
+ this.middlename = middlename
128
+ this.lastname = lastname
129
+ this.initials = initials
130
+ this.email = email
131
+ }
132
+
133
+ getName() {
134
+ return this.name
135
+ }
136
+ getFirstName() {
137
+ return this.firstname
138
+ }
139
+ getMiddleName() {
140
+ return this.middlename
141
+ }
142
+ getLastName() {
143
+ return this.lastname
144
+ }
145
+ getInitials() {
146
+ return this.initials
147
+ }
148
+ getEmail() {
149
+ return this.email
150
+ }
151
+ }
152
+
153
+ export class RevisionInfo {
154
+ constructor(number, date, remark) {
155
+ this._number = number ?? null
156
+ this._date = date ?? null
157
+ this._remark = remark ?? null
158
+ }
159
+
160
+ isEmpty() {
161
+ return !this._number && !this._date && !this._remark
162
+ }
163
+ getNumber() {
164
+ return this._number
165
+ }
166
+ getDate() {
167
+ return this._date
168
+ }
169
+ getRemark() {
170
+ return this._remark
171
+ }
172
+ }
173
+
174
+ // ── Document ──────────────────────────────────────────────────────────────────
175
+
176
+ export class Document extends AbstractBlock {
177
+ /** @internal */
178
+ _converter
179
+ /** @internal */
180
+ _maxAttributeValueSize
181
+ /** @internal */
182
+ _docinfoProcessorExtensions
183
+ /** @internal */
184
+ _attributesModified
185
+ /** @internal */
186
+ _counters
187
+ /** @internal */
188
+ _headerAttributes
189
+ /** @internal */
190
+ _reftexts
191
+ /** @internal */
192
+ _parsed
193
+ /** @internal */
194
+ _inputMtime
195
+ /** @internal */
196
+ _parentDoctype
197
+ /** @internal */
198
+ _initializeExtensions
199
+ /** @internal */
200
+ _timings
201
+ /** @internal */
202
+ _attributeOverrides
203
+ /** @type {Reader} */
204
+ reader
205
+ /** @type {string} */
206
+ doctype
207
+ /** @type {string} */
208
+ baseDir
209
+ /** @type {string} */
210
+ backend
211
+ /** @type {number} */
212
+ safe
213
+ /** @type {boolean} */
214
+ compatMode
215
+ /** Override AbstractNode's getter so Document can own its converter directly. */
216
+ get converter() {
217
+ return this._converter
218
+ }
219
+ set converter(v) {
220
+ this._converter = v
221
+ }
222
+
223
+ constructor(data = null, options = {}) {
224
+ // Bootstrap: call super with a temporary placeholder — we'll fix parent ref below.
225
+ // AbstractBlock(parent, context, opts) — we pass `null` and patch afterward.
226
+ super(null, 'document', options)
227
+ // Document is its own parent/document (write _parent directly to avoid shadowing the accessor).
228
+ /** @internal */
229
+ this._parent = this
230
+ /** @internal */
231
+ this.document = this
232
+
233
+ const parentDoc = options.parent ?? null
234
+ delete options.parent
235
+
236
+ // ── Nested document setup ─────────────────────────────────────────────────
237
+ if (parentDoc) {
238
+ this.parentDocument = parentDoc
239
+ options.base_dir ??= parentDoc.baseDir
240
+ if (parentDoc.options.catalog_assets) options.catalog_assets = true
241
+ if (parentDoc.options.to_dir) options.to_dir = parentDoc.options.to_dir
242
+
243
+ this.catalog = { ...parentDoc.catalog, footnotes: [] }
244
+
245
+ // Clone parent's attribute overrides merged with parent attributes
246
+ this._attributeOverrides = {
247
+ ...parentDoc._attributeOverrides,
248
+ ...parentDoc.attributes,
249
+ }
250
+ const attrOverrides = this._attributeOverrides
251
+ delete attrOverrides['compat-mode']
252
+ const parentDoctype = attrOverrides.doctype
253
+ delete attrOverrides.doctype
254
+ delete attrOverrides.notitle
255
+ delete attrOverrides.showtitle
256
+ delete attrOverrides.toc
257
+ this.attributes['toc-placement'] =
258
+ attrOverrides['toc-placement'] ?? 'auto'
259
+ delete attrOverrides['toc-placement']
260
+ delete attrOverrides['toc-position']
261
+
262
+ this.safe = parentDoc.safe
263
+ this.compatMode = parentDoc.compatMode
264
+ if (this.compatMode) this.attributes['compat-mode'] = ''
265
+ this.outfilesuffix = parentDoc.outfilesuffix
266
+ this.sourcemap = parentDoc.sourcemap
267
+ this._timings = null
268
+ this.pathResolver = parentDoc.pathResolver
269
+ this.converter = parentDoc.converter
270
+ this.extensions = parentDoc.extensions
271
+ this.syntaxHighlighter = parentDoc.syntaxHighlighter
272
+ this._initializeExtensions = null
273
+
274
+ // For nested: re-use parent's @_parentDoctype
275
+ this._parentDoctype = parentDoctype
276
+ } else {
277
+ // ── Root document setup ───────────────────────────────────────────────
278
+ this.parentDocument = null
279
+ this.catalog = {
280
+ ids: {}, // deprecated
281
+ refs: {},
282
+ footnotes: [],
283
+ links: [],
284
+ images: [],
285
+ callouts: new Callouts(),
286
+ includes: {},
287
+ }
288
+
289
+ // Process attribute overrides from options
290
+ this._attributeOverrides = {}
291
+ const attrOverrides = this._attributeOverrides
292
+ for (let [key, val] of Object.entries(options.attributes ?? {})) {
293
+ if (key.endsWith('@')) {
294
+ if (key.startsWith('!')) {
295
+ key = key.slice(1, -1)
296
+ val = false
297
+ } else if (key.endsWith('!@')) {
298
+ key = key.slice(0, -2)
299
+ val = false
300
+ } else {
301
+ key = key.slice(0, -1)
302
+ val = `${val}@`
303
+ }
304
+ } else if (key.startsWith('!')) {
305
+ key = key.slice(1)
306
+ val = val === '@' ? false : null
307
+ } else if (key.endsWith('!')) {
308
+ key = key.slice(0, -1)
309
+ val = val === '@' ? false : null
310
+ }
311
+ attrOverrides[key.toLowerCase()] = val
312
+ }
313
+
314
+ if (typeof options.to_file === 'string') {
315
+ attrOverrides.outfilesuffix = extname(options.to_file)
316
+ }
317
+
318
+ // Resolve safe mode
319
+ const safeMode = options.safe
320
+ if (!safeMode) {
321
+ this.safe = SafeMode.SECURE
322
+ } else if (typeof safeMode === 'number') {
323
+ this.safe = safeMode
324
+ } else {
325
+ this.safe = SafeMode.valueForName(safeMode) ?? SafeMode.SECURE
326
+ }
327
+
328
+ this._inputMtime = options.input_mtime ?? null
329
+ delete options.input_mtime
330
+ this.compatMode = 'compat-mode' in attrOverrides
331
+ this.sourcemap = options.sourcemap ?? false
332
+ this._timings = options.timings ?? null
333
+ delete options.timings
334
+ this.pathResolver = new PathResolver()
335
+ if (options.extension_registry) {
336
+ this.extensions = options.extension_registry.activate(this)
337
+ } else {
338
+ this.extensions = null
339
+ // If no explicit registry but global extension groups are registered, activate them.
340
+ const globalGroups = Extensions.groups()
341
+ if (Object.keys(globalGroups).length > 0) {
342
+ this.extensions = new Registry()
343
+ this.extensions.activate(this)
344
+ }
345
+ }
346
+ this.syntaxHighlighter = null
347
+ this._initializeExtensions = true // set to class if available
348
+ this._parentDoctype = null
349
+
350
+ // Normalize :header_footer → :standalone
351
+ if ('header_footer' in options && !('standalone' in options)) {
352
+ options.standalone = options.header_footer
353
+ }
354
+ }
355
+
356
+ this._parsed = false
357
+ this._reftexts = null
358
+ this.header = null
359
+ this._headerAttributes = null
360
+ this._counters = {}
361
+ this._attributesModified = new Set()
362
+ this._docinfoProcessorExtensions = {}
363
+ const standalone = options.standalone ?? false
364
+ this.options = Object.freeze({ ...options })
365
+
366
+ const attrs = this.attributes
367
+
368
+ if (!parentDoc) {
369
+ attrs['attribute-undefined'] = Compliance.attributeUndefined
370
+ attrs['attribute-missing'] = Compliance.attributeMissing
371
+ Object.assign(attrs, DEFAULT_ATTRIBUTES)
372
+ }
373
+
374
+ if (standalone) {
375
+ delete this._attributeOverrides.embedded
376
+ attrs.copycss = ''
377
+ attrs['iconfont-remote'] = ''
378
+ attrs.stylesheet = ''
379
+ attrs.webfonts = ''
380
+ } else {
381
+ this._attributeOverrides.embedded = ''
382
+ const ao = this._attributeOverrides
383
+ const showtitle = ao.showtitle
384
+ const notitle = ao.notitle
385
+ if (
386
+ 'showtitle' in ao &&
387
+ ['showtitle', 'notitle'].filter((k) => k in ao).pop() === 'showtitle'
388
+ ) {
389
+ ao.notitle = { null: '', false: '@', '@': false }[showtitle]
390
+ } else if ('notitle' in ao) {
391
+ ao.showtitle = { null: '', false: '@', '@': false }[notitle]
392
+ } else {
393
+ attrs.notitle = ''
394
+ }
395
+ }
396
+
397
+ const attrOverrides = this._attributeOverrides
398
+ attrOverrides.asciidoctor = ''
399
+ attrOverrides['asciidoctor-version'] = '3.0.0.dev' // matches Ruby VERSION
400
+
401
+ const safeModeName = SafeMode.nameForValue(this.safe)
402
+ attrOverrides['safe-mode-name'] = safeModeName
403
+ attrOverrides[`safe-mode-${safeModeName}`] = ''
404
+ attrOverrides['safe-mode-level'] = this.safe
405
+ attrOverrides['max-include-depth'] ??= 64
406
+ attrOverrides['allow-uri-read'] ??= null
407
+
408
+ // Remap legacy attributes
409
+ if ('numbered' in attrOverrides) {
410
+ const _v = attrOverrides.numbered
411
+ delete attrOverrides.numbered
412
+ attrOverrides.sectnums = _v
413
+ }
414
+ if ('hardbreaks' in attrOverrides) {
415
+ const _v = attrOverrides.hardbreaks
416
+ delete attrOverrides.hardbreaks
417
+ attrOverrides['hardbreaks-option'] = _v
418
+ }
419
+
420
+ // Resolve base_dir
421
+ if (options.base_dir) {
422
+ this.baseDir = attrOverrides.docdir = _expandPath(options.base_dir)
423
+ } else if (attrOverrides.docdir) {
424
+ this.baseDir = attrOverrides.docdir
425
+ } else {
426
+ this.baseDir = attrOverrides.docdir = _cwd()
427
+ }
428
+
429
+ if (options.backend) attrOverrides.backend = String(options.backend)
430
+ if (options.doctype) attrOverrides.doctype = String(options.doctype)
431
+
432
+ if (this.safe >= SafeMode.SERVER) {
433
+ attrOverrides.copycss ??= null
434
+ attrOverrides['source-highlighter'] ??= null
435
+ attrOverrides.backend ??= DEFAULT_BACKEND
436
+ if (!parentDoc && 'docfile' in attrOverrides) {
437
+ const docdir = attrOverrides.docdir ?? ''
438
+ attrOverrides.docfile = attrOverrides.docfile.slice(docdir.length + 1)
439
+ }
440
+ attrOverrides.docdir = ''
441
+ attrOverrides['user-home'] ??= '.'
442
+ if (this.safe >= SafeMode.SECURE) {
443
+ if (!('max-attribute-value-size' in attrOverrides)) {
444
+ attrOverrides['max-attribute-value-size'] = 4096
445
+ }
446
+ attrOverrides.linkcss ??= ''
447
+ attrOverrides.icons ??= null
448
+ }
449
+ } else {
450
+ attrOverrides['user-home'] ??= USER_HOME
451
+ }
452
+
453
+ const sizeAttr = (attrOverrides['max-attribute-value-size'] ??= null)
454
+ this._maxAttributeValueSize =
455
+ sizeAttr != null ? Math.abs(parseInt(sizeAttr, 10)) : null
456
+
457
+ // Apply attribute overrides — overrides that survive (non-soft) stay in attrOverrides.
458
+ const softKeys = []
459
+ for (const [key, val] of Object.entries(attrOverrides)) {
460
+ if (val != null && val !== false) {
461
+ let effective = val
462
+ let isSoft = false
463
+ if (typeof val === 'string' && val.endsWith('@')) {
464
+ effective = val.slice(0, -1)
465
+ isSoft = true
466
+ }
467
+ attrs[key] = effective
468
+ if (isSoft) softKeys.push(key)
469
+ } else {
470
+ delete attrs[key]
471
+ if (val === false) softKeys.push(key) // false = soft-lock delete; null = hard-lock absent (stays in overrides)
472
+ }
473
+ }
474
+ for (const key of softKeys) delete attrOverrides[key]
475
+
476
+ if (parentDoc) {
477
+ this.backend = attrs.backend
478
+ const parentDoctype = this._parentDoctype
479
+ if ((this.doctype = attrs.doctype = parentDoctype) !== DEFAULT_DOCTYPE) {
480
+ this._updateDoctypeAttributes(DEFAULT_DOCTYPE)
481
+ }
482
+ // Set up reader only — parsing is deferred to Document.create() / doc.parse().
483
+ this.reader = new PreprocessorReader(this, data, options.cursor)
484
+ if (this.sourcemap) this.sourceLocation = this.reader.cursor
485
+ } else {
486
+ this.backend = null
487
+ const initialBackend = attrs.backend || DEFAULT_BACKEND
488
+ if (initialBackend === 'manpage') {
489
+ this.doctype = attrs.doctype = attrOverrides.doctype = 'manpage'
490
+ } else {
491
+ this.doctype = attrs.doctype ??= DEFAULT_DOCTYPE
492
+ }
493
+ this._updateBackendAttributes(initialBackend, true)
494
+
495
+ attrs.stylesdir ??= '.'
496
+ attrs.iconsdir ??= `${attrs.imagesdir ?? './images'}/icons`
497
+
498
+ this._fillDatetimeAttributes(attrs, this._inputMtime)
499
+
500
+ // Extensions initialization deferred — handle in parse()
501
+ this.reader = new PreprocessorReader(
502
+ this,
503
+ data,
504
+ new Cursor(attrs.docfile ?? null, this.baseDir),
505
+ { normalize: true }
506
+ )
507
+ if (this.sourcemap) this.sourceLocation = this.reader.cursor
508
+ }
509
+ }
510
+
511
+ /** Alias catalog as references (backwards compat). */
512
+ get references() {
513
+ return this.catalog
514
+ }
515
+
516
+ /** @returns {boolean} True if this is a nested (child) document. */
517
+ nested() {
518
+ return !!this.parentDocument
519
+ }
520
+
521
+ /**
522
+ * Factory — create and fully parse a Document asynchronously.
523
+ * @param {string|string[]|null} data - The AsciiDoc source.
524
+ * @param {Object} [options={}] - Processing options.
525
+ * @returns {Promise<Document>} The parsed Document.
526
+ */
527
+ static async create(data, options = {}) {
528
+ const doc = new Document(data, options)
529
+ await doc.parse()
530
+ return doc
531
+ }
532
+
533
+ /**
534
+ * Parse the AsciiDoc source and populate the document AST.
535
+ *
536
+ * This method is idempotent — repeated calls are no-ops once parsing is done.
537
+ * You rarely need to call it directly: prefer {@link Document.create} (factory) or
538
+ * the top-level {@link load} / {@link loadFile} functions, which call `parse()` for you.
539
+ *
540
+ * Call `parse()` explicitly only when you constructed `new Document(...)` by hand and
541
+ * need to defer the work, or when you want to supply a replacement `data` source.
542
+ *
543
+ * @param {string|string[]|null} [data=null] - Optional replacement source lines.
544
+ * When provided, replaces the data that was given to the constructor.
545
+ * @returns {Promise<Document>} This Document instance (allows chaining).
546
+ *
547
+ * @example
548
+ * const doc = new Document('= Hello', {})
549
+ * await doc.parse()
550
+ * console.log(doc.getTitle()) // → 'Hello'
551
+ */
552
+ async parse(data = null) {
553
+ if (this._parsed) return this
554
+
555
+ if (data) {
556
+ this.reader = new PreprocessorReader(
557
+ this,
558
+ data,
559
+ new Cursor(this.attributes.docfile ?? null, this.baseDir),
560
+ { normalize: true }
561
+ )
562
+ if (this.sourcemap) this.sourceLocation = this.reader.cursor
563
+ }
564
+
565
+ if (!this.parentDocument && this.extensions?.hasPreprocessors?.()) {
566
+ for (const ext of this.extensions.preprocessors()) {
567
+ this.reader = ext.processMethod(this, this.reader) ?? this.reader
568
+ }
569
+ }
570
+
571
+ await Parser.parse(this.reader, this, {
572
+ header_only: this.options.parse_header_only,
573
+ })
574
+ this.restoreAttributes()
575
+
576
+ if (!this.parentDocument && this.extensions?.hasTreeProcessors?.()) {
577
+ for (const ext of this.extensions.treeProcessors()) {
578
+ const result = ext.processMethod(this)
579
+ if (result instanceof Document && result !== this) {
580
+ return result
581
+ }
582
+ }
583
+ }
584
+
585
+ // Pre-compute all async text values (titles, list item text, cell text, reftexts)
586
+ // so that synchronous getters work correctly during conversion.
587
+ await this._resolveAllTexts(this)
588
+ // Reset the footnote counter so that body-content footnotes (processed during conversion)
589
+ // start numbering from 1, reproducing Ruby's "out of sequence" quirk: title footnotes are
590
+ // numbered during parsing via apply_title_subs, then the counter restarts for body content.
591
+ delete this.attributes['footnote-number']
592
+ delete this._counters['footnote-number']
593
+ // Pre-compute reftext for all registered inline anchor nodes.
594
+ for (const ref of Object.values(this.catalog.refs)) {
595
+ if (ref && typeof ref.precomputeReftext === 'function') {
596
+ await ref.precomputeReftext()
597
+ }
598
+ }
599
+ // Build the reftext→id lookup map so that resolveId() is synchronous.
600
+ await this._buildReftextsMap()
601
+
602
+ this._parsed = true
603
+ return this
604
+ }
605
+
606
+ /**
607
+ * Return whether the document has been fully parsed.
608
+ * @returns {boolean}
609
+ */
610
+ isParsed() {
611
+ return this._parsed
612
+ }
613
+
614
+ /**
615
+ * Get the named counter and advance it by one step.
616
+ *
617
+ * Counters are document-scoped sequences used for automatic numbering (figures,
618
+ * tables, custom labels, …). Each call increments the sequence and returns the
619
+ * new value. Numeric counters increment by 1; alphabetic counters advance through
620
+ * the alphabet (`'a'` → `'b'` → … → `'z'`).
621
+ *
622
+ * When the counter does not yet exist:
623
+ * - If `seed` is a number (or a string that parses as an integer), the counter starts at `seed`.
624
+ * - If `seed` is a letter (`'a'`–`'z'`), the counter starts at that letter.
625
+ * - If `seed` is `null` (default), the counter starts at `1`.
626
+ *
627
+ * @param {string} name - Counter name (document-scoped key).
628
+ * @param {string|number|null} [seed=null] - Starting value for new counters.
629
+ * @returns {string|number} The new counter value after incrementing.
630
+ *
631
+ * @example <caption>Numeric counter (auto-starts at 1)</caption>
632
+ * doc.counter('figure-number') // → 1
633
+ * doc.counter('figure-number') // → 2
634
+ *
635
+ * @example <caption>Alphabetic counter</caption>
636
+ * doc.counter('appendix-number', 'A') // → 'A'
637
+ * doc.counter('appendix-number', 'A') // → 'B'
638
+ *
639
+ * @example <caption>Numeric counter with custom start</caption>
640
+ * doc.counter('example-number', 5) // → 5
641
+ * doc.counter('example-number', 5) // → 6
642
+ */
643
+ counter(name, seed = null) {
644
+ if (this.parentDocument) return this.parentDocument.counter(name, seed)
645
+ const isLocked = this.isAttributeLocked(name)
646
+ let currVal = this._counters[name]
647
+ let nextVal
648
+ if (
649
+ (isLocked && currVal != null) ||
650
+ ((currVal = this.attributes[name]) != null && currVal !== '')
651
+ ) {
652
+ nextVal = this._counters[name] = nextval(currVal)
653
+ } else if (seed != null) {
654
+ nextVal = this._counters[name] =
655
+ String(seed) === String(parseInt(seed, 10)) ? parseInt(seed, 10) : seed
656
+ } else {
657
+ nextVal = this._counters[name] = 1
658
+ }
659
+ if (!isLocked) this.attributes[name] = nextVal
660
+ return nextVal
661
+ }
662
+
663
+ /**
664
+ * Increment the specified counter and store it in the block's attributes.
665
+ * @param {string} counterName
666
+ * @param {Object} block
667
+ * @returns {string|number} The new counter value.
668
+ */
669
+ incrementAndStoreCounter(counterName, block) {
670
+ return new AttributeEntry(counterName, this.counter(counterName)).saveTo(
671
+ block.attributes
672
+ ).value
673
+ }
674
+
675
+ /** @deprecated Use incrementAndStoreCounter instead. */
676
+ counterIncrement(counterName, block) {
677
+ return this.incrementAndStoreCounter(counterName, block)
678
+ }
679
+
680
+ /**
681
+ * Register a reference in the document catalog.
682
+ * @param {string} type - Catalog type ('ids', 'refs', 'footnotes', 'links', 'images', 'callouts').
683
+ * @param {*} value - The value to register.
684
+ */
685
+ register(type, value) {
686
+ switch (type) {
687
+ case 'ids': {
688
+ // deprecated
689
+ const id = value[0]
690
+ const ref = new Inline(this, 'anchor', value[1], { type: 'ref', id })
691
+ this.catalog.refs[id] ??= ref
692
+ // Keep _reftexts in sync if the map was already built (post-parse registration).
693
+ if (this._reftexts && value[1]) this._reftexts[value[1]] ??= id
694
+ return ref
695
+ }
696
+ case 'refs': {
697
+ const id = value[0]
698
+ if (id in this.catalog.refs) return false
699
+ this.catalog.refs[id] = value[1]
700
+ return true
701
+ }
702
+ case 'footnotes':
703
+ this.catalog.footnotes.push(value)
704
+ return
705
+ default:
706
+ if (this.options.catalog_assets) {
707
+ const entry =
708
+ type === 'images'
709
+ ? new ImageReference(value, this.attributes.imagesdir)
710
+ : value
711
+ this.catalog[type]?.push(entry)
712
+ }
713
+ }
714
+ }
715
+
716
+ /**
717
+ * Find the first registered reference matching the given reftext.
718
+ * @param {string} text - The reftext to look up.
719
+ * @returns {string|null} The matching ID, or null.
720
+ */
721
+ resolveId(text) {
722
+ if (this._reftexts) return this._reftexts[text] ?? null
723
+ // Fallback: scan refs synchronously (for documents not parsed via parse()).
724
+ for (const [id, ref] of Object.entries(this.catalog.refs)) {
725
+ const xreftext = ref.reftext ?? null
726
+ if (xreftext === text) return id
727
+ }
728
+ return null
729
+ }
730
+
731
+ /**
732
+ * @private
733
+ * @internal
734
+ * Build the reftext→id lookup map. Called at end of parse().
735
+ */
736
+ async _buildReftextsMap() {
737
+ this._reftexts = {}
738
+ for (const [id, ref] of Object.entries(this.catalog.refs)) {
739
+ const xreftext = ref.xreftext ? await ref.xreftext() : null
740
+ if (xreftext != null) this._reftexts[xreftext] ??= id
741
+ }
742
+ }
743
+
744
+ /** @returns {boolean} True if this document has child Section objects. */
745
+ hasSections() {
746
+ return this._nextSectionIndex > 0
747
+ }
748
+
749
+ isMultipart() {
750
+ if (this.doctype !== 'book') return undefined
751
+ return this.blocks.some((b) => {
752
+ if (b.context !== 'section') return false
753
+ if (b.level === 0) return true
754
+ if (!b.special) return false // break in Ruby → but some() handles this
755
+ return false
756
+ })
757
+ }
758
+
759
+ hasFootnotes() {
760
+ return this.catalog.footnotes.length > 0
761
+ }
762
+ get footnotes() {
763
+ return this.catalog.footnotes
764
+ }
765
+ get callouts() {
766
+ return this.catalog.callouts
767
+ }
768
+
769
+ isNested() {
770
+ return this.parentDocument != null
771
+ }
772
+ isEmbedded() {
773
+ return 'embedded' in this.attributes
774
+ }
775
+ hasExtensions() {
776
+ return this.extensions != null
777
+ }
778
+
779
+ source() {
780
+ return this.reader?.source?.() ?? null
781
+ }
782
+ sourceLines() {
783
+ return this.reader?.sourceLines ?? null
784
+ }
785
+
786
+ basebackend(base) {
787
+ return this.attributes.basebackend === base
788
+ }
789
+
790
+ /** @returns {string|null} The document title. */
791
+ get title() {
792
+ return this.doctitle()
793
+ }
794
+ set title(val) {
795
+ let sect = this.header
796
+ if (!sect) {
797
+ sect = this.header = new Section(this, 0)
798
+ sect.sectname = 'header'
799
+ }
800
+ sect.title = val
801
+ }
802
+
803
+ /**
804
+ * Resolve the primary title for the document.
805
+ * @param {Object} [opts={}]
806
+ * @param {boolean} [opts.use_fallback] - Use 'untitled-label' if no title found.
807
+ * @param {boolean|string} [opts.partition] - Return a DocumentTitle instead of a string.
808
+ * @param {boolean} [opts.sanitize] - Strip XML tags from the title.
809
+ * @returns {string|DocumentTitle|null}
810
+ */
811
+ doctitle(opts = {}) {
812
+ let val = this.attributes.title
813
+ if (val == null) {
814
+ const sect = this.firstSection()
815
+ if (sect) {
816
+ val = sect.title
817
+ } else if (opts.use_fallback) {
818
+ val = this.attributes['untitled-label']
819
+ }
820
+ if (val == null) return null
821
+ }
822
+ if (opts.partition) {
823
+ const sep =
824
+ opts.partition === true
825
+ ? this.attributes['title-separator']
826
+ : opts.partition
827
+ return new DocumentTitle(val, { ...opts, separator: sep })
828
+ }
829
+ if (opts.sanitize && val.includes('<')) {
830
+ return val.replace(XmlSanitizeRx, '').replace(/ {2,}/g, ' ').trim()
831
+ }
832
+ return val
833
+ }
834
+
835
+ get name() {
836
+ return this.doctitle()
837
+ }
838
+
839
+ /**
840
+ * @param {string|null} [_xrefstyle=null]
841
+ * @returns {Promise<string|null>}
842
+ */
843
+ xreftext(_xrefstyle = null) {
844
+ const val = this.reftext
845
+ return val && val.length > 0 ? val : this.title
846
+ }
847
+
848
+ get author() {
849
+ return this.attributes.author ?? null
850
+ }
851
+ get revdate() {
852
+ return this.attributes.revdate ?? null
853
+ }
854
+
855
+ authors() {
856
+ const attrs = this.attributes
857
+ if (!('author' in attrs)) return []
858
+ const list = [
859
+ new Author(
860
+ attrs.author,
861
+ attrs.firstname,
862
+ attrs.middlename,
863
+ attrs.lastname,
864
+ attrs.authorinitials,
865
+ attrs.email
866
+ ),
867
+ ]
868
+ const numAuthors = parseInt(attrs.authorcount ?? '0', 10)
869
+ for (let idx = 2; idx <= numAuthors; idx++) {
870
+ list.push(
871
+ new Author(
872
+ attrs[`author_${idx}`],
873
+ attrs[`firstname_${idx}`],
874
+ attrs[`middlename_${idx}`],
875
+ attrs[`lastname_${idx}`],
876
+ attrs[`authorinitials_${idx}`],
877
+ attrs[`email_${idx}`]
878
+ )
879
+ )
880
+ }
881
+ return list
882
+ }
883
+
884
+ isNotitle() {
885
+ return 'notitle' in this.attributes
886
+ }
887
+ isNoheader() {
888
+ return 'noheader' in this.attributes
889
+ }
890
+ isNofooter() {
891
+ return 'nofooter' in this.attributes
892
+ }
893
+
894
+ firstSection() {
895
+ return (
896
+ this.header ?? this.blocks.find((b) => b.context === 'section') ?? null
897
+ )
898
+ }
899
+
900
+ hasHeader() {
901
+ return this.header != null
902
+ }
903
+
904
+ /**
905
+ * Append a child Block, assigning index if it's a section.
906
+ * @param {Object} block
907
+ * @returns {Object} The appended block.
908
+ */
909
+ append(block) {
910
+ if (block.context === 'section') this.assignNumeral(block)
911
+ return super.append(block)
912
+ }
913
+
914
+ /**
915
+ * @private
916
+ * Called by parser after parsing header, before parsing body.
917
+ */
918
+ finalizeHeader(unrootedAttributes, headerValid = true) {
919
+ this._clearPlaybackAttributes(unrootedAttributes)
920
+ this._saveAttributes()
921
+ if (!headerValid) unrootedAttributes['invalid-header'] = true
922
+ return unrootedAttributes
923
+ }
924
+
925
+ /**
926
+ * Replay attribute assignments from block attributes.
927
+ * @param {Object} blockAttributes
928
+ */
929
+ playbackAttributes(blockAttributes) {
930
+ if (!('attribute_entries' in blockAttributes)) return
931
+ for (const entry of blockAttributes.attribute_entries) {
932
+ if (entry.negate) {
933
+ delete this.attributes[entry.name]
934
+ if (entry.name === 'compat-mode') this.compatMode = false
935
+ } else {
936
+ this.attributes[entry.name] = entry.value
937
+ if (entry.name === 'compat-mode') this.compatMode = true
938
+ }
939
+ }
940
+ }
941
+
942
+ /**
943
+ * Set the specified attribute if not locked, applying attribute value substitutions.
944
+ * @param {string} name
945
+ * @param {string} [value='']
946
+ * @returns {string|null} The substituted value, or `null` if the attribute is locked.
947
+ */
948
+ setAttribute(name, value = '') {
949
+ return this._setAttributeInternal(name, value, false)
950
+ }
951
+
952
+ /**
953
+ * Delete the specified attribute if not locked.
954
+ * @param {string} name
955
+ * @returns {boolean} True if deleted, false if locked.
956
+ */
957
+ deleteAttribute(name) {
958
+ if (this.isAttributeLocked(name)) return false
959
+ delete this.attributes[name]
960
+ this._attributesModified.add(name)
961
+ return true
962
+ }
963
+
964
+ /**
965
+ * Check if the attribute is locked (set via attribute overrides).
966
+ * @param {string} name
967
+ * @returns {boolean}
968
+ */
969
+ isAttributeLocked(name) {
970
+ return name in this._attributeOverrides
971
+ }
972
+
973
+ /** @deprecated Use isAttributeLocked instead. */
974
+ attributeLocked(name) {
975
+ return this.isAttributeLocked(name)
976
+ }
977
+
978
+ /**
979
+ * Assign a value to the specified attribute in the document header.
980
+ * @param {string} name
981
+ * @param {string} [value='']
982
+ * @param {boolean} [overwrite=true]
983
+ * @returns {boolean} False if the attribute exists and overwrite is false.
984
+ */
985
+ setHeaderAttribute(name, value = '', overwrite = true) {
986
+ const target = this._headerAttributes ?? this.attributes
987
+ if (!overwrite && name in target) return false
988
+ target[name] = value
989
+ return true
990
+ }
991
+
992
+ /**
993
+ * Convert the parsed document to its output format (HTML5 by default).
994
+ *
995
+ * If `parse()` has not been called yet, it is called automatically.
996
+ *
997
+ * @param {Object} [opts={}] - Conversion options.
998
+ * @param {boolean} [opts.standalone] - When `true`, wraps output in a full
999
+ * document shell (html/head/body). Defaults to the `standalone` option
1000
+ * passed at load time (which itself defaults to `true`).
1001
+ * @param {string} [opts.outfile] - Path of the output file; stored as the
1002
+ * `outfile` document attribute during conversion.
1003
+ * @param {string} [opts.outdir] - Directory of the output file; stored as the
1004
+ * `outdir` document attribute during conversion.
1005
+ * @returns {Promise<string>} The converted output string.
1006
+ *
1007
+ * @example <caption>Embedded HTML (no html/head/body wrapper)</caption>
1008
+ * const doc = await Document.create('= Hello\nWorld', {})
1009
+ * const html = await doc.convert({ standalone: false })
1010
+ *
1011
+ * @example <caption>Full standalone HTML page</caption>
1012
+ * const html = await doc.convert({ standalone: true })
1013
+ */
1014
+ async convert(opts = {}) {
1015
+ if (this._timings) this._timings.start('convert')
1016
+ await this.parse()
1017
+ // Pre-compute AsciiDoc table cell content now that parse is done:
1018
+ // callouts are rewound and all refs are registered.
1019
+ if (!this.parentDocument) await this._convertAsciiDocCells()
1020
+ if (this.safe < SafeMode.SERVER && Object.keys(opts).length > 0) {
1021
+ if (!opts.outfile) delete this.attributes.outfile
1022
+ else this.attributes.outfile = opts.outfile
1023
+ if (!opts.outdir) delete this.attributes.outdir
1024
+ else this.attributes.outdir = opts.outdir
1025
+ }
1026
+
1027
+ let output
1028
+ if (this.doctype === 'inline') {
1029
+ const block = this.blocks[0] ?? this.header
1030
+ if (block) {
1031
+ if (
1032
+ block.contentModel === 'compound' ||
1033
+ block.contentModel === 'empty'
1034
+ ) {
1035
+ this.logger.warn(
1036
+ 'no inline candidate; use the inline doctype to convert a single paragraph, verbatim, or raw block'
1037
+ )
1038
+ } else {
1039
+ output = await block.content()
1040
+ }
1041
+ }
1042
+ } else {
1043
+ let transform
1044
+ if ('standalone' in opts) {
1045
+ transform = opts.standalone ? 'document' : 'embedded'
1046
+ } else if ('header_footer' in opts) {
1047
+ transform = opts.header_footer ? 'document' : 'embedded'
1048
+ } else {
1049
+ transform = this.options.standalone ? 'document' : 'embedded'
1050
+ }
1051
+ output = await this.converter.convert(this, transform)
1052
+ }
1053
+
1054
+ if (!this.parentDocument && this.extensions?.hasPostprocessors?.()) {
1055
+ for (const ext of this.extensions.postprocessors()) {
1056
+ output = ext.processMethod(this, output)
1057
+ }
1058
+ }
1059
+
1060
+ if (this._timings) this._timings.record('convert')
1061
+ return output
1062
+ }
1063
+
1064
+ /** @deprecated Use convert instead. */
1065
+ render(opts = {}) {
1066
+ return this.convert(opts)
1067
+ }
1068
+
1069
+ /**
1070
+ * Write converted output to a file path or a writable stream.
1071
+ *
1072
+ * When `target` is a **string**, the output is written to that file path using
1073
+ * `node:fs/promises.writeFile`.
1074
+ * When `target` is a **writable stream** (has a `.write()` method), the output
1075
+ * is written to the stream in two chunks (content + newline).
1076
+ * When the converter itself implements `write()`, that method is called instead.
1077
+ *
1078
+ * @param {string} output - The converted output string returned by {@link convert}.
1079
+ * @param {string|import('stream').Writable} target - File path or writable stream.
1080
+ * @returns {Promise<void>}
1081
+ *
1082
+ * @example <caption>Write to a file</caption>
1083
+ * const output = await doc.convert()
1084
+ * await doc.write(output, 'out/index.html')
1085
+ *
1086
+ * @example <caption>Write to a stream</caption>
1087
+ * await doc.write(output, process.stdout)
1088
+ */
1089
+ async write(output, target) {
1090
+ if (this._timings) this._timings.start('write')
1091
+ if (typeof this.converter.write === 'function') {
1092
+ this.converter.write(output, target)
1093
+ } else {
1094
+ if (target && typeof target.write === 'function') {
1095
+ if (output && output.length > 0) {
1096
+ target.write(output.replace(/\n$/, ''))
1097
+ target.write(LF)
1098
+ }
1099
+ } else {
1100
+ try {
1101
+ const { writeFile } = await import('node:fs/promises')
1102
+ await writeFile(target, output ?? '', 'utf8')
1103
+ } catch {}
1104
+ }
1105
+ if (
1106
+ this.backend === 'manpage' &&
1107
+ typeof target === 'string' &&
1108
+ typeof this.converter.constructor?.writeAlternatePages === 'function'
1109
+ ) {
1110
+ this.converter.constructor.writeAlternatePages(
1111
+ this.attributes.mannames,
1112
+ this.attributes.manvolnum,
1113
+ target
1114
+ )
1115
+ }
1116
+ }
1117
+ if (this._timings) this._timings.record('write')
1118
+ }
1119
+
1120
+ async content() {
1121
+ delete this.attributes.title
1122
+ return super.content()
1123
+ }
1124
+
1125
+ /**
1126
+ * Read the docinfo file(s) for inclusion in the document template.
1127
+ * @param {string} [location='head'] - 'head' or 'footer'.
1128
+ * @param {string|null} [suffix=null] - File suffix override.
1129
+ * @returns {Promise<string>} Combined docinfo content.
1130
+ */
1131
+ async docinfo(location = 'head', suffix = null) {
1132
+ let content = null
1133
+ if (this.safe < SafeMode.SECURE) {
1134
+ const qualifier = location !== 'head' ? `-${location}` : ''
1135
+ suffix ??= this.outfilesuffix
1136
+
1137
+ let docinfo = this.attributes.docinfo
1138
+ if (!docinfo) {
1139
+ if ('docinfo2' in this.attributes) {
1140
+ docinfo = ['private', 'shared']
1141
+ } else if ('docinfo1' in this.attributes) {
1142
+ docinfo = ['shared']
1143
+ } else {
1144
+ docinfo = docinfo != null ? ['private'] : null
1145
+ }
1146
+ } else {
1147
+ docinfo = docinfo.split(',').map((k) => k.trim())
1148
+ }
1149
+
1150
+ if (docinfo) {
1151
+ content = []
1152
+ const docinfoFile = `docinfo${qualifier}${suffix}`
1153
+ const docinfoDir = this.attributes.docinfodir
1154
+ const docinfoSubs = this._resolveDocinfoSubs()
1155
+
1156
+ const hasShared =
1157
+ docinfo.includes('shared') || docinfo.includes(`shared-${location}`)
1158
+ if (hasShared) {
1159
+ const path = this.normalizeSystemPath(docinfoFile, docinfoDir)
1160
+ const shared = await this.readAsset(path, { normalize: true })
1161
+ if (shared) content.push(await this.applySubs(shared, docinfoSubs))
1162
+ }
1163
+
1164
+ const docname = this.attributes.docname
1165
+ const hasPrivate =
1166
+ docname &&
1167
+ (docinfo.includes('private') ||
1168
+ docinfo.includes(`private-${location}`))
1169
+ if (hasPrivate) {
1170
+ const path = this.normalizeSystemPath(
1171
+ `${docname}-${docinfoFile}`,
1172
+ docinfoDir
1173
+ )
1174
+ const priv = await this.readAsset(path, { normalize: true })
1175
+ if (priv) content.push(await this.applySubs(priv, docinfoSubs))
1176
+ }
1177
+ }
1178
+ }
1179
+
1180
+ if (this.extensions && this.hasDocinfoProcessors(location)) {
1181
+ const extContent = this._docinfoProcessorExtensions[location]
1182
+ .map((ext) => ext.processMethod(this))
1183
+ .filter(Boolean)
1184
+ return (content ?? []).concat(extContent).join(LF)
1185
+ }
1186
+ return content ? content.join(LF) : ''
1187
+ }
1188
+
1189
+ /**
1190
+ * @param {string} [location='head'] A location for checking docinfo extensions at a given location (head or footer).
1191
+ * @returns {boolean} True if docinfo processors are registered for the given location.
1192
+ */
1193
+ hasDocinfoProcessors(location = 'head') {
1194
+ if (location in this._docinfoProcessorExtensions) {
1195
+ return this._docinfoProcessorExtensions[location] !== false
1196
+ }
1197
+ if (this.extensions?.hasDocinfoProcessors?.(location)) {
1198
+ const exts = this.extensions.docinfoProcessors(location)
1199
+ this._docinfoProcessorExtensions[location] = exts || false
1200
+ return !!exts
1201
+ }
1202
+ this._docinfoProcessorExtensions[location] = false
1203
+ return false
1204
+ }
1205
+
1206
+ // ── JavaScript-style accessors ────────────────────────────────────────────────
1207
+
1208
+ /** @returns {string|null} The document title. */
1209
+ getTitle() {
1210
+ return this.title
1211
+ }
1212
+
1213
+ /** @param {string} val */
1214
+ setTitle(val) {
1215
+ this.title = val
1216
+ }
1217
+
1218
+ /**
1219
+ * @deprecated Use {@link getDocumentTitle} instead.
1220
+ * @see getDocumentTitle
1221
+ */
1222
+ getDoctitle(opts = {}) {
1223
+ return this.doctitle(opts)
1224
+ }
1225
+
1226
+ /**
1227
+ * Resolve the primary title for the document.
1228
+ *
1229
+ * Searches the following locations in order, returning the first non-empty value:
1230
+ * - document-level attribute named `title`
1231
+ * - header title (the document title)
1232
+ * - title of the first section
1233
+ * - document-level attribute named `untitled-label` (if `opts.use_fallback` is set)
1234
+ *
1235
+ * If no value can be resolved, `null` is returned.
1236
+ *
1237
+ * If `opts.partition` is specified, the value is parsed into a {@link DocumentTitle} object.
1238
+ * If `opts.sanitize` is specified, XML elements are removed from the value.
1239
+ * @param {Object} [opts={}]
1240
+ * @param {boolean} [opts.partition] - Parse the title into a {@link DocumentTitle} with main and subtitle parts.
1241
+ * @param {boolean} [opts.sanitize] - Strip XML/HTML elements from the resolved title.
1242
+ * @param {boolean} [opts.use_fallback] - Fall back to the `untitled-label` attribute if no title is found.
1243
+ * @returns {string|DocumentTitle|null} The resolved title, or null if none found.
1244
+ */
1245
+ getDocumentTitle(opts = {}) {
1246
+ return this.doctitle(opts)
1247
+ }
1248
+
1249
+ /** @returns {string} The captioned title. */
1250
+ getCaptionedTitle() {
1251
+ return this.captionedTitle()
1252
+ }
1253
+
1254
+ /** @returns {string} The document type (e.g. 'article', 'book'). */
1255
+ getDoctype() {
1256
+ return this.doctype
1257
+ }
1258
+
1259
+ /** @returns {string} The backend name (e.g. 'html5', 'docbook5'). */
1260
+ getBackend() {
1261
+ return this.backend
1262
+ }
1263
+
1264
+ /**
1265
+ * @returns {number} The safe mode level as a numeric value.
1266
+ * Corresponds to {@link SafeMode}: unsafe (0), safe (1), server (10), secure (20).
1267
+ */
1268
+ getSafe() {
1269
+ return this.safe
1270
+ }
1271
+
1272
+ /**
1273
+ * Get the AsciiDoc compatibility mode flag.
1274
+ *
1275
+ * Enabling this attribute activates the following syntax changes:
1276
+ * - single quotes as constrained emphasis formatting marks
1277
+ * - single backticks parsed as inline literal, formatted as monospace
1278
+ * - single plus parsed as constrained, monospaced inline formatting
1279
+ * - double plus parsed as constrained, monospaced inline formatting
1280
+ * @returns {boolean} True if compat mode is enabled.
1281
+ */
1282
+ getCompatMode() {
1283
+ return this.compatMode
1284
+ }
1285
+
1286
+ /** @returns {boolean} True if sourcemap is enabled. */
1287
+ getSourcemap() {
1288
+ return this.sourcemap
1289
+ }
1290
+
1291
+ /** @param {boolean} val */
1292
+ setSourcemap(val) {
1293
+ this.sourcemap = val
1294
+ }
1295
+
1296
+ /** @returns {string} The output file suffix (e.g. '.html'). */
1297
+ getOutfilesuffix() {
1298
+ return this.outfilesuffix
1299
+ }
1300
+
1301
+ /** @returns {Object} The frozen options object. */
1302
+ getOptions() {
1303
+ return this.options
1304
+ }
1305
+
1306
+ /** @returns {Object} The converter instance. */
1307
+ getConverter() {
1308
+ return this.converter
1309
+ }
1310
+
1311
+ /**
1312
+ * Set the converter instance for this document.
1313
+ * @param {Object} converter - The converter instance.
1314
+ */
1315
+ setConverter(converter) {
1316
+ this.converter = converter
1317
+ }
1318
+
1319
+ /** @returns {string|null} The raw AsciiDoc source. */
1320
+ getSource() {
1321
+ return this.source()
1322
+ }
1323
+
1324
+ /** @returns {string[]|null} The source lines. */
1325
+ getSourceLines() {
1326
+ return this.sourceLines()
1327
+ }
1328
+
1329
+ /** @returns {Object} The preprocessor reader. */
1330
+ getReader() {
1331
+ return this.reader
1332
+ }
1333
+
1334
+ /** @returns {Footnote[]} The registered footnotes. */
1335
+ getFootnotes() {
1336
+ return this.footnotes
1337
+ }
1338
+
1339
+ /** @returns {Object} The callouts registry. */
1340
+ getCallouts() {
1341
+ return this.callouts
1342
+ }
1343
+
1344
+ /** @returns {Object} The asset catalog. */
1345
+ getCatalog() {
1346
+ return this.catalog
1347
+ }
1348
+
1349
+ /** @returns {Object} The counters map. */
1350
+ getCounters() {
1351
+ return this._counters
1352
+ }
1353
+
1354
+ /** @returns {string|null} The first author name. */
1355
+ getAuthor() {
1356
+ return this.author
1357
+ }
1358
+
1359
+ /** @returns {Author[]} All document authors. */
1360
+ getAuthors() {
1361
+ return this.authors()
1362
+ }
1363
+
1364
+ /** @returns {string} The base directory path. */
1365
+ getBaseDir() {
1366
+ return this.baseDir
1367
+ }
1368
+
1369
+ /** @returns {RevisionInfo} The revision information. */
1370
+ getRevisionInfo() {
1371
+ const attrs = this.attributes
1372
+ return new RevisionInfo(
1373
+ attrs.revnumber ?? null,
1374
+ attrs.revdate ?? null,
1375
+ attrs.revremark ?? null
1376
+ )
1377
+ }
1378
+
1379
+ /** @returns {Object|null} The extensions registry. */
1380
+ getExtensions() {
1381
+ return this.extensions
1382
+ }
1383
+
1384
+ /** @returns {Document|undefined} The parent document, or undefined for root documents. */
1385
+ getParentDocument() {
1386
+ return this.parentDocument ?? undefined
1387
+ }
1388
+
1389
+ /**
1390
+ * Get the parent node of this node.
1391
+ * Always returns undefined for a root Document (Document is its own internal parent).
1392
+ * @returns {undefined}
1393
+ */
1394
+ getParent() {
1395
+ return undefined
1396
+ }
1397
+
1398
+ /** @returns {Object|null} The syntax highlighter instance. */
1399
+ getSyntaxHighlighter() {
1400
+ return this.syntaxHighlighter
1401
+ }
1402
+
1403
+ /** @returns {Object} The id→node reference map. */
1404
+ getRefs() {
1405
+ return this.catalog.refs
1406
+ }
1407
+
1408
+ /** @returns {ImageReference[]} The registered image references. */
1409
+ getImages() {
1410
+ return this.catalog.images
1411
+ }
1412
+
1413
+ /** @returns {string[]} The registered links. */
1414
+ getLinks() {
1415
+ return this.catalog.links
1416
+ }
1417
+
1418
+ /** @returns {Object|null} The level-0 Section (document header). */
1419
+ getHeader() {
1420
+ return this.header
1421
+ }
1422
+
1423
+ /** @returns {boolean} True if the basebackend attribute is set. */
1424
+ isBasebackend() {
1425
+ return !!this.attributes.basebackend
1426
+ }
1427
+
1428
+ /** @returns {Object} The asset catalog (alias for getCatalog). */
1429
+ getReferences() {
1430
+ return this.catalog
1431
+ }
1432
+
1433
+ /** @returns {string|undefined} The revision date. */
1434
+ getRevisionDate() {
1435
+ return this.attributes.revdate ?? undefined
1436
+ }
1437
+
1438
+ /** @returns {string|undefined} The revision date (alias for getRevisionDate). */
1439
+ getRevdate() {
1440
+ return this.attributes.revdate ?? undefined
1441
+ }
1442
+
1443
+ /** @returns {string|undefined} The revision number. */
1444
+ getRevisionNumber() {
1445
+ return this.attributes.revnumber ?? undefined
1446
+ }
1447
+
1448
+ /** @returns {string|undefined} The revision remark. */
1449
+ getRevisionRemark() {
1450
+ return this.attributes.revremark ?? undefined
1451
+ }
1452
+
1453
+ /** @returns {boolean} True if any revision info is set. */
1454
+ hasRevisionInfo() {
1455
+ return !this.getRevisionInfo().isEmpty()
1456
+ }
1457
+
1458
+ /** @returns {boolean} True if the notitle attribute is set. */
1459
+ getNotitle() {
1460
+ return this.isNotitle()
1461
+ }
1462
+
1463
+ /** @returns {boolean} True if the noheader attribute is set. */
1464
+ getNoheader() {
1465
+ return this.isNoheader()
1466
+ }
1467
+
1468
+ /** @returns {boolean} True if the nofooter attribute is set. */
1469
+ getNofooter() {
1470
+ return this.isNofooter()
1471
+ }
1472
+
1473
+ /** Restore attributes to their saved header state. */
1474
+ restoreAttributes() {
1475
+ if (!this.parentDocument) this.catalog.callouts.rewind()
1476
+ const toRestore = this._headerAttributes
1477
+ if (toRestore) {
1478
+ for (const key of Object.keys(this.attributes)) {
1479
+ if (!(key in toRestore)) delete this.attributes[key]
1480
+ }
1481
+ Object.assign(this.attributes, toRestore)
1482
+ }
1483
+ }
1484
+
1485
+ /**
1486
+ * @param {string} [location='head']
1487
+ * @param {string} [suffix]
1488
+ * @returns {Promise<string>}
1489
+ */
1490
+ async getDocinfo(location = 'head', suffix = undefined) {
1491
+ return this.docinfo(location, suffix)
1492
+ }
1493
+
1494
+ /**
1495
+ * Delete the specified attribute if not locked.
1496
+ * @param {string} name - The attribute name to remove.
1497
+ * @returns {string|undefined} The previous value, or undefined if not present or locked.
1498
+ */
1499
+ removeAttribute(name) {
1500
+ const prev = this.attributes[name]
1501
+ this.deleteAttribute(name)
1502
+ return prev
1503
+ }
1504
+
1505
+ toString() {
1506
+ return `#<Document {doctype: '${this.doctype}', doctitle: ${JSON.stringify(this.header?.title ?? null)}, blocks: ${this.blocks.length}}>`
1507
+ }
1508
+
1509
+ // ── Private methods ─────────────────────────────────────────────────────────
1510
+
1511
+ /**
1512
+ * @private
1513
+ * @internal
1514
+ * Set the specified attribute without applying attribute value substitutions.
1515
+ * Used internally by the parser when the value is already resolved.
1516
+ * @param {string} name
1517
+ * @param {string} [value='']
1518
+ * @returns {string|null} The value as-is, or `null` if the attribute is locked.
1519
+ */
1520
+ _setAttributeRaw(name, value = '') {
1521
+ return this._setAttributeInternal(name, value, true)
1522
+ }
1523
+
1524
+ /**
1525
+ * @private
1526
+ * @internal
1527
+ */
1528
+ _setAttributeInternal(name, value, skipSubs) {
1529
+ if (this.isAttributeLocked(name)) return null
1530
+ if (!skipSubs && value && value !== '')
1531
+ value = this._applyAttributeValueSubs(value)
1532
+ if (this._headerAttributes) {
1533
+ // Beyond the document header; only update live attributes, not the header snapshot.
1534
+ this.attributes[name] = value
1535
+ } else {
1536
+ switch (name) {
1537
+ case 'backend':
1538
+ this._updateBackendAttributes(
1539
+ value,
1540
+ this._attributesModified.delete('htmlsyntax') &&
1541
+ value === this.backend
1542
+ )
1543
+ break
1544
+ case 'doctype':
1545
+ this._updateDoctypeAttributes(value)
1546
+ break
1547
+ default:
1548
+ this.attributes[name] = value
1549
+ }
1550
+ this._attributesModified.add(name)
1551
+ }
1552
+ return value
1553
+ }
1554
+
1555
+ /**
1556
+ * @private
1557
+ * @internal
1558
+ * Walk the block tree in document order and pre-compute the content of
1559
+ * every AsciiDoc-style table cell. Must be called AFTER parse() has finished so
1560
+ * that (a) callouts.rewind() has been called and (b) all cross-references from
1561
+ * the main document are already registered in the catalog.
1562
+ */
1563
+ async _convertAsciiDocCells(block = this) {
1564
+ for (const child of block.blocks ?? []) {
1565
+ if (child.context === 'table') {
1566
+ for (const section of ['head', 'body', 'foot']) {
1567
+ for (const row of child.rows[section] ?? []) {
1568
+ for (const cell of row) {
1569
+ if (
1570
+ cell.style === 'asciidoc' &&
1571
+ cell._innerDocument &&
1572
+ cell._innerContent == null
1573
+ ) {
1574
+ cell._innerContent = await cell._innerDocument.convert()
1575
+ }
1576
+ }
1577
+ }
1578
+ }
1579
+ } else {
1580
+ await this._convertAsciiDocCells(child)
1581
+ }
1582
+ }
1583
+ }
1584
+
1585
+ /**
1586
+ * @private
1587
+ * Sync version: applies only synchronous subs (specialcharacters, attributes, replacements).
1588
+ * Used by setAttribute() which must remain sync for the {set:...} inline directive path.
1589
+ * Async subs (quotes, macros, …) in pass macros are handled by _applyAttributeEntryValueSubs.
1590
+ */
1591
+ _applyAttributeValueSubs(value) {
1592
+ const m = value.match(AttributeEntryPassMacroRx)
1593
+ if (m) {
1594
+ let result = m[2] ?? ''
1595
+ if (m[1]) {
1596
+ const subs = this.resolvePassSubs(m[1])
1597
+ if (subs) {
1598
+ for (const sub of subs) {
1599
+ if (sub === 'specialcharacters')
1600
+ result = this.subSpecialchars(result)
1601
+ else if (sub === 'attributes') result = this.subAttributes(result)
1602
+ else if (sub === 'replacements')
1603
+ result = this.subReplacements(result)
1604
+ }
1605
+ }
1606
+ }
1607
+ return this._maxAttributeValueSize != null
1608
+ ? _limitBytesize(result, this._maxAttributeValueSize)
1609
+ : result
1610
+ }
1611
+ const result = this.applyHeaderSubs(value)
1612
+ return this._maxAttributeValueSize != null
1613
+ ? _limitBytesize(result, this._maxAttributeValueSize)
1614
+ : result
1615
+ }
1616
+
1617
+ /**
1618
+ * @private
1619
+ * Async version: applies all subs including async ones (quotes, macros, …).
1620
+ * Used by processAttributeEntry() which can await the result.
1621
+ */
1622
+ async _applyAttributeEntryValueSubs(value) {
1623
+ const m = value.match(AttributeEntryPassMacroRx)
1624
+ if (m) {
1625
+ let result = m[2] ?? ''
1626
+ if (m[1]) {
1627
+ const subs = this.resolvePassSubs(m[1])
1628
+ if (subs) result = await this.applySubs(result, subs)
1629
+ }
1630
+ return this._maxAttributeValueSize != null
1631
+ ? _limitBytesize(result, this._maxAttributeValueSize)
1632
+ : result
1633
+ }
1634
+ const result = this.applyHeaderSubs(value)
1635
+ return this._maxAttributeValueSize != null
1636
+ ? _limitBytesize(result, this._maxAttributeValueSize)
1637
+ : result
1638
+ }
1639
+
1640
+ /**
1641
+ * @private
1642
+ * Resolve the list of substitutions to apply to docinfo files.
1643
+ *
1644
+ * Resolves subs from the `docinfosubs` document attribute if present,
1645
+ * otherwise returns `['attributes']` as the default.
1646
+ * @returns {string[]} The list of substitutions to apply.
1647
+ */
1648
+ _resolveDocinfoSubs() {
1649
+ return 'docinfosubs' in this.attributes
1650
+ ? this.resolveSubs(this.attributes.docinfosubs, 'block', null, 'docinfo')
1651
+ : ['attributes']
1652
+ }
1653
+
1654
+ /**
1655
+ * @private
1656
+ * Walk the block tree and pre-compute all async text values.
1657
+ * Handles titles (AbstractBlock), list item text, table cell text, and reftexts.
1658
+ */
1659
+ async _resolveAllTexts(block) {
1660
+ // Skip title pre-computation for blocks with an explicit empty id ([id=]).
1661
+ // In Ruby, apply_title_subs is lazy: it is never called during parsing for such
1662
+ // blocks because section.title is never accessed. An explicit empty id is
1663
+ // distinguished by block.attributes.id === '' (the AttributeList parser preserves it).
1664
+ if (block.attributes?.id !== '') {
1665
+ await block.precomputeTitle?.()
1666
+ }
1667
+ await block.precomputeReftext?.()
1668
+ const ctx = block.context
1669
+ if (ctx === 'dlist') {
1670
+ // dlist.blocks is an array of [[term, ...], item_or_null] pairs.
1671
+ for (const [terms, item] of block.blocks ?? []) {
1672
+ for (const term of terms ?? []) {
1673
+ await term.precomputeText?.()
1674
+ await this._resolveAllTexts(term)
1675
+ }
1676
+ if (item) {
1677
+ await item.precomputeText?.()
1678
+ await this._resolveAllTexts(item)
1679
+ }
1680
+ }
1681
+ } else if (ctx === 'table') {
1682
+ for (const row of [
1683
+ ...(block.rows?.head ?? []),
1684
+ ...(block.rows?.body ?? []),
1685
+ ...(block.rows?.foot ?? []),
1686
+ ]) {
1687
+ for (const cell of row) {
1688
+ await cell.precomputeText?.()
1689
+ await cell.precomputeReftext?.()
1690
+ }
1691
+ }
1692
+ } else {
1693
+ for (const child of block.blocks ?? []) {
1694
+ await child.precomputeText?.()
1695
+ await this._resolveAllTexts(child)
1696
+ }
1697
+ }
1698
+ }
1699
+
1700
+ /**
1701
+ * @private
1702
+ * Create and initialize an instance of the converter for this document.
1703
+ * @param {string} backend - The backend name (e.g. 'html5', 'docbook5').
1704
+ * @param {string} [delegateBackend] - An optional delegate backend to use when resolving the converter.
1705
+ */
1706
+ _createConverter(backend, delegateBackend) {
1707
+ const opts = this.options
1708
+ if (!this.converter && opts._preCreatedConverter) {
1709
+ return opts._preCreatedConverter
1710
+ }
1711
+ const converterOpts = {
1712
+ document: this,
1713
+ htmlsyntax: this.attributes.htmlsyntax,
1714
+ }
1715
+ if (opts.template_dirs || opts.template_dir) {
1716
+ converterOpts.template_dirs = [].concat(
1717
+ opts.template_dirs ?? opts.template_dir
1718
+ )
1719
+ converterOpts.template_cache = opts.template_cache ?? true
1720
+ converterOpts.template_engine = opts.template_engine
1721
+ converterOpts.template_engine_options = opts.template_engine_options
1722
+ converterOpts.eruby = opts.eruby
1723
+ converterOpts.safe = this.safe
1724
+ if (delegateBackend) converterOpts.delegate_backend = delegateBackend
1725
+ }
1726
+ if (opts.converter) {
1727
+ return new CustomFactory({ [backend]: opts.converter }).createSync(
1728
+ backend,
1729
+ converterOpts
1730
+ )
1731
+ }
1732
+ const factory = opts.converter_factory ?? Converter
1733
+ return factory.createSync(backend, converterOpts)
1734
+ }
1735
+
1736
+ /**
1737
+ * Delete any attributes stored for playback
1738
+ * @param attributes
1739
+ * @private
1740
+ * @internal
1741
+ */
1742
+ _clearPlaybackAttributes(attributes) {
1743
+ delete attributes.attribute_entries
1744
+ }
1745
+
1746
+ /**
1747
+ * Branch the attributes so that the original state can be restored
1748
+ * at a future time.
1749
+ * @returns the duplicated attributes, which will later be restored
1750
+ * @private
1751
+ * @internal
1752
+ */
1753
+ _saveAttributes() {
1754
+ const attrs = this.attributes
1755
+ if (!('doctitle' in attrs)) {
1756
+ const dt = this.doctitle()
1757
+ if (dt) attrs.doctitle = dt
1758
+ }
1759
+ this.id ??= attrs['css-signature'] ?? null
1760
+
1761
+ // Handle toc / toc2
1762
+ // NOTE: delete toc/toc2 from attrs first; only re-add specific placement/position attrs
1763
+ let tocVal
1764
+ if ('toc2' in attrs) {
1765
+ delete attrs.toc2
1766
+ tocVal = 'left'
1767
+ } else if ('toc' in attrs) {
1768
+ tocVal = attrs.toc
1769
+ delete attrs.toc
1770
+ }
1771
+ if (tocVal != null) {
1772
+ const tocPlacementVal = attrs['toc-placement'] ?? 'macro'
1773
+ const tocPositionVal =
1774
+ tocPlacementVal && tocPlacementVal !== 'auto'
1775
+ ? tocPlacementVal
1776
+ : attrs['toc-position']
1777
+ if (tocVal !== '' || tocPositionVal) {
1778
+ const defaultTocPosition = 'left'
1779
+ let defaultTocClass = 'toc2'
1780
+ const position = !tocPositionVal
1781
+ ? tocVal || defaultTocPosition
1782
+ : tocPositionVal
1783
+ attrs['toc-placement'] = 'auto'
1784
+ switch (position) {
1785
+ case 'left':
1786
+ case '<':
1787
+ case '&lt;':
1788
+ attrs['toc-position'] = 'left'
1789
+ break
1790
+ case 'right':
1791
+ case '>':
1792
+ case '&gt;':
1793
+ attrs['toc-position'] = 'right'
1794
+ break
1795
+ case 'top':
1796
+ case '^':
1797
+ attrs['toc-position'] = 'top'
1798
+ break
1799
+ case 'bottom':
1800
+ case 'v':
1801
+ attrs['toc-position'] = 'bottom'
1802
+ break
1803
+ case 'preamble':
1804
+ case 'macro':
1805
+ attrs['toc-position'] = 'content'
1806
+ attrs['toc-placement'] = position
1807
+ defaultTocClass = null
1808
+ break
1809
+ default:
1810
+ delete attrs['toc-position']
1811
+ defaultTocClass = null
1812
+ }
1813
+ if (defaultTocClass) attrs['toc-class'] ??= defaultTocClass
1814
+ }
1815
+ attrs.toc = ''
1816
+ }
1817
+
1818
+ const iconsVal = attrs.icons
1819
+ if (iconsVal != null && !('icontype' in attrs)) {
1820
+ if (iconsVal !== '' && iconsVal !== 'font') {
1821
+ attrs.icons = ''
1822
+ if (iconsVal !== 'image') attrs.icontype = iconsVal
1823
+ }
1824
+ }
1825
+
1826
+ this.compatMode = 'compat-mode' in attrs
1827
+ if (this.compatMode && 'language' in attrs) {
1828
+ attrs['source-language'] = attrs.language
1829
+ }
1830
+
1831
+ if (!this.parentDocument) {
1832
+ const basebackend = attrs.basebackend
1833
+ if (basebackend === 'html') {
1834
+ const syntaxHlName = attrs['source-highlighter']
1835
+ if (syntaxHlName && !attrs[`${syntaxHlName}-unavailable`]) {
1836
+ // SyntaxHighlighter — optional integration, handle gracefully
1837
+ try {
1838
+ const factory = this.options.syntax_highlighter_factory
1839
+ const syntaxHls = this.options.syntax_highlighters
1840
+ if (factory) {
1841
+ this.syntaxHighlighter = factory.create(
1842
+ syntaxHlName,
1843
+ this.backend,
1844
+ { document: this }
1845
+ )
1846
+ } else if (syntaxHls) {
1847
+ this.syntaxHighlighter = new DefaultFactoryProxy(
1848
+ syntaxHls,
1849
+ SyntaxHighlighter
1850
+ ).create(syntaxHlName, this.backend, { document: this })
1851
+ } else {
1852
+ this.syntaxHighlighter = SyntaxHighlighter.create(
1853
+ syntaxHlName,
1854
+ this.backend,
1855
+ { document: this }
1856
+ )
1857
+ }
1858
+ } catch {}
1859
+ }
1860
+ } else if (basebackend === 'docbook') {
1861
+ if (
1862
+ !this.isAttributeLocked('toc') &&
1863
+ !this._attributesModified.has('toc')
1864
+ ) {
1865
+ attrs.toc = ''
1866
+ }
1867
+ if (
1868
+ !this.isAttributeLocked('sectnums') &&
1869
+ !this._attributesModified.has('sectnums')
1870
+ ) {
1871
+ attrs.sectnums = ''
1872
+ }
1873
+ }
1874
+ this.outfilesuffix = attrs.outfilesuffix ?? null
1875
+
1876
+ for (const name of FLEXIBLE_ATTRIBUTES) {
1877
+ const _fv = this._attributeOverrides[name]
1878
+ if (name in this._attributeOverrides && _fv != null && _fv !== false) {
1879
+ delete this._attributeOverrides[name]
1880
+ }
1881
+ }
1882
+ }
1883
+
1884
+ this._headerAttributes = { ...attrs }
1885
+ }
1886
+
1887
+ /**
1888
+ * Assign the local and document datetime attributes, which includes localdate, localyear, localtime,
1889
+ * localdatetime, docdate, docyear, doctime, and docdatetime. Honor the SOURCE_DATE_EPOCH environment variable, if set.
1890
+ * @param attrs
1891
+ * @param inputMtime
1892
+ * @private
1893
+ * @internal
1894
+ */
1895
+ _fillDatetimeAttributes(attrs, inputMtime) {
1896
+ const sourceDateEpoch =
1897
+ typeof process !== 'undefined' ? process.env.SOURCE_DATE_EPOCH : null
1898
+ const now =
1899
+ sourceDateEpoch && sourceDateEpoch !== ''
1900
+ ? new Date(parseInt(sourceDateEpoch, 10) * 1000)
1901
+ : new Date()
1902
+
1903
+ let localdate = attrs.localdate
1904
+ if (localdate) {
1905
+ attrs.localyear ??= localdate.length >= 4 ? localdate.slice(0, 4) : null
1906
+ } else {
1907
+ localdate = attrs.localdate = _formatDate(now)
1908
+ attrs.localyear ??= String(now.getFullYear())
1909
+ }
1910
+ const localtime = (attrs.localtime ??= _formatTime(now))
1911
+ attrs.localdatetime ??= `${localdate} ${localtime}`
1912
+
1913
+ const effectiveMtime =
1914
+ sourceDateEpoch && sourceDateEpoch !== ''
1915
+ ? now
1916
+ : inputMtime instanceof Date
1917
+ ? inputMtime
1918
+ : now
1919
+
1920
+ let docdate = attrs.docdate
1921
+ if (docdate) {
1922
+ attrs.docyear ??= docdate.length >= 4 ? docdate.slice(0, 4) : null
1923
+ } else {
1924
+ docdate = attrs.docdate = _formatDate(effectiveMtime)
1925
+ attrs.docyear ??= String(effectiveMtime.getFullYear())
1926
+ }
1927
+ const doctime = (attrs.doctime ??= _formatTime(effectiveMtime))
1928
+ attrs.docdatetime ??= `${docdate} ${doctime}`
1929
+ }
1930
+
1931
+ /**
1932
+ * Update the backend attributes to reflect a change in the active backend.
1933
+ *
1934
+ * This method also handles updating the related doctype attributes if the
1935
+ * doctype attribute is assigned at the time this method is called.
1936
+ *
1937
+ * @param newBackend
1938
+ * @param init
1939
+ * @returns {undefined|*} the resolved String backend if updated, nothing otherwise.
1940
+ * @private
1941
+ * @internal
1942
+ */
1943
+ _updateBackendAttributes(newBackend, init = false) {
1944
+ if (!init && newBackend === this.backend) return undefined
1945
+ const currentBackend = this.backend
1946
+ const attrs = this.attributes
1947
+ const currentBasebackend = attrs.basebackend
1948
+ const currentDoctype = this.doctype
1949
+
1950
+ let delegateBackend = null
1951
+ let actualBackend = null
1952
+ if (newBackend.includes(':')) {
1953
+ const parts = newBackend.split(':')
1954
+ actualBackend = parts[0]
1955
+ newBackend = parts[1]
1956
+ }
1957
+ if (newBackend.startsWith('xhtml')) {
1958
+ attrs.htmlsyntax = 'xml'
1959
+ newBackend = newBackend.slice(1)
1960
+ } else if (newBackend.startsWith('html')) {
1961
+ attrs.htmlsyntax ??= 'html'
1962
+ }
1963
+ newBackend = BACKEND_ALIASES[newBackend] ?? newBackend
1964
+ if (actualBackend) {
1965
+ delegateBackend = newBackend
1966
+ newBackend = actualBackend
1967
+ }
1968
+
1969
+ if (currentDoctype) {
1970
+ if (currentBackend) {
1971
+ delete attrs[`backend-${currentBackend}`]
1972
+ delete attrs[`backend-${currentBackend}-doctype-${currentDoctype}`]
1973
+ }
1974
+ attrs[`backend-${newBackend}-doctype-${currentDoctype}`] = ''
1975
+ attrs[`doctype-${currentDoctype}`] = ''
1976
+ } else if (currentBackend) {
1977
+ delete attrs[`backend-${currentBackend}`]
1978
+ }
1979
+ attrs[`backend-${newBackend}`] = ''
1980
+ this.backend = attrs.backend = newBackend
1981
+
1982
+ // Create the converter (may be async in some environments; here synchronous)
1983
+ const converter = this._createConverter(newBackend, delegateBackend)
1984
+ let newBasebackend, newFiletype
1985
+
1986
+ if (converter && typeof converter._getBackendTraits === 'function') {
1987
+ newBasebackend = converter.basebackend()
1988
+ newFiletype = converter.filetype()
1989
+ const htmlsyntax = converter.htmlsyntax()
1990
+ if (htmlsyntax) attrs.htmlsyntax = htmlsyntax
1991
+ if (init) {
1992
+ attrs.outfilesuffix ??= converter.outfilesuffix()
1993
+ } else if (!this.isAttributeLocked('outfilesuffix')) {
1994
+ attrs.outfilesuffix = converter.outfilesuffix()
1995
+ }
1996
+ } else if (converter) {
1997
+ const traits = deriveBackendTraits(newBackend)
1998
+ newBasebackend = traits.basebackend
1999
+ newFiletype = traits.filetype
2000
+ if (init) {
2001
+ attrs.outfilesuffix ??= traits.outfilesuffix
2002
+ } else if (!this.isAttributeLocked('outfilesuffix')) {
2003
+ attrs.outfilesuffix = traits.outfilesuffix
2004
+ }
2005
+ } else {
2006
+ throw new Error(
2007
+ `asciidoctor: FAILED: missing converter for backend '${newBackend}'. Processing aborted.`
2008
+ )
2009
+ }
2010
+ this.converter = converter
2011
+
2012
+ const currentFiletype = attrs.filetype
2013
+ if (currentFiletype) delete attrs[`filetype-${currentFiletype}`]
2014
+ attrs.filetype = newFiletype
2015
+ attrs[`filetype-${newFiletype}`] = ''
2016
+
2017
+ const pageWidth = DEFAULT_PAGE_WIDTHS[newBasebackend]
2018
+ if (pageWidth) {
2019
+ attrs.pagewidth = pageWidth
2020
+ } else {
2021
+ delete attrs.pagewidth
2022
+ }
2023
+
2024
+ if (newBasebackend !== currentBasebackend) {
2025
+ if (currentDoctype) {
2026
+ if (currentBasebackend) {
2027
+ delete attrs[`basebackend-${currentBasebackend}`]
2028
+ delete attrs[
2029
+ `basebackend-${currentBasebackend}-doctype-${currentDoctype}`
2030
+ ]
2031
+ }
2032
+ attrs[`basebackend-${newBasebackend}-doctype-${currentDoctype}`] = ''
2033
+ } else if (currentBasebackend) {
2034
+ delete attrs[`basebackend-${currentBasebackend}`]
2035
+ }
2036
+ attrs[`basebackend-${newBasebackend}`] = ''
2037
+ attrs.basebackend = newBasebackend
2038
+ }
2039
+ return newBackend
2040
+ }
2041
+
2042
+ /**
2043
+ * Update the doctype and backend attributes to reflect a change in the active doctype.
2044
+ *
2045
+ * @param newDoctype
2046
+ * @returns {undefined|*} the String doctype if updated, nothing otherwise.
2047
+ * @private
2048
+ * @internal
2049
+ */
2050
+ _updateDoctypeAttributes(newDoctype) {
2051
+ if (!newDoctype || newDoctype === this.doctype) return undefined
2052
+ const currentBackend = this.backend
2053
+ const attrs = this.attributes
2054
+ const currentBasebackend = attrs.basebackend
2055
+ const currentDoctype = this.doctype
2056
+ if (currentDoctype) {
2057
+ delete attrs[`doctype-${currentDoctype}`]
2058
+ if (currentBackend) {
2059
+ delete attrs[`backend-${currentBackend}-doctype-${currentDoctype}`]
2060
+ attrs[`backend-${currentBackend}-doctype-${newDoctype}`] = ''
2061
+ }
2062
+ if (currentBasebackend) {
2063
+ delete attrs[
2064
+ `basebackend-${currentBasebackend}-doctype-${currentDoctype}`
2065
+ ]
2066
+ attrs[`basebackend-${currentBasebackend}-doctype-${newDoctype}`] = ''
2067
+ }
2068
+ } else {
2069
+ if (currentBackend)
2070
+ attrs[`backend-${currentBackend}-doctype-${newDoctype}`] = ''
2071
+ if (currentBasebackend)
2072
+ attrs[`basebackend-${currentBasebackend}-doctype-${newDoctype}`] = ''
2073
+ }
2074
+ attrs[`doctype-${newDoctype}`] = ''
2075
+ this.doctype = attrs.doctype = newDoctype
2076
+ return newDoctype
2077
+ }
2078
+ }
2079
+
2080
+ // ── Helpers ───────────────────────────────────────────────────────────────────
2081
+
2082
+ function _expandPath(p) {
2083
+ try {
2084
+ return require('node:path').resolve(p)
2085
+ } catch {
2086
+ return p
2087
+ }
2088
+ }
2089
+
2090
+ function _cwd() {
2091
+ return typeof process !== 'undefined' ? process.cwd() : '/'
2092
+ }
2093
+
2094
+ function _pad2(n) {
2095
+ return String(n).padStart(2, '0')
2096
+ }
2097
+
2098
+ function _formatDate(d) {
2099
+ return `${d.getFullYear()}-${_pad2(d.getMonth() + 1)}-${_pad2(d.getDate())}`
2100
+ }
2101
+
2102
+ function _formatTime(d) {
2103
+ const offset = -d.getTimezoneOffset()
2104
+ const sign = offset >= 0 ? '+' : '-'
2105
+ const abs = Math.abs(offset)
2106
+ const hh = _pad2(Math.floor(abs / 60))
2107
+ const mm = _pad2(abs % 60)
2108
+ return `${_pad2(d.getHours())}:${_pad2(d.getMinutes())}:${_pad2(d.getSeconds())} ${offset === 0 ? 'UTC' : `${sign}${hh}${mm}`}`
2109
+ }
2110
+
2111
+ function _limitBytesize(str, max) {
2112
+ const encoded = new TextEncoder().encode(str)
2113
+ if (encoded.length <= max) return str
2114
+ // Walk back from max to find the last complete UTF-8 character boundary.
2115
+ let end = max
2116
+ // Back up past continuation bytes (0x80–0xBF).
2117
+ while (end > 0 && (encoded[end - 1] & 0xc0) === 0x80) end--
2118
+ // If the byte at end-1 is a multibyte start byte, check whether its full
2119
+ // sequence fits within max.
2120
+ if (end > 0 && (encoded[end - 1] & 0x80) !== 0) {
2121
+ const b = encoded[end - 1]
2122
+ const charLen = b >= 0xf0 ? 4 : b >= 0xe0 ? 3 : b >= 0xc0 ? 2 : 1
2123
+ if (end - 1 + charLen > max) {
2124
+ end-- // sequence extends past max → exclude this start byte
2125
+ } else {
2126
+ end = max // sequence fits entirely → restore max
2127
+ }
2128
+ }
2129
+ return new TextDecoder().decode(encoded.slice(0, end))
2130
+ }
2131
+
2132
+ applyLogging(Document.prototype)
2133
+
2134
+ Document.Footnote = Footnote