epubcheck-standalone-cli 0.0.0-oidc-bootstrap → 5.3.0-build2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js ADDED
@@ -0,0 +1,855 @@
1
+ #!/usr/bin/env node
2
+ // epubcheck-standalone-cli -- a zero-Java command-line EPUB validator that mirrors the
3
+ // official epubcheck 5.3.0 CLI (com.adobe.epubcheck.tool.EpubChecker /
4
+ // Checker) byte-for-byte, on top of the epubcheck-standalone engine.
5
+ //
6
+ // The behavior here is a faithful re-implementation of epubcheck 5.3.0's
7
+ // EpubChecker.processArguments / run / validateFile / processFile and
8
+ // DefaultReportImpl (see the source files named in the package README). Flags
9
+ // that change what the engine validates or which locale it speaks (--mode,
10
+ // --profile, --locale, -u/--usage) are passed straight through to the engine via
11
+ // the `args` option on the library's single `validate(source, options)` call; the
12
+ // console text is still reconstructed here from the engine's report so the
13
+ // reporting-level filtering, report files, and exit codes stay byte-identical to
14
+ // the jar. Every input runs through that same `validate()` call, differing only in
15
+ // the source handed to it: a packaged `.epub` (`fs`), an expanded directory
16
+ // (`fsDir`, for `--mode exp` and directory inputs), or an http(s) URL (`url`).
17
+ // Custom message overrides (`-c/--customMessages`) ride through the
18
+ // `customMessages` option (or, for a missing overrides file, straight through
19
+ // `args` so the engine emits CHK-001 like the jar). The one capability the engine
20
+ // still cannot provide is an unshipped locale, which the CLI refuses loudly rather
21
+ // than silently diverging -- see `unsupportedReason` and the README "Unsupported
22
+ // flags" table.
23
+ import { writeFile, readFile, realpath, stat, unlink } from "node:fs/promises";
24
+ import { resolve as pathResolve, dirname, basename, join as pathJoin } from "node:path";
25
+ import { fileURLToPath } from "node:url";
26
+ import { validate } from "epubcheck-standalone";
27
+ import { fs as fsSource, fsDir, url as urlSource } from "epubcheck-standalone/plugins";
28
+ // The console line/summary formatting lives in the LIBRARY (one implementation,
29
+ // shared with the library's batch `formatConsoleReport`); the CLI drives these
30
+ // same primitives from the engine's live per-message/-feature stream so its
31
+ // stdout/stderr stay byte-identical to the jar while still printing during the
32
+ // run. CLI-specific concerns (flag/--quiet handling, reporting-level selection,
33
+ // locale wiring, stream routing) stay here.
34
+ import { renderConsoleMessageLine, countConsoleSeverities, displayedConsoleCounts, renderConsoleSummaryLine, renderValidatingLine, severityReportingLevel, formatTemplate, } from "epubcheck-standalone/formatters";
35
+ import { M, messagesFor, resolveLocale, EPUBCHECK_VERSION, ReportingLevel, } from "./messages.js";
36
+ import { HELP_OUTPUT } from "./help-text.js";
37
+ import { createArchive } from "./archive.js";
38
+ import { LIST_CHECKS_TSV } from "./list-checks-data.js";
39
+ import { LIST_CHECKS_TSV_BY_LOCALE } from "./list-checks-locale-data.js";
40
+ /** Exit code used when a flag is valid epubcheck syntax but unsupportable here. */
41
+ const EXIT_UNSUPPORTED = 2;
42
+ const KNOWN_PROFILES = new Set(["DEFAULT", "IDX", "DICT", "EDUPUB", "PREVIEW"]);
43
+ // Polyfill-safe well-known dispose key (the same key the library's plugins use).
44
+ const DISPOSE = Symbol.dispose ?? Symbol.for("Symbol.dispose");
45
+ /** Async existsSync: stat the path and report success, false on any error --
46
+ * behavior-identical to fs.existsSync (which is itself stat-based) but without
47
+ * blocking the event loop (async law). */
48
+ async function pathExists(p) {
49
+ try {
50
+ await stat(p);
51
+ return true;
52
+ }
53
+ catch {
54
+ return false;
55
+ }
56
+ }
57
+ /** Reproduce Java's FileNotFoundException.getMessage() ("<path> (<strerror>)")
58
+ * for a Node write error, so the --listChecks write-failure stderr matches the
59
+ * jar byte-for-byte. The path is the ORIGINAL argument (as the jar's `new
60
+ * File(arg)` carries it), and the OS strerror is keyed off the errno code. */
61
+ function javaIoErrorMessage(err, originalPath) {
62
+ const code = err?.code;
63
+ const strerror = {
64
+ ENOENT: "No such file or directory",
65
+ EACCES: "Permission denied",
66
+ EISDIR: "Is a directory",
67
+ ENOTDIR: "Not a directory",
68
+ EEXIST: "File exists",
69
+ EROFS: "Read-only file system",
70
+ };
71
+ const text = code && strerror[code] ? strerror[code] : String(err?.message ?? err);
72
+ return `${originalPath} (${text})`;
73
+ }
74
+ class ParseError extends Error {
75
+ }
76
+ export async function run(args) {
77
+ const st = {
78
+ path: null,
79
+ mode: null,
80
+ version: "3.0",
81
+ profile: null,
82
+ expanded: false,
83
+ jsonOutput: false,
84
+ xmlOutput: false,
85
+ xmpOutput: false,
86
+ fileOut: undefined,
87
+ listChecks: false,
88
+ listChecksOut: null,
89
+ customMessagesPath: null,
90
+ displayHelp: false,
91
+ displayVersion: false,
92
+ failOnWarnings: false,
93
+ save: false,
94
+ reportingLevel: ReportingLevel.Info,
95
+ quiet: false,
96
+ localeTag: null,
97
+ };
98
+ // Emit helpers. Everything that epubcheck routes through `outWriter` (stdout)
99
+ // is suppressed under --quiet; System.err and report bodies are not.
100
+ const outln = (s) => {
101
+ if (!st.quiet)
102
+ process.stdout.write(s + "\n");
103
+ };
104
+ const errln = (s) => {
105
+ process.stderr.write(s + "\n");
106
+ };
107
+ // USAGE messages route to stdout and, unlike everything else on stdout, ignore
108
+ // --quiet: DefaultReportImpl.message() pushes the out writer to the report's own
109
+ // (never-quiet) setting just for USAGE before printing.
110
+ const usageln = (s) => {
111
+ process.stdout.write(s + "\n");
112
+ };
113
+ const displayHelp = () => outln(HELP_OUTPUT.slice(0, -1)); // HELP_OUTPUT ends with the println newline
114
+ const displayVersion = () => outln(formatTemplate(M.epubcheck_version_text, EPUBCHECK_VERSION));
115
+ // --- processArguments -------------------------------------------------------
116
+ let parsedOk = true;
117
+ try {
118
+ parsedOk = await processArguments(args, st, { outln, errln, displayHelp, displayVersion });
119
+ }
120
+ catch (e) {
121
+ // Mirrors run()'s `catch (Exception ignored) { returnValue = 1 }` for the
122
+ // -v / -mode / -profile "argument expected/invalid" throw paths (which have
123
+ // already printed `-help displays help` to stdout).
124
+ if (e instanceof ParseError)
125
+ return 1;
126
+ throw e;
127
+ }
128
+ if (!parsedOk)
129
+ return 1;
130
+ // Early returns (before any report is created -> no summary block printed).
131
+ if (st.displayHelp || (st.displayVersion && st.path === null))
132
+ return 0;
133
+ // The (possibly localized) message table for every string the CLI formats
134
+ // itself from here on. English unless a shipped non-English locale was named.
135
+ const msgs = messagesFor(st.localeTag);
136
+ // --listChecks: dump the (embedded) message dictionary, no validation.
137
+ //
138
+ // The jar localizes the Message/Suggestion columns from the report's localized
139
+ // MessageBundle (MessageDictionaryDumper), so `--listChecks --locale <tag>`
140
+ // emits the dictionary in that locale (ID/Severity columns stay locale-
141
+ // invariant). It does NOT validate/refuse the locale on this path (an unshipped
142
+ // tag simply falls back to English, exit 0), so the listChecks branch runs
143
+ // BEFORE the unsupportedReason gate -- matching the jar exactly. Select the
144
+ // localized dump the SAME way a run resolves the locale (resolveLocale): a shipped
145
+ // non-English key -> its dump; English / an English-fallback tag -> the default.
146
+ if (st.listChecks) {
147
+ const listZero = { fatal: 0, error: 0, warning: 0, info: 0, usage: 0 };
148
+ const listKey = st.localeTag !== null ? resolveLocale(st.localeTag) : null;
149
+ const listTsv = (listKey !== null && LIST_CHECKS_TSV_BY_LOCALE[listKey]) || LIST_CHECKS_TSV;
150
+ if (st.listChecksOut === null) {
151
+ // Written straight to System.out; the trailing summary is swallowed
152
+ // because epubcheck's dumper closes the stdout stream.
153
+ process.stdout.write(listTsv);
154
+ }
155
+ else {
156
+ // EpubChecker.dumpMessageDictionary SWALLOWS a write failure: it prints the
157
+ // absolute path (listChecksOut.getAbsoluteFile()) + the IOException message
158
+ // to stderr, but run() still returns 0 on the listChecks branch and its
159
+ // finally still prints the completion summary. So a write failure exits 0
160
+ // with the summary, NOT 1 without it.
161
+ try {
162
+ await writeFile(st.listChecksOut, listTsv);
163
+ }
164
+ catch (e) {
165
+ errln(formatTemplate(msgs.error_creating_config_file, pathResolve(process.cwd(), st.listChecksOut)));
166
+ errln(javaIoErrorMessage(e, st.listChecksOut));
167
+ }
168
+ printCompleted(st, listZero, outln, msgs);
169
+ }
170
+ return 0;
171
+ }
172
+ // Anything the engine cannot faithfully reproduce -> fail loudly.
173
+ const reason = unsupportedReason(st);
174
+ if (reason !== null) {
175
+ errln(`epubcheck-standalone-cli: unsupported option: ${reason}`);
176
+ errln(" This capability is not available in the epubcheck-standalone engine.");
177
+ errln(" See the \"Unsupported flags\" section of the epubcheck-standalone-cli README.");
178
+ return EXIT_UNSUPPORTED;
179
+ }
180
+ const path = st.path;
181
+ const cwd = process.cwd();
182
+ // An http(s) URL input runs the engine's jar-parity URL mode (the URL itself
183
+ // is epubcheck's input path), not read from disk, so the on-disk
184
+ // existence/expanded routing is skipped.
185
+ const isUrl = path.startsWith("http://") || path.startsWith("https://");
186
+ const absPath = isUrl ? path : pathResolve(cwd, path);
187
+ const exists = !isUrl && (await pathExists(absPath));
188
+ const isDir = exists && (await stat(absPath)).isDirectory();
189
+ const zeroCounts = { fatal: 0, error: 0, warning: 0, info: 0, usage: 0 };
190
+ // Expanded (directory / --mode exp) vs packaged/single-file routing, mirroring
191
+ // epubcheck 5.3.0's EpubChecker: `-mode exp` (st.expanded) always means expanded
192
+ // validation, and a directory input with no single-file `-mode` is auto-detected
193
+ // as expanded too (a `.epub`-named directory, or a directory validated under a
194
+ // `--profile`). A single-file `-mode` (xhtml/opf/svg/mo/nav) is never expanded.
195
+ const useExpanded = !isUrl && (st.expanded || (st.mode === null && isDir));
196
+ // The EPUB name epubcheck's DefaultReportImpl.formatMessage prefixes onto every
197
+ // message location -- the same name the engine validated under (computed HERE,
198
+ // before the run, so the live console stream can prefix each message as it
199
+ // arrives): the URL itself for a URL input; the freshly packaged
200
+ // `./<name>.epub` for an explicit `--mode exp` directory (the jar reports the
201
+ // temp archive it built, relative to the working directory, so the prefix keeps
202
+ // the leading `./`); else the bare input basename (a packaged file, a
203
+ // single-file mode, or an auto-detected expanded directory validated in place).
204
+ const reportedName = isUrl
205
+ ? path
206
+ : useExpanded && st.expanded
207
+ ? `./${basename(absPath)}.epub`
208
+ : basename(absPath);
209
+ const formatActive = st.jsonOutput || st.xmlOutput || st.xmpOutput;
210
+ // Live console streaming (DefaultReportImpl). With NO report format, the CLI
211
+ // writes each console line AS THE ENGINE EMITS IT, exactly like the jar prints
212
+ // during a run: per-message lines via the engine's live `onMessage`, and the
213
+ // "Validating using EPUB version X rules." line via `onFeature` the moment the
214
+ // FORMAT_VERSION feature arrives. The line/summary FORMATTING is the library's
215
+ // shared console renderer (renderConsoleMessageLine / renderValidatingLine),
216
+ // the same primitives the library's batch `formatConsoleReport` uses -- one
217
+ // implementation, streamed here and batched there. Stream ROUTING stays a CLI
218
+ // concern: USAGE goes to stdout (ignoring --quiet, like DefaultReportImpl's
219
+ // pushQuiet), every other severity to stderr, both filtered by the reporting
220
+ // level; the validating line is out-writer output (gated by level <= Info and
221
+ // suppressed by --quiet via outln). A report format suppresses all per-message
222
+ // console output, so no stream is installed then.
223
+ const attachConsoleStream = (opts) => {
224
+ if (formatActive)
225
+ return opts;
226
+ opts.onFeature = (feature) => {
227
+ if (feature.feature === "FORMAT_VERSION" && st.reportingLevel <= ReportingLevel.Info) {
228
+ const vline = renderValidatingLine(feature.value, msgs);
229
+ if (vline !== null)
230
+ outln(vline);
231
+ }
232
+ };
233
+ opts.onMessage = (m) => {
234
+ if (severityReportingLevel(m.severity) < st.reportingLevel)
235
+ return;
236
+ const line = renderConsoleMessageLine(m, reportedName);
237
+ if (m.severity === "USAGE")
238
+ usageln(line);
239
+ else
240
+ errln(line);
241
+ };
242
+ return opts;
243
+ };
244
+ // --- validate() (the real work, via the engine) -----------------------------
245
+ // The newly unlocked flags (--mode, --profile, --locale, -u) ride through to the
246
+ // engine here; everything else stays a JS-layer concern (see buildEngineArgs).
247
+ // Every branch just produces `result`; message rendering reproduces the jar's
248
+ // console formatting from result.messages' raw location fields, prefixing the
249
+ // reported EPUB name derived per branch below. Report documents come from the library rendering
250
+ // the run's live report-event stream (result.reports), byte-identical to
251
+ // epubcheck's own writers and already embedding the right filename.
252
+ let result;
253
+ // -s/--save: the path of the packaged .epub this run wrote (expanded mode
254
+ // only), so the post-run tail can delete it when the check found errors.
255
+ let savedEpubPath = null;
256
+ if (isUrl) {
257
+ // Jar-parity URL mode: the URL is handed to
258
+ // epubcheck as the input path, the engine suspends mid-run while the
259
+ // library's async http bridge downloads it, and message locations carry
260
+ // the URL exactly like the jar's. Download/connection failures no longer throw here: the
261
+ // engine prints the jar's exception headline to stderr and exits 1, so
262
+ // `result` comes back normally; the catch stays as a net for bridge-level
263
+ // failures (e.g. url() rejecting a malformed URL).
264
+ try {
265
+ result = await validate(await urlSource(path), attachConsoleStream(await buildEngineOpts(st, cwd, false)));
266
+ }
267
+ catch (e) {
268
+ process.stderr.write(String(e?.stack ?? e) + "\n");
269
+ printCompleted(st, zeroCounts, outln, msgs);
270
+ return 1;
271
+ }
272
+ }
273
+ else if (useExpanded) {
274
+ // `--mode exp` requested but the input is not a directory. Mirrors epubcheck's
275
+ // expanded path: "Directory not found" when the path is missing; otherwise no
276
+ // messages are produced and the run finishes with errors (exit 1). Any
277
+ // `-mode`/`-v`-ignored notice for a `.epub` path was already printed above.
278
+ if (!isDir) {
279
+ if (!exists) {
280
+ errln(formatTemplate(msgs.directory_not_found, path));
281
+ }
282
+ else {
283
+ errln(msgs.there_were_errors);
284
+ }
285
+ printCompleted(st, zeroCounts, outln, msgs);
286
+ return 1;
287
+ }
288
+ // -s/--save with an EXPLICIT `--mode exp`: epubcheck packages the directory
289
+ // into `<canonical-parent>/<dir-name>.epub` BEFORE validating (overwriting
290
+ // any file already there) and deletes it again after a failing check. The
291
+ // jar builds its Archive only on the explicit exp path, so an auto-detected
292
+ // `.epub`-named directory never saves; and a packaging failure aborts with
293
+ // "Check finished with errors" + the completion summary, exit 1, exactly
294
+ // like EpubChecker.processFile's RuntimeException catch (which may leave a
295
+ // partial file behind -- so does this).
296
+ if (st.expanded && st.save) {
297
+ try {
298
+ savedEpubPath = await createArchive(absPath);
299
+ }
300
+ catch {
301
+ errln(msgs.there_were_errors);
302
+ printCompleted(st, zeroCounts, outln, msgs);
303
+ return 1;
304
+ }
305
+ }
306
+ try {
307
+ const dirOpts = await buildEngineOpts(st, cwd, true);
308
+ if (st.expanded) {
309
+ // EXPLICIT `--mode exp`: the jar packages the directory tree into a
310
+ // temp `<name>.epub` and validates that, so message locations carry
311
+ // the packaged name. The library default is 'direct', so set 'exp'
312
+ // explicitly to keep packaging (byte-identical to the jar's `--mode
313
+ // exp`).
314
+ dirOpts.dirMode = "exp";
315
+ }
316
+ else {
317
+ // AUTO-DETECTED expanded book (a `.epub`-named directory, or a
318
+ // directory under --profile, with no explicit `--mode exp`): the jar
319
+ // never packages here -- EpubChecker leaves `expanded` false and
320
+ // EpubCheck validates the directory IN PLACE, so message locations
321
+ // carry the directory name itself (`junk.epub/mimetype`), not a
322
+ // doubled `<name>.epub.epub` package name. The library's 'direct'
323
+ // dirMode is exactly that path (and is now the library default).
324
+ dirOpts.dirMode = "direct";
325
+ }
326
+ result = await validate(await fsDir(absPath), attachConsoleStream(dirOpts));
327
+ }
328
+ catch (e) {
329
+ process.stderr.write(String(e?.stack ?? e) + "\n");
330
+ printCompleted(st, zeroCounts, outln, msgs);
331
+ return 1;
332
+ }
333
+ }
334
+ else {
335
+ // Missing file -> file_not_found (stderr) + summary block (stdout), exit 1.
336
+ if (!exists) {
337
+ errln(formatTemplate(msgs.file_not_found, path));
338
+ printCompleted(st, zeroCounts, outln, msgs);
339
+ return 1;
340
+ }
341
+ try {
342
+ // A single-file `--mode` pointed at a DIRECTORY: epubcheck reads the
343
+ // directory as a file and fails with FATAL(PKG-008) "Unable to read
344
+ // file <abs>", the location carrying the trailing-slash directory form.
345
+ // Hand the engine a directory placeholder (mounted as an empty VFS dir
346
+ // at the host path) so it reproduces that byte-for-byte, rather than a
347
+ // byte source whose read merely throws. A regular (possibly unreadable)
348
+ // file goes straight through fs(), which now DEFERS an unreadable-file
349
+ // error into the run itself -- FATAL(PKG-008) byte-for-byte like the jar.
350
+ const source = isDir
351
+ ? {
352
+ name: basename(absPath),
353
+ hostDir: dirname(absPath),
354
+ isDirectory: true,
355
+ size: 0,
356
+ read: () => {
357
+ throw new Error("epubcheck-standalone-cli: a directory has no file bytes");
358
+ },
359
+ [DISPOSE]() { },
360
+ }
361
+ : await fsSource(absPath);
362
+ result = await validate(source, attachConsoleStream(await buildEngineOpts(st, cwd, false)));
363
+ }
364
+ catch (e) {
365
+ // Mirrors the top-level `catch` -> exit 1, then the completion summary.
366
+ process.stderr.write(String(e?.stack ?? e) + "\n");
367
+ printCompleted(st, zeroCounts, outln, msgs);
368
+ return 1;
369
+ }
370
+ }
371
+ // A run that returned normally but FAILED before producing any structured
372
+ // message -- e.g. a URL download error (404 / connection refused) -- mirrors
373
+ // EpubChecker.run's catch/finally: the engine already carries the jar-identical
374
+ // exception headline on result.stderr and the jar-parity exit code on
375
+ // result.exitCode. The count-derived tail below never fires (no messages), so
376
+ // it would otherwise print a bogus "No errors or warnings detected." and exit
377
+ // 0; instead surface the engine's failure output verbatim (headline to stderr,
378
+ // completion summary to stdout) and exit with its jar-parity code. System.err
379
+ // ignores --quiet, exactly like the jar.
380
+ if ((result.exitCode ?? 0) !== 0 && result.messages.length === 0) {
381
+ if (result.stderr.length > 0)
382
+ process.stderr.write(result.stderr);
383
+ printCompleted(st, zeroCounts, outln, msgs);
384
+ return result.exitCode;
385
+ }
386
+ const actual = countConsoleSeverities(result.messages);
387
+ const shown = displayedConsoleCounts(actual, st.reportingLevel);
388
+ const reportToConsole = formatActive && st.fileOut === null;
389
+ // 1) The report document (JSON/XML/XMP), if requested. A report format
390
+ // suppresses all per-message console output, so nothing was streamed during
391
+ // the run (attachConsoleStream installs no stream when a format is active).
392
+ if (formatActive) {
393
+ const reportText = renderReport(st, result);
394
+ if (st.fileOut === null) {
395
+ process.stdout.write(reportText); // straight to System.out, ignores --quiet
396
+ }
397
+ else {
398
+ const outPath = st.fileOut;
399
+ try {
400
+ await writeFile(outPath, reportText);
401
+ }
402
+ catch {
403
+ // JSON writer prints this to stdout and returns 1 on IO failure.
404
+ if (st.jsonOutput)
405
+ outln("Incorrect path to save JsonFile.");
406
+ else
407
+ errln("Error while generating the report.");
408
+ printCompleted(st, shown, outln, msgs);
409
+ return 1;
410
+ }
411
+ }
412
+ }
413
+ // 2 & 3) The "Validating using EPUB version X rules." line and the per-message
414
+ // console lines (DefaultReportImpl) were STREAMED LIVE during validate() by
415
+ // attachConsoleStream -- each written to stdout/stderr the instant the
416
+ // engine emitted it, byte-identical to the jar and printed AS the checker
417
+ // produced it (not batched at the end). Only the run-completion chrome
418
+ // below (validateFile's tail + printEpubCheckCompleted) is emitted here.
419
+ // 4) validateFile's tail: no_errors / there_were_warnings / there_were_errors,
420
+ // computed from the reporting-level-filtered counts.
421
+ let exitCode;
422
+ if (shown.fatal === 0 && shown.error === 0 && shown.warning === 0) {
423
+ if (!reportToConsole)
424
+ outln(msgs.no_errors__or_warnings);
425
+ exitCode = 0;
426
+ }
427
+ else if (shown.warning > 0 && shown.fatal === 0 && shown.error === 0) {
428
+ errln(msgs.there_were_warnings);
429
+ exitCode = st.failOnWarnings ? 1 : 0;
430
+ }
431
+ else {
432
+ errln(msgs.there_were_errors);
433
+ exitCode = 1;
434
+ }
435
+ // 4b) -s/--save deletion (EpubChecker.processFile): the packaged file this run
436
+ // wrote is deleted again, with the announcement on stderr, when the check
437
+ // found errors or fatals -- judged on the same reporting-level-filtered
438
+ // counts as the exit code, so `-f` on an error-only book keeps the file
439
+ // (verified against the jar). Warnings keep it too, even with
440
+ // --failonwarnings. The delete-failure text is the jar's hardcoded
441
+ // Archive.deleteEpubFile string (never localized).
442
+ if (savedEpubPath !== null && (shown.fatal > 0 || shown.error > 0)) {
443
+ errln(msgs.deleting_archive);
444
+ try {
445
+ await unlink(savedEpubPath);
446
+ }
447
+ catch {
448
+ errln("Unable to delete generated archive.");
449
+ }
450
+ }
451
+ // 5) The "Messages: ... / EPUBCheck completed" summary (printEpubCheckCompleted).
452
+ // Suppressed when the report went to the console (epubcheck closes stdout).
453
+ if (!reportToConsole) {
454
+ printCompleted(st, shown, outln, msgs);
455
+ }
456
+ return exitCode;
457
+ }
458
+ /** printEpubCheckCompleted: the summary counters + "EPUBCheck completed". */
459
+ function printCompleted(st, counts, outln, msgs) {
460
+ // The "Messages: ..." line is the library's shared summary renderer (the same
461
+ // one the batch formatConsoleReport uses); the CLI supplies its own localized
462
+ // label table (Messages structurally satisfies ConsoleLabels).
463
+ const line = renderConsoleSummaryLine(counts, st.reportingLevel, msgs);
464
+ if (line.length > 0) {
465
+ // messageCount.append("\n"); outWriter.println(messageCount) -> "...\n\n"
466
+ outln(line + "\n");
467
+ }
468
+ outln(msgs.epubcheck_completed);
469
+ }
470
+ function renderReport(st, result) {
471
+ // The report content (result.reports) is rendered by the library's formatters
472
+ // from the run's live report-event stream -- byte-identical to epubcheck's
473
+ // own --json/--out/--xmp writers, from ONE validation run.
474
+ const reports = result.reports ?? {};
475
+ if (st.xmlOutput)
476
+ return reports.xml ?? "";
477
+ if (st.xmpOutput)
478
+ return reports.xmp ?? "";
479
+ return reports.json ?? "";
480
+ }
481
+ /** The shipped locale tags, in the order epubcheck's help lists them, for the
482
+ * unsupported-locale message. */
483
+ const SUPPORTED_LOCALE_LIST = "da, de, en, es, fr, it, ja, ko-KR, nl, pt-BR, zh-TW";
484
+ /** Returns a human description of the first unsupported flag, or null. */
485
+ function unsupportedReason(st) {
486
+ // A locale is supportable when it resolves to a shipped bundle through its own
487
+ // ResourceBundle candidate chain (region -> language), independently of the host
488
+ // default locale. A tag that would need Java's host-dependent default-locale
489
+ // fallback (bare ko/pt/zh, or an unshipped language like pl/ru) is refused: its
490
+ // jar output is not provably byte-identical across hosts.
491
+ if (st.localeTag !== null && resolveLocale(st.localeTag) === null) {
492
+ return `--locale ${st.localeTag} (the engine image only ships these locales: ${SUPPORTED_LOCALE_LIST})`;
493
+ }
494
+ return null;
495
+ }
496
+ /**
497
+ * The epubcheck CLI arguments to hand to the engine, placed before the input path
498
+ * exactly as on the stock CLI. Only flags the CLI cannot reproduce post-hoc from a
499
+ * default INFO-level run are passed through: `-u` (USAGE is below INFO, so the
500
+ * engine must run at that level for the tap to carry usage messages), `--mode`
501
+ * with its `-v` version (single-file checking), a non-default `--profile`, and
502
+ * `--locale` (localized message text + locale-sensitive validation). Everything
503
+ * else (severity filtering, --quiet, report files, --save, --failonwarnings) stays
504
+ * a JS-layer concern so its byte-identical behavior is preserved.
505
+ *
506
+ * `forDirectory` builds the args for expanded mode (a directory source): the
507
+ * engine injects `--mode exp` itself and rejects a conflicting `--mode`/`-v`, so
508
+ * the single-file mode/version pair is omitted while `-u`, `--profile`, and
509
+ * `--locale` still ride through.
510
+ */
511
+ function buildEngineArgs(st, forDirectory = false) {
512
+ const args = [];
513
+ if (st.reportingLevel === ReportingLevel.Usage) {
514
+ args.push("-u");
515
+ }
516
+ if (!forDirectory && st.mode !== null) {
517
+ args.push("--mode", st.mode, "-v", st.version);
518
+ }
519
+ if (st.profile !== null && st.profile !== "DEFAULT" && KNOWN_PROFILES.has(st.profile)) {
520
+ args.push("--profile", st.profile.toLowerCase());
521
+ }
522
+ if (st.localeTag !== null) {
523
+ args.push("--locale", st.localeTag);
524
+ }
525
+ return args;
526
+ }
527
+ /** The epubcheck engine options for a run: the passthrough args plus, when
528
+ * -c/--customMessages named a file, the custom-message overrides. An EXISTING
529
+ * overrides file rides through the `customMessages` option (the engine mounts it
530
+ * and points -c at the mounted copy, so the CHK-00x locations print ./<name>);
531
+ * a MISSING one is left on `args` verbatim so the engine emits CHK-001 exactly
532
+ * like the jar does for a missing cwd-relative file. */
533
+ async function buildEngineOpts(st, cwd, forDirectory) {
534
+ const args = buildEngineArgs(st, forDirectory);
535
+ const opts = { args };
536
+ if (st.customMessagesPath !== null) {
537
+ const cmAbs = pathResolve(cwd, st.customMessagesPath);
538
+ // An EXISTING overrides file is fed by CONTENT under its basename (the engine
539
+ // mounts it and points -c at that name); a MISSING one is left on args
540
+ // verbatim so the engine emits CHK-001 like the jar does for a missing file.
541
+ if (await pathExists(cmAbs)) {
542
+ opts.customMessages = await readFile(cmAbs, "utf8");
543
+ opts.customMessagesName = basename(st.customMessagesPath);
544
+ }
545
+ else {
546
+ args.push("-c", st.customMessagesPath);
547
+ }
548
+ }
549
+ // Report output comes via the library's reports option (writer-identical
550
+ // documents rendered from the run's live report-event stream).
551
+ const reports = [];
552
+ if (st.jsonOutput)
553
+ reports.push("json");
554
+ if (st.xmlOutput)
555
+ reports.push("xml");
556
+ if (st.xmpOutput)
557
+ reports.push("xmp");
558
+ if (reports.length > 0)
559
+ opts.reports = reports;
560
+ return opts;
561
+ }
562
+ /** Faithful port of EpubChecker.processArguments (returns false == exit 1). */
563
+ async function processArguments(args, st, e) {
564
+ if (args.length < 1) {
565
+ e.errln(M.argument_needed);
566
+ return false;
567
+ }
568
+ // Env var pre-load (setCustomMessageFileFromEnvironment).
569
+ const envFile = process.env["ePubCheckCustomMessageFile"];
570
+ if (envFile && envFile.length > 0 && (await pathExists(envFile))) {
571
+ st.customMessagesPath = envFile;
572
+ }
573
+ const argPattern = /^--?(.*)$/;
574
+ for (let i = 0; i < args.length; i++) {
575
+ const arg = args[i];
576
+ const match = argPattern.exec(arg);
577
+ if (match) {
578
+ const key = match[1];
579
+ switch (key) {
580
+ case "v":
581
+ if (i + 1 < args.length) {
582
+ ++i;
583
+ const v = args[i];
584
+ if (v === "2.0" || v === "2")
585
+ st.version = "2.0";
586
+ else if (v === "3.0" || v === "3")
587
+ st.version = "3.0";
588
+ else {
589
+ e.outln(M.display_help);
590
+ throw new ParseError("unsupported version");
591
+ }
592
+ }
593
+ else {
594
+ e.outln(M.display_help);
595
+ throw new ParseError("version argument expected");
596
+ }
597
+ break;
598
+ case "m":
599
+ case "mode":
600
+ if (i + 1 < args.length) {
601
+ st.mode = args[++i];
602
+ st.expanded = st.mode === "exp";
603
+ }
604
+ else {
605
+ e.outln(M.display_help);
606
+ throw new ParseError("mode argument expected");
607
+ }
608
+ break;
609
+ case "p":
610
+ case "profile":
611
+ if (i + 1 < args.length) {
612
+ const profileStr = args[++i];
613
+ const up = profileStr.toUpperCase();
614
+ if (KNOWN_PROFILES.has(up)) {
615
+ st.profile = up;
616
+ }
617
+ else {
618
+ // epubcheck prints the (mis-keyed) mode_version_ignored text and
619
+ // falls back to the default profile.
620
+ e.errln(M.mode_version_ignored);
621
+ st.profile = "DEFAULT";
622
+ }
623
+ }
624
+ else {
625
+ e.outln(M.display_help);
626
+ throw new ParseError("profile argument expected");
627
+ }
628
+ break;
629
+ case "s":
630
+ case "save":
631
+ // epubcheck's `keep` flag: with an explicit `--mode exp`, keep the
632
+ // packaged .epub the expanded check builds beside the input directory
633
+ // (it is deleted again when the check finds errors or fatals).
634
+ st.save = true;
635
+ break;
636
+ case "o":
637
+ case "out":
638
+ i += await consumeOutputArg(args, i, st, "xml");
639
+ st.xmlOutput = true;
640
+ break;
641
+ case "j":
642
+ case "json":
643
+ i += await consumeOutputArg(args, i, st, "json");
644
+ st.jsonOutput = true;
645
+ break;
646
+ case "x":
647
+ case "xmp":
648
+ i += await consumeOutputArg(args, i, st, "xmp");
649
+ st.xmpOutput = true;
650
+ break;
651
+ case "i":
652
+ case "info":
653
+ st.reportingLevel = ReportingLevel.Info;
654
+ break;
655
+ case "f":
656
+ case "fatal":
657
+ st.reportingLevel = ReportingLevel.Fatal;
658
+ break;
659
+ case "e":
660
+ case "error":
661
+ st.reportingLevel = ReportingLevel.Error;
662
+ break;
663
+ case "w":
664
+ case "warn":
665
+ st.reportingLevel = ReportingLevel.Warning;
666
+ break;
667
+ case "u":
668
+ case "usage":
669
+ st.reportingLevel = ReportingLevel.Usage;
670
+ break;
671
+ case "q":
672
+ case "quiet":
673
+ st.quiet = true;
674
+ break;
675
+ case "failonwarnings":
676
+ st.failOnWarnings = true;
677
+ break;
678
+ case "r":
679
+ case "redir":
680
+ if (i + 1 < args.length) {
681
+ st.fileOut = args[++i];
682
+ }
683
+ break;
684
+ case "c":
685
+ case "customMessages":
686
+ if (i + 1 < args.length) {
687
+ const fileName = args[i + 1];
688
+ if (fileName.toLowerCase() === "none") {
689
+ st.customMessagesPath = null;
690
+ ++i;
691
+ }
692
+ else if (!fileName.startsWith("-")) {
693
+ st.customMessagesPath = fileName;
694
+ ++i;
695
+ }
696
+ else {
697
+ e.errln(formatTemplate(M.expected_message_filename, fileName));
698
+ e.displayHelp();
699
+ return false;
700
+ }
701
+ }
702
+ break;
703
+ case "l":
704
+ case "listChecks":
705
+ if (i + 1 < args.length) {
706
+ if (!args[i + 1].startsWith("-")) {
707
+ st.listChecksOut = args[++i];
708
+ }
709
+ else {
710
+ st.listChecksOut = null;
711
+ }
712
+ }
713
+ st.listChecks = true;
714
+ break;
715
+ case "locale":
716
+ if (i + 1 < args.length) {
717
+ if (args[i + 1].startsWith("-")) {
718
+ e.errln(formatTemplate(M.incorrect_locale, args[i + 1]));
719
+ e.displayHelp();
720
+ return false;
721
+ }
722
+ else {
723
+ st.localeTag = args[++i];
724
+ }
725
+ }
726
+ else {
727
+ e.errln(formatTemplate(M.missing_locale));
728
+ e.displayHelp();
729
+ return false;
730
+ }
731
+ break;
732
+ case "h":
733
+ case "?":
734
+ case "help":
735
+ e.displayHelp();
736
+ st.displayHelp = true;
737
+ break;
738
+ case "version":
739
+ e.displayVersion();
740
+ st.displayVersion = true;
741
+ break;
742
+ default:
743
+ e.errln(formatTemplate(M.unrecognized_argument, arg));
744
+ e.displayHelp();
745
+ return false;
746
+ }
747
+ }
748
+ else {
749
+ if (st.path === null) {
750
+ st.path = arg;
751
+ }
752
+ else {
753
+ e.errln(formatTemplate(M.unrecognized_argument, arg));
754
+ e.displayHelp();
755
+ return false;
756
+ }
757
+ }
758
+ }
759
+ if ((st.xmlOutput && st.xmpOutput) ||
760
+ (st.xmlOutput && st.jsonOutput) ||
761
+ (st.xmpOutput && st.jsonOutput)) {
762
+ e.errln(M.output_type_conflict);
763
+ return false;
764
+ }
765
+ if (st.path !== null) {
766
+ st.path = st.path.replace(/\\/g, "/");
767
+ }
768
+ if (st.path === null) {
769
+ if (st.listChecks || st.displayHelp || st.displayVersion) {
770
+ return true;
771
+ }
772
+ e.errln(M.no_file_specified);
773
+ return false;
774
+ }
775
+ else if (/^.+\.[Ee][Pp][Uu][Bb]$/.test(st.path)) {
776
+ if (st.mode !== null || st.version !== "3.0") {
777
+ e.errln(M.mode_version_ignored);
778
+ st.mode = null;
779
+ }
780
+ }
781
+ else if (st.mode === null && st.profile === null) {
782
+ e.outln(M.mode_required);
783
+ return false;
784
+ }
785
+ return true;
786
+ }
787
+ /**
788
+ * The -o/-j/-x value rule (EpubChecker lines 570-647): explicit filename, "-"
789
+ * for console (fileOut=null), or auto-derive the report filename. Sets
790
+ * st.fileOut and returns how many extra args were consumed (0 or 1).
791
+ */
792
+ async function consumeOutputArg(args, i, st, kind) {
793
+ const next = args[i + 1];
794
+ if (next !== undefined && !next.startsWith("-")) {
795
+ st.fileOut = next;
796
+ return 1;
797
+ }
798
+ else if (next !== undefined && next.toLowerCase() === "-") {
799
+ st.fileOut = null;
800
+ return 1;
801
+ }
802
+ else {
803
+ // Auto-derive. epubcheck's own rule (EpubChecker.processArguments): when the
804
+ // input is a directory it writes the report beside it, at
805
+ // <absolute-parent>/<basename>check.<ext>; otherwise it appends
806
+ // "check.<ext>" to the raw path.
807
+ //
808
+ // `path` may still be null here if the flag preceded the positional (e.g.
809
+ // `-o -q`, `-o` last, `-j -x`, or even `-o -q book.epub` -- the positional
810
+ // is seen only later in the loop). The jar builds `new File(path)` at THIS
811
+ // point, so a null path is `new File((String)null)` -> NullPointerException,
812
+ // thrown inside processArguments and swallowed by run()'s
813
+ // `catch (Exception ignored){returnValue=1}` -> exit 1 with NO stdout/stderr.
814
+ // Reproduce that exactly: a silent ParseError (run() returns 1, nothing
815
+ // printed), NOT our former "No file specified in the arguments." text.
816
+ if (st.path === null) {
817
+ throw new ParseError("-o/-j/-x auto-derive with no input path (jar NPE)");
818
+ }
819
+ if ((await pathExists(st.path)) && (await stat(st.path)).isDirectory()) {
820
+ const abs = pathResolve(st.path);
821
+ st.fileOut = pathJoin(dirname(abs), basename(abs) + "check." + kind);
822
+ }
823
+ else {
824
+ st.fileOut = st.path + "check." + kind;
825
+ }
826
+ return 0;
827
+ }
828
+ }
829
+ // --- Entry point ------------------------------------------------------------
830
+ // Run only when invoked as the CLI binary (not when imported by tests). Compare
831
+ // resolved real paths so it also fires through the `node_modules/.bin` symlink.
832
+ async function isMainModule() {
833
+ const argv1 = process.argv[1];
834
+ if (!argv1)
835
+ return false;
836
+ try {
837
+ return (await realpath(argv1)) === (await realpath(fileURLToPath(import.meta.url)));
838
+ }
839
+ catch {
840
+ return false;
841
+ }
842
+ }
843
+ // Top-level await is available (this is an ES module); resolving the two
844
+ // realpaths asynchronously keeps the bootstrap guard off the event loop like
845
+ // every other fs touch (async law). Importers (the tests) just await the
846
+ // module's evaluation as they already do for any ESM; isMainModule() is false
847
+ // for them so run() is never invoked.
848
+ if (await isMainModule()) {
849
+ run(process.argv.slice(2)).then((code) => {
850
+ process.exitCode = code;
851
+ }, (err) => {
852
+ process.stderr.write(String(err?.stack ?? err) + "\n");
853
+ process.exitCode = 1;
854
+ });
855
+ }