@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/LICENSE +21 -0
- package/README.md +154 -0
- package/design/layouts/before-after.json +6 -0
- package/design/layouts/funnel.json +6 -0
- package/design/layouts/journey.json +5 -0
- package/design/layouts/key-message.json +5 -0
- package/design/layouts/portfolio.json +8 -0
- package/design/layouts/priority-matrix.json +7 -0
- package/design/layouts/pros-cons.json +6 -0
- package/design/layouts/pyramid.json +6 -0
- package/design/layouts/risk-map.json +8 -0
- package/design/layouts/roadmap.json +6 -0
- package/design/themes/default.json +159 -0
- package/package.json +57 -0
- package/src/cli.mjs +1114 -0
- package/src/deck/assets.mjs +910 -0
- package/src/deck/chart.mjs +424 -0
- package/src/deck/context.mjs +62 -0
- package/src/deck/highlight.mjs +206 -0
- package/src/deck/kit.mjs +342 -0
- package/src/deck/layout.mjs +1599 -0
- package/src/deck/parse.mjs +776 -0
- package/src/deck/suggest.mjs +33 -0
- package/src/deck/theme.mjs +1135 -0
- package/src/deck/tokens.mjs +268 -0
- package/src/deck/validate.mjs +737 -0
- package/src/html/render.mjs +1586 -0
- package/src/kit/archive.mjs +448 -0
- package/src/pptx/anim.mjs +196 -0
- package/src/pptx/fonts.mjs +204 -0
- package/src/pptx/morph.mjs +103 -0
- package/src/pptx/render.mjs +1379 -0
- package/src/vendor.mjs +281 -0
- package/src/worker/protocol.d.ts +82 -0
- package/src/worker/worker.mjs +145 -0
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
|
+
}
|