@asciidoctor/core 3.0.3 → 4.0.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/README.md +42 -10
  2. package/build/browser/index.js +24154 -0
  3. package/build/node/index.cjs +24735 -0
  4. package/{dist/css/asciidoctor.css → data/asciidoctor-default.css} +54 -53
  5. package/package.json +53 -100
  6. package/src/abstract_block.js +849 -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 +1899 -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 +343 -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.ts +73 -3731
  69. package/types/inline.d.ts +69 -0
  70. package/types/list.d.ts +114 -0
  71. package/types/load.d.ts +39 -0
  72. package/types/logging.d.ts +187 -0
  73. package/types/parser.d.ts +114 -0
  74. package/types/path_resolver.d.ts +103 -0
  75. package/types/reader.d.ts +184 -0
  76. package/types/rx.d.ts +513 -0
  77. package/types/section.d.ts +122 -0
  78. package/types/stylesheets.d.ts +10 -0
  79. package/types/substitutors.d.ts +208 -0
  80. package/types/syntaxHighlighter/highlightjs.d.ts +33 -0
  81. package/types/syntaxHighlighter/html_pipeline.d.ts +16 -0
  82. package/types/syntax_highlighter.d.ts +167 -0
  83. package/types/table.d.ts +231 -0
  84. package/types/timings.d.ts +25 -0
  85. package/types/tsconfig.json +9 -0
  86. package/LICENSE +0 -21
  87. package/dist/browser/asciidoctor.js +0 -47654
  88. package/dist/browser/asciidoctor.min.js +0 -1452
  89. package/dist/graalvm/asciidoctor.js +0 -47402
  90. package/dist/node/asciidoctor.cjs +0 -21567
  91. package/dist/node/asciidoctor.js +0 -23037
