@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
|
@@ -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
|
+
}
|