@lutrin/core 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.
package/src/cli.mjs ADDED
@@ -0,0 +1,1114 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * lutrin — a Markdown → PowerPoint / HTML presentation compiler,
4
+ * themable (generic theme by default; an organization's brand shipped
5
+ * as a KIT — directory or .deckkit archive, see `lutrin kit`).
6
+ *
7
+ * Usage:
8
+ * lutrin build <input.md> [-o output.pptx|output.html] [--html] [--kit <kit|file.json|directory>] [--vendor-assets] [--verbose] [--force]
9
+ * lutrin preview <input.md> [--port 4321] [--kit <kit|file.json|directory>]
10
+ * lutrin validate <input.md> [--json] [--kit <kit|file.json|directory>]
11
+ * lutrin inspect <input.md> [-o output.json] [--kit <kit|file.json|directory>]
12
+ * lutrin vendor <input.md> [--kit <kit|file.json|directory>]
13
+ * lutrin config [--kit <kit|file.json|none>] [--unset]
14
+ * lutrin kit install <file.deckkit|https://…> [--force] [--name <name>]
15
+ * lutrin kit list | remove <name> | create <directory> [-o <file.deckkit>]
16
+ * lutrin capabilities [<input.md>] [--kit <kit|file.json|directory>] [--json]
17
+ *
18
+ * `lutrin <input.md> …` with no subcommand = `build` (compatibility).
19
+ * `--kit` takes precedence over the frontmatter `kit:` key (which is itself
20
+ * resolved relative to the deck's directory), over the project default
21
+ * (a package.json carrying "lutrin": { "kit": … }) and over the user default
22
+ * (`lutrin config`, shared across projects — see theme.mjs).
23
+ *
24
+ * Remote images go to the user cache (`~/.cache/lutrin/remote/`): compiling
25
+ * writes nothing into the source tree. `--vendor-assets`, or `assets: vendor`
26
+ * in the frontmatter, copies them into `assets/remote/` next to the deck — for
27
+ * a self-contained directory (archiving, handover).
28
+ *
29
+ * TWO RULES hold this whole module together, and every function below is
30
+ * merely their application:
31
+ *
32
+ * 1. CHECK BEFORE WRITING. A command that fails leaves nothing behind it:
33
+ * the output extension, the path, the requested kit and the deck's
34
+ * diagnostics are all checked before the first byte is written. The
35
+ * historical fault — `-o report` produced a file "report" that nothing
36
+ * knows how to open, because the post-processing failed AFTER the
37
+ * write — is the one these upfront checks remove.
38
+ * 2. DO NOT SILENCE WHAT FAILED. A kit requested EXPLICITLY (`--kit`, or
39
+ * the frontmatter `kit:`) and not found is an ERROR: delivering the deck
40
+ * under the generic theme would hand over a document that is wrong to
41
+ * the eye, and `vendor` even announced "the directory is self-contained"
42
+ * for a directory stripped of the intended brand. An IMPLICIT default
43
+ * (the user config shared across projects) that fails to resolve stays a
44
+ * plain warning: the deck asked for nothing, the generic fallback is
45
+ * legitimate — that distinction is carried by the severity of
46
+ * resolveTheme's diagnostics (see theme.mjs).
47
+ */
48
+
49
+ import fs from 'node:fs';
50
+ import http from 'node:http';
51
+ import path from 'node:path';
52
+ import { fileURLToPath } from 'node:url';
53
+ import { parseDeck } from './deck/parse.mjs';
54
+ import { buildScenes } from './deck/layout.mjs';
55
+ import { prepareDeckContext } from './deck/context.mjs';
56
+ import { FONTS } from './deck/tokens.mjs';
57
+ import { closest } from './deck/suggest.mjs';
58
+ import {
59
+ isKitName,
60
+ resolveTheme,
61
+ userConfigRoot,
62
+ userKitsDir,
63
+ listInstalledKits,
64
+ readUserKit,
65
+ setUserKit,
66
+ migrateUserConfig,
67
+ } from './deck/theme.mjs';
68
+ import { validateDeck, capabilities } from './deck/validate.mjs';
69
+ import { readKit, parseKitManifest } from './deck/kit.mjs';
70
+ import {
71
+ readKitArchive,
72
+ packKit,
73
+ fetchKitArchive,
74
+ installKitArchive,
75
+ sha256,
76
+ } from './kit/archive.mjs';
77
+
78
+ const COMMANDS = [
79
+ 'build',
80
+ 'preview',
81
+ 'validate',
82
+ 'inspect',
83
+ 'capabilities',
84
+ 'config',
85
+ 'kit',
86
+ 'vendor',
87
+ ];
88
+
89
+ const USAGE = `Usage:
90
+ lutrin build <input.md> [-o output.pptx|output.html] [--html] [--kit <kit|file.json|directory>] [--vendor-assets] [--verbose] [--force]
91
+ lutrin preview <input.md> [--port 4321] [--kit <kit|file.json|directory>]
92
+ lutrin validate <input.md> [--json] [--kit <kit|file.json|directory>]
93
+ lutrin inspect <input.md> [-o output.json] [--kit <kit|file.json|directory>]
94
+ lutrin vendor <input.md> [--kit <kit|file.json|directory>]
95
+ lutrin config [--kit <kit|file.json|none>] [--unset]
96
+ lutrin kit install <file.deckkit|https://…> [--force] [--name <name>]
97
+ lutrin kit list
98
+ lutrin kit remove <name>
99
+ lutrin kit create <directory> [-o <file.deckkit>]
100
+ lutrin capabilities [<input.md>] [--kit <kit|file.json|directory>] [--json]
101
+ lutrin --version | --help`;
102
+
103
+ /** Help that was ASKED FOR is an answer (stdout, exit code 0); usage recalled
104
+ * for want of arguments is an error (stderr, non-zero exit code). A script
105
+ * running `lutrin --help | …` must not receive an empty stream. */
106
+ function usage(code = 1) {
107
+ if (code === 0) console.log(USAGE);
108
+ else console.error(USAGE);
109
+ process.exit(code);
110
+ }
111
+
112
+ /** Usage error: one line on stderr, exit code 1 — never a stack trace. */
113
+ function fail(message) {
114
+ console.error(`✖ ${message}`);
115
+ process.exit(1);
116
+ }
117
+
118
+ /** The published version: the one in the package's package.json, not a copied
119
+ * constant that would diverge on the first `npm version`. */
120
+ function printVersion() {
121
+ const pkg = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'package.json');
122
+ console.log(JSON.parse(fs.readFileSync(pkg, 'utf8')).version);
123
+ process.exit(0);
124
+ }
125
+
126
+ // ---------------------------------------------------------------------------
127
+ // Argument parser — STRICT
128
+ // ---------------------------------------------------------------------------
129
+
130
+ /**
131
+ * Every command declares its flags and their arity: `'value'` (the flag
132
+ * consumes an argument) or `'boolean'`. Everything else is refused.
133
+ *
134
+ * The old parser swallowed any `-xyz` and set it as a true flag: a typo
135
+ * (`--kti brand`) compiled silently WITHOUT the brand, and a trailing `-o`
136
+ * absorbed the next flag as a file name. The contract is now: what is not
137
+ * understood is said.
138
+ */
139
+ const FLAGS_KIT = { kit: 'value', theme: 'value' }; // `--theme`: deprecated alias
140
+ const FLAG_SPECS = {
141
+ build: {
142
+ ...FLAGS_KIT,
143
+ o: 'value',
144
+ output: 'value',
145
+ html: 'boolean',
146
+ 'vendor-assets': 'boolean',
147
+ verbose: 'boolean',
148
+ force: 'boolean',
149
+ ir: 'boolean',
150
+ },
151
+ preview: { ...FLAGS_KIT, port: 'value' },
152
+ validate: { ...FLAGS_KIT, json: 'boolean' },
153
+ // no `ir` here: on `inspect`, the flag never did anything — the command IS
154
+ // the dump. It is kept only on `build` (compatibility with the old `--ir`,
155
+ // removed from argv before delegating); elsewhere the strict parser refuses
156
+ // it with its suggestion, rather than accepting it with no effect.
157
+ inspect: { ...FLAGS_KIT, o: 'value', output: 'value' },
158
+ vendor: { ...FLAGS_KIT },
159
+ config: { ...FLAGS_KIT, unset: 'boolean' },
160
+ kit: { name: 'value', o: 'value', output: 'value', force: 'boolean' },
161
+ // `capabilities` takes the same kit flags as the deck commands: the catalog
162
+ // it publishes IS AUTHORITATIVE, so it must be able to be the catalog of the
163
+ // real context (installed kit, layouts/ next to the deck) and not just that
164
+ // of the bare engine.
165
+ capabilities: { ...FLAGS_KIT, json: 'boolean' },
166
+ };
167
+
168
+ /**
169
+ * Splits `argv` into { _: positionals, …flags }.
170
+ *
171
+ * - the GNU form `--kit=value` is understood (split on the FIRST "=");
172
+ * - `--kit=` (empty value) and `--html=true` (valued boolean) are named
173
+ * errors, not guessed values — since `--html=false` disables nothing,
174
+ * accepting it would be a lie;
175
+ * - the suggestion is computed on the NAME ALONE: on "name=value", no edit
176
+ * distance ever finds the intended flag again;
177
+ * - `--` ends the options (POSIX): without it, a deck named `-deck.md` was
178
+ * not compilable at all.
179
+ */
180
+ function parseArgs(argv, spec = {}) {
181
+ const names = Object.keys(spec);
182
+ const args = { _: [] };
183
+ let endOfOptions = false;
184
+
185
+ for (let i = 0; i < argv.length; i++) {
186
+ const a = argv[i];
187
+ if (endOfOptions) {
188
+ args._.push(a);
189
+ continue;
190
+ }
191
+ if (a === '--') {
192
+ endOfOptions = true;
193
+ continue;
194
+ }
195
+ if (a === '-h' || a === '--help') usage(0);
196
+ // a lone `-` is a positional by convention (standard input), not a flag
197
+ if (!a.startsWith('-') || a === '-') {
198
+ args._.push(a);
199
+ continue;
200
+ }
201
+
202
+ const eq = a.indexOf('=');
203
+ const written = eq === -1 ? a : a.slice(0, eq); // the "--kit" of "--kit=value"
204
+ const name = written.replace(/^--?/, '');
205
+ const dash = written.startsWith('--') ? '--' : '-';
206
+ const arity = spec[name];
207
+
208
+ if (!arity) {
209
+ const nearest = closest(name, names);
210
+ fail(`unknown flag: ${written}${nearest ? ` — did you mean "${dash}${nearest}"?` : ''}`);
211
+ }
212
+
213
+ if (arity === 'boolean') {
214
+ if (eq !== -1) fail(`${written} takes no value: it is a flag, remove "=${a.slice(eq + 1)}".`);
215
+ args[name] = true;
216
+ continue;
217
+ }
218
+
219
+ if (eq !== -1) {
220
+ const value = a.slice(eq + 1);
221
+ if (!value) fail(`${written} expects a value — it is empty after the "=".`);
222
+ args[name] = value;
223
+ continue;
224
+ }
225
+ const next = argv[i + 1];
226
+ if (next === undefined) fail(`${written} expects a value.`);
227
+ if (next.startsWith('-') && next !== '-')
228
+ fail(
229
+ `${written} expects a value — "${next}" looks like a flag (use "${written}=${next}" if that really is the intended value).`,
230
+ );
231
+ args[name] = next;
232
+ i++;
233
+ }
234
+ return args;
235
+ }
236
+
237
+ /** The input file: one only, and one that exists. A second positional was
238
+ * silently ignored — the author believed two decks had been compiled. */
239
+ function requireInput(args) {
240
+ if (args._.length > 1)
241
+ fail(`a single input file is expected — got ${args._.length}: ${args._.join(', ')}`);
242
+ const input = args._[0];
243
+ if (!input) usage();
244
+ if (!fs.existsSync(input)) fail(`file not found: ${input}`);
245
+ return input;
246
+ }
247
+
248
+ const readSource = (input) => fs.readFileSync(input, 'utf8');
249
+ const baseDirOf = (input) => path.dirname(path.resolve(input));
250
+ /** The CLI's --kit: the NAME of an installed kit is passed through to
251
+ * resolveTheme as is (resolved from the user config, hence independent of the
252
+ * cwd); ANY other reference is a path resolved against the current directory
253
+ * (not the deck) — a non-existent path thus becomes an explicit error, never a
254
+ * silent namesake next to the deck. `--theme` is still accepted as a
255
+ * deprecated alias. */
256
+ const themePathOf = (args) => {
257
+ const ref =
258
+ typeof args.kit === 'string' ? args.kit : typeof args.theme === 'string' ? args.theme : null;
259
+ if (ref === null) return null;
260
+ return isKitName(ref) || ref === 'none' ? ref : path.resolve(ref);
261
+ };
262
+
263
+ const SEVERITY_ICON = { error: '✖', warning: '⚠', info: 'ℹ' };
264
+
265
+ /**
266
+ * The safety net for the EXPLICIT kit (rule 2 at the top of the module): the
267
+ * resolution is played DRY, before any write, and its errors stop the command.
268
+ *
269
+ * It is resolveTheme that arbitrates explicit vs implicit: it downgrades to
270
+ * `warning` what comes from the user default, and leaves at `error` what the
271
+ * deck or the project designated. Here, we only honour that severity — the
272
+ * rule stays stated in a single place.
273
+ */
274
+ function requireKit(meta, { baseDir, themePath }) {
275
+ const { diagnostics } = resolveTheme(meta, { baseDir, themePath });
276
+ const errors = diagnostics.filter((d) => d.severity === 'error');
277
+ if (!errors.length) return;
278
+ for (const d of errors) console.error(`✖ ${d.code} — ${d.message}`);
279
+ console.error(
280
+ ' The kit was requested explicitly (--kit, or the frontmatter "kit:"): nothing was produced.',
281
+ );
282
+ process.exit(1);
283
+ }
284
+
285
+ // ---------------------------------------------------------------------------
286
+ // build
287
+ // ---------------------------------------------------------------------------
288
+
289
+ /**
290
+ * Checking the output path BEFORE compiling (rule 1): the extension must match
291
+ * the format actually written, and the path must not be a directory (the write
292
+ * otherwise failed with a bare EISDIR, halfway through rendering).
293
+ */
294
+ function checkOutput(output, html) {
295
+ const ext = path.extname(output).toLowerCase();
296
+ const ok = html ? ext === '.html' || ext === '.htm' : ext === '.pptx';
297
+ if (!ok)
298
+ fail(
299
+ html
300
+ ? `output "${output}": an HTML output must have the .html extension.`
301
+ : `output "${output}": a PowerPoint output must have the .pptx extension (or compile to HTML with --html).`,
302
+ );
303
+ let st = null;
304
+ try {
305
+ st = fs.statSync(output);
306
+ } catch {
307
+ /* absent: this is the common case */
308
+ }
309
+ if (st && !st.isFile()) fail(`output "${output}": this path exists and is not a file.`);
310
+ }
311
+
312
+ /** The extension of an output, checked BEFORE the first byte is written
313
+ * (rule 1): a file delivered under a name that nothing knows how to open is
314
+ * the fault these upfront checks remove. */
315
+ function checkOutputExt(output, ext, what) {
316
+ if (path.extname(output).toLowerCase() !== ext)
317
+ fail(`output "${output}": ${what} must have the ${ext} extension.`);
318
+ }
319
+
320
+ /** The parent directories of an output are created — `-o dist/v2/deck.html`
321
+ * failed with ENOENT after compiling everything. */
322
+ const prepareOutputDir = (output) =>
323
+ fs.mkdirSync(path.dirname(path.resolve(output)), { recursive: true });
324
+
325
+ /** A deck's diagnostics, printed in the shape `validate` uses. */
326
+ const printDiagnostic = (input, d) => {
327
+ const hint =
328
+ d.suggestion && d.code !== 'LAYOUT_SUGGESTION' ? ` (did you mean "${d.suggestion}"?)` : '';
329
+ // a RENDER diagnostic attaches to no line of the source: the file name alone
330
+ // is better than "deck.md:undefined"
331
+ const where = d.line == null ? input : `${input}:${d.line}`;
332
+ console.error(
333
+ `${where} ${SEVERITY_ICON[d.severity]} ${d.severity} ${d.code}\n ${d.message}${hint}`,
334
+ );
335
+ };
336
+
337
+ async function cmdBuild(argv) {
338
+ const args = parseArgs(argv, FLAG_SPECS.build);
339
+ // compatibility: the old `--ir` — the flag is REMOVED from argv, inspect's
340
+ // spec no longer knows it (see FLAG_SPECS.inspect)
341
+ if (args.ir) return cmdInspect(argv.filter((a) => a !== '--ir' && a !== '-ir'));
342
+ const input = requireInput(args);
343
+ let output = args.o ?? args.output ?? null;
344
+ const html = Boolean(args.html) || /\.html?$/i.test(output ?? '');
345
+ output ??= input.replace(/\.md$/i, '') + (html ? '.html' : '.pptx');
346
+ checkOutput(output, html);
347
+
348
+ const baseDir = baseDirOf(input);
349
+ const themePath = themePathOf(args);
350
+ const source = readSource(input);
351
+ const deck = parseDeck(source);
352
+ requireKit(deck.meta, { baseDir, themePath });
353
+
354
+ // theme + the deck's layouts/*.json — before buildScenes (themed geometry)
355
+ const prep = prepareDeckContext(deck.meta, { baseDir, themePath });
356
+ const scenes = buildScenes(deck);
357
+
358
+ // ERROR diagnostics: compiling in spite of them produced a wrong deck without
359
+ // saying anything. `--force` is still the escape hatch — a showable draft is
360
+ // sometimes worth more than a refusal — but it is explicit and noisy.
361
+ const diagnostics = validateDeck(source, { baseDir, themePath, deck, scenes });
362
+ const errors = diagnostics.filter((d) => d.severity === 'error');
363
+ if (errors.length) {
364
+ for (const d of errors) printDiagnostic(input, d);
365
+ if (!args.force) {
366
+ console.error(
367
+ `\n${errors.length} error${errors.length > 1 ? 's' : ''} — nothing was written. Fix them, or compile anyway with --force.`,
368
+ );
369
+ process.exit(1);
370
+ }
371
+ console.error(
372
+ `\n${errors.length} error${errors.length > 1 ? 's' : ''} — compilation forced (--force): the output may be incomplete.`,
373
+ );
374
+ }
375
+
376
+ // EMPTY deck: `validate` already sees it (EMPTY_DECK) but as a warning, and
377
+ // build therefore delivered a 47 kB .pptx without a single slide, announced
378
+ // by a ✓ and exit code 0 — the one place in the journey where the success
379
+ // line lies. The refusal is pronounced HERE, before the first byte is written
380
+ // (rule 1); --force is still the escape hatch, and the final line then
381
+ // becomes a ⚠.
382
+ const empty = diagnostics.find((d) => d.code === 'EMPTY_DECK');
383
+ if (empty) {
384
+ printDiagnostic(input, empty);
385
+ if (!args.force) {
386
+ console.error(
387
+ '\n0 slides — nothing was written. Check the input file, or compile anyway with --force.',
388
+ );
389
+ process.exit(1);
390
+ }
391
+ }
392
+
393
+ if (args.verbose) {
394
+ scenes.forEach((s, k) =>
395
+ console.error(
396
+ ` slide ${k + 1} [${s.layout}${s.animSteps ? ` · ${s.animSteps} steps` : ''}] ${s.title ?? '(untitled)'}`,
397
+ ),
398
+ );
399
+ }
400
+
401
+ // --vendor-assets takes precedence over the frontmatter `assets:` (as --kit
402
+ // does over kit:)
403
+ const vendor = args['vendor-assets'] ? true : undefined;
404
+
405
+ prepareOutputDir(output);
406
+ let stats;
407
+ if (html) {
408
+ const { renderDeckHtml } = await import('./html/render.mjs');
409
+ const out = await renderDeckHtml(scenes, deck.meta, baseDir, { vendor });
410
+ fs.writeFileSync(output, out.html);
411
+ stats = out.stats;
412
+ } else {
413
+ const { renderDeck } = await import('./pptx/render.mjs');
414
+ stats = await renderDeck(scenes, deck.meta, baseDir, output, { vendor });
415
+ }
416
+ stats.warnings = [...prep.diagnostics.map((d) => d.message), ...(stats.warnings ?? [])];
417
+
418
+ // RENDER diagnostics: produced during the write, hence outside validateDeck's
419
+ // upfront net. They only reached this point folded into `stats.warnings` — a
420
+ // RASTER_UNAVAILABLE of severity `error` printed as a ⚠ under a green ✓, at
421
+ // exit code 0, for a .pptx whose charts had become text: exactly the lie that
422
+ // the refusal of the empty deck, a few lines above, has just forbidden.
423
+ //
424
+ // The output is NOT deleted, unlike the empty deck: the fallback is
425
+ // deliberate (a readable slide is better than a hole) and the cause is the
426
+ // installation, not the deck — destroying the file would deprive the author
427
+ // whose platform has no @resvg/resvg-js of any export at all. But a truncated
428
+ // deliverable does not announce itself as a full success: ⚠ instead of ✓, the
429
+ // diagnostic printed as the error it is, and exit code 1 — with --force as
430
+ // the escape hatch, as for the two previous refusals.
431
+ const renderErrors = (stats.diagnostics ?? []).filter((d) => d.severity === 'error');
432
+
433
+ console.log(
434
+ stats.slideCount === 0
435
+ ? `⚠ ${output} — 0 slides. ${empty?.message ?? 'No slides: neither a frontmatter `title:`, nor a `# heading` in the body.'}`
436
+ : renderErrors.length
437
+ ? `⚠ ${output} — ${stats.slideCount} slides, INCOMPLETE rendering (see below)`
438
+ : `✓ ${output} — ${stats.slideCount} slides`,
439
+ );
440
+ if (stats.fontsEmbedded)
441
+ console.log(
442
+ html
443
+ ? ` font "${FONTS.body}" inlined (${stats.fontsEmbedded} woff2 variant${stats.fontsEmbedded > 1 ? 's' : ''})`
444
+ : ` font "${FONTS.body}" embedded (${stats.fontsEmbedded} variant${stats.fontsEmbedded > 1 ? 's' : ''})`,
445
+ );
446
+ if (stats.animatedSlides)
447
+ console.log(
448
+ html
449
+ ? ` ${stats.animatedSlides} animated slide${stats.animatedSlides > 1 ? 's' : ''} — click the slide to reveal the steps`
450
+ : ` ${stats.animatedSlides} animated slide${stats.animatedSlides > 1 ? 's' : ''} — appear on click (native PowerPoint animations)`,
451
+ );
452
+ if (stats.morphSlides)
453
+ console.log(
454
+ ` ${stats.morphSlides} "(cont.)" slide${stats.morphSlides > 1 ? 's' : ''} in Morph transition (fade fallback before PowerPoint 2019)`,
455
+ );
456
+ if (stats.mermaidTotal && stats.mermaidRendered < stats.mermaidTotal) {
457
+ const missing = stats.mermaidTotal - stats.mermaidRendered;
458
+ console.log(
459
+ ` ${missing} Mermaid diagram${missing > 1 ? 's' : ''} rendered as a text fallback (install @mermaid-js/mermaid-cli for graphical rendering)`,
460
+ );
461
+ }
462
+ if (stats.remoteTotal) {
463
+ const dest = stats.remoteVendored ? 'assets/remote/ (self-contained deck)' : 'the user cache';
464
+ console.log(
465
+ ` ${stats.remoteFetched}/${stats.remoteTotal} remote images downloaded into ${dest}`,
466
+ );
467
+ if (stats.remoteFetched < stats.remoteTotal)
468
+ console.log(
469
+ ' → the images that were not downloaded keep a placeholder (check the URL or the connection)',
470
+ );
471
+ }
472
+ if (stats.iconsTotal && stats.iconsRendered < stats.iconsTotal) {
473
+ const missing = stats.iconsTotal - stats.iconsRendered;
474
+ console.log(
475
+ ` ${missing} icon${missing > 1 ? 's' : ''} not found — check the name on lucide.dev`,
476
+ );
477
+ }
478
+ if (stats.mathTotal && stats.mathRendered < stats.mathTotal) {
479
+ const missing = stats.mathTotal - stats.mathRendered;
480
+ console.log(
481
+ ` ${missing} equation${missing > 1 ? 's' : ''} rendered as a text fallback (invalid LaTeX, or install mathjax-full)`,
482
+ );
483
+ }
484
+ // error diagnostics ALSO travel in `warnings` (the only channel historically
485
+ // printed): remove them here so they are not said twice, once under the right
486
+ // icon and once under the wrong one
487
+ const saidAsError = new Set(renderErrors.map((d) => d.message));
488
+ for (const w of stats.warnings ?? []) if (!saidAsError.has(w)) console.log(` ⚠ ${w}`);
489
+
490
+ if (renderErrors.length) {
491
+ for (const d of renderErrors) printDiagnostic(input, d);
492
+ if (!args.force) {
493
+ console.error(
494
+ `\n${renderErrors.length} render error${renderErrors.length > 1 ? 's' : ''} — ${output} was written, but truncated. Fix them, or accept the output as it is with --force.`,
495
+ );
496
+ process.exit(1);
497
+ }
498
+ console.error(
499
+ `\n${renderErrors.length} render error${renderErrors.length > 1 ? 's' : ''} — accepted (--force).`,
500
+ );
501
+ }
502
+ }
503
+
504
+ // ---------------------------------------------------------------------------
505
+ // validate
506
+ // ---------------------------------------------------------------------------
507
+
508
+ function cmdValidate(argv) {
509
+ const args = parseArgs(argv, FLAG_SPECS.validate);
510
+ const input = requireInput(args);
511
+ const diagnostics = validateDeck(readSource(input), {
512
+ baseDir: baseDirOf(input),
513
+ themePath: themePathOf(args),
514
+ });
515
+ const errors = diagnostics.filter((d) => d.severity === 'error').length;
516
+
517
+ if (args.json) {
518
+ console.log(JSON.stringify({ valid: errors === 0, diagnostics }, null, 2));
519
+ } else if (!diagnostics.length) {
520
+ console.log(`✓ ${input} — no diagnostics`);
521
+ } else {
522
+ for (const d of diagnostics) {
523
+ // LAYOUT_SUGGESTION: the suggestion is a recommendation already stated in
524
+ // the message, not the correction of a typo
525
+ const hint =
526
+ d.suggestion && d.code !== 'LAYOUT_SUGGESTION' ? ` (did you mean "${d.suggestion}"?)` : '';
527
+ console.log(
528
+ `${input}:${d.line} ${SEVERITY_ICON[d.severity]} ${d.severity} ${d.code}\n ${d.message}${hint}`,
529
+ );
530
+ }
531
+ const warnings = diagnostics.filter((d) => d.severity === 'warning').length;
532
+ console.log(
533
+ `\n${errors} error${errors !== 1 ? 's' : ''}, ${warnings} warning${warnings !== 1 ? 's' : ''}`,
534
+ );
535
+ }
536
+ process.exit(errors ? 1 : 0);
537
+ }
538
+
539
+ // ---------------------------------------------------------------------------
540
+ // inspect (formerly --ir)
541
+ // ---------------------------------------------------------------------------
542
+
543
+ function cmdInspect(argv) {
544
+ const args = parseArgs(argv, FLAG_SPECS.inspect);
545
+ const input = requireInput(args);
546
+ const baseDir = baseDirOf(input);
547
+ const themePath = themePathOf(args);
548
+ const deck = parseDeck(readSource(input));
549
+ // the reference JSON describes the geometry actually produced: publishing it
550
+ // under the generic theme when a kit was requested would make it wrong
551
+ requireKit(deck.meta, { baseDir, themePath });
552
+ // same preparation as build: the reference JSON shows the geometry actually
553
+ // produced, theme and user layouts included
554
+ prepareDeckContext(deck.meta, { baseDir, themePath });
555
+ const scenes = buildScenes(deck);
556
+ const json = JSON.stringify({ meta: deck.meta, slides: deck.slides, scenes }, null, 2);
557
+
558
+ // `-o` was declared, consumed by the parser… and never read: the 116 kB of
559
+ // JSON went to stdout, no file was written, and the CI that then read
560
+ // that file only failed at the next step (rule 2).
561
+ const out = args.o ?? args.output ?? null;
562
+ if (out === null) {
563
+ console.log(json);
564
+ return;
565
+ }
566
+ checkOutputExt(out, '.json', 'an inspection output'); // before writing (rule 1)
567
+ prepareOutputDir(out);
568
+ fs.writeFileSync(out, json);
569
+ console.log(`✓ ${out} — ${scenes.length} slides`);
570
+ }
571
+
572
+ // ---------------------------------------------------------------------------
573
+ // preview — local server + recompile on change + SSE reload
574
+ // ---------------------------------------------------------------------------
575
+
576
+ const SSE_CLIENT = `<script>new EventSource('/__events').onmessage = () => location.reload();</script>`;
577
+
578
+ /**
579
+ * Hosts admitted by the preview server.
580
+ *
581
+ * Listening on 127.0.0.1 is NOT enough: a third-party site can have its own
582
+ * domain resolve to the local loopback (DNS rebinding) and have the preview —
583
+ * hence the content of the deck being written — read by the victim's browser.
584
+ * The countermeasure is to refuse any request whose `Host` header is not a local
585
+ * name: a browser ALWAYS sends the name it resolved.
586
+ */
587
+ const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]', '::1']);
588
+
589
+ /** The host name of a `Host` header (port removed, IPv6 brackets kept). */
590
+ function hostnameOf(header) {
591
+ if (typeof header !== 'string' || !header) return null;
592
+ if (header.startsWith('[')) return header.slice(0, header.indexOf(']') + 1) || null;
593
+ const colon = header.indexOf(':');
594
+ return (colon === -1 ? header : header.slice(0, colon)).toLowerCase();
595
+ }
596
+
597
+ /**
598
+ * Listens on the first free port starting from `port`.
599
+ *
600
+ * A busy port surfaced as an uncaught EADDRINUSE — a stack trace for a
601
+ * perfectly ordinary case (two previews open). We announce and we switch; the
602
+ * caller learns the port ACTUALLY listened on.
603
+ */
604
+ function listenFromPort(server, port, attempts = 20) {
605
+ return new Promise((resolve, reject) => {
606
+ let current = port;
607
+ let remaining = attempts;
608
+ const onError = (e) => {
609
+ if (e?.code !== 'EADDRINUSE' || remaining-- <= 0) {
610
+ server.off('error', onError);
611
+ reject(e);
612
+ return;
613
+ }
614
+ console.log(`⚠ port ${current} busy — switching to ${current + 1}`);
615
+ current += 1;
616
+ server.listen(current, '127.0.0.1');
617
+ };
618
+ server.on('error', onError);
619
+ server.once('listening', () => {
620
+ server.off('error', onError);
621
+ resolve(current);
622
+ });
623
+ server.listen(current, '127.0.0.1');
624
+ });
625
+ }
626
+
627
+ async function cmdPreview(argv) {
628
+ const args = parseArgs(argv, FLAG_SPECS.preview);
629
+ const input = requireInput(args);
630
+ const requested = args.port ?? '4321';
631
+ if (!/^\d+$/.test(String(requested)) || Number(requested) < 1 || Number(requested) > 65535)
632
+ fail(`--port expects an integer between 1 and 65535 — got "${requested}".`);
633
+ const port = Number(requested);
634
+ const absInput = path.resolve(input);
635
+ const baseDir = baseDirOf(input);
636
+ const themePath = themePathOf(args);
637
+ requireKit(parseDeck(readSource(absInput)).meta, { baseDir, themePath });
638
+ const { compileHtml } = await import('./html/render.mjs');
639
+
640
+ let html = '<!doctype html><p>compiling…</p>';
641
+ let watchedThemeDir = null; // theme directory OUTSIDE baseDir, watched separately
642
+ let themeWatcher = null;
643
+ async function recompile() {
644
+ const t = Date.now();
645
+ const source = readSource(absInput);
646
+ const { html: doc, stats, themeFile } = await compileHtml(source, { baseDir, themePath });
647
+ // inject before the LAST </body>: the document's inline scripts may contain
648
+ // that literal (the first one found is not necessarily the right one)
649
+ const at = doc.lastIndexOf('</body>');
650
+ html = at === -1 ? doc + SSE_CLIENT : doc.slice(0, at) + SSE_CLIENT + doc.slice(at);
651
+ watchTheme(themeFile);
652
+ const diagnostics = validateDeck(source, { baseDir, themePath });
653
+ console.log(`↻ ${stats.slideCount} slides in ${Date.now() - t} ms`);
654
+ for (const d of diagnostics)
655
+ console.log(` ${input}:${d.line} ${SEVERITY_ICON[d.severity]} ${d.code} — ${d.message}`);
656
+ }
657
+
658
+ // a theme outside the deck's directory (--theme ../elsewhere.json, or a
659
+ // frontmatter reference going up) escapes the main fs.watch: watch ITS
660
+ // directory too (the directory, not the file — editors replace the file when
661
+ // saving it), and follow path changes between recompilations
662
+ function watchTheme(themeFile) {
663
+ const dir = themeFile ? path.dirname(themeFile) : null;
664
+ const outside = dir && dir !== baseDir && !dir.startsWith(baseDir + path.sep);
665
+ const target = outside ? dir : null;
666
+ if (target === watchedThemeDir) return;
667
+ themeWatcher?.close();
668
+ themeWatcher = null;
669
+ watchedThemeDir = target;
670
+ if (target) themeWatcher = fs.watch(target, (_event, name) => onFsEvent(name));
671
+ }
672
+ await recompile();
673
+
674
+ const clients = new Set();
675
+ const server = http.createServer((req, res) => {
676
+ // `no-store` everywhere: the deck is recompiled on every keystroke, a page
677
+ // kept in cache would show an already-wrong state on reload
678
+ const hostname = hostnameOf(req.headers.host);
679
+ if (!hostname || !LOCAL_HOSTS.has(hostname)) {
680
+ res.writeHead(403, {
681
+ 'Content-Type': 'text/plain; charset=utf-8',
682
+ 'Cache-Control': 'no-store',
683
+ });
684
+ res.end(
685
+ `403 — Host header "${req.headers.host ?? '(absent)'}" refused: this preview only answers to localhost / 127.0.0.1.\n`,
686
+ );
687
+ return;
688
+ }
689
+ if (req.url === '/__events') {
690
+ res.writeHead(200, {
691
+ 'Content-Type': 'text/event-stream',
692
+ 'Cache-Control': 'no-store',
693
+ Connection: 'keep-alive',
694
+ });
695
+ res.write(':ok\n\n');
696
+ clients.add(res);
697
+ req.on('close', () => clients.delete(res));
698
+ return;
699
+ }
700
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
701
+ res.end(html);
702
+ });
703
+
704
+ // Watches the file's directory (editors often replace the file instead of
705
+ // writing it in place), ignoring the outputs and, for a deck that vendors its
706
+ // images, what the compilation itself writes (assets/remote/). The same
707
+ // debounce serves the theme watcher (watchTheme).
708
+ let timer = null;
709
+ const IGNORE = /(^|\/)assets\/remote\/|\.(html|pptx)$/;
710
+ function onFsEvent(name) {
711
+ if (!name || IGNORE.test(name)) return;
712
+ clearTimeout(timer);
713
+ timer = setTimeout(async () => {
714
+ try {
715
+ await recompile();
716
+ for (const c of clients) c.write('data: reload\n\n');
717
+ } catch (e) {
718
+ console.error(`✖ ${e.message}`);
719
+ }
720
+ }, 150);
721
+ }
722
+ fs.watch(baseDir, { recursive: true }, (_event, name) => onFsEvent(name));
723
+
724
+ const listening = await listenFromPort(server, port);
725
+ console.log(`Preview: http://localhost:${listening} (Ctrl-C to quit)`);
726
+ }
727
+
728
+ // ---------------------------------------------------------------------------
729
+ // config — default kit and installed kits at the user level
730
+ // ---------------------------------------------------------------------------
731
+
732
+ function cmdConfig(argv) {
733
+ const args = parseArgs(argv, FLAG_SPECS.config);
734
+ const root = userConfigRoot();
735
+ const configFile = path.join(root, 'config.json');
736
+ const rootSource = process.env.LUTRIN_CONFIG
737
+ ? 'LUTRIN_CONFIG'
738
+ : process.env.XDG_CONFIG_HOME
739
+ ? 'XDG_CONFIG_HOME'
740
+ : 'default';
741
+
742
+ // a config written before kits still carries themes/: migrate it here, where
743
+ // the user sees the result, rather than letting them discover an empty
744
+ // directory
745
+ const moved = migrateUserConfig();
746
+ // announce EVERY move: the root move (mtl-deck → lutrin) and the themes/ →
747
+ // kits/ rename can happen in the same run, and showing only the last one
748
+ // would suggest the old directory is still there
749
+ for (const m of moved.moves ?? []) console.log(`✓ configuration migrated: ${m.from} → ${m.to}`);
750
+ if (moved.moves?.length) console.log();
751
+
752
+ // ------ write -------------------------------------------------------------
753
+ if (args.unset) {
754
+ const file = setUserKit(null);
755
+ console.log(`✓ user default kit removed — ${file}`);
756
+ return;
757
+ }
758
+ const posed =
759
+ typeof args.kit === 'string' && args.kit.trim()
760
+ ? args.kit.trim()
761
+ : typeof args.theme === 'string' && args.theme.trim()
762
+ ? args.theme.trim()
763
+ : null;
764
+ if (posed) {
765
+ const file = setUserKit(posed);
766
+ console.log(`✓ user default kit: ${posed}\n written to ${file}`);
767
+ if (posed === 'none') {
768
+ console.log(' → "none" forces the generic theme in every project with no kit of its own.');
769
+ return;
770
+ }
771
+ // check that the reference resolves from the config directory (it may also
772
+ // be installed later: we warn without blocking)
773
+ const { theme, layoutsDir, diagnostics } = resolveTheme({ kit: posed }, { baseDir: root });
774
+ for (const d of diagnostics) console.log(` ${SEVERITY_ICON[d.severity] ?? '•'} ${d.message}`);
775
+ if ((theme || layoutsDir) && !diagnostics.some((d) => d.severity === 'error'))
776
+ console.log(` → kit resolved${theme?.name ? ` ("${theme.name}")` : ''}.`);
777
+ return;
778
+ }
779
+
780
+ // ------ read --------------------------------------------------------------
781
+ const { ref, error } = readUserKit();
782
+ const kits = listInstalledKits();
783
+ console.log('Lutrin configuration');
784
+ console.log(` Directory : ${root} [${rootSource}]`);
785
+ console.log(` Config file : ${configFile}${fs.existsSync(configFile) ? '' : ' (absent)'}`);
786
+ console.log(
787
+ ` Default kit : ${error ? `⚠ ${error.message}` : (ref ?? '(none — the host or generic default applies)')}`,
788
+ );
789
+ console.log(` Installed kits: ${userKitsDir()}${kits.length ? '' : ' (none)'}`);
790
+ for (const k of kits) {
791
+ const version = k.manifest?.version ? ` v${k.manifest.version}` : '';
792
+ console.log(
793
+ ` - ${k.name.padEnd(24)}${k.error ? `⚠ ${k.error}` : `${version.padEnd(10)}${k.path}`}`,
794
+ );
795
+ }
796
+ console.log('\nSet : lutrin config --kit <installed-name | file.json | directory | none>');
797
+ console.log('Remove : lutrin config --unset');
798
+ console.log(
799
+ 'Precedence: --kit > frontmatter "kit:" > project default (package.json) > THIS user default > host default.',
800
+ );
801
+ }
802
+
803
+ // ---------------------------------------------------------------------------
804
+ // kit — install, list, remove, pack
805
+ // ---------------------------------------------------------------------------
806
+
807
+ const KIT_ACTIONS = ['install', 'list', 'remove', 'create'];
808
+
809
+ async function cmdKit(argv) {
810
+ const args = parseArgs(argv, FLAG_SPECS.kit);
811
+ const [action, target] = args._;
812
+ if (!KIT_ACTIONS.includes(action)) {
813
+ console.error(`Usage:
814
+ lutrin kit install <file.deckkit|https://…> [--force] [--name <name>]
815
+ lutrin kit list
816
+ lutrin kit remove <name>
817
+ lutrin kit create <directory> [-o <file.deckkit>]`);
818
+ process.exit(1);
819
+ }
820
+
821
+ // ------ list --------------------------------------------------------------
822
+ if (action === 'list') {
823
+ const kits = listInstalledKits();
824
+ if (!kits.length) {
825
+ console.log(`No kit installed in ${userKitsDir()}.`);
826
+ console.log('Install: lutrin kit install <file.deckkit | https://…>');
827
+ return;
828
+ }
829
+ console.log(`Installed kits (${userKitsDir()}):`);
830
+ for (const k of kits) {
831
+ if (k.error) {
832
+ console.log(` ⚠ ${k.name.padEnd(24)} ${k.error}`);
833
+ continue;
834
+ }
835
+ const v = k.manifest.version ? `v${k.manifest.version}` : '';
836
+ console.log(` • ${k.name.padEnd(24)} ${v.padEnd(10)} ${k.manifest.description ?? ''}`);
837
+ }
838
+ return;
839
+ }
840
+
841
+ // ------ remove ------------------------------------------------------------
842
+ if (action === 'remove') {
843
+ if (!target) {
844
+ console.error('Usage: lutrin kit remove <name>');
845
+ process.exit(1);
846
+ }
847
+ const kits = listInstalledKits();
848
+ const found = kits.find((k) => k.name === target);
849
+ if (!found) {
850
+ console.error(`✖ kit "${target}" is not installed.`);
851
+ if (kits.length) console.error(` Installed: ${kits.map((k) => k.name).join(', ')}`);
852
+ process.exit(1);
853
+ }
854
+ fs.rmSync(found.path, { recursive: true, force: true });
855
+ console.log(`✓ kit "${target}" removed — ${found.path}`);
856
+ // a user default pointing at the removed kit would become a warning on
857
+ // every compilation: say it HERE, where the user can act
858
+ if (readUserKit().ref === target)
859
+ console.log(
860
+ ` ⚠ it was still the user default kit — change it with "lutrin config --kit <name>" or "--unset".`,
861
+ );
862
+ return;
863
+ }
864
+
865
+ // ------ create ------------------------------------------------------------
866
+ if (action === 'create') {
867
+ if (!target) {
868
+ console.error('Usage: lutrin kit create <directory> [-o <file.deckkit>]');
869
+ process.exit(1);
870
+ }
871
+ // extension checked BEFORE packing (rule 1): an archive written under
872
+ // another name does not install — better not to produce it at all
873
+ const requested = args.o ?? args.output ?? null;
874
+ if (requested !== null) checkOutputExt(requested, '.deckkit', 'a kit archive');
875
+ const { buffer, manifest, entries, skipped } = await packKit(target);
876
+ const out = requested ?? `${manifest.name}.deckkit`;
877
+ prepareOutputDir(out);
878
+ fs.writeFileSync(out, buffer);
879
+ console.log(
880
+ `✓ ${out} — kit "${manifest.name}"${manifest.version ? ` v${manifest.version}` : ''}, ${entries.length} file${entries.length > 1 ? 's' : ''}, ${(buffer.length / 1024).toFixed(1)} kB`,
881
+ );
882
+ console.log(` sha256: ${sha256(buffer)}`);
883
+ // never silence what was skipped: a logo missing from the kit would
884
+ // otherwise be discovered on a user's first compilation
885
+ for (const s of skipped) console.log(` ⚠ skipped: ${s}`);
886
+ return;
887
+ }
888
+
889
+ // ------ install -----------------------------------------------------------
890
+ if (!target) {
891
+ console.error('Usage: lutrin kit install <file.deckkit|https://…>');
892
+ process.exit(1);
893
+ }
894
+ const remote = /^[a-z][a-z0-9+.-]*:\/\//i.test(target);
895
+ let buf;
896
+ if (remote) {
897
+ // announce AFTER validating the protocol: "downloading http://…" followed
898
+ // by a refusal would suggest a request had gone out
899
+ if (!/^https:\/\//i.test(target)) {
900
+ console.error(
901
+ `✖ install — Protocol refused: ${target.split('://')[0]}:// — only https is accepted to install a kit.`,
902
+ );
903
+ process.exit(1);
904
+ }
905
+ console.log(`downloading ${target}…`);
906
+ buf = await fetchKitArchive(target);
907
+ } else {
908
+ if (!fs.existsSync(target)) {
909
+ console.error(`✖ file not found: ${target}`);
910
+ process.exit(1);
911
+ }
912
+ buf = fs.readFileSync(target);
913
+ }
914
+
915
+ const archive = await readKitArchive(buf);
916
+ if (typeof args.name === 'string' && args.name.trim()) {
917
+ // rename AT INSTALL TIME: two variants of the same kit can coexist. The
918
+ // name goes through the SAME validation as the manifest's own.
919
+ const renamed = parseKitManifest(
920
+ { ...archive.manifest, name: args.name.trim() },
921
+ { where: '--name' },
922
+ );
923
+ if (!renamed.manifest) {
924
+ console.error(
925
+ `✖ --name: ${renamed.diagnostics.find((d) => d.severity === 'error')?.message}`,
926
+ );
927
+ process.exit(1);
928
+ }
929
+ archive.manifest = renamed.manifest;
930
+ }
931
+
932
+ for (const d of archive.diagnostics)
933
+ console.log(` ${SEVERITY_ICON[d.severity] ?? '•'} ${d.message}`);
934
+ const { dir, replaced } = installKitArchive(archive, userKitsDir(), {
935
+ force: Boolean(args.force),
936
+ });
937
+ const m = archive.manifest;
938
+ console.log(
939
+ `✓ kit "${m.name}"${m.version ? ` v${m.version}` : ''} ${replaced ? 'replaced' : 'installed'} — ${dir}`,
940
+ );
941
+ console.log(` sha256: ${archive.digest}`);
942
+
943
+ // check what has just been written: if the kit is not readable by the
944
+ // resolver, say it now rather than on the first compilation
945
+ const check = readKit(dir);
946
+ for (const d of check.diagnostics)
947
+ console.log(` ${SEVERITY_ICON[d.severity] ?? '•'} ${d.message}`);
948
+ if (!check.manifest) process.exit(1);
949
+ console.log(` use: lutrin build <deck.md> --kit ${m.name} or lutrin config --kit ${m.name}`);
950
+ }
951
+
952
+ // ---------------------------------------------------------------------------
953
+ // vendor
954
+ // ---------------------------------------------------------------------------
955
+
956
+ async function cmdVendor(argv) {
957
+ const args = parseArgs(argv, FLAG_SPECS.vendor);
958
+ const input = requireInput(args);
959
+ const themePath = themePathOf(args);
960
+ // the safety net BEFORE vendorDeck: vendoring without the requested kit would
961
+ // write a directory announced as "self-contained" and stripped of the
962
+ // intended brand — the worst of all results, since it would only be
963
+ // discovered at the recipient's end
964
+ requireKit(parseDeck(readSource(input)).meta, { baseDir: baseDirOf(input), themePath });
965
+ const { vendorDeck } = await import('./vendor.mjs');
966
+ const r = await vendorDeck(input, { themePath });
967
+
968
+ const reportLine = (done, total, what, where) =>
969
+ console.log(`${done === total ? '✓' : '⚠'} ${done}/${total} ${what} → ${where}`);
970
+
971
+ if (r.images.total) reportLine(r.images.done, r.images.total, 'remote images', 'assets/remote/');
972
+ if (r.mermaid.total)
973
+ reportLine(r.mermaid.done, r.mermaid.total, 'Mermaid diagrams', 'assets/mermaid/');
974
+ if (r.kit?.alreadyVendored) console.log(`✓ kit "${r.kit.name}" already vendored`);
975
+ else if (r.kit) console.log(`✓ kit "${r.kit.name}" (${r.kit.files} files) → assets/kit/`);
976
+
977
+ // The warnings go out BEFORE any verdict, and notably before the early
978
+ // return: the two cases that refuse a kit (place already taken by a foreign
979
+ // directory, bare theme file outside the deck's directory) are exactly the
980
+ // ones that leave `r.kit` at null. Returning early printed "Nothing to vendor:
981
+ // … no kit" to an author who had just asked for one — and sent them away
982
+ // with a directory they believed was self-contained.
983
+ for (const w of r.warnings) console.log(`⚠ ${w}`);
984
+
985
+ if (!r.images.total && !r.mermaid.total && !r.kit) {
986
+ console.log(
987
+ r.warnings.length
988
+ ? '\nNothing was vendored: see above. The directory is NOT self-contained.'
989
+ : 'Nothing to vendor: this deck has no remote image, no diagram and no kit.',
990
+ );
991
+ return;
992
+ }
993
+ for (const f of r.frontmatter) console.log(` frontmatter: ${f}`);
994
+ if (r.mermaid.done < r.mermaid.total)
995
+ console.log(' → diagrams not rendered: install @mermaid-js/mermaid-cli, then re-run vendor');
996
+ if (r.images.done < r.images.total)
997
+ console.log(' → images not downloaded: check the URL or the connection, then re-run vendor');
998
+ // self-containment is announced ONLY if nothing was left aside: until now the
999
+ // promise followed the warning that contradicted it
1000
+ console.log(
1001
+ r.warnings.length
1002
+ ? '\nINCOMPLETE directory: see the warnings above — it will not compile identically offline.'
1003
+ : '\nThe directory is self-contained: it compiles offline, with no kit installed.',
1004
+ );
1005
+ }
1006
+
1007
+ // ---------------------------------------------------------------------------
1008
+ // capabilities
1009
+ // ---------------------------------------------------------------------------
1010
+
1011
+ /**
1012
+ * The engine's catalog — the source of truth that the documentation translates
1013
+ * and that agents query before writing a deck.
1014
+ *
1015
+ * With no argument, it describes the BARE engine: built-in layouts and the
1016
+ * official catalog. That was its only form, and it was a lie by omission as
1017
+ * soon as a brand came into play — `capabilities()` was called WITHOUT a
1018
+ * context, so `userLayouts` stayed empty while validation, for its part, knew
1019
+ * the kit's layouts perfectly: the same directory refused
1020
+ * `<!-- layout: my-four -->` with an UNKNOWN_LAYOUT naming `my-four` that
1021
+ * `capabilities` had never listed. An agent working in a branded project
1022
+ * therefore NEVER saw that brand's layouts.
1023
+ *
1024
+ * With a deck and/or `--kit`, we take exactly the path of `validate` and
1025
+ * `inspect`: explicit kit required (rule 2), then `prepareDeckContext` — after
1026
+ * which the registry holds the kit's layouts and the deck's `layouts/*.json`,
1027
+ * and `capabilities()` publishes them.
1028
+ */
1029
+ function cmdCapabilities(argv) {
1030
+ // `--json` is the EXPLICIT form of the default (the output has always been
1031
+ // JSON): accepted, with no effect — but any other flag is refused
1032
+ const args = parseArgs(argv, FLAG_SPECS.capabilities);
1033
+ if (args._.length > 1)
1034
+ fail(`a single input file is expected — got ${args._.length}: ${args._.join(', ')}`);
1035
+ const input = args._[0] ?? null;
1036
+ if (input !== null && !fs.existsSync(input)) fail(`file not found: ${input}`);
1037
+ const themePath = themePathOf(args);
1038
+
1039
+ if (input !== null || themePath !== null) {
1040
+ // the deck is read only for its frontmatter: that is what carries "kit:",
1041
+ // and ignoring it would publish the catalog of a brand OTHER than the one
1042
+ // this deck compiles under
1043
+ const meta = input === null ? {} : parseDeck(readSource(input)).meta;
1044
+ const baseDir = input === null ? process.cwd() : baseDirOf(input);
1045
+ requireKit(meta, { baseDir, themePath });
1046
+ const { diagnostics } = prepareDeckContext(meta, { baseDir, themePath });
1047
+ // an unreadable custom layout must not be erased under a catalog that does
1048
+ // not contain it (rule 2) — but on STDERR: stdout stays JSON that `| jq`
1049
+ // can work with
1050
+ for (const d of diagnostics.filter((d) => d.severity !== 'info'))
1051
+ console.error(`${SEVERITY_ICON[d.severity]} ${d.severity} ${d.code} — ${d.message}`);
1052
+ }
1053
+
1054
+ console.log(JSON.stringify(capabilities(), null, 2));
1055
+ }
1056
+
1057
+ // ---------------------------------------------------------------------------
1058
+ // dispatch
1059
+ // ---------------------------------------------------------------------------
1060
+
1061
+ const argv = process.argv.slice(2);
1062
+ if (!argv.length) usage(1);
1063
+ if (argv[0] === '-h' || argv[0] === '--help') usage(0);
1064
+ if (argv[0] === '--version' || argv[0] === '-v') printVersion();
1065
+
1066
+ // compatibility: `lutrin file.md …` (or the old cli with --html/--ir) = build.
1067
+ // But a MISSPELLED subcommand must no longer slip into that fallback:
1068
+ // `lutrin buidl deck.md` then accused the input file — which existed — of not
1069
+ // being found, which helps nobody.
1070
+ const [head, ...rest] = argv;
1071
+ if (!COMMANDS.includes(head) && !head.startsWith('-') && !fs.existsSync(head)) {
1072
+ const nearest = closest(head, COMMANDS);
1073
+ if (nearest) fail(`unknown subcommand: ${head} — did you mean "${nearest}"?`);
1074
+ }
1075
+ const command = COMMANDS.includes(head) ? head : 'build';
1076
+ const rest2 = COMMANDS.includes(head) ? rest : argv;
1077
+
1078
+ try {
1079
+ switch (command) {
1080
+ case 'build':
1081
+ await cmdBuild(rest2);
1082
+ break;
1083
+ case 'preview':
1084
+ await cmdPreview(rest2);
1085
+ break;
1086
+ case 'validate':
1087
+ cmdValidate(rest2);
1088
+ break;
1089
+ case 'inspect':
1090
+ cmdInspect(rest2);
1091
+ break;
1092
+ case 'config':
1093
+ cmdConfig(rest2);
1094
+ break;
1095
+ case 'kit':
1096
+ await cmdKit(rest2);
1097
+ break;
1098
+ case 'vendor':
1099
+ await cmdVendor(rest2);
1100
+ break;
1101
+ case 'capabilities':
1102
+ cmdCapabilities(rest2);
1103
+ break;
1104
+ default:
1105
+ usage();
1106
+ }
1107
+ } catch (e) {
1108
+ // never a raw stack trace: the COMMAND and the cause, nothing else.
1109
+ // The old net prefixed with rest2[0] — the first argument, often a perfectly
1110
+ // valid path, designated as the culprit of an error that came from
1111
+ // elsewhere.
1112
+ console.error(`✖ ${command} — ${e?.message ?? e}`.trim());
1113
+ process.exit(1);
1114
+ }