@jti/adoc2typst2pdf 1.0.0

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.
@@ -0,0 +1,761 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * adoc2typst2pdf.js
5
+ *
6
+ * Drop-in replacement for asciidoctor-pdf using the pipeline:
7
+ * asciidoctor -b docbook → docbook2typst.js → typst compile
8
+ *
9
+ * Usage:
10
+ * node adoc2typst2pdf.js [options] <input.adoc>
11
+ *
12
+ * Recognized options (all others are silently ignored, so this can replace
13
+ * asciidoctor-pdf on existing command lines):
14
+ * -a name[=value] AsciiDoc attribute definition (repeatable)
15
+ * -o <file> Output PDF path (default: <input>.pdf)
16
+ */
17
+
18
+ 'use strict';
19
+
20
+ const { spawn, execFileSync } = require('child_process')
21
+ const fs = require('fs')
22
+ const path = require('path')
23
+
24
+ const VERSION = '1.0.0'
25
+
26
+
27
+ // ── 1. Parse CLI arguments ────────────────────────────────────────────────────
28
+
29
+ const argv = process.argv.slice(2)
30
+
31
+ if (argv.includes('--version')) {
32
+ console.log(VERSION)
33
+ process.exit(0)
34
+ }
35
+
36
+ if (argv.length === 0 || argv.includes('--help') || argv.includes('-h')) {
37
+ console.log(`
38
+ Usage: node adoc2typst2pdf.js [options] <input.adoc|->
39
+
40
+ Converts an AsciiDoc file to PDF using the pipeline:
41
+ asciidoctor -b docbook → docbook2typst.js → typst compile
42
+
43
+ Requires asciidoctor and typst.
44
+ Pass - as the input file to read AsciiDoc from stdin (-o is required in that case).
45
+
46
+ Options:
47
+ -a name[=value] AsciiDoc attribute definition (passed through to
48
+ asciidoctor; may be repeated)
49
+ -d, --doctype <type> Set the document type (passed through to asciidoctor)
50
+ -n, --section-numbers Auto-number section titles (passed through to asciidoctor)
51
+ -o <file> Output PDF path (required when reading from stdin;
52
+ default for file input: <input-dir>/<component>-<date>.pdf)
53
+ -S, --safe-mode <lvl> Set safe mode: unsafe, safe, server, secure
54
+ (passed through to asciidoctor)
55
+ --safe Set safe mode to "safe" (passed through to asciidoctor)
56
+ -t, --typst-template <file>
57
+ Path to a Typst preamble file. When provided, the
58
+ generated .typ file is prefixed with an import of
59
+ this file and title/subtitle are passed to typst via
60
+ --input. When omitted, no preamble is added.
61
+ -v, --verbose Show full command lines for each pipeline step
62
+ --version Print the version and exit
63
+ -h, --help Show this help and exit
64
+ `)
65
+ process.exit(0)
66
+ }
67
+
68
+ // Flags that take a following argument but whose value we don't need.
69
+ const SKIP_VALUE_FLAGS = new Set([
70
+ '-D', '--destination-dir',
71
+ '-B', '--base-dir',
72
+ '-T', '--template-dir',
73
+ '-E', '--template-engine',
74
+ '--failure-level',
75
+ '--log-level',
76
+ '-I', '--load-path',
77
+ '--theme', '--pdf-theme'
78
+ ])
79
+
80
+ const attributes = [] // collected -a / --attribute entries
81
+ const safeArgs = [] // collected --safe / --safe-mode / -S entries
82
+ const requireArgs = [] // collected --require / -r entries
83
+ let sectionNumbers = false
84
+ let doctype = null
85
+ let typstTemplate = null
86
+ let verbose = false
87
+ let inputFile = null
88
+ let outputFile = null
89
+
90
+ // Process CLI parameters.
91
+ for (let i = 0; i < argv.length; i++) {
92
+ const arg = argv[i]
93
+
94
+ // -a name[=value]
95
+ if (arg === '-a' || arg === '--attribute') {
96
+ const val = argv[++i]
97
+ if (val !== undefined) attributes.push(val)
98
+ continue
99
+ }
100
+ if (arg.startsWith('-a') && arg.length > 2) {
101
+ attributes.push(arg.slice(2))
102
+ continue
103
+ }
104
+ if (arg.startsWith('--attribute=')) {
105
+ attributes.push(arg.slice('--attribute='.length))
106
+ continue
107
+ }
108
+
109
+ // -d / --doctype <type>
110
+ if (arg === '-d' || arg === '--doctype') {
111
+ const val = argv[++i]
112
+ if (val !== undefined) doctype = val
113
+ continue
114
+ }
115
+ if (arg.startsWith('--doctype=')) {
116
+ doctype = arg.slice('--doctype='.length)
117
+ continue
118
+ }
119
+
120
+ // -n / --section-numbers (boolean flag)
121
+ if (arg === '-n' || arg === '--section-numbers') {
122
+ sectionNumbers = true
123
+ continue
124
+ }
125
+
126
+ // --require (string)
127
+ if (arg === '--require' || arg === '-r') {
128
+ const val = argv[++i]
129
+ if (val !== undefined) requireArgs.push('-r', val)
130
+ continue
131
+ }
132
+
133
+ // --safe (boolean flag)
134
+ if (arg === '--safe') {
135
+ safeArgs.push('--safe')
136
+ continue
137
+ }
138
+ // --safe-mode <level> / -S <level>
139
+ if (arg === '--safe-mode' || arg === '-S') {
140
+ const val = argv[++i]
141
+ if (val !== undefined) safeArgs.push('--safe-mode', val)
142
+ continue
143
+ }
144
+ if (arg.startsWith('--safe-mode=')) {
145
+ safeArgs.push(arg)
146
+ continue
147
+ }
148
+
149
+ // -t / --typst-template <file>
150
+ if (arg === '-t' || arg === '--typst-template') {
151
+ const val = argv[++i]
152
+ if (val !== undefined) typstTemplate = val
153
+ continue
154
+ }
155
+ if (arg.startsWith('--typst-template=')) {
156
+ typstTemplate = arg.slice('--typst-template='.length)
157
+ continue
158
+ }
159
+
160
+ // -v / --verbose
161
+ if (arg === '-v' || arg === '--verbose') {
162
+ verbose = true
163
+ continue
164
+ }
165
+
166
+ // -o / --out-file
167
+ if (arg === '-o' || arg === '--out-file') {
168
+ outputFile = argv[++i]
169
+ continue
170
+ }
171
+ if (arg.startsWith('--out-file=')) {
172
+ outputFile = arg.slice('--out-file='.length)
173
+ continue
174
+ }
175
+
176
+ // flags whose value we skip
177
+ if (SKIP_VALUE_FLAGS.has(arg)) {
178
+ i++ // consume the next token
179
+ continue
180
+ }
181
+ // long --flag=value forms that we skip
182
+ if (arg.startsWith('--') && arg.includes('=')) {
183
+ continue
184
+ }
185
+ // '-' on its own means read from stdin — treat as a positional
186
+ if (arg === '-') {
187
+ inputFile = arg
188
+ continue
189
+ }
190
+
191
+ // other short/long flags with no value
192
+ if (arg.startsWith('-')) {
193
+ continue
194
+ }
195
+
196
+ // positional argument → the AsciiDoc input file
197
+ inputFile = arg
198
+ }
199
+
200
+ if (!inputFile) {
201
+ console.error('Error: no input file specified.')
202
+ console.error('Run with --help for usage information.')
203
+ process.exit(1)
204
+ }
205
+
206
+ const stdinMode = inputFile === '-'
207
+
208
+ if (stdinMode && !outputFile) {
209
+ console.error('Error: -o <file> is required when reading from stdin.')
210
+ process.exit(1)
211
+ }
212
+
213
+ if (stdinMode) {
214
+ const adocFile = path.join(
215
+ path.dirname(outputFile),
216
+ path.basename(outputFile, path.extname(outputFile)) + '.adoc'
217
+ )
218
+ fs.writeFileSync(adocFile, fs.readFileSync(0, 'utf-8'), 'utf-8')
219
+ inputFile = adocFile
220
+ } else if (!fs.existsSync(inputFile)) {
221
+ console.error(`Error: input file not found: ${inputFile}`)
222
+ process.exit(1)
223
+ }
224
+
225
+
226
+ // ── Derive Antora component title ─────────────────────────────────────────────
227
+
228
+ // Walk upward from `startDir` looking for an antora.yml that declares a `title`.
229
+ function findAntoraTitle(startDir) {
230
+ let dir = path.resolve(startDir)
231
+ while (true) {
232
+ const candidate = path.join(dir, 'antora.yml')
233
+ if (fs.existsSync(candidate)) {
234
+ // Match: title: "quoted value" | title: 'quoted value' | title: bare value
235
+ const m = fs.readFileSync(candidate, 'utf-8')
236
+ .match(/^title:\s*(?:"([^"]*?)"|'([^']*?)'|(.*?))\s*$/m)
237
+ if (m) {
238
+ const title = (m[1] ?? m[2] ?? m[3] ?? '').trim()
239
+ if (title) return title
240
+ }
241
+ }
242
+ const parent = path.dirname(dir)
243
+ if (parent === dir) break // reached filesystem root
244
+ dir = parent
245
+ }
246
+ return null
247
+ }
248
+
249
+ // Convert a title string to a filename-safe slug:
250
+ // spaces → underscores, strip punctuation, collapse runs of underscores.
251
+ function titleToSlug(title) {
252
+ return title
253
+ .replace(/\s+/g, '_') // spaces → underscores
254
+ .replace(/[^\w]/g, '') // remove anything not a word char (a-z, A-Z, 0-9, _)
255
+ .replace(/_+/g, '_') // collapse consecutive underscores
256
+ .replace(/^_|_$/g, '') // strip leading/trailing underscores
257
+ }
258
+
259
+ const componentTitle =
260
+ findAntoraTitle(stdinMode ? process.cwd() : path.dirname(path.resolve(inputFile))) ||
261
+ findAntoraTitle(path.join(__dirname, 'docs')) ||
262
+ 'document'
263
+
264
+ const componentName = titleToSlug(componentTitle)
265
+
266
+
267
+ // ── Derive intermediate and final paths ───────────────────────────────────────
268
+
269
+ const today = new Date()
270
+ const year = today.getFullYear()
271
+ const month = today.getMonth() + 1
272
+ const day = today.getDate()
273
+ const dateStr = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`
274
+
275
+ const inputBase = inputFile.replace(/\.(adoc|asciidoc|asc)$/i, '')
276
+ const xmlFile = inputBase + '.xml'
277
+ const typFile = inputBase + '.typ'
278
+ const pdfFile = outputFile ||
279
+ path.join(process.cwd(), `${componentName}-${dateStr}.pdf`)
280
+
281
+
282
+ // ── 2. Check tool availability ────────────────────────────────────────────────
283
+
284
+ function toolExists(name) {
285
+ try {
286
+ execFileSync('which', [name], { stdio: 'ignore' })
287
+ return true
288
+ } catch {
289
+ return false
290
+ }
291
+ }
292
+
293
+ const missing = ['asciidoctor'].filter(t => !toolExists(t))
294
+ if (missing.length > 0) {
295
+ console.error(
296
+ `Error: the following required tools are not on your PATH: ${missing.join(', ')}`
297
+ )
298
+ console.error('Please install them before running this script.')
299
+ process.exit(1)
300
+ }
301
+
302
+ // ── Helpers ───────────────────────────────────────────────────────────────────
303
+
304
+ // Return p as a path relative to cwd; if p is not absolute, resolve it first.
305
+ function relPath(p) {
306
+ return path.relative(process.cwd(), path.resolve(p)) || p
307
+ }
308
+
309
+ // For verbose display: only convert absolute paths, leave flags/values as-is.
310
+ function relIfAbs(s) {
311
+ return path.isAbsolute(s) ? relPath(s) : s
312
+ }
313
+
314
+ function extractTitle(adocPath) {
315
+ try {
316
+ const lines = fs.readFileSync(adocPath, 'utf-8').split('\n')
317
+ for (const line of lines) {
318
+ const m = line.match(/^=\s+(.+)/)
319
+ if (m) return m[1].trim()
320
+ }
321
+ } catch { /* fall through */ }
322
+ return path.basename(adocPath, path.extname(adocPath))
323
+ }
324
+
325
+
326
+ // ── Timing ────────────────────────────────────────────────────────────────────
327
+
328
+ const t0 = performance.now()
329
+
330
+ function fmtElapsed() {
331
+ const ms = performance.now() - t0
332
+ const s = ms / 1000
333
+ if (s < 60) return `${s.toFixed(2)}s`
334
+
335
+ const m = Math.floor(s / 60)
336
+ const sRem = (s % 60).toFixed(2).padStart(5, '0')
337
+ return `${m}:${sRem}s`
338
+ }
339
+
340
+ // ── Command runner ─────────────────────────────────────────────────────────────
341
+
342
+ async function run(phase, cmd, args) {
343
+ const phaseStart = performance.now()
344
+ if (verbose) {
345
+ const display = cmd === process.execPath ? 'node' : cmd
346
+ console.log(`\n→ ${display} ${args.map(relIfAbs).join(' ')}`)
347
+ }
348
+ setPhase(phase)
349
+
350
+ return new Promise((resolve, reject) => {
351
+ // In non-verbose mode suppress child stdout/stderr so they don't tear
352
+ // the spinner; capture stderr to surface on failure.
353
+ const stdio = verbose ? 'inherit' : ['ignore', 'ignore', 'pipe']
354
+ const proc = spawn(cmd, args, { stdio })
355
+
356
+ let stderr = ''
357
+ if (proc.stderr) proc.stderr.on('data', d => { stderr += d })
358
+
359
+ proc.on('close', code => {
360
+ if (verbose) {
361
+ const ms = performance.now() - phaseStart
362
+ console.log(`Elapsed: ${(ms / 1000).toFixed(2)} secs`)
363
+ }
364
+ if (code !== 0) {
365
+ if (stderr) process.stderr.write(stderr)
366
+ const cmdLabel = cmd === process.execPath ? 'docbook2typst converter' : cmd
367
+ reject(new Error(`${cmdLabel} exited with an error.`))
368
+ } else {
369
+ resolve()
370
+ }
371
+ })
372
+ proc.on('error', reject)
373
+ })
374
+ }
375
+
376
+
377
+ // ── Setup: symlinks and build args ────────────────────────────────────────────
378
+
379
+ const typstRoot = path.dirname(path.dirname(path.resolve(pdfFile)))
380
+ const fontRoot = typstTemplate
381
+ ? path.join(path.dirname(path.resolve(typstTemplate)), 'fonts')
382
+ : null
383
+
384
+ // Create a symlink idempotently. EEXIST is silently ignored so concurrent
385
+ // invocations don't race; the symlinks are stable build artifacts and are
386
+ // intentionally left in place after the process exits.
387
+ function ensureSymlink(target, linkPath, type) {
388
+ try {
389
+ fs.symlinkSync(target, linkPath, type)
390
+ } catch (err) {
391
+ if (err.code !== 'EEXIST') throw err
392
+ }
393
+ }
394
+
395
+ ensureSymlink(path.join(__dirname, 'typst'), path.join(typstRoot, 'typst'), 'dir')
396
+
397
+ const componentFolder = path.relative(typstRoot, outputFile).split(path.sep)[0]
398
+ const imageSymlinkDir = path.join(typstRoot, componentFolder)
399
+ fs.mkdirSync(imageSymlinkDir, { recursive: true })
400
+ ensureSymlink(path.join('..', componentFolder), path.join(imageSymlinkDir, componentFolder), 'dir')
401
+
402
+
403
+ // ── Concurrent progress display ───────────────────────────────────────────────
404
+ //
405
+ // Multiple concurrent invocations each own one line in a shared "progress
406
+ // block" at the bottom of the terminal. A file-based lock serialises all
407
+ // writes; each writer moves the cursor up to its line, rewrites it, then
408
+ // moves the cursor back down so the next writer finds the anchor in the
409
+ // expected position.
410
+
411
+ const isTTY = Boolean(process.stdout.isTTY)
412
+ const progressDir = path.join(typstRoot, '.progress')
413
+ const lockFile = path.join(progressDir, 'lock')
414
+ const totalFile = path.join(progressDir, 'total')
415
+
416
+ fs.mkdirSync(progressDir, { recursive: true })
417
+ cleanStaleSlots()
418
+
419
+ // Remove slot files whose owning process is no longer alive. When no live
420
+ // peers remain, reset the line counter so the next batch starts a fresh block.
421
+ function cleanStaleSlots() {
422
+ // Clear a stale TTY lock left by a crashed process.
423
+ try {
424
+ const lockPid = parseInt(fs.readFileSync(lockFile, 'utf-8').trim(), 10)
425
+ if (Number.isFinite(lockPid)) {
426
+ try { process.kill(lockPid, 0) }
427
+ catch (e) { if (e.code === 'ESRCH') try { fs.unlinkSync(lockFile) } catch {} }
428
+ }
429
+ } catch {}
430
+
431
+ let hasLive = false
432
+ let files
433
+ try { files = fs.readdirSync(progressDir) }
434
+ catch { return }
435
+
436
+ for (const f of files) {
437
+ if (!f.startsWith('slot-')) continue
438
+ const slotFile = path.join(progressDir, f)
439
+ try {
440
+ const pid = parseInt(fs.readFileSync(slotFile, 'utf-8').trim(), 10)
441
+ if (!Number.isFinite(pid)) {
442
+ try { fs.unlinkSync(slotFile) }
443
+ catch {} continue
444
+ }
445
+ process.kill(pid, 0) // throws ESRCH if dead
446
+ hasLive = true
447
+ } catch (e) {
448
+ if (e.code === 'ESRCH') try { fs.unlinkSync(slotFile) } catch {}
449
+ else hasLive = true // EPERM → process exists but belongs to another user
450
+ }
451
+ }
452
+ if (!hasLive) try { fs.writeFileSync(totalFile, '0', 'utf-8') } catch {}
453
+ }
454
+
455
+ // Spin-acquire the file-based TTY write lock (held only for the duration of
456
+ // a single terminal write, so contention is brief).
457
+ function acquireTTYLock() {
458
+ const deadline = Date.now() + 5000
459
+ while (Date.now() < deadline) {
460
+ try {
461
+ fs.writeFileSync(lockFile, String(process.pid), { flag: 'wx' })
462
+ return
463
+ } catch (e) {
464
+ if (e.code !== 'EEXIST') throw e
465
+ const t = Date.now() + 1
466
+ while (Date.now() < t) {} // 1 ms busy-wait
467
+ }
468
+ }
469
+ fs.writeFileSync(lockFile, String(process.pid)) // stale lock: forcibly claim
470
+ }
471
+
472
+ function releaseTTYLock() {
473
+ try { fs.unlinkSync(lockFile) }
474
+ catch {}
475
+ }
476
+
477
+ function readTotal() {
478
+ try { return parseInt(fs.readFileSync(totalFile, 'utf-8'), 10) || 0 }
479
+ catch { return 0 }
480
+ }
481
+
482
+ // Claim the lowest available slot index (one per concurrent invocation).
483
+ let mySlot = -1
484
+ function claimSlot() {
485
+ for (let n = 0; ; n++) {
486
+ try {
487
+ fs.writeFileSync(path.join(progressDir, `slot-${n}`), String(process.pid), { flag: 'wx' })
488
+ mySlot = n
489
+ return
490
+ } catch (e) {
491
+ if (e.code !== 'EEXIST') throw e
492
+ }
493
+ }
494
+ }
495
+
496
+ function releaseSlot() {
497
+ if (mySlot < 0) return
498
+ try { fs.unlinkSync(path.join(progressDir, `slot-${mySlot}`)) }
499
+ catch {}
500
+
501
+ mySlot = -1
502
+ // If no slots remain, we are the last invocation: remove build symlinks so
503
+ // subsequent tools (e.g. htmltest) don't follow them into a loop.
504
+ try {
505
+ if (fs.readdirSync(progressDir).every(f => !f.startsWith('slot-'))) {
506
+ cleanupSymlinks()
507
+ }
508
+ }
509
+ catch {}
510
+ }
511
+
512
+ function cleanupSymlinks() {
513
+ // Shared typst theme symlink.
514
+ try {
515
+ fs.unlinkSync(path.join(typstRoot, 'typst'))
516
+ }
517
+ catch {}
518
+
519
+ // Per-component image symlinks: each lives at typstRoot/<dir>/<dir>.
520
+ try {
521
+ for (const entry of fs.readdirSync(typstRoot, { withFileTypes: true })) {
522
+ if (!entry.isDirectory() || entry.name.startsWith('.')) continue
523
+ const link = path.join(typstRoot, entry.name, entry.name)
524
+ try {
525
+ if (fs.lstatSync(link).isSymbolicLink()) fs.unlinkSync(link)
526
+ }
527
+ catch {}
528
+ }
529
+ }
530
+ catch {}
531
+ }
532
+
533
+ // Write the initial two-line block for this invocation (header + spinner),
534
+ // claiming the next two rows in the shared progress block. myLineIndex is
535
+ // set to the spinner line so that updateProgressLine targets it correctly.
536
+ let myLineIndex = -1
537
+ function initProgressLine(header, spinner) {
538
+ acquireTTYLock()
539
+ try {
540
+ const T = readTotal()
541
+ myLineIndex = T + 1 // spinner is the second of the two lines
542
+ process.stdout.write(`${header}\n${spinner}\n`)
543
+ fs.writeFileSync(totalFile, String(T + 2), 'utf-8')
544
+ } finally {
545
+ releaseTTYLock()
546
+ }
547
+ }
548
+
549
+ // Overwrite this invocation's line in-place. The cursor is left at the
550
+ // bottom of the progress block (base + totalLines) after every locked write,
551
+ // so the distance from cursor to our line is always (total − myLineIndex).
552
+ function updateProgressLine(text) {
553
+ if (myLineIndex < 0) return
554
+ acquireTTYLock()
555
+ try {
556
+ const dist = readTotal() - myLineIndex
557
+ process.stdout.write(`\x1b[${dist}A\r\x1b[2K${text}\x1b[${dist}B\r`)
558
+ }
559
+ finally {
560
+ releaseTTYLock()
561
+ }
562
+ }
563
+
564
+ // ── Spinner state ─────────────────────────────────────────────────────────────
565
+
566
+ // Select spinners from the cli-spinners package.
567
+ const spinners = {
568
+ "dots": [ "⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏" ],
569
+ "dots2": [ "⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷" ],
570
+ "dots3": [ "⠋", "⠙", "⠚", "⠞", "⠖", "⠦", "⠴", "⠲", "⠳", "⠓" ],
571
+ "dots4": [ "⠄", "⠆", "⠇", "⠋", "⠙", "⠸", "⠰", "⠠", "⠰", "⠸", "⠙", "⠋", "⠇", "⠆" ],
572
+ "dots5": [ "⠋", "⠙", "⠚", "⠒", "⠂", "⠂", "⠒", "⠲", "⠴", "⠦", "⠖", "⠒", "⠐", "⠐", "⠒", "⠓", "⠋" ],
573
+ "dots6": [ "⠁", "⠉", "⠙", "⠚", "⠒", "⠂", "⠂", "⠒", "⠲", "⠴", "⠤", "⠄", "⠄", "⠤", "⠴", "⠲", "⠒", "⠂", "⠂", "⠒", "⠚", "⠙", "⠉", "⠁" ],
574
+ "dots7": [ "⠈", "⠉", "⠋", "⠓", "⠒", "⠐", "⠐", "⠒", "⠖", "⠦", "⠤", "⠠", "⠠", "⠤", "⠦", "⠖", "⠒", "⠐", "⠐", "⠒", "⠓", "⠋", "⠉", "⠈" ],
575
+ "dots8": [ "⠁", "⠁", "⠉", "⠙", "⠚", "⠒", "⠂", "⠂", "⠒", "⠲", "⠴", "⠤", "⠄", "⠄", "⠤", "⠠", "⠠", "⠤", "⠦", "⠖", "⠒", "⠐", "⠐", "⠒", "⠓", "⠋", "⠉", "⠈", "⠈" ],
576
+ "dots9": [ "⢹", "⢺", "⢼", "⣸", "⣇", "⡧", "⡗", "⡏" ],
577
+ "dots10": [ "⢄", "⢂", "⢁", "⡁", "⡈", "⡐", "⡠" ],
578
+ "dots11": [ "⠁", "⠂", "⠄", "⡀", "⢀", "⠠", "⠐", "⠈" ],
579
+ "dots12": [ "⢀⠀", "⡀⠀", "⠄⠀", "⢂⠀", "⡂⠀", "⠅⠀", "⢃⠀", "⡃⠀", "⠍⠀", "⢋⠀", "⡋⠀", "⠍⠁", "⢋⠁", "⡋⠁", "⠍⠉", "⠋⠉", "⠋⠉", "⠉⠙", "⠉⠙", "⠉⠩", "⠈⢙", "⠈⡙", "⢈⠩", "⡀⢙", "⠄⡙", "⢂⠩", "⡂⢘", "⠅⡘", "⢃⠨", "⡃⢐", "⠍⡐", "⢋⠠", "⡋⢀", "⠍⡁", "⢋⠁", "⡋⠁", "⠍⠉", "⠋⠉", "⠋⠉", "⠉⠙", "⠉⠙", "⠉⠩", "⠈⢙", "⠈⡙", "⠈⠩", "⠀⢙", "⠀⡙", "⠀⠩", "⠀⢘", "⠀⡘", "⠀⠨", "⠀⢐", "⠀⡐", "⠀⠠", "⠀⢀", "⠀⡀" ],
580
+ "dots13": [ "⣼", "⣹", "⢻", "⠿", "⡟", "⣏", "⣧", "⣶" ],
581
+ "dots14": [ "⠉⠉", "⠈⠙", "⠀⠹", "⠀⢸", "⠀⣰", "⢀⣠", "⣀⣀", "⣄⡀", "⣆⠀", "⡇⠀", "⠏⠀", "⠋⠁" ],
582
+ "dots8Bit": [ "⠀", "⠁", "⠂", "⠃", "⠄", "⠅", "⠆", "⠇", "⡀", "⡁", "⡂", "⡃", "⡄", "⡅", "⡆", "⡇", "⠈", "⠉", "⠊", "⠋", "⠌", "⠍", "⠎", "⠏", "⡈", "⡉", "⡊", "⡋", "⡌", "⡍", "⡎", "⡏", "⠐", "⠑", "⠒", "⠓", "⠔", "⠕", "⠖", "⠗", "⡐", "⡑", "⡒", "⡓", "⡔", "⡕", "⡖", "⡗", "⠘", "⠙", "⠚", "⠛", "⠜", "⠝", "⠞", "⠟", "⡘", "⡙", "⡚", "⡛", "⡜", "⡝", "⡞", "⡟", "⠠", "⠡", "⠢", "⠣", "⠤", "⠥", "⠦", "⠧", "⡠", "⡡", "⡢", "⡣", "⡤", "⡥", "⡦", "⡧", "⠨", "⠩", "⠪", "⠫", "⠬", "⠭", "⠮", "⠯", "⡨", "⡩", "⡪", "⡫", "⡬", "⡭", "⡮", "⡯", "⠰", "⠱", "⠲", "⠳", "⠴", "⠵", "⠶", "⠷", "⡰", "⡱", "⡲", "⡳", "⡴", "⡵", "⡶", "⡷", "⠸", "⠹", "⠺", "⠻", "⠼", "⠽", "⠾", "⠿", "⡸", "⡹", "⡺", "⡻", "⡼", "⡽", "⡾", "⡿", "⢀", "⢁", "⢂", "⢃", "⢄", "⢅", "⢆", "⢇", "⣀", "⣁", "⣂", "⣃", "⣄", "⣅", "⣆", "⣇", "⢈", "⢉", "⢊", "⢋", "⢌", "⢍", "⢎", "⢏", "⣈", "⣉", "⣊", "⣋", "⣌", "⣍", "⣎", "⣏", "⢐", "⢑", "⢒", "⢓", "⢔", "⢕", "⢖", "⢗", "⣐", "⣑", "⣒", "⣓", "⣔", "⣕", "⣖", "⣗", "⢘", "⢙", "⢚", "⢛", "⢜", "⢝", "⢞", "⢟", "⣘", "⣙", "⣚", "⣛", "⣜", "⣝", "⣞", "⣟", "⢠", "⢡", "⢢", "⢣", "⢤", "⢥", "⢦", "⢧", "⣠", "⣡", "⣢", "⣣", "⣤", "⣥", "⣦", "⣧", "⢨", "⢩", "⢪", "⢫", "⢬", "⢭", "⢮", "⢯", "⣨", "⣩", "⣪", "⣫", "⣬", "⣭", "⣮", "⣯", "⢰", "⢱", "⢲", "⢳", "⢴", "⢵", "⢶", "⢷", "⣰", "⣱", "⣲", "⣳", "⣴", "⣵", "⣶", "⣷", "⢸", "⢹", "⢺", "⢻", "⢼", "⢽", "⢾", "⢿", "⣸", "⣹", "⣺", "⣻", "⣼", "⣽", "⣾", "⣿" ],
583
+ "dotsCircle": [ "⢎ ", "⠎⠁", "⠊⠑", "⠈⠱", " ⡱", "⢀⡰", "⢄⡠", "⢆⡀" ],
584
+ "sand": [ "⠁", "⠂", "⠄", "⡀", "⡈", "⡐", "⡠", "⣀", "⣁", "⣂", "⣄", "⣌", "⣔", "⣤", "⣥", "⣦", "⣮", "⣶", "⣷", "⣿", "⡿", "⠿", "⢟", "⠟", "⡛", "⠛", "⠫", "⢋", "⠋", "⠍", "⡉", "⠉", "⠑", "⠡", "⢁" ],
585
+ "line": [ "-", "\\", "|", "/" ],
586
+ "line2": [ "⠂", "-", "–", "—", "–", "-" ],
587
+ "rollingLine": [ "/ ", " - ", " \\ ", " |", " |", " \\ ", " - ", "/ " ],
588
+ "pipe": [ "┤", "┘", "┴", "└", "├", "┌", "┬", "┐" ],
589
+ "simpleDots": [ ". ", ".. ", "...", " " ],
590
+ "simpleDotsScrolling": [ ". ", ".. ", "...", " ..", " .", " " ],
591
+ "star": [ "✶", "✸", "✹", "✺", "✹", "✷" ],
592
+ "star2": [ "+", "x", "*" ],
593
+ "flip": [ "_", "_", "_", "-", "`", "`", "'", "´", "-", "_", "_", "_" ],
594
+ "hamburger": [ "☱", "☲", "☴" ],
595
+ "growVertical": [ "▁", "▃", "▄", "▅", "▆", "▇", "▆", "▅", "▄", "▃" ],
596
+ "growHorizontal": [ "▏", "▎", "▍", "▌", "▋", "▊", "▉", "▊", "▋", "▌", "▍", "▎" ],
597
+ "balloon": [ " ", ".", "o", "O", "@", "*", " " ],
598
+ "balloon2": [ ".", "o", "O", "°", "O", "o", "." ],
599
+ "noise": [ "▓", "▒", "░" ],
600
+ "bounce": [ "⠁", "⠂", "⠄", "⠂" ]
601
+ }
602
+
603
+ const SPINNER_CHARS = spinners['dots']
604
+ let spinFrame = 0
605
+ let spinTimer = null
606
+ let phaseMsg = ''
607
+ const progressLabel = `[${componentName}]`
608
+
609
+ function fmtLine(ch) {
610
+ return ` ${ch} ${fmtElapsed()} ${phaseMsg}`
611
+ // return ` ${ch} ${fmtElapsed()} ${progressLabel} ${phaseMsg}`
612
+ }
613
+
614
+ function tickSpinner() {
615
+ if (!isTTY) return
616
+ updateProgressLine(fmtLine(SPINNER_CHARS[spinFrame++ % SPINNER_CHARS.length]))
617
+ }
618
+
619
+ function setPhase(msg) {
620
+ phaseMsg = msg
621
+ if (!verbose) tickSpinner()
622
+ }
623
+
624
+ function endProgress() {
625
+ if (spinTimer) {
626
+ clearInterval(spinTimer)
627
+ spinTimer = null
628
+ }
629
+ if (!verbose && isTTY) {
630
+ updateProgressLine(` ✓ ${fmtElapsed()} Done.`)
631
+ // updateProgressLine(` ✓ ${fmtElapsed()} ${progressLabel} Done.`)
632
+ } else if (!verbose) {
633
+ process.stdout.write(` ✓ ${fmtElapsed()} Done.\n`)
634
+ }
635
+ }
636
+
637
+ process.on('exit', releaseSlot)
638
+
639
+ const asciidoctorArgs = [
640
+ '-b', 'docbook',
641
+ '-a', 'experimental',
642
+ '-o', xmlFile,
643
+ ...requireArgs,
644
+ ...safeArgs,
645
+ ...(sectionNumbers ? ['-n'] : []),
646
+ ...(doctype ? ['-d', doctype] : []),
647
+ ...attributes.flatMap(a => ['-a', a]),
648
+ inputFile,
649
+ ]
650
+
651
+ const converterArgs = [
652
+ 'docbook2typst',
653
+ '-o', typFile,
654
+ ...(typstTemplate ? [] : ['--no-theme']),
655
+ xmlFile,
656
+ ]
657
+
658
+ const typstBaseArgs = [
659
+ 'typst',
660
+ 'compile',
661
+ '--root', typstRoot,
662
+ '--pdf-standard', '2.0',
663
+ ...(fontRoot ? ['--font-path', fontRoot, '--ignore-system-fonts'] : []),
664
+ ]
665
+
666
+
667
+ // ── 3–6. Pipeline ─────────────────────────────────────────────────────────────
668
+
669
+ (async () => {
670
+ claimSlot()
671
+ if (verbose) {
672
+ console.log(`Generating PDF: ${relPath(pdfFile)}`)
673
+ } else if (isTTY) {
674
+ initProgressLine(`Generating PDF: ${path.basename(pdfFile)}`, fmtLine(SPINNER_CHARS[0]))
675
+ spinTimer = setInterval(tickSpinner, 80)
676
+ } else {
677
+ process.stdout.write(`Generating PDF: ${relPath(pdfFile)}\n`)
678
+ }
679
+
680
+ try {
681
+ await run('Running asciidoctor...', 'asciidoctor', asciidoctorArgs)
682
+
683
+ // Postprocess DocBook XML: the Assembler titles each assembled file as
684
+ // "Component Name: Page Title", which Asciidoctor splits into <title> and
685
+ // <subtitle> on the <book><info> block. The first content section becomes
686
+ // a <preface> whose <title> is left empty. Copy the subtitle into it so
687
+ // the first entry in the PDF table of contents has a visible title.
688
+ // Also capture the title and subtitle for passing to Typst via --input.
689
+ // The XML <info><title> holds the clean component title; the assembled
690
+ // AsciiDoc heading combines both as "Title: Subtitle", so reading from
691
+ // the XML avoids duplicating the subtitle on the cover page.
692
+ let xmlTitle = ''
693
+ let subtitle = ''
694
+ {
695
+ let xml = fs.readFileSync(xmlFile, 'utf-8')
696
+ const info = xml.match(/<info>([\s\S]*?)<\/info>/)
697
+ if (info) {
698
+ const mTitle = info[1].match(/<title>([^<]*)<\/title>/)
699
+ if (mTitle) xmlTitle = mTitle[1].trim()
700
+ const mSub = info[1].match(/<subtitle>([^<]*)<\/subtitle>/)
701
+ if (mSub) subtitle = mSub[1].trim()
702
+ }
703
+ if (subtitle) {
704
+ xml = xml.replace(
705
+ /(<preface(?:\s[^>]*)?>)([ \t\n]*)<title>([ \t\n]*)<\/title>/,
706
+ (_, tag, ws) => `${tag}${ws}<title>${subtitle}</title>`
707
+ )
708
+ fs.writeFileSync(xmlFile, xml, 'utf-8')
709
+ }
710
+ }
711
+
712
+ await run('Converting to Docbook...', 'npx', converterArgs)
713
+
714
+ // Prepend Typst preamble import; title/subtitle are passed via --input.
715
+ // When no template is specified the generated .typ is compiled as-is.
716
+ const typstInputArgs = []
717
+ if (typstTemplate) {
718
+ const preamble = fs.readFileSync(path.resolve(typstTemplate), 'utf-8').trimEnd()
719
+ + '\n#show: book-setup\n'
720
+ fs.writeFileSync(typFile, preamble + fs.readFileSync(typFile, 'utf-8'), 'utf-8')
721
+ if (verbose) console.log(`\n→ Prepended Typst preamble to ${relPath(typFile)}`)
722
+
723
+ const title = xmlTitle || extractTitle(inputFile)
724
+ typstInputArgs.push('--input', `title=${title}`, '--input', `subtitle=${subtitle}`)
725
+ } else {
726
+ // No theme — emit a bare document-title directive so PDF/UA-1 is satisfied.
727
+ const title = xmlTitle || extractTitle(inputFile)
728
+ const docTitle = subtitle ? `${title}: ${subtitle}` : title
729
+ const header = `#set document(title: [${docTitle}])\n`
730
+ fs.writeFileSync(typFile, header + fs.readFileSync(typFile, 'utf-8'), 'utf-8')
731
+ }
732
+
733
+ // FIXME
734
+ await run('Running Typst...', 'npx', [
735
+ 'typst',
736
+ ...typstBaseArgs,
737
+ ...typstInputArgs,
738
+ typFile,
739
+ pdfFile,
740
+ ])
741
+
742
+ endProgress()
743
+
744
+ if (verbose) console.log(`\nDone. PDF written to: ${relPath(pdfFile)}`)
745
+
746
+ // Typst files are intentionally preserved for inspection.
747
+ } catch (err) {
748
+ if (spinTimer) {
749
+ clearInterval(spinTimer)
750
+ spinTimer = null
751
+ }
752
+ if (!verbose && isTTY) {
753
+ updateProgressLine(` ✗ ${fmtElapsed()} Error.`)
754
+ // updateProgressLine(` ✗ ${fmtElapsed()} ${progressLabel} Error.`)
755
+ } else if (!verbose) {
756
+ process.stdout.write('\n')
757
+ }
758
+ console.error(err.message)
759
+ process.exit(1)
760
+ }
761
+ })()