@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.
@@ -0,0 +1,1135 @@
1
+ /**
2
+ * JSON themes — new styles without recompiling, distributed as KITS
3
+ * (kit.mjs for the manifest, kit/archive.mjs for `.deckkit` packaging).
4
+ *
5
+ * A theme is referenced in six ways, by decreasing priority:
6
+ * 1. CLI flag `--kit` (name of an installed kit, JSON file, or directory);
7
+ * 2. frontmatter `kit:` — name of an installed kit, JSON file next to the
8
+ * deck (`./my-theme.json`, resolved relative to the deck's directory) or
9
+ * kit directory; `kit: none` forces the default theme.
10
+ * `theme:` is still accepted as a DEPRECATED ALIAS (diagnostic);
11
+ * 3. project default: the `"lutrin": { "kit": … }` field of the first
12
+ * package.json found while walking up from the deck's directory —
13
+ * the organization declares its kit once;
14
+ * 4. USER default: the `"kit"` field of `<configRoot>/config.json`
15
+ * (`~/.config/lutrin`, overridable by `LUTRIN_CONFIG`) — a kit
16
+ * chosen once and shared across ALL of the user's projects,
17
+ * plugins included. Below the project default (more specific), above
18
+ * the host default (the brand imposed by a plugin is thereby overridden
19
+ * by the user's explicit choice);
20
+ * 5. HOST default (`defaultTheme`): branded extensions (VS Code,
21
+ * Obsidian) ship their kit and impose it on decks that choose
22
+ * nothing — below everything else, so that the document always wins;
23
+ * 6. otherwise, the default theme (generic design tokens from tokens.mjs).
24
+ *
25
+ * THREE FORMS of reference, and three only (see resolveThemeRef):
26
+ *
27
+ * - `none` — explicit opt-out;
28
+ * - a BARE NAME (`brand-acme`) — kit installed in `<configRoot>/kits/`,
29
+ * resolved by name from any project: "install once, use everywhere",
30
+ * with nothing to do in each project;
31
+ * - a PATH — a `.json` file (bare theme, historical behaviour) or a
32
+ * directory carrying a `kit.json` (unpacked kit, not installed). That is
33
+ * how extensions designate the kit they ship.
34
+ *
35
+ * Resolution through node_modules has been REMOVED: a theme is no longer
36
+ * distributed as an npm package. Do not reintroduce it — it forced an `npm i`
37
+ * per project, a walk up the tree, a CORE_ROOT fallback for the hosts, and it
38
+ * is the only reason two resolution paths were needed.
39
+ *
40
+ * A KIT (kit.mjs) carries its `kit.json` manifest: theme.json + layouts/ +
41
+ * fonts + logos. Its theme.json follows exactly the file-theme schema —
42
+ * fonts and logos resolved relative to it, therefore INSIDE the kit; its
43
+ * layouts/ directory provides JSON layouts loaded on every compilation
44
+ * (context.mjs).
45
+ *
46
+ * CONFINEMENT of the asset paths (`logos`, `fonts.files`) — see
47
+ * `withinAny`: those paths designate files that will be READ and then
48
+ * EMBEDDED in the deliverable, so a theme that chooses them chooses what
49
+ * leaves the machine. The allowed root depends on the theme's PROVENANCE,
50
+ * because the trust is not the same:
51
+ *
52
+ * - theme from a KIT — the kit is THIRD-PARTY content, installed from an
53
+ * archive: it is confined to ITS directory, strictly. Without that, a
54
+ * `"cover": "../../../.ssh/id_rsa"` would be read and embedded in the
55
+ * .pptx;
56
+ * - FILE theme (outside a kit) — written by the deck's author, who is
57
+ * already master of their own project: the root is the deck's directory
58
+ * (more precisely the one the reference was resolved from) OR the theme's.
59
+ * Confining such a theme to its own directory alone would break the
60
+ * legitimate arrangement "kit: ./design/theme.json" pointing at
61
+ * "../fonts/Body.ttf", for no gain: the author can write whatever they
62
+ * want anyway.
63
+ *
64
+ * The theme overrides the tokens of tokens.mjs by IN-PLACE MUTATION:
65
+ * ES bindings being live and every consumer reading the tokens at call time,
66
+ * no renderer changes.
67
+ *
68
+ * Life cycle — the hosts (extension worker, preview) are hot processes
69
+ * shared between decks: applyTheme() ALWAYS restarts from the snapshot of the
70
+ * default values taken at load time, then merges the theme, then re-runs
71
+ * deriveTokens() so that the derived groups (LAYER_SHADES, SEMANTIC,
72
+ * TREND_INK, PAGE margins) follow the palette, then re-merges the explicit
73
+ * overrides of those groups. applyTheme(null) therefore restores exactly the
74
+ * default theme — never a theme leak between requests.
75
+ *
76
+ * Validation (resolveTheme) NEVER throws: a theme that could not be read or an
77
+ * invalid entry becomes a diagnostic and the entry is dropped — the deck
78
+ * always compiles, on the default theme if need be.
79
+ */
80
+
81
+ import fs from 'node:fs';
82
+ import os from 'node:os';
83
+ import path from 'node:path';
84
+ import {
85
+ COLORS,
86
+ FONTS,
87
+ FONT_FILES,
88
+ LOGOS,
89
+ TYPE,
90
+ SPACE,
91
+ PAGE,
92
+ ROUNDED,
93
+ CHROME,
94
+ CHART_COLORS,
95
+ LAYER_SHADES,
96
+ TREND_INK,
97
+ SEMANTIC,
98
+ deriveTokens,
99
+ } from './tokens.mjs';
100
+ import { closest } from './suggest.mjs';
101
+ import { readKit, KIT_MANIFEST, KIT_NAME_RE } from './kit.mjs';
102
+
103
+ // ---------------------------------------------------------------------------
104
+ // Snapshot of the default values (generic theme from tokens.mjs)
105
+ // ---------------------------------------------------------------------------
106
+
107
+ const clone = (v) => JSON.parse(JSON.stringify(v));
108
+
109
+ /** Groups merged BEFORE deriveTokens() (base values). */
110
+ const BASE_GROUPS = {
111
+ colors: COLORS,
112
+ fonts: FONTS,
113
+ type: TYPE,
114
+ space: SPACE,
115
+ rounded: ROUNDED,
116
+ chrome: CHROME,
117
+ };
118
+ /** Groups merged AFTER deriveTokens() (overrides of the derived ones) — PAGE
119
+ * is one of them: deriveTokens() recomputes margin/gutter/footerHeight from
120
+ * SPACE, so an explicit theme override must be merged AFTER in order to take
121
+ * precedence over the recomputation (otherwise it would be silently
122
+ * overwritten). */
123
+ const DERIVED_GROUPS = { page: PAGE, trendInk: TREND_INK, semantic: SEMANTIC };
124
+ const ALL_LIVE = {
125
+ ...BASE_GROUPS,
126
+ ...DERIVED_GROUPS,
127
+ chartColors: CHART_COLORS,
128
+ layerShades: LAYER_SHADES,
129
+ logos: LOGOS,
130
+ fontFiles: FONT_FILES,
131
+ };
132
+
133
+ const BASE = clone(ALL_LIVE); // taken at load time, after the initial deriveTokens()
134
+
135
+ /** Top-level keys accepted in a theme file. */
136
+ export const THEME_KEYS = [
137
+ 'name',
138
+ 'colors',
139
+ 'fonts',
140
+ 'type',
141
+ 'space',
142
+ 'page',
143
+ 'rounded',
144
+ 'chrome',
145
+ 'chartColors',
146
+ 'layerShades',
147
+ 'trendInk',
148
+ 'semantic',
149
+ 'logos',
150
+ ];
151
+
152
+ // ---------------------------------------------------------------------------
153
+ // Safe in-place merge
154
+ // ---------------------------------------------------------------------------
155
+
156
+ const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
157
+ const UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
158
+
159
+ /** Deep merge of `src` into `target`, limited to the keys `target` already
160
+ * has (a theme can neither inject inert keys nor pollute the prototype).
161
+ * Arrays replace the whole array. */
162
+ function mergeInto(target, src) {
163
+ if (!isPlainObject(src)) return;
164
+ for (const key of Object.keys(src)) {
165
+ if (UNSAFE_KEYS.has(key) || !Object.hasOwn(target, key)) continue;
166
+ const val = src[key];
167
+ if (isPlainObject(target[key]) && isPlainObject(val)) mergeInto(target[key], val);
168
+ else if (Array.isArray(target[key]) && Array.isArray(val))
169
+ target[key].splice(0, target[key].length, ...clone(val));
170
+ else if (val !== undefined && !isPlainObject(val) && !Array.isArray(val)) target[key] = val;
171
+ }
172
+ }
173
+
174
+ /** Restores a live object to be identical to its snapshot. */
175
+ function restore(live, base) {
176
+ if (Array.isArray(live)) {
177
+ live.splice(0, live.length, ...clone(base));
178
+ return;
179
+ }
180
+ for (const k of Object.keys(live)) if (!Object.hasOwn(base, k)) delete live[k];
181
+ for (const k of Object.keys(base)) {
182
+ if (isPlainObject(base[k]) || Array.isArray(base[k])) {
183
+ if (!Object.hasOwn(live, k) || typeof live[k] !== typeof base[k]) live[k] = clone(base[k]);
184
+ else restore(live[k], base[k]);
185
+ } else live[k] = base[k];
186
+ }
187
+ }
188
+
189
+ /**
190
+ * Applies a theme (an object already validated by resolveTheme), or restores
191
+ * the default theme with `null`. Always called at the head of a compilation —
192
+ * never once per process.
193
+ */
194
+ export function applyTheme(theme = null) {
195
+ for (const [key, live] of Object.entries(ALL_LIVE)) restore(live, BASE[key]);
196
+ if (!theme) return;
197
+
198
+ for (const [key, live] of Object.entries(BASE_GROUPS)) mergeInto(live, theme[key]);
199
+ deriveTokens();
200
+ for (const [key, live] of Object.entries(DERIVED_GROUPS)) mergeInto(live, theme[key]);
201
+ if (Array.isArray(theme.chartColors) && theme.chartColors.length)
202
+ CHART_COLORS.splice(0, CHART_COLORS.length, ...theme.chartColors);
203
+ if (Array.isArray(theme.layerShades) && theme.layerShades.length)
204
+ LAYER_SHADES.splice(0, LAYER_SHADES.length, ...clone(theme.layerShades));
205
+
206
+ // paths already resolved (relative to the theme file) by resolveTheme;
207
+ // the *Svg slots (HTML output) fall back on the bitmap, a dedicated .svg wins
208
+ if (theme.logos?.cover) {
209
+ LOGOS.cover = theme.logos.cover;
210
+ LOGOS.coverSvg = theme.logos.cover;
211
+ }
212
+ if (theme.logos?.section) {
213
+ LOGOS.section = theme.logos.section;
214
+ LOGOS.sectionSvg = theme.logos.section;
215
+ }
216
+ if (theme.logos?.coverSvg) LOGOS.coverSvg = theme.logos.coverSvg;
217
+ if (theme.logos?.sectionSvg) LOGOS.sectionSvg = theme.logos.sectionSvg;
218
+ if (theme.fonts?.files) {
219
+ // the theme defines ITS font: the variants it does not provide must not
220
+ // fall back on the default theme's files (mixed weights)
221
+ FONT_FILES.regular = theme.fonts.files.regular ?? null;
222
+ FONT_FILES.bold = theme.fonts.files.bold ?? null;
223
+ FONT_FILES.italic = theme.fonts.files.italic ?? null;
224
+ } else if (theme.fonts?.body && theme.fonts.body !== BASE.fonts.body) {
225
+ // family changed WITHOUT files: do not embed the default's glyphs under
226
+ // the new name (the HTML would render the default font in disguise while
227
+ // PowerPoint renders the real one) — no embedding at all, both outputs
228
+ // fall back together on the installed font
229
+ FONT_FILES.regular = FONT_FILES.bold = FONT_FILES.italic = null;
230
+ }
231
+ }
232
+
233
+ // ---------------------------------------------------------------------------
234
+ // WCAG contrast (shared with test/contrast.test.mjs)
235
+ // ---------------------------------------------------------------------------
236
+
237
+ /** WCAG 2.x relative luminance of a 6-digit hex color (without #). */
238
+ export function luminance(hex) {
239
+ const [r, g, b] = [0, 2, 4].map((i) => {
240
+ const c = Number.parseInt(hex.slice(i, i + 2), 16) / 255;
241
+ return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
242
+ });
243
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
244
+ }
245
+
246
+ export function contrastRatio(a, b) {
247
+ const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
248
+ return (hi + 0.05) / (lo + 0.05);
249
+ }
250
+
251
+ /**
252
+ * Checks, on the LIVE tokens (after applyTheme), the thresholds the brand
253
+ * claims and that contrast.test.mjs guarantees for the default theme —
254
+ * a user theme may violate them without any test breaking: this is where
255
+ * the accessibility promise is kept for it too.
256
+ */
257
+ export function themeContrastDiagnostics() {
258
+ const diags = [];
259
+ const check = (ratio, min, what) => {
260
+ if (ratio < min)
261
+ diags.push({
262
+ severity: 'warning',
263
+ code: 'THEME_CONTRAST',
264
+ message: `Theme: ${what} — contrast ${ratio.toFixed(2)}:1 < ${min}:1 (the brand's WCAG threshold).`,
265
+ });
266
+ };
267
+ // pairs that are everywhere in the rendering (all the deck's text depends on them)
268
+ check(
269
+ contrastRatio(COLORS.neutralPrimary, COLORS.ground),
270
+ 4.5,
271
+ `main text (#${COLORS.neutralPrimary}) on the background`,
272
+ );
273
+ check(
274
+ contrastRatio(COLORS.neutralSecondary, COLORS.ground),
275
+ 4.5,
276
+ `secondary text — footers, captions (#${COLORS.neutralSecondary}) on the background`,
277
+ );
278
+ check(
279
+ contrastRatio(COLORS.ground, COLORS.primary),
280
+ 3,
281
+ `section slide title (#${COLORS.ground} on the primary background #${COLORS.primary}, large bold type)`,
282
+ );
283
+ for (const c of CHART_COLORS)
284
+ check(contrastRatio(c, COLORS.ground), 3, `chart color #${c} on the background`);
285
+ LAYER_SHADES.forEach((s, k) =>
286
+ check(contrastRatio(s.ink, s.fill), 4.5, `ink of layer ${k + 1} (#${s.ink} on #${s.fill})`),
287
+ );
288
+ for (const [kind, sem] of Object.entries(SEMANTIC))
289
+ check(
290
+ contrastRatio(sem.text, sem.fill),
291
+ 4.5,
292
+ `text of the :::${kind} callout (#${sem.text} on #${sem.fill})`,
293
+ );
294
+ for (const [kind, ink] of Object.entries(TREND_INK))
295
+ check(
296
+ contrastRatio(ink, COLORS.ground),
297
+ 4.5,
298
+ `"${kind}" trend ink (#${ink}) on the background`,
299
+ );
300
+ return diags;
301
+ }
302
+
303
+ // ---------------------------------------------------------------------------
304
+ // Resolution + validation of a theme file
305
+ // ---------------------------------------------------------------------------
306
+
307
+ const HEX_RE = /^#?[0-9a-fA-F]{6}$/;
308
+ const normHex = (v) => String(v).replace(/^#/, '').toUpperCase();
309
+
310
+ /** A true regular file — existsSync would accept a directory, which would
311
+ * then make the renderers crash with EISDIR in the middle of the render. */
312
+ const isFile = (p) => {
313
+ try {
314
+ return fs.statSync(p).isFile();
315
+ } catch {
316
+ return false;
317
+ }
318
+ };
319
+
320
+ const isDir = (p) => {
321
+ try {
322
+ return fs.statSync(p).isDirectory();
323
+ } catch {
324
+ return false;
325
+ }
326
+ };
327
+
328
+ /** Is `p` INSIDE `base` (or `base` itself), lexically? */
329
+ const contains = (base, p) => {
330
+ const r = path.relative(base, p);
331
+ return r === '' || (!r.startsWith('..') && !path.isAbsolute(r));
332
+ };
333
+
334
+ /**
335
+ * Confinement of an asset path (logo, font) within one of the allowed
336
+ * roots — see the module header for the choice of those roots.
337
+ *
338
+ * The symbolic link is checked AFTER resolution: without that, a kit would
339
+ * embed a `logo.png → /etc/passwd` link whose declared path is nonetheless
340
+ * beyond reproach. BOTH sides are dereferenced, or neither — comparing a
341
+ * realpath against a root that is not one would make every legitimate file
342
+ * look like an escape as soon as the root crosses a symbolic link (macOS's
343
+ * /var → /private/var, and many a HOME). Same reasoning, and same shape,
344
+ * as `insideKit` (kit.mjs).
345
+ */
346
+ function within(root, abs) {
347
+ const base = path.resolve(root);
348
+ if (!contains(base, abs)) return false;
349
+ try {
350
+ // the path may not exist: with no link to dereference, the lexical check
351
+ // above is authoritative (the absence will be reported afterwards as
352
+ // "not found", not as an escape)
353
+ if (!contains(fs.realpathSync(base), fs.realpathSync(abs))) return false;
354
+ } catch {
355
+ /* does not exist */
356
+ }
357
+ return true;
358
+ }
359
+
360
+ const withinAny = (abs, roots) => roots.some((root) => within(root, abs));
361
+
362
+ /** The .woff2 twin of a .ttf: same path but for the extension (inlined in the HTML). */
363
+ const twin = (ttf) => ttf.replace(/\.ttf$/i, '.woff2');
364
+
365
+ // ---------------------------------------------------------------------------
366
+ // Theme references: file, npm package, project default
367
+ // ---------------------------------------------------------------------------
368
+
369
+ /** A reference that explicitly designates a PATH (never a kit name). */
370
+ const looksLikePath = (ref) =>
371
+ path.isAbsolute(ref) || /^[.~]/.test(ref) || ref.includes('/') || ref.includes('\\');
372
+
373
+ /**
374
+ * True if `ref` designates an INSTALLED KIT by its name rather than a path:
375
+ * a bare name matching KIT_NAME_RE, with no separator and no path prefix.
376
+ * `theme.json` remains a file (the dot excludes it from KIT_NAME_RE), and
377
+ * `./brand` a path. Used by the CLI to decide whether `--kit` resolves against
378
+ * the current directory (path) or is passed as is to resolveTheme (kit name,
379
+ * resolved from the user config, hence independent of the cwd).
380
+ */
381
+ export function isKitName(ref) {
382
+ return typeof ref === 'string' && !looksLikePath(ref) && KIT_NAME_RE.test(ref);
383
+ }
384
+
385
+ // ---------------------------------------------------------------------------
386
+ // User configuration: default theme and installed themes shared across
387
+ // projects (~/.config/lutrin) — mirror of userCacheRoot() (assets.mjs).
388
+ // ---------------------------------------------------------------------------
389
+
390
+ /** User configuration root: LUTRIN_CONFIG, otherwise XDG_CONFIG_HOME/lutrin,
391
+ * otherwise ~/.config/lutrin. Evaluated on EVERY call (hot hosts and tests
392
+ * change the environment in flight). The directory may not exist: every read
393
+ * then fails cleanly.
394
+ *
395
+ * `MTL_DECK_CONFIG` is still honoured as a fallback — the tool used to be
396
+ * called mtl-deck, and a script or a CI that sets it must not silently start
397
+ * writing elsewhere. It is read only when LUTRIN_CONFIG is absent. */
398
+ export function userConfigRoot() {
399
+ return (
400
+ process.env.LUTRIN_CONFIG ||
401
+ process.env.MTL_DECK_CONFIG ||
402
+ (process.env.XDG_CONFIG_HOME
403
+ ? path.join(process.env.XDG_CONFIG_HOME, 'lutrin')
404
+ : path.join(os.homedir(), '.config', 'lutrin'))
405
+ );
406
+ }
407
+
408
+ /** Former configuration root (the tool used to be called mtl-deck) — a source
409
+ * for the migration, never a destination. */
410
+ function legacyConfigRoot() {
411
+ return process.env.XDG_CONFIG_HOME
412
+ ? path.join(process.env.XDG_CONFIG_HOME, 'mtl-deck')
413
+ : path.join(os.homedir(), '.config', 'mtl-deck');
414
+ }
415
+
416
+ /** Directory of the INSTALLED KITS at user level, resolved by name from any
417
+ * project. */
418
+ export const userKitsDir = () => path.join(userConfigRoot(), 'kits');
419
+
420
+ /**
421
+ * Migrates the old `themes/` configuration to `kits/`, once.
422
+ *
423
+ * Called by the config reads rather than by an entry point: the hosts
424
+ * (extension worker, Obsidian plugin) have no startup to hook it into, and a
425
+ * user moving from one version to the next must find their themes again
426
+ * without a gesture, whichever tool they arrive through.
427
+ *
428
+ * Migrates ONLY if `kits/` does not exist: two directories side by side mean
429
+ * the migration has already happened and that `themes/` is a leftover —
430
+ * overwriting it would destroy the work done since. Never throws: a migration
431
+ * that cannot happen (permissions, full disk) leaves the old directory intact,
432
+ * and the absence of a kit will report itself.
433
+ *
434
+ * @returns {{ migrated: boolean, from: string|null, to: string|null }}
435
+ */
436
+ export function migrateUserConfig() {
437
+ const root = userConfigRoot();
438
+ const moves = [];
439
+
440
+ // 1. root: ~/.config/mtl-deck → ~/.config/lutrin (the tool was renamed).
441
+ // Skipped if the user drives the root through an environment variable:
442
+ // they designated a precise directory, it is not ours to move.
443
+ const pinned = process.env.LUTRIN_CONFIG || process.env.MTL_DECK_CONFIG;
444
+ const legacy = legacyConfigRoot();
445
+ if (!pinned && isDir(legacy) && !isDir(root)) {
446
+ try {
447
+ fs.mkdirSync(path.dirname(root), { recursive: true });
448
+ fs.renameSync(legacy, root);
449
+ moves.push({ from: legacy, to: root });
450
+ } catch {
451
+ /* permissions, disk: the old config stays intact */
452
+ }
453
+ }
454
+
455
+ // 2. themes → kits (change of vocabulary), INSIDE the current root
456
+ const from = path.join(root, 'themes');
457
+ const to = path.join(root, 'kits');
458
+ if (isDir(from) && !isDir(to)) {
459
+ try {
460
+ fs.renameSync(from, to);
461
+ moves.push({ from, to });
462
+ } catch {
463
+ /* same: the old directory stays intact */
464
+ }
465
+ }
466
+
467
+ // historical shape kept (a single move = the direct fields), plus `moves`
468
+ // for the callers that want to announce everything
469
+ const last = moves[moves.length - 1];
470
+ return { migrated: moves.length > 0, from: last?.from ?? null, to: last?.to ?? null, moves };
471
+ }
472
+
473
+ /** User configuration file (default theme, settings to come). */
474
+ const userConfigFile = () => path.join(userConfigRoot(), 'config.json');
475
+
476
+ /**
477
+ * The user's default kit: the `"kit"` field of config.json — shared across
478
+ * all projects, above the host default. NEVER throws: config absent →
479
+ * { ref: null } (the common case); could not be read or field of the wrong
480
+ * type → a diagnostic, and the deck compiles anyway.
481
+ *
482
+ * `"theme"` is still read as a fallback (configs written before kits): a user
483
+ * who updates the tool does not lose their default. `setUserKit` rewrites the
484
+ * key under the new name at the first modification.
485
+ *
486
+ * @returns {{ ref: string|null, error: {code: string, message: string}|null }}
487
+ */
488
+ export function readUserKit() {
489
+ migrateUserConfig();
490
+ const file = userConfigFile();
491
+ let raw;
492
+ try {
493
+ raw = fs.readFileSync(file, 'utf8');
494
+ } catch {
495
+ return { ref: null, error: null }; // no user config: normal
496
+ }
497
+ let conf;
498
+ try {
499
+ conf = JSON.parse(raw);
500
+ } catch (e) {
501
+ return {
502
+ ref: null,
503
+ error: {
504
+ code: 'USER_CONFIG_INVALID',
505
+ message: `User configuration could not be read: ${file} — invalid JSON (${e?.message ?? e}). Ignored.`,
506
+ },
507
+ };
508
+ }
509
+ if (!isPlainObject(conf))
510
+ return {
511
+ ref: null,
512
+ error: {
513
+ code: 'USER_CONFIG_INVALID',
514
+ message: `User configuration: ${file} must be a JSON object ({ "kit": … }). Ignored.`,
515
+ },
516
+ };
517
+ const key = conf.kit != null ? 'kit' : 'theme'; // "theme": historical fallback
518
+ const val = conf[key];
519
+ if (val == null) return { ref: null, error: null }; // valid object, no default
520
+ if (typeof val === 'string' && val.trim()) return { ref: val.trim(), error: null };
521
+ return {
522
+ ref: null,
523
+ error: {
524
+ code: 'USER_CONFIG_INVALID',
525
+ message: `User configuration: the "${key}" field of ${file} must be a non-empty string (name of an installed kit, path, or "none"). Ignored.`,
526
+ },
527
+ };
528
+ }
529
+
530
+ /** A kit installed under `<kits>/<name>/`: a directory carrying kit.json.
531
+ * null otherwise. */
532
+ function userKitDir(name) {
533
+ const dir = path.join(userKitsDir(), name);
534
+ return isFile(path.join(dir, KIT_MANIFEST)) ? dir : null;
535
+ }
536
+
537
+ /**
538
+ * Kits installed in `<kits>/` (for `config` and `kit list`). Sorted by name;
539
+ * never throws.
540
+ *
541
+ * A directory whose manifest is invalid is listed all the same, with its
542
+ * `error`: `kit list` must SHOW a broken kit — omitting it would leave the
543
+ * user facing a directory they can see in their file browser and that the tool
544
+ * claims does not exist. Only directories with no kit.json at all are ignored
545
+ * (leftovers, `.DS_Store`, interrupted extraction).
546
+ *
547
+ * @returns {Array<{ name: string, path: string, manifest: object|null, error: string|null }>}
548
+ */
549
+ export function listInstalledKits() {
550
+ migrateUserConfig();
551
+ const dir = userKitsDir();
552
+ let entries;
553
+ try {
554
+ entries = fs.readdirSync(dir, { withFileTypes: true });
555
+ } catch {
556
+ return [];
557
+ }
558
+ const out = [];
559
+ for (const e of entries.sort((a, b) => a.name.localeCompare(b.name))) {
560
+ if (!e.isDirectory()) continue;
561
+ const p = path.join(dir, e.name);
562
+ if (!isFile(path.join(p, KIT_MANIFEST))) continue;
563
+ const { manifest, diagnostics } = readKit(p);
564
+ const err = diagnostics.find((d) => d.severity === 'error');
565
+ out.push({ name: e.name, path: p, manifest, error: err ? err.message : null });
566
+ }
567
+ return out;
568
+ }
569
+
570
+ /**
571
+ * Writes the user's default kit into config.json (creates the directory if
572
+ * needed, PRESERVES the other existing keys). An empty/null `ref` removes the
573
+ * default. Returns the path of the file written.
574
+ *
575
+ * ALWAYS removes the old `theme` key: leaving it beside `kit` would give a
576
+ * config with two truths of which readUserKit reads only one, and the user
577
+ * would believe they had set a default that does not apply.
578
+ */
579
+ export function setUserKit(ref) {
580
+ const root = userConfigRoot();
581
+ const file = path.join(root, 'config.json');
582
+ let conf = {};
583
+ try {
584
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
585
+ if (isPlainObject(parsed)) conf = parsed;
586
+ } catch {
587
+ // config absent or unreadable: start again from an empty object — we
588
+ // overwrite only what was not usable anyway
589
+ }
590
+ delete conf.theme;
591
+ if (typeof ref === 'string' && ref.trim()) conf.kit = ref.trim();
592
+ else delete conf.kit;
593
+ fs.mkdirSync(root, { recursive: true });
594
+ fs.writeFileSync(file, `${JSON.stringify(conf, null, 2)}\n`);
595
+ return file;
596
+ }
597
+
598
+ /**
599
+ * PROJECT kit default: the first package.json found while walking up from
600
+ * `baseDir` that carries { "lutrin": { "kit": … } }. Lets an organization
601
+ * declare its kit once for all its decks. `theme` is still read as a fallback
602
+ * (projects written before kits).
603
+ */
604
+ function projectKitRef(baseDir) {
605
+ let dir = path.resolve(baseDir);
606
+ for (;;) {
607
+ const pj = path.join(dir, 'package.json');
608
+ if (isFile(pj)) {
609
+ try {
610
+ // "lutrin" is the current field; "mtl-deck" the one from before the
611
+ // rename. Two cascading fallbacks — the project may have been written
612
+ // before kits AND before the rename — without either of them asking
613
+ // anything of someone whose project already works.
614
+ const json = JSON.parse(fs.readFileSync(pj, 'utf8'));
615
+ const conf = json?.lutrin ?? json?.['mtl-deck'];
616
+ const t = conf?.kit ?? conf?.theme;
617
+ if (typeof t === 'string' && t.trim()) return { ref: t.trim(), fromDir: dir, source: pj };
618
+ } catch {
619
+ // package.json could not be read: it declares nothing, keep walking up
620
+ }
621
+ }
622
+ const parent = path.dirname(dir);
623
+ if (parent === dir) return null;
624
+ dir = parent;
625
+ }
626
+ }
627
+
628
+ /**
629
+ * Resolves a reference to its theme FILE and, if there is one, the layouts
630
+ * directory that comes with it.
631
+ *
632
+ * Three forms, in this order:
633
+ * 1. BARE NAME matching KIT_NAME_RE → installed kit (`<configRoot>/kits/<name>`);
634
+ * 2. PATH to a directory carrying `kit.json` → kit not installed
635
+ * (unpacked, shipped by a host);
636
+ * 3. PATH to a `.json` file → bare theme, without layouts.
637
+ *
638
+ * A bare name that ALSO designates a file existing relative to `fromDir`
639
+ * keeps its file meaning: `theme: mytheme.json` has always designated the
640
+ * neighbouring file, and an installed kit of the same name must not divert it
641
+ * silently.
642
+ *
643
+ * `kitDir` is filled in only for a KIT: it is what tells resolveTheme that the
644
+ * theme is third-party content, and gives the confinement root for its logos
645
+ * and its fonts (module header). A file theme has a null `kitDir`.
646
+ *
647
+ * @returns {{ file: string|null, layoutsDir: string|null, kitDir: string|null,
648
+ * kitName: string|null, error: {code, message}|null }}
649
+ */
650
+ function resolveThemeRef(ref, fromDir) {
651
+ const none = { file: null, layoutsDir: null, kitDir: null, kitName: null, error: null };
652
+ const asFile = path.resolve(fromDir, ref);
653
+
654
+ // --- bare name: installed kit --------------------------------------------
655
+ if (isKitName(ref) && !isFile(asFile)) {
656
+ const dir = userKitDir(ref);
657
+ if (!dir)
658
+ return {
659
+ ...none,
660
+ error: {
661
+ code: 'KIT_NOT_FOUND',
662
+ message: `Kit not found: "${ref}" — no kit by that name in ${userKitsDir()}, and no ${asFile} file. Install it (kit install <file.deckkit | URL>), or fix the reference.`,
663
+ },
664
+ };
665
+ return fromKitDir(dir, ref, none);
666
+ }
667
+
668
+ // --- path to a kit directory ---------------------------------------------
669
+ if (isDir(asFile)) {
670
+ if (!isFile(path.join(asFile, KIT_MANIFEST)))
671
+ return {
672
+ ...none,
673
+ error: {
674
+ code: 'KIT_INVALID',
675
+ message: `Kit: ${asFile} is a directory without ${KIT_MANIFEST}.`,
676
+ },
677
+ };
678
+ return fromKitDir(asFile, ref, none);
679
+ }
680
+
681
+ // --- path to a bare theme file (historical behaviour) --------------------
682
+ return { ...none, file: asFile };
683
+ }
684
+
685
+ /** The part common to both ways of reaching a kit (by name, by path):
686
+ * read its manifest and extract from it what resolveTheme needs. */
687
+ function fromKitDir(dir, ref, none) {
688
+ const { manifest, themeFile, layoutsDir, diagnostics } = readKit(dir);
689
+ if (!manifest) {
690
+ const err = diagnostics.find((d) => d.severity === 'error');
691
+ return {
692
+ ...none,
693
+ error: {
694
+ code: err?.code ?? 'KIT_INVALID',
695
+ message: `Kit "${ref}": ${err?.message ?? 'manifest could not be read'}`,
696
+ },
697
+ };
698
+ }
699
+ // a LAYOUTS-ONLY kit is legitimate (readKit allows it): no theme file, but
700
+ // a layoutsDir to load — the default theme stays in place
701
+ return {
702
+ file: themeFile,
703
+ layoutsDir,
704
+ kitDir: path.resolve(dir),
705
+ kitName: manifest.name,
706
+ error: null,
707
+ };
708
+ }
709
+
710
+ /** The PAGE keys that are the physical frame of the slide. */
711
+ const PAGE_LOCKED = new Set(['width', 'height']);
712
+ const LOGO_EXTS = new Set(['.png', '.jpg', '.jpeg']);
713
+ const FONT_VARIANTS = ['regular', 'bold', 'italic'];
714
+
715
+ /**
716
+ * Loads and validates the theme designated by `themePath` (CLI flag, which
717
+ * takes precedence), `meta.kit` (frontmatter, resolved relative to baseDir),
718
+ * the project default (nearest package.json carrying "lutrin": { "kit": … }),
719
+ * the user default, or `defaultTheme` (host default, last resort before the
720
+ * generic one). Each reference is the name of an installed kit, a kit
721
+ * directory or a JSON file; `kit: none` forces the default theme (ignoring
722
+ * project, user and host).
723
+ *
724
+ * @returns {{ theme: object|null, path: string|null,
725
+ * layoutsDir: string|null, kitName: string|null,
726
+ * diagnostics: Array<{severity, code, message, suggestion?}> }}
727
+ * `theme` is a CLEANED object (invalid entries dropped, colors
728
+ * normalized, paths resolved) ready for applyTheme;
729
+ * `layoutsDir`/`kitName` describe the resolved kit (layouts to be
730
+ * loaded by prepareDeckContext); the diagnostics carry no line —
731
+ * the caller positions them (the frontmatter's `kit:` line on the
732
+ * validation side).
733
+ */
734
+ export function resolveTheme(
735
+ meta = {},
736
+ { baseDir = process.cwd(), themePath = null, defaultTheme = null } = {},
737
+ ) {
738
+ const diags = [];
739
+ const push = (severity, code, message, suggestion) =>
740
+ diags.push({ severity, code, message, ...(suggestion ? { suggestion } : {}) });
741
+ const bare = { theme: null, path: null, layoutsDir: null, kitName: null };
742
+
743
+ // frontmatter: `kit:` is the current key, `theme:` a deprecated alias —
744
+ // decks written before kits must keep compiling, but their author must know
745
+ // that the key is going to disappear
746
+ let frontmatter = null;
747
+ if (typeof meta?.kit === 'string' && meta.kit.trim()) {
748
+ frontmatter = meta.kit.trim();
749
+ } else if (typeof meta?.theme === 'string' && meta.theme.trim()) {
750
+ frontmatter = meta.theme.trim();
751
+ push(
752
+ 'warning',
753
+ 'KIT_DEPRECATED_KEY',
754
+ 'Frontmatter: "theme:" is deprecated — rename it to "kit:" (same value). The key will be removed in a later version.',
755
+ );
756
+ }
757
+
758
+ let ref = themePath ?? frontmatter;
759
+ let fromDir = baseDir;
760
+ // a kit designated by the USER DEFAULT (global config.json) that does not
761
+ // resolve must never make `validate` fail for a project that asked for
762
+ // nothing: its resolution errors are downgraded to warnings (like
763
+ // USER_CONFIG_INVALID). A kit chosen by the deck (frontmatter/CLI) or by the
764
+ // project stays an error: the author opted for THIS kit, at THIS scope.
765
+ let fromUserDefault = false;
766
+ if (!ref) {
767
+ const proj = projectKitRef(baseDir);
768
+ if (proj) {
769
+ ref = proj.ref;
770
+ fromDir = proj.fromDir;
771
+ } else {
772
+ // USER default (config.json, shared across projects): below the project
773
+ // default (more specific), ABOVE the host default — a kit chosen here
774
+ // applies everywhere, plugins included
775
+ const user = readUserKit();
776
+ if (user.error) push('warning', user.error.code, user.error.message);
777
+ if (user.ref) {
778
+ ref = user.ref;
779
+ fromDir = userConfigRoot(); // paths resolved from the config directory
780
+ fromUserDefault = true;
781
+ } else if (typeof defaultTheme === 'string' && defaultTheme.trim()) {
782
+ ref = defaultTheme.trim(); // host default
783
+ } else {
784
+ return { ...bare, diagnostics: diags };
785
+ }
786
+ }
787
+ }
788
+ if (ref === 'none') return { ...bare, diagnostics: diags }; // explicit opt-out
789
+ const sev = fromUserDefault ? 'warning' : 'error';
790
+
791
+ // The generic fallback is PROMISED only where it actually happens: on the
792
+ // user default downgraded to a warning, compilation carries on under the
793
+ // default theme. On the explicit path (error), build stops and prints that
794
+ // nothing was produced — so the diagnostic must limit itself there to the
795
+ // finding and the remedy, on pain of contradicting itself on the next line
796
+ // and sending the author looking for a file that does not exist. The rule
797
+ // holds for ALL resolution failures, not only for the kit that was not
798
+ // found: malformed JSON is the most frequent case, and it was still using
799
+ // the lying formula.
800
+ const withFallback = (msg) =>
801
+ sev === 'warning' ? `${msg} The default theme will be used.` : msg;
802
+
803
+ const { file, layoutsDir, kitDir, kitName, error } = resolveThemeRef(ref, fromDir);
804
+ if (error) {
805
+ push(sev, error.code, withFallback(error.message));
806
+ return { ...bare, diagnostics: diags };
807
+ }
808
+ const found = { layoutsDir, kitName };
809
+
810
+ // LAYOUTS-ONLY kit: nothing to validate on the token side, but layoutsDir
811
+ // must come back up — the deck keeps the default theme AND receives the layouts
812
+ if (!file) return { ...bare, ...found, diagnostics: diags };
813
+
814
+ let raw;
815
+ try {
816
+ raw = fs.readFileSync(file, 'utf8');
817
+ } catch {
818
+ push(sev, 'THEME_NOT_FOUND', withFallback(`Theme not found: ${ref} (resolved to ${file}).`));
819
+ return { ...bare, diagnostics: diags };
820
+ }
821
+ let json;
822
+ try {
823
+ json = JSON.parse(raw);
824
+ } catch (e) {
825
+ push(
826
+ sev,
827
+ 'THEME_INVALID',
828
+ withFallback(`Theme could not be read: ${ref} — invalid JSON (${e?.message ?? e}).`),
829
+ );
830
+ return { ...bare, diagnostics: diags };
831
+ }
832
+ if (!isPlainObject(json)) {
833
+ push(
834
+ sev,
835
+ 'THEME_INVALID',
836
+ withFallback(`Theme could not be read: ${ref} — a JSON object is expected.`),
837
+ );
838
+ return { ...bare, diagnostics: diags };
839
+ }
840
+
841
+ const themeDir = path.dirname(file);
842
+ const theme = {};
843
+
844
+ // --- confinement of the asset paths (logos, fonts) -----------------------
845
+ // Allowed roots according to the theme's provenance — see the header. A KIT
846
+ // is locked into its own home; a FILE theme may draw on its directory or on
847
+ // the one its reference was resolved from (the deck's directory for a
848
+ // frontmatter `kit:` or a `--kit`, the project root for a project default,
849
+ // the config directory for a user default).
850
+ const assetRoots = kitDir ? [kitDir] : [themeDir, fromDir];
851
+ /** True if `p` is admitted; otherwise pushes the EXACT diagnostic of its branch. */
852
+ const confined = (p, what, rawPath) => {
853
+ if (withinAny(p, assetRoots)) return true;
854
+ push(
855
+ 'warning',
856
+ 'THEME_PATH_ESCAPE',
857
+ kitDir
858
+ ? `Theme: ${what} (${rawPath}) leaves the "${kitName}" kit (${kitDir}) — a kit may only designate the files it contains; ignored.`
859
+ : `Theme: ${what} (${rawPath}) leaves the project — a file theme may only designate files in its own directory (${themeDir}) or in the deck's (${fromDir}); ignored.`,
860
+ );
861
+ return false;
862
+ };
863
+
864
+ const unknownKey = (key, candidates, where) =>
865
+ push(
866
+ 'warning',
867
+ 'THEME_UNKNOWN_KEY',
868
+ `Theme: unknown key "${key}"${where ? ` in ${where}` : ''} — ignored.`,
869
+ closest(key, candidates) ?? undefined,
870
+ );
871
+
872
+ /** Cleans a theme object against the corresponding snapshot: hex colors
873
+ * normalized, finite numbers, unknown keys dropped. */
874
+ function sanitize(src, base, where, { colorsOnly = false } = {}) {
875
+ const out = {};
876
+ for (const key of Object.keys(src)) {
877
+ if (UNSAFE_KEYS.has(key)) continue;
878
+ if (!Object.hasOwn(base, key)) {
879
+ unknownKey(key, Object.keys(base), where);
880
+ continue;
881
+ }
882
+ const val = src[key];
883
+ const ref2 = base[key];
884
+ if (isPlainObject(ref2)) {
885
+ if (isPlainObject(val)) {
886
+ const sub = sanitize(val, ref2, `${where}.${key}`, { colorsOnly });
887
+ if (Object.keys(sub).length) out[key] = sub;
888
+ } else
889
+ push('warning', 'THEME_BAD_VALUE', `Theme: ${where}.${key} must be an object — ignored.`);
890
+ continue;
891
+ }
892
+ if (typeof ref2 === 'number') {
893
+ if (typeof val === 'number' && Number.isFinite(val) && val >= 0) out[key] = val;
894
+ else
895
+ push(
896
+ 'warning',
897
+ 'THEME_BAD_VALUE',
898
+ `Theme: ${where}.${key} must be a positive number — ignored.`,
899
+ );
900
+ continue;
901
+ }
902
+ // strings: a hex color where the default is a color, text otherwise
903
+ if (colorsOnly || HEX_RE.test(ref2)) {
904
+ if (typeof val === 'string' && HEX_RE.test(val)) out[key] = normHex(val);
905
+ else
906
+ push(
907
+ 'warning',
908
+ 'THEME_BAD_VALUE',
909
+ `Theme: ${where}.${key} must be a 6-digit hex color ("#0B735F") — ignored.`,
910
+ );
911
+ } else if (typeof val === 'string' && val.trim()) out[key] = val.trim();
912
+ else
913
+ push(
914
+ 'warning',
915
+ 'THEME_BAD_VALUE',
916
+ `Theme: ${where}.${key} must be a non-empty string — ignored.`,
917
+ );
918
+ }
919
+ return out;
920
+ }
921
+
922
+ for (const key of Object.keys(json)) {
923
+ if (UNSAFE_KEYS.has(key)) continue;
924
+ if (!THEME_KEYS.includes(key)) {
925
+ unknownKey(key, THEME_KEYS);
926
+ }
927
+ }
928
+
929
+ // object groups → sanitize; a group that is NOT an object is reported
930
+ // (same treatment as fonts/logos — never a silent no-op)
931
+ const OBJECT_GROUPS = [
932
+ ['colors', { colorsOnly: true }],
933
+ ['type', {}],
934
+ ['space', {}],
935
+ ['rounded', {}],
936
+ ['chrome', {}],
937
+ ['trendInk', { colorsOnly: true }],
938
+ ['semantic', {}],
939
+ ];
940
+ for (const [key, opts] of OBJECT_GROUPS) {
941
+ if (json[key] == null) continue;
942
+ if (!isPlainObject(json[key])) {
943
+ push('warning', 'THEME_BAD_VALUE', `Theme: ${key} must be an object — ignored.`);
944
+ continue;
945
+ }
946
+ theme[key] = sanitize(json[key], BASE[key], key, opts);
947
+ }
948
+
949
+ if (json.page != null && isPlainObject(json.page)) {
950
+ const page = { ...json.page };
951
+ for (const locked of PAGE_LOCKED) {
952
+ if (Object.hasOwn(page, locked)) {
953
+ push(
954
+ 'warning',
955
+ 'THEME_BAD_VALUE',
956
+ `Theme: page.${locked} is the physical frame of the slide (16:9 at 96 dpi) — not themable, ignored.`,
957
+ );
958
+ delete page[locked];
959
+ }
960
+ }
961
+ theme.page = sanitize(page, BASE.page, 'page');
962
+ } else if (json.page != null) {
963
+ push('warning', 'THEME_BAD_VALUE', 'Theme: page must be an object — ignored.');
964
+ }
965
+
966
+ if (json.chartColors != null) {
967
+ if (
968
+ Array.isArray(json.chartColors) &&
969
+ json.chartColors.length &&
970
+ json.chartColors.every((c) => typeof c === 'string' && HEX_RE.test(c))
971
+ )
972
+ theme.chartColors = json.chartColors.map(normHex);
973
+ else
974
+ push(
975
+ 'warning',
976
+ 'THEME_BAD_VALUE',
977
+ 'Theme: chartColors must be a non-empty array of hex colors — ignored.',
978
+ );
979
+ }
980
+
981
+ if (json.layerShades != null) {
982
+ if (
983
+ Array.isArray(json.layerShades) &&
984
+ json.layerShades.length &&
985
+ json.layerShades.every(
986
+ (s) => isPlainObject(s) && HEX_RE.test(s.fill ?? '') && HEX_RE.test(s.ink ?? ''),
987
+ )
988
+ )
989
+ theme.layerShades = json.layerShades.map((s) => ({
990
+ fill: normHex(s.fill),
991
+ ink: normHex(s.ink),
992
+ }));
993
+ else
994
+ push(
995
+ 'warning',
996
+ 'THEME_BAD_VALUE',
997
+ 'Theme: layerShades must be a non-empty array of { fill, ink } hex colors — ignored.',
998
+ );
999
+ }
1000
+
1001
+ if (json.fonts != null && isPlainObject(json.fonts)) {
1002
+ const { files, ...families } = json.fonts;
1003
+ theme.fonts = sanitize(families, BASE.fonts, 'fonts');
1004
+ // the families are interpolated as they are into the CSS of the generated
1005
+ // HTML document: letters/digits/spaces/.,'- only — a font name never needs
1006
+ // anything else, and nothing can be injected into it
1007
+ for (const k of Object.keys(theme.fonts)) {
1008
+ if (!/^[\p{L}\p{N} .,'-]{1,64}$/u.test(theme.fonts[k])) {
1009
+ push(
1010
+ 'warning',
1011
+ 'THEME_BAD_VALUE',
1012
+ `Theme: fonts.${k} contains characters not allowed in a font name — ignored.`,
1013
+ );
1014
+ delete theme.fonts[k];
1015
+ }
1016
+ }
1017
+ if (files != null) {
1018
+ if (!isPlainObject(files)) {
1019
+ push(
1020
+ 'warning',
1021
+ 'THEME_BAD_VALUE',
1022
+ 'Theme: fonts.files must be an object { regular, bold, italic } — ignored.',
1023
+ );
1024
+ } else {
1025
+ const resolved = {};
1026
+ for (const variant of Object.keys(files)) {
1027
+ if (!FONT_VARIANTS.includes(variant)) {
1028
+ unknownKey(variant, FONT_VARIANTS, 'fonts.files');
1029
+ continue;
1030
+ }
1031
+ const rawPath = String(files[variant]);
1032
+ const p = path.resolve(themeDir, rawPath);
1033
+ if (path.extname(p).toLowerCase() !== '.ttf')
1034
+ push(
1035
+ 'warning',
1036
+ 'THEME_BAD_VALUE',
1037
+ `Theme: fonts.files.${variant} must be a .ttf (embedded in the .pptx; its .woff2 twin of the same name is inlined in the HTML) — ignored.`,
1038
+ );
1039
+ // confinement BEFORE any read: TWO files leave in the deliverables
1040
+ // (the .ttf in the .pptx, its .woff2 twin in the HTML). The twin
1041
+ // carries the same path but for the extension, yet it is a distinct
1042
+ // file: it may be a link that leaves where the .ttf stays
1043
+ // well-behaved. Each is therefore judged on its own.
1044
+ else if (!confined(p, `fonts.files.${variant}`, rawPath)) {
1045
+ /* reported */
1046
+ } else if (
1047
+ !confined(twin(p), `the .woff2 twin of fonts.files.${variant}`, path.basename(twin(p)))
1048
+ ) {
1049
+ /* reported */
1050
+ } else if (!isFile(p))
1051
+ push(
1052
+ 'warning',
1053
+ 'THEME_BAD_VALUE',
1054
+ `Theme: font fonts.files.${variant} not found (${files[variant]}) — ignored.`,
1055
+ );
1056
+ else if (!isFile(twin(p)))
1057
+ push(
1058
+ 'warning',
1059
+ 'THEME_BAD_VALUE',
1060
+ `Theme: the .woff2 twin of fonts.files.${variant} is missing (${path.basename(p).replace(/\.ttf$/i, '.woff2')} expected next to the .ttf) — variant ignored, to keep the HTML and the .pptx identical.`,
1061
+ );
1062
+ else resolved[variant] = p;
1063
+ }
1064
+ if (Object.keys(resolved).length) theme.fonts.files = resolved;
1065
+ }
1066
+ }
1067
+ // files WITHOUT a family name: the glyphs would be embedded/inlined under
1068
+ // the DEFAULT family — the HTML would render the theme's font disguised as
1069
+ // that family while PowerPoint rendered the real installed font (silent
1070
+ // HTML/.pptx divergence). fonts.files requires fonts.body.
1071
+ if (theme.fonts.files && !theme.fonts.body) {
1072
+ push(
1073
+ 'warning',
1074
+ 'THEME_BAD_VALUE',
1075
+ 'Theme: fonts.files supplies fonts without fonts.body (the family name) — without it, the glyphs would be embedded under the default font and the HTML would diverge from the .pptx; fonts.files ignored.',
1076
+ );
1077
+ delete theme.fonts.files;
1078
+ }
1079
+ } else if (json.fonts != null) {
1080
+ push(
1081
+ 'warning',
1082
+ 'THEME_BAD_VALUE',
1083
+ 'Theme: fonts must be an object { body, mono, files } — ignored.',
1084
+ );
1085
+ }
1086
+
1087
+ if (json.logos != null && isPlainObject(json.logos)) {
1088
+ theme.logos = {};
1089
+ // cover/section: bitmap required (embedded as it is in the .pptx);
1090
+ // coverSvg/sectionSvg: variant for the HTML output, .svg allowed
1091
+ const LOGO_SLOTS = ['cover', 'section', 'coverSvg', 'sectionSvg'];
1092
+ for (const slot of Object.keys(json.logos)) {
1093
+ if (!LOGO_SLOTS.includes(slot)) {
1094
+ unknownKey(slot, LOGO_SLOTS, 'logos');
1095
+ continue;
1096
+ }
1097
+ const svgSlot = slot.endsWith('Svg');
1098
+ const rawPath = String(json.logos[slot]);
1099
+ const p = path.resolve(themeDir, rawPath);
1100
+ const ext = path.extname(p).toLowerCase();
1101
+ if (!(LOGO_EXTS.has(ext) || (svgSlot && ext === '.svg')))
1102
+ push(
1103
+ 'warning',
1104
+ 'THEME_BAD_VALUE',
1105
+ svgSlot
1106
+ ? `Theme: logos.${slot} must be an SVG, a PNG or a JPEG (inlined in the HTML) — ignored.`
1107
+ : `Theme: logos.${slot} must be a PNG or a JPEG (embedded as it is in the .pptx; SVG goes in logos.${slot}Svg) — ignored.`,
1108
+ );
1109
+ // confinement BEFORE any read: this file leaves in the deliverable
1110
+ else if (!confined(p, `logos.${slot}`, rawPath)) {
1111
+ /* reported */
1112
+ } else if (!isFile(p))
1113
+ push(
1114
+ 'warning',
1115
+ 'THEME_BAD_VALUE',
1116
+ `Theme: logo logos.${slot} not found (${json.logos[slot]}) — the default signature will be kept.`,
1117
+ );
1118
+ else theme.logos[slot] = p;
1119
+ }
1120
+ if (!Object.keys(theme.logos).length) delete theme.logos;
1121
+ } else if (json.logos != null) {
1122
+ push(
1123
+ 'warning',
1124
+ 'THEME_BAD_VALUE',
1125
+ 'Theme: logos must be an object { cover, section, coverSvg, sectionSvg } — ignored.',
1126
+ );
1127
+ }
1128
+
1129
+ // groups emptied by the cleaning: remove them (clean theme, exact no-op)
1130
+ for (const k of Object.keys(theme)) {
1131
+ if (isPlainObject(theme[k]) && !Object.keys(theme[k]).length) delete theme[k];
1132
+ }
1133
+
1134
+ return { theme, path: file, ...found, diagnostics: diags };
1135
+ }