package/src/reader.js ADDED
@@ -0,0 +1,1755 @@
1
+ // ESM conversion of reader.rb
2
+ //
3
+ // Ruby-to-JavaScript notes:
4
+ // - @lines is an Array used as a reversed stack: @lines[-1] is the next line.
5
+ // In JS: this._lines[this._lines.length - 1] / this._lines.pop() / push().
6
+ // - Ruby private methods called by subclasses (shift, unshift, unshift_all,
7
+ // process_line, prepare_lines) use the _ prefix convention rather than JS
8
+ // # private, because PreprocessorReader must be able to override/call them.
9
+ // - JS # private fields are used only for data that is truly inaccessible to
10
+ // subclasses and external callers (none here, to keep inheritance clean).
11
+ // - PreprocessorReader overrides _shift() to strip the backslash from escaped
12
+ // directives, mirroring the Ruby `def shift` override.
13
+ // - PreprocessorReader overrides _prepareLines() to add front-matter handling
14
+ // and indentation adjustment (mirrors `def prepare_lines`).
15
+ // - The Logging mixin is implemented with inline helper methods; the logger
16
+ // defaults to this._document?.logger ?? console.
17
+ // - File I/O uses node:fs/promises async APIs (unavailable in browsers).
18
+ // - URI-based includes use the async Fetch API.
19
+ // - Compliance.attribute_missing defaults to 'skip' until compliance.js exists.
20
+ // - Parser.adjustIndentation is referenced but forwarded as a TODO.
21
+ // - RUBY_ENGINE_OPAL branches are omitted.
22
+ // - JRuby-specific unshift_all variant is omitted; the standard branch is used.
23
+
24
+ import {
25
+ LF,
26
+ MAX_INT,
27
+ ATTR_REF_HEAD,
28
+ LIST_CONTINUATION,
29
+ ASCIIDOC_EXTENSIONS,
30
+ SafeMode,
31
+ } from './constants.js'
32
+ import {
33
+ ConditionalDirectiveRx,
34
+ IncludeDirectiveRx,
35
+ TagDirectiveRx,
36
+ EvalExpressionRx,
37
+ } from './rx.js'
38
+ import {
39
+ prepareSourceArray,
40
+ prepareSourceString,
41
+ rootname,
42
+ isUriish,
43
+ } from './helpers.js'
44
+ import { LoggerManager, Logger } from './logging.js'
45
+ import { Compliance } from './compliance.js'
46
+ import { resolveBrowserIncludePath } from './browser/reader.js'
47
+
48
+ // ── Node.js fs (lazy, optional) ───────────────────────────────────────────────
49
+ // Loaded on first use in Node.js; silently absent in browser/WebWorker environments.
50
+ let _fsp // undefined = not tried, null = unavailable, object = available
51
+ let _fsConstants // node:fs constants (F_OK etc.) — not on node:fs/promises
52
+
53
+ async function _requireFsp() {
54
+ if (_fsp !== undefined) return
55
+ try {
56
+ _fsp = await import('node:fs/promises')
57
+ _fsConstants = (await import('node:fs')).constants
58
+ } catch {
59
+ _fsp = null
60
+ }
61
+ }
62
+
63
+ // ── path helpers (no node:path dependency) ───────────────────────────────────
64
+ function fsdirname(p) {
65
+ if (!p) return '.'
66
+ const idx = Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\'))
67
+ return idx < 0 ? '.' : idx === 0 ? '/' : p.slice(0, idx)
68
+ }
69
+ function fsbasename(p) {
70
+ const idx = Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\'))
71
+ return p ? p.slice(idx + 1) : ''
72
+ }
73
+ async function fileExists(path) {
74
+ await _requireFsp()
75
+ if (!_fsp) return false
76
+ try {
77
+ await _fsp.access(path, _fsConstants.F_OK)
78
+ return true
79
+ } catch {
80
+ return false
81
+ }
82
+ }
83
+
84
+ // ── adjustIndentation ─────────────────────────────────────────────────────────
85
+ // Port of Parser.adjust_indentation! from Ruby.
86
+ // Mutates `lines` in place to remove block indent, then optionally re-indent.
87
+ function _adjustIndentation(lines, indentSize, tabSize = 0) {
88
+ if (lines.length === 0) return
89
+ // Determine block indent (minimum leading spaces of non-blank lines)
90
+ let blockIndent = null
91
+ for (const line of lines) {
92
+ if (line === '') continue
93
+ const lineIndent = line.length - line.trimStart().length
94
+ if (lineIndent === 0) {
95
+ blockIndent = null
96
+ break
97
+ }
98
+ if (blockIndent === null || lineIndent < blockIndent)
99
+ blockIndent = lineIndent
100
+ }
101
+ if (indentSize === 0) {
102
+ if (blockIndent) {
103
+ for (let i = 0; i < lines.length; i++) {
104
+ if (lines[i] !== '') lines[i] = lines[i].slice(blockIndent)
105
+ }
106
+ }
107
+ } else {
108
+ const newIndent = ' '.repeat(indentSize)
109
+ for (let i = 0; i < lines.length; i++) {
110
+ if (lines[i] !== '') {
111
+ lines[i] = blockIndent
112
+ ? newIndent + lines[i].slice(blockIndent)
113
+ : newIndent + lines[i]
114
+ }
115
+ }
116
+ }
117
+ }
118
+
119
+ // ── Cursor ────────────────────────────────────────────────────────────────────
120
+
121
+ export class Cursor {
122
+ constructor(file, dir = null, path = null, lineno = 1) {
123
+ this.file = file
124
+ this.dir = dir
125
+ this.path = path
126
+ this.lineno = lineno
127
+ }
128
+
129
+ advance(num) {
130
+ this.lineno += num
131
+ }
132
+ get lineInfo() {
133
+ return `${this.path}: line ${this.lineno}`
134
+ }
135
+ toString() {
136
+ return this.lineInfo
137
+ }
138
+
139
+ // Public API (mirrors Ruby Asciidoctor::Reader::Cursor)
140
+ getLineNumber() {
141
+ return this.lineno
142
+ }
143
+ getFile() {
144
+ return this.file ?? undefined
145
+ }
146
+ getDirectory() {
147
+ return this.dir
148
+ }
149
+ getPath() {
150
+ return this.path
151
+ }
152
+
153
+ /**
154
+ * Get the line info string for this cursor (e.g. "path/to/file.adoc: line 42").
155
+ * @returns {string}
156
+ */
157
+ getLineInfo() {
158
+ return this.lineInfo
159
+ }
160
+ }
161
+
162
+ // ── Reader ────────────────────────────────────────────────────────────────────
163
+
164
+ export class Reader {
165
+ constructor(data = null, cursor = null, opts = {}) {
166
+ if (!cursor) {
167
+ this.file = null
168
+ this._dir = '.'
169
+ this.path = '<stdin>'
170
+ this.lineno = 1
171
+ } else if (typeof cursor === 'string') {
172
+ this.file = cursor
173
+ this._dir = fsdirname(cursor)
174
+ this.path = fsbasename(cursor)
175
+ this.lineno = 1
176
+ } else {
177
+ if ((this.file = cursor.file)) {
178
+ this._dir = cursor.dir || fsdirname(this.file)
179
+ this.path = cursor.path || fsbasename(this.file)
180
+ } else {
181
+ this._dir = cursor.dir || '.'
182
+ this.path = cursor.path || '<stdin>'
183
+ }
184
+ this.lineno = cursor.lineno || 1
185
+ }
186
+ if (opts.document) this._document = opts.document
187
+ this.sourceLines = this._prepareLines(data, opts)
188
+ this._lines = this.sourceLines.slice().reverse()
189
+ this._mark = null
190
+ this._lookAhead = 0
191
+ this.processLines = true
192
+ this._unescapeNextLine = false
193
+ this.unterminated = null
194
+ this._saved = null
195
+ }
196
+
197
+ // ── Public API ──────────────────────────────────────────────────────────────
198
+
199
+ /** @returns {boolean | Promise<boolean>} */
200
+ hasMoreLines() {
201
+ if (this._lines.length === 0) {
202
+ this._lookAhead = 0
203
+ return false
204
+ }
205
+ return true
206
+ }
207
+
208
+ /** @returns {boolean | Promise<boolean>} */
209
+ empty() {
210
+ if (this._lines.length === 0) {
211
+ this._lookAhead = 0
212
+ return true
213
+ }
214
+ return false
215
+ }
216
+
217
+ /** @returns {boolean | Promise<boolean>} */
218
+ eof() {
219
+ return this.empty()
220
+ }
221
+
222
+ async nextLineEmpty() {
223
+ const l = await this.peekLine()
224
+ return !l
225
+ }
226
+ async isNextLineEmpty() {
227
+ return await this.nextLineEmpty()
228
+ }
229
+
230
+ /**
231
+ * Peek at the next line without consuming it.
232
+ * @param {boolean} [direct=false] - When true, bypass processLine and return the raw stack top.
233
+ * @returns {Promise<string|undefined>} The next line, or undefined if there are no more lines.
234
+ */
235
+ async peekLine(direct = false) {
236
+ while (true) {
237
+ const nextLine = this._lines[this._lines.length - 1]
238
+ if (direct || this._lookAhead > 0) {
239
+ return this._unescapeNextLine ? nextLine.slice(1) : nextLine
240
+ }
241
+ if (nextLine !== undefined) {
242
+ const line = await this.processLine(nextLine)
243
+ if (line !== null && line !== undefined) return line
244
+ } else {
245
+ this._lookAhead = 0
246
+ return undefined
247
+ }
248
+ }
249
+ }
250
+
251
+ /**
252
+ * Peek at the next num lines without consuming them.
253
+ * @param {number|null} [num=null]
254
+ * @param {boolean} [direct=false]
255
+ * @returns {Promise<string[]>}
256
+ */
257
+ async peekLines(num = null, direct = false) {
258
+ const oldLookAhead = this._lookAhead
259
+ const result = []
260
+ const limit = num != null ? num : MAX_INT
261
+ for (let i = 0; i < limit; i++) {
262
+ const line = direct ? this._shift() : await this.readLine()
263
+ if (line !== undefined) {
264
+ result.push(line)
265
+ } else {
266
+ if (direct) this.lineno--
267
+ break
268
+ }
269
+ }
270
+ if (result.length > 0) {
271
+ this._unshiftAll(result)
272
+ if (direct) this._lookAhead = oldLookAhead
273
+ }
274
+ return result
275
+ }
276
+
277
+ async readLine() {
278
+ return this._lookAhead > 0 || (await this.hasMoreLines())
279
+ ? this._shift()
280
+ : undefined
281
+ }
282
+
283
+ async readLines() {
284
+ const lines = []
285
+ while (await this.hasMoreLines()) lines.push(this._shift())
286
+ return lines
287
+ }
288
+ async readlines() {
289
+ return await this.readLines()
290
+ }
291
+
292
+ async read() {
293
+ return (await this.readLines()).join(LF)
294
+ }
295
+
296
+ async advance() {
297
+ return this._shift() !== undefined
298
+ }
299
+
300
+ unshiftLine(lineToRestore) {
301
+ this._unshift(lineToRestore)
302
+ }
303
+ restoreLine(lineToRestore) {
304
+ this._unshift(lineToRestore)
305
+ }
306
+
307
+ unshiftLines(linesToRestore) {
308
+ this._unshiftAll(linesToRestore)
309
+ }
310
+ restoreLines(linesToRestore) {
311
+ this._unshiftAll(linesToRestore)
312
+ }
313
+
314
+ replaceNextLine(replacement) {
315
+ this._shift()
316
+ this._unshift(replacement)
317
+ return true
318
+ }
319
+ replaceLine(replacement) {
320
+ return this.replaceNextLine(replacement)
321
+ }
322
+
323
+ async skipBlankLines() {
324
+ if (await this.empty()) return undefined
325
+ let numSkipped = 0
326
+ let nextLine
327
+ while ((nextLine = await this.peekLine()) !== undefined) {
328
+ if (String(nextLine) !== '') return numSkipped
329
+ this._shift()
330
+ numSkipped++
331
+ }
332
+ return undefined
333
+ }
334
+
335
+ async skipCommentLines() {
336
+ if (await this.empty()) return
337
+ let nextLine
338
+ while (
339
+ (nextLine = await this.peekLine()) !== undefined &&
340
+ nextLine !== ''
341
+ ) {
342
+ if (!nextLine.startsWith('//')) break
343
+ if (nextLine.startsWith('///')) {
344
+ const ll = nextLine.length
345
+ if (!(ll > 3 && nextLine === '/'.repeat(ll))) break
346
+ await this.readLinesUntil({
347
+ terminator: nextLine,
348
+ skipFirstLine: true,
349
+ readLastLine: true,
350
+ skipProcessing: true,
351
+ context: 'comment',
352
+ })
353
+ } else {
354
+ this._shift()
355
+ }
356
+ }
357
+ }
358
+
359
+ async skipLineComments() {
360
+ if (await this.empty()) return []
361
+ const commentLines = []
362
+ let nextLine
363
+ while (
364
+ (nextLine = await this.peekLine()) !== undefined &&
365
+ nextLine !== ''
366
+ ) {
367
+ if (!nextLine.startsWith('//')) break
368
+ commentLines.push(this._shift())
369
+ }
370
+ return commentLines
371
+ }
372
+
373
+ terminate() {
374
+ this.lineno += this._lines.length
375
+ this._lines.length = 0
376
+ this._lookAhead = 0
377
+ }
378
+
379
+ /**
380
+ * Read lines until a termination condition is met.
381
+ * @param {Object} [options={}]
382
+ * @param {string} [options.terminator] - Line at which to stop.
383
+ * @param {boolean} [options.breakOnBlankLines] - Stop on blank lines.
384
+ * @param {boolean} [options.breakOnListContinuation] - Stop on a list continuation (+).
385
+ * @param {boolean} [options.skipFirstLine] - Skip the first line before scanning.
386
+ * @param {boolean} [options.preserveLastLine] - Push the terminating line back.
387
+ * @param {boolean} [options.readLastLine] - Include the terminating line in result.
388
+ * @param {boolean} [options.skipLineComments] - Skip line comments.
389
+ * @param {boolean} [options.skipProcessing] - Disable line preprocessing for this call.
390
+ * @param {string} [options.context] - Name used in unterminated-block warnings.
391
+ * @param {Cursor} [options.cursor] - Starting cursor for unterminated-block warnings.
392
+ * @param {Function|null} [filter=null] - Optional function(line) returning true to break.
393
+ * @returns {Promise<string[]>}
394
+ */
395
+ async readLinesUntil(options = {}, filter = null) {
396
+ const result = []
397
+ let restoreProcessLines = false
398
+ if (
399
+ this.processLines &&
400
+ (options.skipProcessing || options.skip_processing)
401
+ ) {
402
+ this.processLines = false
403
+ restoreProcessLines = true
404
+ }
405
+
406
+ const terminator = options.terminator ?? null
407
+ let startCursor, breakOnBlankLines, breakOnListContinuation
408
+ if (terminator) {
409
+ startCursor = options.cursor || this.cursor
410
+ breakOnBlankLines = false
411
+ breakOnListContinuation = false
412
+ } else {
413
+ breakOnBlankLines =
414
+ options.breakOnBlankLines || options.break_on_blank_lines || false
415
+ breakOnListContinuation =
416
+ options.breakOnListContinuation ||
417
+ options.break_on_list_continuation ||
418
+ false
419
+ }
420
+
421
+ const skipComments =
422
+ options.skipLineComments || options.skip_line_comments || false
423
+ let lineRead = false
424
+ let lineRestored = false
425
+ let line
426
+
427
+ if (options.skipFirstLine || options.skip_first_line) this._shift()
428
+
429
+ while ((line = await this.readLine()) !== undefined) {
430
+ let shouldBreak = false
431
+ if (terminator) {
432
+ shouldBreak = line === terminator
433
+ } else {
434
+ if (breakOnBlankLines && line === '') {
435
+ shouldBreak = true
436
+ } else if (
437
+ breakOnListContinuation &&
438
+ lineRead &&
439
+ line === LIST_CONTINUATION
440
+ ) {
441
+ options.preserveLastLine = options.preserve_last_line = true
442
+ shouldBreak = true
443
+ } else if (filter?.(line)) {
444
+ shouldBreak = true
445
+ }
446
+ }
447
+
448
+ if (shouldBreak) {
449
+ if (options.readLastLine || options.read_last_line) result.push(line)
450
+ if (options.preserveLastLine || options.preserve_last_line) {
451
+ this._unshift(line)
452
+ lineRestored = true
453
+ }
454
+ break
455
+ }
456
+
457
+ if (!(skipComments && line.startsWith('//') && !line.startsWith('///'))) {
458
+ result.push(line)
459
+ lineRead = true
460
+ }
461
+ }
462
+
463
+ if (restoreProcessLines) {
464
+ this.processLines = true
465
+ if (lineRestored && !terminator) this._lookAhead--
466
+ }
467
+
468
+ if (terminator && terminator !== line) {
469
+ const context = 'context' in options ? options.context : terminator
470
+ if (context) {
471
+ const sc = startCursor === 'at_mark' ? this.cursorAtMark() : startCursor
472
+ this._logWarn(`unterminated ${context} block`, { sourceLocation: sc })
473
+ this.unterminated = true
474
+ }
475
+ }
476
+
477
+ return result
478
+ }
479
+
480
+ // ── Cursor helpers ──────────────────────────────────────────────────────────
481
+
482
+ get cursor() {
483
+ return new Cursor(this.file, this._dir, this.path, this.lineno)
484
+ }
485
+ cursorAtLine(lineno) {
486
+ return new Cursor(this.file, this._dir, this.path, lineno)
487
+ }
488
+ cursorAtMark() {
489
+ return this._mark ? new Cursor(...this._mark) : this.cursor
490
+ }
491
+ cursorBeforeMark() {
492
+ if (this._mark) {
493
+ const [mFile, mDir, mPath, mLineno] = this._mark
494
+ return new Cursor(mFile, mDir, mPath, mLineno - 1)
495
+ }
496
+ return new Cursor(this.file, this._dir, this.path, this.lineno - 1)
497
+ }
498
+ cursorAtPrevLine() {
499
+ return new Cursor(this.file, this._dir, this.path, this.lineno - 1)
500
+ }
501
+
502
+ mark() {
503
+ this._mark = [this.file, this._dir, this.path, this.lineno]
504
+ }
505
+
506
+ lineInfo() {
507
+ return `${this.path}: line ${this.lineno}`
508
+ }
509
+
510
+ /**
511
+ * Returns the remaining lines in forward order (first remaining line at index 0).
512
+ * The returned object is a mutable proxy so that element assignments like
513
+ * `reader.lines[i] = newValue` are reflected back into the internal reversed stack.
514
+ * @returns {string[]}
515
+ */
516
+ get lines() {
517
+ const _l = this._lines
518
+ const fwd = _l.slice().reverse()
519
+ return new Proxy(fwd, {
520
+ set(target, prop, value) {
521
+ target[prop] = value
522
+ const idx = parseInt(prop, 10)
523
+ if (!Number.isNaN(idx) && idx >= 0 && idx < _l.length) {
524
+ _l[_l.length - 1 - idx] = value
525
+ }
526
+ return true
527
+ },
528
+ })
529
+ }
530
+
531
+ string() {
532
+ return this._lines.slice().reverse().join(LF)
533
+ }
534
+ source() {
535
+ return this.sourceLines.join(LF)
536
+ }
537
+
538
+ // ── Save / restore ──────────────────────────────────────────────────────────
539
+
540
+ save() {
541
+ this._saved = {
542
+ file: this.file,
543
+ dir: this._dir,
544
+ path: this.path,
545
+ lineno: this.lineno,
546
+ lines: [...this._lines],
547
+ mark: this._mark,
548
+ lookAhead: this._lookAhead,
549
+ processLines: this.processLines,
550
+ unescapeNextLine: this._unescapeNextLine,
551
+ unterminated: this.unterminated,
552
+ }
553
+ }
554
+
555
+ restoreSave() {
556
+ if (!this._saved) return
557
+ const s = this._saved
558
+ this.file = s.file
559
+ this._dir = s.dir
560
+ this.path = s.path
561
+ this.lineno = s.lineno
562
+ this._lines = s.lines
563
+ this._mark = s.mark
564
+ this._lookAhead = s.lookAhead
565
+ this.processLines = s.processLines
566
+ this._unescapeNextLine = s.unescapeNextLine
567
+ this.unterminated = s.unterminated
568
+ this._saved = null
569
+ }
570
+
571
+ discardSave() {
572
+ this._saved = null
573
+ }
574
+
575
+ toString() {
576
+ return `#<Reader {path: ${JSON.stringify(this.path)}, line: ${this.lineno}}>`
577
+ }
578
+
579
+ // ── Internal (inheritable) ──────────────────────────────────────────────────
580
+
581
+ /**
582
+ * Shift the top line off the stack and increment lineno.
583
+ * Subclasses may override to post-process consumed lines (see PreprocessorReader).
584
+ * @returns {string|undefined}
585
+ * @internal
586
+ */
587
+ _shift() {
588
+ this.lineno++
589
+ if (this._lookAhead > 0) this._lookAhead--
590
+ return this._lines.pop()
591
+ }
592
+
593
+ /**
594
+ * Push a line onto the stack and decrement lineno.
595
+ * @param {string} line
596
+ * @internal
597
+ */
598
+ _unshift(line) {
599
+ this.lineno--
600
+ this._lookAhead++
601
+ this._lines.push(line)
602
+ }
603
+
604
+ /**
605
+ * Restore multiple lines onto the stack.
606
+ * @param {string[]} linesToRestore
607
+ * @internal
608
+ */
609
+ _unshiftAll(linesToRestore) {
610
+ this.lineno -= linesToRestore.length
611
+ this._lookAhead += linesToRestore.length
612
+ this._lines.push(...linesToRestore.slice().reverse())
613
+ }
614
+
615
+ /**
616
+ * Process a line on first visit. Returns the line unmodified by default;
617
+ * subclasses override to evaluate preprocessor directives.
618
+ * @param {string} line
619
+ * @returns {string}
620
+ * @internal
621
+ */
622
+ processLine(line) {
623
+ if (this.processLines) this._lookAhead++
624
+ return line
625
+ }
626
+
627
+ /**
628
+ * Prepare the source data into a String Array.
629
+ * Subclasses override to add front-matter / indentation handling.
630
+ * @param {string|string[]|null} data
631
+ * @param {Object} [opts={}]
632
+ * @returns {string[]}
633
+ * @internal
634
+ */
635
+ _prepareLines(data, opts = {}) {
636
+ const normalize = opts.normalize
637
+ if (normalize) {
638
+ const trimEnd = normalize !== 'chomp'
639
+ return Array.isArray(data)
640
+ ? prepareSourceArray(data, trimEnd)
641
+ : prepareSourceString(data != null ? String(data) : '', trimEnd)
642
+ }
643
+ if (Array.isArray(data)) return [...data]
644
+ if (data != null) {
645
+ let s = String(data)
646
+ if (s.includes('\r')) s = s.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
647
+ return s.replace(/\n$/, '').split('\n')
648
+ }
649
+ return []
650
+ }
651
+
652
+ // ── Public API (mirrors Ruby Asciidoctor::Reader) ───────────────────────────
653
+
654
+ getCursor() {
655
+ return this.cursor
656
+ }
657
+ getLines() {
658
+ return this.sourceLines
659
+ }
660
+ getString() {
661
+ return this.source()
662
+ }
663
+ getLogger() {
664
+ return LoggerManager.logger
665
+ }
666
+ createLogMessage(text, context = {}) {
667
+ return Logger.AutoFormattingMessage.attach({ text, ...context })
668
+ }
669
+
670
+ // ── Logging helpers ─────────────────────────────────────────────────────────
671
+
672
+ get logger() {
673
+ return this._document?.logger ?? console
674
+ }
675
+
676
+ /** @param {string} msg @param {{ sourceLocation?: any, includeLocation?: any }} [opts] */
677
+ _logWarn(msg, { sourceLocation, includeLocation } = {}) {
678
+ let text = sourceLocation ? `${sourceLocation.lineInfo}: ${msg}` : msg
679
+ if (includeLocation) text += ` (${includeLocation.lineInfo})`
680
+ this.logger.warn(text)
681
+ }
682
+ _logError(msg, opts = {}) {
683
+ let text = opts.sourceLocation
684
+ ? `${opts.sourceLocation.lineInfo}: ${msg}`
685
+ : msg
686
+ if (opts.includeLocation) text += ` (${opts.includeLocation.lineInfo})`
687
+ this.logger.error(text)
688
+ }
689
+ /** @param {string} msg @param {{ sourceLocation?: any }} [opts] */
690
+ _logInfo(msg, { sourceLocation } = {}) {
691
+ const text = sourceLocation ? `${sourceLocation.lineInfo}: ${msg}` : msg
692
+ this.logger.info(text)
693
+ }
694
+ }
695
+
696
+ // ── PreprocessorReader ────────────────────────────────────────────────────────
697
+
698
+ export class PreprocessorReader extends Reader {
699
+ constructor(document, data = null, cursor = null, opts = {}) {
700
+ if (
701
+ 'skip-front-matter' in document.attributes &&
702
+ !('skipFrontMatter' in opts)
703
+ ) {
704
+ opts = { ...opts, skipFrontMatter: true }
705
+ }
706
+ // Pass document in opts so that _prepareLines (called from super) can access it.
707
+ if (!opts.document) opts = { ...opts, document }
708
+ super(data, cursor, opts)
709
+ this._document = document
710
+ this._sourcemap = document.sourcemap
711
+ const defaultDepth = parseInt(
712
+ document.attributes['max-include-depth'] ?? 64,
713
+ 10
714
+ )
715
+ this._maxdepth =
716
+ defaultDepth > 0
717
+ ? { abs: defaultDepth, curr: defaultDepth, rel: defaultDepth }
718
+ : null
719
+ this.includeStack = []
720
+ this._includes = document.catalog.includes
721
+ this._skipping = false
722
+ this._conditionalStack = []
723
+ this._includeProcessorExtensions = null
724
+ }
725
+
726
+ get logger() {
727
+ return this._document?.logger ?? console
728
+ }
729
+
730
+ /**
731
+ * Drain conditional stack at EOS; treat blank lines as lines (not as EOF).
732
+ * `peekLine()` returns undefined only at true EOF; '' for blank lines.
733
+ * @returns {Promise<boolean>}
734
+ */
735
+ async hasMoreLines() {
736
+ return (await this.peekLine()) !== undefined
737
+ }
738
+ async empty() {
739
+ return (await this.peekLine()) === undefined
740
+ }
741
+ async eof() {
742
+ return await this.empty()
743
+ }
744
+
745
+ async peekLine(direct = false) {
746
+ const line = await super.peekLine(direct)
747
+ if (line !== undefined) return line
748
+ if (this.includeStack.length === 0) {
749
+ let endCursor = null
750
+ this._conditionalStack = this._conditionalStack.filter((cond) => {
751
+ const loc =
752
+ cond.sourceLocation || (endCursor ??= this.cursorAtPrevLine())
753
+ this._logError(
754
+ `detected unterminated preprocessor conditional directive: ${cond.name}::${cond.target || ''}[${cond.expr || ''}]`,
755
+ { sourceLocation: loc }
756
+ )
757
+ return false
758
+ })
759
+ return undefined
760
+ }
761
+ this._popInclude()
762
+ return await this.peekLine(direct)
763
+ }
764
+
765
+ /**
766
+ * Strip leading backslash from escaped directives.
767
+ * @returns {string|undefined}
768
+ */
769
+ _shift() {
770
+ if (this._unescapeNextLine) {
771
+ this._unescapeNextLine = false
772
+ const line = super._shift()
773
+ return line.slice(1)
774
+ }
775
+ return super._shift()
776
+ }
777
+
778
+ /**
779
+ * Push new source onto the reader, switching the include context.
780
+ * @param {string|string[]} data
781
+ * @param {string|null} [file=null]
782
+ * @param {string|null} [path=null]
783
+ * @param {number} [lineno=1]
784
+ * @param {Object} [attributes={}]
785
+ * @returns {this}
786
+ */
787
+ pushInclude(data, file = null, path = null, lineno = 1, attributes = {}) {
788
+ this.includeStack.push([
789
+ this._lines,
790
+ this.file,
791
+ this._dir,
792
+ this.path,
793
+ this.lineno,
794
+ this._maxdepth,
795
+ this.processLines,
796
+ ])
797
+
798
+ if ((this.file = file)) {
799
+ this._dir = fsdirname(String(file))
800
+ this.path = path || fsbasename(String(file))
801
+ const fileStr = String(file)
802
+ if (
803
+ (this.processLines = Object.keys(ASCIIDOC_EXTENSIONS).some((ext) =>
804
+ fileStr.endsWith(ext)
805
+ ))
806
+ ) {
807
+ const key = this.path.slice(0, this.path.lastIndexOf('.'))
808
+ this._includes[key] ??= 'partial-option' in attributes ? null : true
809
+ }
810
+ } else {
811
+ this._dir = '.'
812
+ this.processLines = true
813
+ if ((this.path = path)) {
814
+ this._includes[rootname(this.path)] ??=
815
+ 'partial-option' in attributes ? null : true
816
+ } else {
817
+ this.path = '<stdin>'
818
+ }
819
+ }
820
+
821
+ this.lineno = lineno
822
+
823
+ if (this._maxdepth && 'depth' in attributes) {
824
+ const relMaxdepth = parseInt(attributes.depth, 10)
825
+ if (relMaxdepth > 0) {
826
+ const absMaxdepth = this._maxdepth.abs
827
+ let currMaxdepth = this.includeStack.length + relMaxdepth
828
+ let effRel = relMaxdepth
829
+ if (currMaxdepth > absMaxdepth) currMaxdepth = effRel = absMaxdepth
830
+ this._maxdepth = { abs: absMaxdepth, curr: currMaxdepth, rel: effRel }
831
+ } else {
832
+ this._maxdepth = {
833
+ abs: this._maxdepth.abs,
834
+ curr: this.includeStack.length,
835
+ rel: 0,
836
+ }
837
+ }
838
+ }
839
+
840
+ this._lines = this._prepareLines(data, {
841
+ include: true,
842
+ normalize: this.processLines || 'chomp',
843
+ indent: attributes.indent,
844
+ skipFrontMatter: 'skip-front-matter-option' in attributes,
845
+ })
846
+
847
+ if (this._lines.length === 0) {
848
+ this._popInclude()
849
+ } else if ('leveloffset' in attributes) {
850
+ const leveloffset = this._document.getAttribute('leveloffset')
851
+ const resetLine = leveloffset
852
+ ? `:leveloffset: ${leveloffset}`
853
+ : ':leveloffset!:'
854
+ const setLine = `:leveloffset: ${attributes.leveloffset}`
855
+ // Build stack-order array: setLine at end (read first), resetLine at start (read last)
856
+ this._lines = [
857
+ resetLine,
858
+ '',
859
+ ...this._lines.slice().reverse(),
860
+ '',
861
+ setLine,
862
+ ]
863
+ this.lineno -= 2
864
+ } else {
865
+ this._lines.reverse()
866
+ }
867
+ this._lookAhead = 0
868
+ return this
869
+ }
870
+
871
+ get includeDepth() {
872
+ return this.includeStack.length
873
+ }
874
+
875
+ exceedsMaxDepth() {
876
+ return (
877
+ this._maxdepth &&
878
+ this.includeStack.length >= this._maxdepth.curr &&
879
+ this._maxdepth.rel
880
+ )
881
+ }
882
+ exceededMaxDepth() {
883
+ return this.exceedsMaxDepth()
884
+ }
885
+
886
+ hasIncludeProcessors() {
887
+ if (this._includeProcessorExtensions === null) {
888
+ const exts = this._document.extensions
889
+ if (
890
+ exts &&
891
+ (this._includeProcessorExtensions = exts.includeProcessors?.())
892
+ )
893
+ return true
894
+ this._includeProcessorExtensions = false
895
+ }
896
+ return this._includeProcessorExtensions !== false
897
+ }
898
+
899
+ createIncludeCursor(file, path, lineno) {
900
+ return new Cursor(String(file), fsdirname(String(file)), path, lineno)
901
+ }
902
+
903
+ toString() {
904
+ return `#<PreprocessorReader {path: ${JSON.stringify(this.path)}, line: ${this.lineno}, include depth: ${this.includeStack.length}}>`
905
+ }
906
+
907
+ /** Save PreprocessorReader-specific fields in addition to Reader fields. */
908
+ save() {
909
+ super.save()
910
+ Object.assign(this._saved, {
911
+ maxdepth: this._maxdepth,
912
+ skipping: this._skipping,
913
+ conditionalStack: this._conditionalStack.map((e) => ({ ...e })),
914
+ includeStack: [...this.includeStack],
915
+ })
916
+ }
917
+
918
+ /** Also restore PreprocessorReader-specific fields. */
919
+ restoreSave() {
920
+ if (!this._saved) return
921
+ this._maxdepth = this._saved.maxdepth
922
+ this._skipping = this._saved.skipping
923
+ this._conditionalStack = this._saved.conditionalStack
924
+ this.includeStack = this._saved.includeStack
925
+ super.restoreSave()
926
+ }
927
+
928
+ /**
929
+ * Add front-matter stripping and indentation adjustment.
930
+ * @param {string|string[]|null} data
931
+ * @param {Object} [opts={}]
932
+ * @returns {string[]}
933
+ */
934
+ _prepareLines(data, opts = {}) {
935
+ const result = super._prepareLines(data, opts)
936
+
937
+ if (opts.skipFrontMatter) {
938
+ const frontMatter = this._skipFrontMatter(result)
939
+ if (frontMatter !== null && !opts.include) {
940
+ this._document.attributes['front-matter'] = frontMatter.join(LF)
941
+ }
942
+ }
943
+
944
+ if (opts.include) {
945
+ if (opts.indent != null) {
946
+ const indentVal = parseInt(opts.indent, 10) || 0
947
+ const tabsize = parseInt(
948
+ this._document.getAttribute('tabsize') ?? 0,
949
+ 10
950
+ )
951
+ _adjustIndentation(result, indentVal, tabsize)
952
+ }
953
+ } else {
954
+ while (result.length > 0 && result[result.length - 1] === '') result.pop()
955
+ }
956
+
957
+ return result
958
+ }
959
+
960
+ /**
961
+ * Evaluate preprocessor directives as lines are visited.
962
+ * @param {string} line
963
+ * @returns {Promise<string|undefined>}
964
+ */
965
+ async processLine(line) {
966
+ if (!this.processLines) return line
967
+
968
+ if (line === '') {
969
+ if (this._skipping) {
970
+ super._shift()
971
+ return undefined
972
+ }
973
+ this._lookAhead++
974
+ return line
975
+ }
976
+
977
+ if (line.endsWith(']') && !line.startsWith('[') && line.includes('::')) {
978
+ if (line.includes('if')) {
979
+ const m = ConditionalDirectiveRx.exec(line)
980
+ if (m) {
981
+ const [, esc, name, target, delimiter, text] = m
982
+ if (esc === '\\') {
983
+ this._unescapeNextLine = true
984
+ this._lookAhead++
985
+ return line.slice(1)
986
+ }
987
+ if (
988
+ this._preprocessConditionalDirective(
989
+ name,
990
+ target || '',
991
+ delimiter || null,
992
+ text || null
993
+ )
994
+ ) {
995
+ super._shift()
996
+ return undefined
997
+ }
998
+ this._lookAhead++
999
+ return line
1000
+ }
1001
+ }
1002
+ if (this._skipping) {
1003
+ super._shift()
1004
+ return undefined
1005
+ }
1006
+ if (line.startsWith('inc') || line.startsWith('\\inc')) {
1007
+ const m = IncludeDirectiveRx.exec(line)
1008
+ if (m) {
1009
+ const [, esc, target, attrlist] = m
1010
+ if (esc === '\\') {
1011
+ this._unescapeNextLine = true
1012
+ this._lookAhead++
1013
+ return line.slice(1)
1014
+ }
1015
+ if (await this._preprocessIncludeDirective(target, attrlist ?? null))
1016
+ return undefined
1017
+ this._lookAhead++
1018
+ return line
1019
+ }
1020
+ }
1021
+ this._lookAhead++
1022
+ return line
1023
+ }
1024
+
1025
+ if (this._skipping) {
1026
+ super._shift()
1027
+ return undefined
1028
+ }
1029
+ this._lookAhead++
1030
+ return line
1031
+ }
1032
+
1033
+ // ── Private preprocessor logic ──────────────────────────────────────────────
1034
+
1035
+ /**
1036
+ * Evaluate a conditional directive (ifdef/ifndef/ifeval/endif).
1037
+ * @param {string} name
1038
+ * @param {string} target
1039
+ * @param {string|null} delimiter
1040
+ * @param {string|null} text
1041
+ * @returns {boolean} True if the cursor should advance past this line.
1042
+ * @internal
1043
+ */
1044
+ _preprocessConditionalDirective(name, target, delimiter, text) {
1045
+ const noTarget = target === ''
1046
+ if (!noTarget) target = target.toLowerCase()
1047
+
1048
+ if (name === 'endif') {
1049
+ if (text) {
1050
+ this._logError(
1051
+ `malformed preprocessor directive - text not permitted: endif::${target}[${text}]`,
1052
+ { sourceLocation: this.cursor }
1053
+ )
1054
+ } else if (this._conditionalStack.length === 0) {
1055
+ this._logError(`unmatched preprocessor directive: endif::${target}[]`, {
1056
+ sourceLocation: this.cursor,
1057
+ })
1058
+ } else {
1059
+ const top = this._conditionalStack[this._conditionalStack.length - 1]
1060
+ if (noTarget || target === top.target) {
1061
+ this._conditionalStack.pop()
1062
+ this._skipping =
1063
+ this._conditionalStack.length === 0
1064
+ ? false
1065
+ : this._conditionalStack[this._conditionalStack.length - 1]
1066
+ .skipping
1067
+ } else {
1068
+ this._logError(
1069
+ `mismatched preprocessor directive: endif::${target}[], expected endif::${top.target || ''}[]`,
1070
+ { sourceLocation: this.cursor }
1071
+ )
1072
+ }
1073
+ }
1074
+ return true
1075
+ }
1076
+
1077
+ let skip
1078
+ if (this._skipping) {
1079
+ if (name === 'ifeval') {
1080
+ if (!(noTarget && text && EvalExpressionRx.test(text.trim())))
1081
+ return true
1082
+ } else if (noTarget) {
1083
+ return true
1084
+ }
1085
+ skip = false
1086
+ } else {
1087
+ const attrs = this._document.attributes
1088
+ if (name === 'ifdef') {
1089
+ if (noTarget) {
1090
+ this._logError(
1091
+ `malformed preprocessor directive - missing target: ifdef::[${text}]`,
1092
+ { sourceLocation: this.cursor }
1093
+ )
1094
+ return true
1095
+ }
1096
+ skip =
1097
+ delimiter === ','
1098
+ ? !target.split(',').some((a) => a in attrs)
1099
+ : delimiter === '+'
1100
+ ? target.split('+').some((a) => !(a in attrs))
1101
+ : !(target in attrs)
1102
+ } else if (name === 'ifndef') {
1103
+ if (noTarget) {
1104
+ this._logError(
1105
+ `malformed preprocessor directive - missing target: ifndef::[${text}]`,
1106
+ { sourceLocation: this.cursor }
1107
+ )
1108
+ return true
1109
+ }
1110
+ skip =
1111
+ delimiter === ','
1112
+ ? target.split(',').some((a) => a in attrs)
1113
+ : delimiter === '+'
1114
+ ? target.split('+').every((a) => a in attrs)
1115
+ : target in attrs
1116
+ } else if (name === 'ifeval') {
1117
+ if (!noTarget) {
1118
+ this._logError(
1119
+ `malformed preprocessor directive - target not permitted: ifeval::${target}[${text}]`,
1120
+ { sourceLocation: this.cursor }
1121
+ )
1122
+ return true
1123
+ }
1124
+ const m = text && EvalExpressionRx.exec(text.trim())
1125
+ if (m) {
1126
+ try {
1127
+ skip = !this._evalOp(
1128
+ this._resolveExprVal(m[1]),
1129
+ m[2],
1130
+ this._resolveExprVal(m[3])
1131
+ )
1132
+ } catch {
1133
+ skip = true
1134
+ }
1135
+ } else {
1136
+ this._logError(
1137
+ `malformed preprocessor directive - ${text ? 'invalid expression' : 'missing expression'}: ifeval::[${text}]`,
1138
+ { sourceLocation: this.cursor }
1139
+ )
1140
+ return true
1141
+ }
1142
+ }
1143
+ }
1144
+
1145
+ if (name === 'ifeval') {
1146
+ if (skip) this._skipping = true
1147
+ this._conditionalStack.push({
1148
+ name,
1149
+ expr: text,
1150
+ skip,
1151
+ skipping: this._skipping,
1152
+ sourceLocation: this._sourcemap ? this.cursor : null,
1153
+ })
1154
+ } else if (text) {
1155
+ if (!this._skipping && !skip) {
1156
+ this.replaceNextLine(text.trimEnd())
1157
+ // Push a dummy line to stand in for the opening conditional directive
1158
+ this._lines.push('')
1159
+ if (text.startsWith('include::')) this._lookAhead--
1160
+ }
1161
+ } else {
1162
+ if (skip) this._skipping = true
1163
+ this._conditionalStack.push({
1164
+ name,
1165
+ target,
1166
+ skip,
1167
+ skipping: this._skipping,
1168
+ sourceLocation: this._sourcemap ? this.cursor : null,
1169
+ })
1170
+ }
1171
+
1172
+ return true
1173
+ }
1174
+
1175
+ /**
1176
+ * Evaluate a conditional include directive.
1177
+ * @param {string} target
1178
+ * @param {string|null} attrlist
1179
+ * @returns {Promise<boolean|undefined>} True if the line under the cursor was consumed or changed.
1180
+ * @internal
1181
+ */
1182
+ async _preprocessIncludeDirective(target, attrlist) {
1183
+ await _requireFsp()
1184
+ const doc = this._document
1185
+ let expandedTarget = target
1186
+
1187
+ if (expandedTarget.includes(ATTR_REF_HEAD)) {
1188
+ const attrMissing =
1189
+ doc.attributes['attribute-missing'] || Compliance.attribute_missing
1190
+ expandedTarget = doc.subAttributes(target, {
1191
+ attributeMissing: attrMissing === 'warn' ? 'drop-line' : attrMissing,
1192
+ })
1193
+ if (expandedTarget === '') {
1194
+ const parsedAttrs = attrlist
1195
+ ? await doc.parseAttributes(attrlist, [], { subInput: true })
1196
+ : {}
1197
+ if ('optional-option' in parsedAttrs) {
1198
+ this._logInfo(
1199
+ `optional include dropped because resolved target is blank: include::${target}[${attrlist ?? ''}]`,
1200
+ { sourceLocation: this.cursor }
1201
+ )
1202
+ super._shift()
1203
+ return true
1204
+ }
1205
+ if (attrMissing === 'drop-line') {
1206
+ this._logInfo(
1207
+ `include dropped due to missing attribute: include::${target}[${attrlist ?? ''}]`,
1208
+ { sourceLocation: this.cursor }
1209
+ )
1210
+ super._shift()
1211
+ return true
1212
+ }
1213
+ this._logWarn(
1214
+ `include dropped because resolved target is blank: include::${target}[${attrlist ?? ''}]`,
1215
+ { sourceLocation: this.cursor }
1216
+ )
1217
+ return this.replaceNextLine(
1218
+ `Unresolved directive in ${this.path} - include::${target}[${attrlist ?? ''}]`
1219
+ )
1220
+ }
1221
+ }
1222
+
1223
+ if (this.hasIncludeProcessors()) {
1224
+ const ext = this._includeProcessorExtensions.find((c) =>
1225
+ c.instance.handles(doc, expandedTarget)
1226
+ )
1227
+ if (ext) {
1228
+ super._shift()
1229
+ const pa = attrlist
1230
+ ? await doc.parseAttributes(attrlist, [], { subInput: true })
1231
+ : {}
1232
+ await ext.processMethod(doc, this, expandedTarget, pa)
1233
+ return true
1234
+ }
1235
+ }
1236
+
1237
+ if (doc.safe >= SafeMode.SECURE) {
1238
+ const lt = expandedTarget.includes(' ')
1239
+ ? `pass:c[${expandedTarget}]`
1240
+ : expandedTarget
1241
+ const la = doc.hasAttribute('compat-mode')
1242
+ ? (attrlist ?? '')
1243
+ : `role=include${attrlist ? `,${attrlist}` : ''}`
1244
+ return this.replaceNextLine(`link:${lt}[${la}]`)
1245
+ }
1246
+
1247
+ if (!this._maxdepth) return undefined
1248
+
1249
+ if (this.includeStack.length >= this._maxdepth.curr) {
1250
+ this._logError(
1251
+ `maximum include depth of ${this._maxdepth.rel} exceeded`,
1252
+ { sourceLocation: this.cursor }
1253
+ )
1254
+ return undefined
1255
+ }
1256
+
1257
+ const parsedAttrs = attrlist
1258
+ ? await doc.parseAttributes(attrlist, [], { subInput: true })
1259
+ : {}
1260
+ const resolution = await this._resolveIncludePath(
1261
+ expandedTarget,
1262
+ attrlist,
1263
+ parsedAttrs
1264
+ )
1265
+ if (!Array.isArray(resolution)) return resolution
1266
+ const [incPath, targetType, relpath] = resolution
1267
+
1268
+ let incLinenos = null
1269
+ let incTags = null
1270
+ if (attrlist) {
1271
+ if ('lines' in parsedAttrs && parsedAttrs.lines !== '') {
1272
+ incLinenos = []
1273
+ for (const ld of this._splitDelimitedValue(parsedAttrs.lines)) {
1274
+ if (ld.includes('..')) {
1275
+ const sep = ld.indexOf('..')
1276
+ const from = parseInt(ld.slice(0, sep), 10)
1277
+ const toStr = ld.slice(sep + 2)
1278
+ if (toStr === '' || parseInt(toStr, 10) < 0) {
1279
+ incLinenos.push(from, Infinity)
1280
+ } else {
1281
+ const to = parseInt(toStr, 10)
1282
+ for (let i = from; i <= to; i++) incLinenos.push(i)
1283
+ }
1284
+ } else {
1285
+ incLinenos.push(parseInt(ld, 10))
1286
+ }
1287
+ }
1288
+ incLinenos =
1289
+ incLinenos.length > 0
1290
+ ? [...new Set(incLinenos)].sort((a, b) => a - b)
1291
+ : null
1292
+ } else if ('tag' in parsedAttrs) {
1293
+ const tag = parsedAttrs.tag
1294
+ if (tag && tag !== '!')
1295
+ incTags = tag.startsWith('!')
1296
+ ? { [tag.slice(1)]: false }
1297
+ : { [tag]: true }
1298
+ } else if ('tags' in parsedAttrs) {
1299
+ incTags = {}
1300
+ for (const td of this._splitDelimitedValue(parsedAttrs.tags)) {
1301
+ if (td && td !== '!') {
1302
+ incTags[td.startsWith('!') ? td.slice(1) : td] = !td.startsWith('!')
1303
+ }
1304
+ }
1305
+ if (Object.keys(incTags).length === 0) incTags = null
1306
+ }
1307
+ }
1308
+
1309
+ if (targetType === 'uri') {
1310
+ let uriContent
1311
+ try {
1312
+ const response = await fetch(incPath)
1313
+ if (!response.ok)
1314
+ throw new Error(`HTTP ${response.status} ${response.statusText}`)
1315
+ uriContent = await response.text()
1316
+ super._shift()
1317
+ } catch (err) {
1318
+ if ('optional-option' in parsedAttrs) {
1319
+ this._logInfo(
1320
+ `optional include dropped because include URI not readable: ${incPath}`,
1321
+ { sourceLocation: this.cursor }
1322
+ )
1323
+ super._shift()
1324
+ return true
1325
+ }
1326
+ this._logError(
1327
+ `include URI not readable: ${incPath} (${err.message})`,
1328
+ { sourceLocation: this.cursor }
1329
+ )
1330
+ return this.replaceNextLine(
1331
+ `Unresolved directive in ${this.path} - include::${expandedTarget}[${attrlist ?? ''}]`
1332
+ )
1333
+ }
1334
+ if (incLinenos) {
1335
+ const { incLines, incOffset } = this._filterLinesByLinenos(
1336
+ uriContent.split('\n'),
1337
+ incLinenos
1338
+ )
1339
+ if (incOffset !== null) {
1340
+ parsedAttrs['partial-option'] = ''
1341
+ this.pushInclude(incLines, incPath, relpath, incOffset, parsedAttrs)
1342
+ }
1343
+ } else if (incTags) {
1344
+ const { incLines, incOffset } = this._filterLinesByTags(
1345
+ uriContent.split('\n'),
1346
+ incPath,
1347
+ expandedTarget,
1348
+ targetType,
1349
+ incTags,
1350
+ parsedAttrs
1351
+ )
1352
+ if (incOffset !== null)
1353
+ this.pushInclude(incLines, incPath, relpath, incOffset, parsedAttrs)
1354
+ } else {
1355
+ this.pushInclude(uriContent, incPath, relpath, 1, parsedAttrs)
1356
+ }
1357
+ return true
1358
+ }
1359
+
1360
+ try {
1361
+ if (incLinenos) {
1362
+ const fileLines = (await _fsp.readFile(incPath, 'utf8')).split('\n')
1363
+ super._shift()
1364
+ const { incLines, incOffset } = this._filterLinesByLinenos(
1365
+ fileLines,
1366
+ incLinenos
1367
+ )
1368
+ if (incOffset !== null) {
1369
+ parsedAttrs['partial-option'] = ''
1370
+ this.pushInclude(incLines, incPath, relpath, incOffset, parsedAttrs)
1371
+ }
1372
+ } else if (incTags) {
1373
+ const fileLines = (await _fsp.readFile(incPath, 'utf8')).split('\n')
1374
+ super._shift()
1375
+ const { incLines, incOffset } = this._filterLinesByTags(
1376
+ fileLines,
1377
+ incPath,
1378
+ expandedTarget,
1379
+ targetType,
1380
+ incTags,
1381
+ parsedAttrs
1382
+ )
1383
+ if (incOffset !== null)
1384
+ this.pushInclude(incLines, incPath, relpath, incOffset, parsedAttrs)
1385
+ } else {
1386
+ let incContent
1387
+ try {
1388
+ incContent = await _fsp.readFile(incPath, 'utf8')
1389
+ super._shift()
1390
+ } catch {
1391
+ this._logError(`include ${targetType} not readable: ${incPath}`, {
1392
+ sourceLocation: this.cursor,
1393
+ })
1394
+ return this.replaceNextLine(
1395
+ `Unresolved directive in ${this.path} - include::${expandedTarget}[${attrlist ?? ''}]`
1396
+ )
1397
+ }
1398
+ this.pushInclude(incContent, incPath, relpath, 1, parsedAttrs)
1399
+ }
1400
+ } catch {
1401
+ this._logError(`include ${targetType} not readable: ${incPath}`, {
1402
+ sourceLocation: this.cursor,
1403
+ })
1404
+ return this.replaceNextLine(
1405
+ `Unresolved directive in ${this.path} - include::${expandedTarget}[${attrlist ?? ''}]`
1406
+ )
1407
+ }
1408
+ return true
1409
+ }
1410
+
1411
+ /**
1412
+ * Check whether the current context requires browser-mode include resolution.
1413
+ * Browser mode applies when there is no Node.js fs (true browser environment) or when
1414
+ * the document base_dir is a URI (file:// or http(s)://), even in Node.js.
1415
+ * @returns {boolean}
1416
+ * @internal
1417
+ */
1418
+ _isBrowserMode() {
1419
+ if (!_fsp) return true
1420
+ const baseDir = this._document.baseDir
1421
+ return (
1422
+ !!baseDir &&
1423
+ baseDir !== '.' &&
1424
+ (baseDir.startsWith('file://') || isUriish(baseDir))
1425
+ )
1426
+ }
1427
+
1428
+ /**
1429
+ * Resolve the include target to [incPath, targetType, relpath] or a Boolean.
1430
+ * @param {string} target
1431
+ * @param {string|null} attrlist
1432
+ * @param {Object} attributes
1433
+ * @returns {Promise<[string, string, string]|boolean|undefined>}
1434
+ * @internal
1435
+ */
1436
+ async _resolveIncludePath(target, attrlist, attributes) {
1437
+ const doc = this._document
1438
+
1439
+ // Delegate to browser-specific resolution when in a URI-based or browserless environment.
1440
+ // This handles file://, http(s)://, and relative targets resolved against a URI base_dir.
1441
+ // See src/browser/reader.js for the full specification.
1442
+ if (this._isBrowserMode()) {
1443
+ const resolution = resolveBrowserIncludePath(this, target, attrlist)
1444
+ if (!Array.isArray(resolution)) return resolution
1445
+ const [incPath, relpath] = resolution
1446
+ return [incPath, 'uri', relpath]
1447
+ }
1448
+
1449
+ if (isUriish(target) || typeof this._dir !== 'string') {
1450
+ if (!doc.getAttribute('allow-uri-read')) {
1451
+ this._logWarn(
1452
+ `cannot include contents of URI: ${target} (allow-uri-read attribute not enabled)`,
1453
+ { sourceLocation: this.cursor }
1454
+ )
1455
+ const lt = target.includes(' ') ? `pass:c[${target}]` : target
1456
+ const la = doc.hasAttribute('compat-mode')
1457
+ ? (attrlist ?? '')
1458
+ : `role=include${attrlist ? `,${attrlist}` : ''}`
1459
+ return this.replaceNextLine(`link:${lt}[${la}]`)
1460
+ }
1461
+ return [target, 'uri', target]
1462
+ }
1463
+
1464
+ const incPath = doc.normalizeSystemPath(target, this._dir, null, {
1465
+ targetName: 'include file',
1466
+ })
1467
+ if (!(await fileExists(incPath))) {
1468
+ if ('optional-option' in attributes) {
1469
+ this._logInfo(
1470
+ `optional include dropped because include file not found: ${incPath}`,
1471
+ { sourceLocation: this.cursor }
1472
+ )
1473
+ super._shift()
1474
+ return true
1475
+ }
1476
+ this._logError(`include file not found: ${incPath}`, {
1477
+ sourceLocation: this.cursor,
1478
+ })
1479
+ return this.replaceNextLine(
1480
+ `Unresolved directive in ${this.path} - include::${target}[${attrlist ?? ''}]`
1481
+ )
1482
+ }
1483
+ const relpath = doc.pathResolver.relativePath(incPath, doc.baseDir)
1484
+ return [incPath, 'file', relpath]
1485
+ }
1486
+
1487
+ /**
1488
+ * Pop the top include context and restore state.
1489
+ * @internal
1490
+ */
1491
+ _popInclude() {
1492
+ if (this.includeStack.length === 0) return
1493
+ ;[
1494
+ this._lines,
1495
+ this.file,
1496
+ this._dir,
1497
+ this.path,
1498
+ this.lineno,
1499
+ this._maxdepth,
1500
+ this.processLines,
1501
+ ] = this.includeStack.pop()
1502
+ this._lookAhead = 0
1503
+ }
1504
+
1505
+ /**
1506
+ * Read lines filtered by line-number ranges.
1507
+ * @param {string[]} fileLines
1508
+ * @param {number[]} incLinenos
1509
+ * @returns {{incLines: string[], incOffset: number|null}}
1510
+ * @internal
1511
+ */
1512
+ _filterLinesByLinenos(fileLines, incLinenos) {
1513
+ const remaining = [...incLinenos]
1514
+ const incLines = []
1515
+ let incOffset = null
1516
+ let selectRemaining = false
1517
+ for (let idx = 0; idx < fileLines.length; idx++) {
1518
+ const incLineno = idx + 1
1519
+ const l = fileLines[idx] + (idx < fileLines.length - 1 ? '\n' : '')
1520
+ if (
1521
+ selectRemaining ||
1522
+ (remaining[0] === Infinity && (selectRemaining = true))
1523
+ ) {
1524
+ incOffset ??= incLineno
1525
+ incLines.push(l)
1526
+ } else if (remaining[0] === incLineno) {
1527
+ incOffset ??= incLineno
1528
+ incLines.push(l)
1529
+ remaining.shift()
1530
+ if (remaining.length === 0) break
1531
+ }
1532
+ }
1533
+ return { incLines, incOffset }
1534
+ }
1535
+
1536
+ /**
1537
+ * Filter lines by tag directives.
1538
+ * @param {string[]} fileLines
1539
+ * @param {string} incPath
1540
+ * @param {string} expandedTarget
1541
+ * @param {string} targetType
1542
+ * @param {Object} incTagsIn
1543
+ * @param {Object} parsedAttrs
1544
+ * @returns {{incLines: string[], incOffset: number|null}}
1545
+ * @internal
1546
+ */
1547
+ _filterLinesByTags(
1548
+ fileLines,
1549
+ incPath,
1550
+ expandedTarget,
1551
+ targetType,
1552
+ incTagsIn,
1553
+ parsedAttrs
1554
+ ) {
1555
+ const tags = { ...incTagsIn }
1556
+ let select, baseSelect, wildcard
1557
+ if ('**' in tags) {
1558
+ select = baseSelect = tags['**']
1559
+ delete tags['**']
1560
+ if ('*' in tags) {
1561
+ wildcard = tags['*']
1562
+ delete tags['*']
1563
+ } else if (!select && Object.values(tags)[0] === false) wildcard = true
1564
+ } else if ('*' in tags) {
1565
+ if (Object.keys(tags)[0] === '*') {
1566
+ select = baseSelect = !(wildcard = tags['*'])
1567
+ } else {
1568
+ select = baseSelect = false
1569
+ wildcard = tags['*']
1570
+ }
1571
+ delete tags['*']
1572
+ } else {
1573
+ select = baseSelect = !Object.values(tags).includes(true)
1574
+ }
1575
+
1576
+ const incLines = []
1577
+ let incOffset = null
1578
+ const tagStack = []
1579
+ const tagsSelected = new Set()
1580
+ let activeTag = null
1581
+
1582
+ for (let idx = 0; idx < fileLines.length; idx++) {
1583
+ const incLineno = idx + 1
1584
+ const l = fileLines[idx] + (idx < fileLines.length - 1 ? '\n' : '')
1585
+ if (l.includes('::') && l.includes('[]')) {
1586
+ const m = TagDirectiveRx.exec(l)
1587
+ if (m) {
1588
+ const [, isEnd, thisTag] = m
1589
+ if (isEnd) {
1590
+ if (thisTag === activeTag) {
1591
+ tagStack.pop()
1592
+ ;[activeTag, select] =
1593
+ tagStack.length === 0
1594
+ ? [null, baseSelect]
1595
+ : tagStack[tagStack.length - 1]
1596
+ } else if (thisTag in tags) {
1597
+ const ic = this.createIncludeCursor(
1598
+ incPath,
1599
+ expandedTarget,
1600
+ incLineno
1601
+ )
1602
+ const si = tagStack.findLastIndex(([k]) => k === thisTag)
1603
+ if (si >= 0) {
1604
+ tagStack.splice(si, 1)
1605
+ this._logWarn(
1606
+ `mismatched end tag (expected '${activeTag}' but found '${thisTag}') at line ${incLineno} of include ${targetType}: ${incPath}`,
1607
+ { sourceLocation: this.cursor, includeLocation: ic }
1608
+ )
1609
+ } else {
1610
+ this._logWarn(
1611
+ `unexpected end tag '${thisTag}' at line ${incLineno} of include ${targetType}: ${incPath}`,
1612
+ { sourceLocation: this.cursor, includeLocation: ic }
1613
+ )
1614
+ }
1615
+ }
1616
+ } else if (thisTag in tags) {
1617
+ if ((select = tags[thisTag])) tagsSelected.add(thisTag)
1618
+ tagStack.push([(activeTag = thisTag), select, incLineno])
1619
+ } else if (wildcard !== undefined) {
1620
+ select = activeTag && !select ? false : wildcard
1621
+ tagStack.push([(activeTag = thisTag), select, incLineno])
1622
+ }
1623
+ continue
1624
+ }
1625
+ }
1626
+ if (select) {
1627
+ incOffset ??= incLineno
1628
+ incLines.push(l)
1629
+ }
1630
+ }
1631
+
1632
+ for (const [tagName, , tagLineno] of tagStack) {
1633
+ const ic = this.createIncludeCursor(incPath, expandedTarget, tagLineno)
1634
+ this._logWarn(
1635
+ `detected unclosed tag '${tagName}' starting at line ${tagLineno} of include ${targetType}: ${incPath}`,
1636
+ { sourceLocation: this.cursor, includeLocation: ic }
1637
+ )
1638
+ }
1639
+
1640
+ const missingTags = Object.entries(tags)
1641
+ .filter(([, v]) => v)
1642
+ .map(([k]) => k)
1643
+ .filter((k) => !tagsSelected.has(k))
1644
+ if (missingTags.length > 0) {
1645
+ this._logWarn(
1646
+ `tag${missingTags.length > 1 ? 's' : ''} '${missingTags.join(', ')}' not found in include ${targetType}: ${incPath}`,
1647
+ { sourceLocation: this.cursor }
1648
+ )
1649
+ }
1650
+
1651
+ if (!baseSelect || wildcard === false || Object.keys(tags).length > 0) {
1652
+ parsedAttrs['partial-option'] = ''
1653
+ }
1654
+
1655
+ return { incLines, incOffset }
1656
+ }
1657
+
1658
+ /**
1659
+ * Strip YAML/TOML front matter from the data Array (in-place).
1660
+ * @param {string[]} data
1661
+ * @param {boolean} [incrementLinenos=true]
1662
+ * @returns {string[]|null} The front-matter lines, or null if no front matter was found.
1663
+ * @internal
1664
+ */
1665
+ _skipFrontMatter(data, incrementLinenos = true) {
1666
+ const delim = data[0]
1667
+ if (delim !== '---' && delim !== '+++') return null
1668
+ const original = [...data]
1669
+ data.shift()
1670
+ const frontMatter = []
1671
+ if (incrementLinenos) this.lineno++
1672
+ let eof = false
1673
+ while (!(eof = data.length === 0) && data[0] !== delim) {
1674
+ frontMatter.push(data.shift())
1675
+ if (incrementLinenos) this.lineno++
1676
+ }
1677
+ if (eof) {
1678
+ data.length = 0
1679
+ data.push(...original)
1680
+ if (incrementLinenos) this.lineno -= original.length
1681
+ return null
1682
+ }
1683
+ data.shift()
1684
+ if (incrementLinenos) this.lineno++
1685
+ return frontMatter
1686
+ }
1687
+
1688
+ /**
1689
+ * Resolve the value of one side of an ifeval expression.
1690
+ * @param {string} val
1691
+ * @returns {string|number|boolean|null}
1692
+ * @internal
1693
+ */
1694
+ _resolveExprVal(val) {
1695
+ let quoted = false
1696
+ if (
1697
+ (val.startsWith('"') && val.endsWith('"')) ||
1698
+ (val.startsWith("'") && val.endsWith("'"))
1699
+ ) {
1700
+ quoted = true
1701
+ val = val.slice(1, val.length - 1)
1702
+ }
1703
+ if (val.includes(ATTR_REF_HEAD)) {
1704
+ val = this._document.subAttributes(val, { attributeMissing: 'drop' })
1705
+ }
1706
+ if (quoted) return val
1707
+ if (val === '') return null
1708
+ if (val === 'true') return true
1709
+ if (val === 'false') return false
1710
+ if (val.trimEnd() === '') return ' '
1711
+ if (val.includes('.')) return parseFloat(val)
1712
+ return parseInt(val, 10)
1713
+ }
1714
+
1715
+ /**
1716
+ * Evaluate a binary comparison.
1717
+ * @param {*} lhs
1718
+ * @param {string} op
1719
+ * @param {*} rhs
1720
+ * @returns {boolean}
1721
+ * @internal
1722
+ */
1723
+ _evalOp(lhs, op, rhs) {
1724
+ // Reject comparisons that mix boolean with non-boolean (invalid in Ruby — throws TypeError).
1725
+ if ((typeof lhs === 'boolean') !== (typeof rhs === 'boolean'))
1726
+ throw new TypeError('incompatible operand types')
1727
+ if (op === '==') return lhs === rhs
1728
+ if (op === '!=') return lhs !== rhs
1729
+ if (op === '<') return lhs < rhs
1730
+ if (op === '>') return lhs > rhs
1731
+ if (op === '<=') return lhs <= rhs
1732
+ if (op === '>=') return lhs >= rhs
1733
+ return false
1734
+ }
1735
+
1736
+ /**
1737
+ * Split a delimited value on comma (if present), otherwise semicolon.
1738
+ * @param {string} val
1739
+ * @returns {string[]}
1740
+ * @internal
1741
+ */
1742
+ _splitDelimitedValue(val) {
1743
+ return val.includes(',') ? val.split(',') : val.split(';')
1744
+ }
1745
+
1746
+ // ── JavaScript-style accessors ────────────────────────────────────────────────
1747
+
1748
+ /**
1749
+ * Get the current include depth (number of nested includes).
1750
+ * @returns {number}
1751
+ */
1752
+ getIncludeDepth() {
1753
+ return this.includeDepth
1754
+ }
1755
+ }