@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,424 @@
1
+ /**
2
+ * Chart rendering: `chart` block → SVG (then a PNG embedded in the .pptx).
3
+ *
4
+ * Why not PptxGenJS's native OOXML charts? Keynote and QuickLook (macOS)
5
+ * do not display them at all — blank slide, verified empirically. An image
6
+ * is faithful everywhere; the trade-off (not editable in PowerPoint) is
7
+ * documented in the SKILL.
8
+ *
9
+ * Dataviz rules applied (see the CHART_COLORS palette in tokens.mjs,
10
+ * validated: lightness band, chroma, color-blindness ΔE, contrast):
11
+ * - thin marks: bars with a 4 px rounded top anchored to the baseline —
12
+ * the ZERO one, not the bottom of the frame: a loss goes below the
13
+ * axis (or runs leftwards in barh) instead of disappearing;
14
+ * 2 px lines, points ≥ 8 px ringed in white;
15
+ * - 2 px of white breathing room between adjacent fills;
16
+ * - grids and axes understated (neutral), never a frame;
17
+ * - a legend from two identities on, never a label on every point;
18
+ * - text carries the text inks, never the series color.
19
+ */
20
+
21
+ import { COLORS, CHART_COLORS, FONTS } from './tokens.mjs';
22
+
23
+ /**
24
+ * Number formatting locale (ticks, legend values).
25
+ * The engine is written for Quebec: "1 500", not "1,500". This is not a
26
+ * brand constant — an English deck must be able to change it without
27
+ * touching the code: chartSvg(block, W, H, { locale }) is the intended
28
+ * extension point, and this value is only its default.
29
+ */
30
+ const DEFAULT_LOCALE = 'fr-CA';
31
+
32
+ // font and inks read at call time (never copied at load time): a theme
33
+ // applied by applyTheme() must be reflected in the charts of the same
34
+ // process
35
+ const FONT = () => `${FONTS.body}, Helvetica, Arial, sans-serif`;
36
+ const ink = () => `#${COLORS.neutralSecondary}`;
37
+ const grid = () => `#${COLORS.underground2}`;
38
+ const axis = () => `#${COLORS.neutralStroke}`;
39
+ const bg = () => `#${COLORS.ground}`;
40
+
41
+ const esc = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
42
+ const fmt = (v, locale = DEFAULT_LOCALE) => v.toLocaleString(locale, { maximumFractionDigits: 2 });
43
+ const textW = (s, size) => String(s).length * size * 0.58;
44
+
45
+ /**
46
+ * The values of a series that can actually be plotted: a value without a
47
+ * category has nowhere to sit. The truncation must happen BEFORE the scale is
48
+ * computed — otherwise a surplus that is never drawn sets the bounds and
49
+ * crushes the visible plot (bars outside the frame, radar vertices collapsed
50
+ * onto the center). The surplus is reported by chartDataDiagnostics(), not
51
+ * lost in silence.
52
+ */
53
+ const shownValues = (series, cats) => (series.values ?? []).slice(0, cats.length);
54
+
55
+ /** A "round" scale: pleasant bounds and step for ~n ticks. */
56
+ function niceScale(min, max, n = 5) {
57
+ // zero always belongs to the domain: it is the baseline the bars anchor
58
+ // to — an entirely negative series would otherwise have its axis outside
59
+ // the frame
60
+ if (min > 0) min = 0;
61
+ if (max < 0) max = 0;
62
+ if (max <= min) max = min + 1;
63
+ const span = max - min;
64
+ const step0 = span / n;
65
+ const mag = 10 ** Math.floor(Math.log10(step0));
66
+ const step = [1, 2, 2.5, 5, 10].map((m) => m * mag).find((s) => s >= step0);
67
+ const lo = Math.floor(min / step) * step;
68
+ const hi = Math.ceil(max / step) * step;
69
+ const ticks = [];
70
+ for (let v = lo; v <= hi + step / 2; v += step) ticks.push(Math.round(v * 1e6) / 1e6);
71
+ return { lo, hi, ticks };
72
+ }
73
+
74
+ const color = (k) => `#${CHART_COLORS[k % CHART_COLORS.length]}`;
75
+
76
+ /**
77
+ * A bar with a rounded free end (4 px), anchored to the zero baseline.
78
+ * `x, y, w, h` always describe the rectangle in increasing coordinates;
79
+ * `negative` says which side the anchor is on, hence which corner is rounded:
80
+ * top of a gain, bottom of a loss, right or left in barh.
81
+ *
82
+ * The radius is bounded by the HALF-THICKNESS of the bar — its width when
83
+ * vertical, but its HEIGHT when horizontal — and by its length: a larger
84
+ * rounding would fold the path back onto itself. Confusing the two produced
85
+ * thin horizontal bars with absurdly round ends.
86
+ */
87
+ function roundedBar(x, y, w, h, r = 4, horizontal = false, fill = '#000', negative = false) {
88
+ if (h <= 0 || w <= 0) return '';
89
+ const rr = horizontal ? Math.min(r, h / 2, w) : Math.min(r, w / 2, h);
90
+ let d;
91
+ if (horizontal && !negative) {
92
+ // anchored on the left (zero), rounded on the right
93
+ d = `M${x},${y} h${w - rr} a${rr},${rr} 0 0 1 ${rr},${rr} v${h - 2 * rr} a${rr},${rr} 0 0 1 -${rr},${rr} h-${w - rr} z`;
94
+ } else if (horizontal) {
95
+ // anchored on the right (zero), rounded on the left
96
+ d = `M${x + w},${y} h-${w - rr} a${rr},${rr} 0 0 0 -${rr},${rr} v${h - 2 * rr} a${rr},${rr} 0 0 0 ${rr},${rr} h${w - rr} z`;
97
+ } else if (!negative) {
98
+ // anchored at the bottom (zero), rounded at the top
99
+ d = `M${x},${y + h} v-${h - rr} a${rr},${rr} 0 0 1 ${rr},-${rr} h${w - 2 * rr} a${rr},${rr} 0 0 1 ${rr},${rr} v${h - rr} z`;
100
+ } else {
101
+ // anchored at the top (zero), rounded at the bottom
102
+ d = `M${x},${y} v${h - rr} a${rr},${rr} 0 0 0 ${rr},${rr} h${w - 2 * rr} a${rr},${rr} 0 0 0 ${rr},-${rr} v-${h - rr} z`;
103
+ }
104
+ return `<path d="${d}" fill="${fill}"/>`;
105
+ }
106
+
107
+ function legendRow(series, x, y, w) {
108
+ const items = series.map((s, k) => ({
109
+ label: s.name,
110
+ c: color(k),
111
+ w: 14 + textW(s.name, 11) + 18,
112
+ }));
113
+ const total = items.reduce((a, b) => a + b.w, 0);
114
+ let cx = x + Math.max(0, (w - total) / 2);
115
+ const out = [];
116
+ for (const it of items) {
117
+ out.push(`<rect x="${cx}" y="${y - 9}" width="10" height="10" rx="2" fill="${it.c}"/>`);
118
+ out.push(
119
+ `<text x="${cx + 14}" y="${y}" font-family="${FONT()}" font-size="11" fill="${ink()}">${esc(it.label)}</text>`,
120
+ );
121
+ cx += it.w;
122
+ }
123
+ return out.join('');
124
+ }
125
+
126
+ // ---------------------------------------------------------------------------
127
+ // Cartesian: bar, barh, line, area
128
+ // ---------------------------------------------------------------------------
129
+
130
+ function cartesian(block, W, H, locale) {
131
+ const { categories: cats, series } = block;
132
+ const legend = series.length > 1;
133
+ // values truncated first: the scale must only know what will actually be
134
+ // plotted (see shownValues)
135
+ const shown = series.map((s) => shownValues(s, cats));
136
+ const allVals = shown.flat();
137
+ const { lo, hi, ticks } = niceScale(Math.min(0, ...allVals), Math.max(0, ...allVals));
138
+ const horizontal = block.chartType === 'barh';
139
+
140
+ const tickLabels = ticks.map((t) => fmt(t, locale));
141
+ const valLabelW = Math.max(...tickLabels.map((t) => textW(t, 11)));
142
+ const catLabelW = Math.max(...cats.map((c) => textW(c, 11)));
143
+ const pad = {
144
+ left: 8 + (horizontal ? catLabelW : valLabelW),
145
+ right: 12,
146
+ top: 10,
147
+ bottom: 24 + (legend ? 26 : 0),
148
+ };
149
+ const plot = {
150
+ x: pad.left + 6,
151
+ y: pad.top,
152
+ w: W - pad.left - pad.right - 6,
153
+ h: H - pad.top - pad.bottom,
154
+ };
155
+ const p = [];
156
+
157
+ const vpos = (v) =>
158
+ horizontal
159
+ ? plot.x + ((v - lo) / (hi - lo)) * plot.w
160
+ : plot.y + plot.h - ((v - lo) / (hi - lo)) * plot.h;
161
+
162
+ // value grid + ticks
163
+ for (let k = 0; k < ticks.length; k++) {
164
+ const t = ticks[k];
165
+ const label = `<text font-family="${FONT()}" font-size="11" fill="${ink()}"`;
166
+ if (horizontal) {
167
+ const x = vpos(t);
168
+ if (t !== lo)
169
+ p.push(
170
+ `<line x1="${x}" y1="${plot.y}" x2="${x}" y2="${plot.y + plot.h}" stroke="${grid()}" stroke-width="1"/>`,
171
+ );
172
+ p.push(
173
+ `${label} x="${x}" y="${plot.y + plot.h + 16}" text-anchor="middle">${esc(tickLabels[k])}</text>`,
174
+ );
175
+ } else {
176
+ const y = vpos(t);
177
+ if (t !== lo)
178
+ p.push(
179
+ `<line x1="${plot.x}" y1="${y}" x2="${plot.x + plot.w}" y2="${y}" stroke="${grid()}" stroke-width="1"/>`,
180
+ );
181
+ p.push(
182
+ `${label} x="${plot.x - 8}" y="${y + 4}" text-anchor="end">${esc(tickLabels[k])}</text>`,
183
+ );
184
+ }
185
+ }
186
+ // baseline (category axis)
187
+ p.push(
188
+ horizontal
189
+ ? `<line x1="${vpos(0)}" y1="${plot.y}" x2="${vpos(0)}" y2="${plot.y + plot.h}" stroke="${axis()}" stroke-width="1"/>`
190
+ : `<line x1="${plot.x}" y1="${vpos(0)}" x2="${plot.x + plot.w}" y2="${vpos(0)}" stroke="${axis()}" stroke-width="1"/>`,
191
+ );
192
+
193
+ const slot = (horizontal ? plot.h : plot.w) / cats.length;
194
+ const center = (i) => (horizontal ? plot.y : plot.x) + slot * (i + 0.5);
195
+
196
+ // category labels
197
+ cats.forEach((c, i) => {
198
+ p.push(
199
+ horizontal
200
+ ? `<text font-family="${FONT()}" font-size="11" fill="${ink()}" x="${plot.x - 8}" y="${center(i) + 4}" text-anchor="end">${esc(c)}</text>`
201
+ : `<text font-family="${FONT()}" font-size="11" fill="${ink()}" x="${center(i)}" y="${plot.y + plot.h + 16}" text-anchor="middle">${esc(c)}</text>`,
202
+ );
203
+ });
204
+
205
+ if (block.chartType === 'bar' || block.chartType === 'barh') {
206
+ // 2 px of white between neighbouring bars of the same group
207
+ const group = Math.min(slot * 0.68, series.length * 64);
208
+ const bw = (group - (series.length - 1) * 2) / series.length;
209
+ shown.forEach((values, si) => {
210
+ values.forEach((v, i) => {
211
+ const c = center(i) - group / 2 + si * (bw + 2);
212
+ // the bar runs from zero to the value, one way or the other: we pass
213
+ // the rectangle in increasing coordinates and the sign separately
214
+ const [z, t] = [vpos(0), vpos(v)];
215
+ const span = Math.abs(t - z);
216
+ if (horizontal) {
217
+ p.push(roundedBar(Math.min(z, t), c, span, bw, 4, true, color(si), v < 0));
218
+ } else {
219
+ p.push(roundedBar(c, Math.min(z, t), bw, span, 4, false, color(si), v < 0));
220
+ }
221
+ });
222
+ });
223
+ } else {
224
+ // line / area — 2 px stroke, 8 px points ringed in white
225
+ shown.forEach((values, si) => {
226
+ const pts = values.map((v, i) => [center(i), vpos(v)]);
227
+ if (!pts.length) return; // series without a category: nothing to join
228
+ const dLine = pts.map(([x, y], i) => `${i ? 'L' : 'M'}${x},${y}`).join(' ');
229
+ if (block.chartType === 'area') {
230
+ p.push(
231
+ `<path d="${dLine} L${pts[pts.length - 1][0]},${vpos(0)} L${pts[0][0]},${vpos(0)} z" fill="${color(si)}" fill-opacity="0.16"/>`,
232
+ );
233
+ }
234
+ p.push(
235
+ `<path d="${dLine}" fill="none" stroke="${color(si)}" stroke-width="2" stroke-linejoin="round"/>`,
236
+ );
237
+ pts.forEach(([x, y]) =>
238
+ p.push(
239
+ `<circle cx="${x}" cy="${y}" r="4" fill="${color(si)}" stroke="${bg()}" stroke-width="2"/>`,
240
+ ),
241
+ );
242
+ });
243
+ }
244
+
245
+ if (legend) p.push(legendRow(series, plot.x, H - 10, plot.w));
246
+ return p.join('\n');
247
+ }
248
+
249
+ // ---------------------------------------------------------------------------
250
+ // Circular: pie, doughnut — color follows the share (a single series)
251
+ // ---------------------------------------------------------------------------
252
+
253
+ function circular(block, W, H, locale) {
254
+ const values = shownValues(block.series[0], block.categories);
255
+ const total = values.reduce((a, b) => a + Math.max(0, b), 0) || 1;
256
+ const legendW = Math.min(W * 0.42, Math.max(...block.categories.map((c) => textW(c, 11))) + 90);
257
+ const cx = (W - legendW) / 2;
258
+ const cy = H / 2;
259
+ const R = Math.min(cx - 10, H / 2 - 10);
260
+ const r0 = block.chartType === 'doughnut' ? R * 0.55 : 0;
261
+ const p = [];
262
+
263
+ let a = -Math.PI / 2;
264
+ const pt = (ang, rad) => [cx + rad * Math.cos(ang), cy + rad * Math.sin(ang)];
265
+ values.forEach((v, k) => {
266
+ const frac = Math.max(0, v) / total;
267
+ const a2 = a + frac * 2 * Math.PI;
268
+ const large = frac > 0.5 ? 1 : 0;
269
+ const [x1, y1] = pt(a, R);
270
+ const [x2, y2] = pt(a2, R);
271
+ let d;
272
+ if (r0) {
273
+ const [x3, y3] = pt(a2, r0);
274
+ const [x4, y4] = pt(a, r0);
275
+ d = `M${x1},${y1} A${R},${R} 0 ${large} 1 ${x2},${y2} L${x3},${y3} A${r0},${r0} 0 ${large} 0 ${x4},${y4} z`;
276
+ } else {
277
+ d = `M${cx},${cy} L${x1},${y1} A${R},${R} 0 ${large} 1 ${x2},${y2} z`;
278
+ }
279
+ // 2 px white edging = breathing room between adjacent shares
280
+ p.push(`<path d="${d}" fill="${color(k)}" stroke="${bg()}" stroke-width="2"/>`);
281
+ if (frac >= 0.07) {
282
+ const [lx, ly] = pt((a + a2) / 2, r0 ? (R + r0) / 2 : R * 0.62);
283
+ p.push(
284
+ `<text x="${lx}" y="${ly + 4}" font-family="${FONT()}" font-size="12" font-weight="bold" fill="${bg()}" text-anchor="middle">${Math.round(frac * 100)} %</text>`,
285
+ );
286
+ }
287
+ a = a2;
288
+ });
289
+
290
+ // legend on the right: swatch + category + value — one entry per SHOWN
291
+ // value (never a category without a share, which would only invent a
292
+ // "— 0": validation reports orphan categories, CHART_DATA_IGNORED)
293
+ const lx = cx + R + 24;
294
+ const lh = 22;
295
+ let ly = cy - ((values.length - 1) * lh) / 2;
296
+ values.forEach((v, k) => {
297
+ p.push(`<rect x="${lx}" y="${ly - 9}" width="10" height="10" rx="2" fill="${color(k)}"/>`);
298
+ p.push(
299
+ `<text x="${lx + 16}" y="${ly}" font-family="${FONT()}" font-size="11" fill="${ink()}">${esc(block.categories[k])} — ${esc(fmt(v, locale))}</text>`,
300
+ );
301
+ ly += lh;
302
+ });
303
+ return p.join('\n');
304
+ }
305
+
306
+ // ---------------------------------------------------------------------------
307
+ // Radar
308
+ // ---------------------------------------------------------------------------
309
+
310
+ function radar(block, W, H) {
311
+ const { categories: cats, series } = block;
312
+ const legend = series.length > 1;
313
+ const cx = W / 2;
314
+ const cy = (H - (legend ? 26 : 0)) / 2;
315
+ const R = Math.min(cx, cy) - 28;
316
+ // same truncation as cartesian() — a vertex without a spoke is never
317
+ // drawn, but if it entered the scale it would crush all the others onto
318
+ // the center (a major finding: radii on the order of a tenth of a pixel)
319
+ const shown = series.map((s) => shownValues(s, cats));
320
+ const hi = niceScale(0, Math.max(0, ...shown.flat())).hi;
321
+ const ang = (i) => -Math.PI / 2 + (i * 2 * Math.PI) / cats.length;
322
+ const pt = (i, v) => [
323
+ cx + ((R * v) / hi) * Math.cos(ang(i)),
324
+ cy + ((R * v) / hi) * Math.sin(ang(i)),
325
+ ];
326
+ const p = [];
327
+
328
+ for (const f of [0.25, 0.5, 0.75, 1]) {
329
+ const ring = cats.map((_, i) => pt(i, hi * f).join(',')).join(' ');
330
+ p.push(`<polygon points="${ring}" fill="none" stroke="${grid()}" stroke-width="1"/>`);
331
+ }
332
+ cats.forEach((c, i) => {
333
+ const [x, y] = pt(i, hi);
334
+ p.push(`<line x1="${cx}" y1="${cy}" x2="${x}" y2="${y}" stroke="${grid()}" stroke-width="1"/>`);
335
+ const [tx, ty] = pt(i, hi * 1.12);
336
+ p.push(
337
+ `<text x="${tx}" y="${ty + 4}" font-family="${FONT()}" font-size="11" fill="${ink()}" text-anchor="middle">${esc(c)}</text>`,
338
+ );
339
+ });
340
+ shown.forEach((values, si) => {
341
+ const pts = cats.map((_, i) => pt(i, values[i] ?? 0));
342
+ p.push(
343
+ `<polygon points="${pts.map((q) => q.join(',')).join(' ')}" fill="${color(si)}" fill-opacity="0.14" stroke="${color(si)}" stroke-width="2"/>`,
344
+ );
345
+ pts.forEach(([x, y]) =>
346
+ p.push(
347
+ `<circle cx="${x}" cy="${y}" r="3.5" fill="${color(si)}" stroke="${bg()}" stroke-width="2"/>`,
348
+ ),
349
+ );
350
+ });
351
+ if (legend) p.push(legendRow(series, 0, H - 10, W));
352
+ return p.join('\n');
353
+ }
354
+
355
+ // ---------------------------------------------------------------------------
356
+ // Entry point
357
+ // ---------------------------------------------------------------------------
358
+
359
+ /**
360
+ * Renders a `chart` block as SVG at the dimensions of the slot (px).
361
+ *
362
+ * @param {object} block `chart` block of the IR
363
+ * @param {number} W width of the slot (px)
364
+ * @param {number} H height of the slot (px)
365
+ * @param {object} [opts] { locale } — number formatting
366
+ * (default DEFAULT_LOCALE, see the module header)
367
+ */
368
+ export function chartSvg(block, W, H, { locale = DEFAULT_LOCALE } = {}) {
369
+ W = Math.max(240, Math.round(W));
370
+ H = Math.max(160, Math.round(H));
371
+ const body =
372
+ block.chartType === 'pie' || block.chartType === 'doughnut'
373
+ ? circular(block, W, H, locale)
374
+ : block.chartType === 'radar'
375
+ ? radar(block, W, H, locale)
376
+ : cartesian(block, W, H, locale);
377
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">
378
+ <rect width="${W}" height="${H}" fill="${bg()}"/>
379
+ ${body}
380
+ </svg>`;
381
+ }
382
+
383
+ /**
384
+ * What the rendering is going to drop, said out loud as diagnostics — the
385
+ * speaking counterpart of shownValues(). The plot truncates to stay inside the
386
+ * frame; without this channel, the data loss would be mute. Wired into
387
+ * validate.mjs (code CHART_DATA_IGNORED), hence visible to `lutrin validate`,
388
+ * VS Code and Obsidian.
389
+ *
390
+ * The circular charts (pie/doughnut) are left to validate.mjs, which handles
391
+ * them with their own constraints (single series, positive shares): diagnosing
392
+ * them here would duplicate that.
393
+ *
394
+ * @param {object} block `chart` block of the IR — possibly malformed
395
+ * @returns {Array<{severity:'warning', code:'CHART_DATA_IGNORED', message:string, line:number}>}
396
+ */
397
+ export function chartDataDiagnostics(block) {
398
+ // a single unbroken guard: the type used to be read via `block?.type` then
399
+ // `block.series` dereferenced without a net — a block without series raised
400
+ // "not iterable" in the caller
401
+ if (!block || block.type !== 'chart') return [];
402
+ if (block.chartType === 'pie' || block.chartType === 'doughnut') return [];
403
+ const cats = block.categories;
404
+ const series = block.series;
405
+ if (!Array.isArray(cats) || !Array.isArray(series)) return [];
406
+
407
+ const out = [];
408
+ for (const s of series) {
409
+ const n = s?.values?.length ?? 0;
410
+ const extra = n - cats.length;
411
+ if (extra <= 0) continue;
412
+ out.push({
413
+ severity: 'warning',
414
+ code: 'CHART_DATA_IGNORED',
415
+ message:
416
+ `Chart "${block.chartType}": series "${s.name}" has ${n} value${n > 1 ? 's' : ''} ` +
417
+ `for ${cats.length} ${cats.length === 1 ? 'category' : 'categories'} — ${
418
+ extra === 1 ? 'the last one will be dropped' : `the last ${extra} will be dropped`
419
+ }.`,
420
+ line: block.line ?? 1,
421
+ });
422
+ }
423
+ return out;
424
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Compilation context of a deck: theme + theme layouts + user layouts.
3
+ *
4
+ * The SINGLE insertion point, called after parseDeck and before buildScenes
5
+ * by every entry point of the pipeline (CLI build/inspect/preview, worker,
6
+ * compileHtml, validateDeck): the tokens and the layout registry are module
7
+ * state mutated in place, and hosts are warm processes shared between
8
+ * decks — so every compilation starts again from a fresh state (user
9
+ * layouts reset, theme re-applied from the default snapshot), then loads
10
+ * what comes with THIS deck: the theme (frontmatter `theme: ./x.json` or
11
+ * `theme: @org/package`, CLI flag `--theme`, or the project default from
12
+ * the nearest package.json — see theme.mjs), the `layouts/` of the resolved
13
+ * theme package, and the deck's own `layouts/*.json`.
14
+ * The theme travels with the document or is installed by npm into the
15
+ * project: no re-packaging of the extensions to theme a deck.
16
+ *
17
+ * Never throws: any problem (theme that could not be read, invalid layout,
18
+ * insufficient contrast) becomes a diagnostic { severity, code, message,
19
+ * suggestion? } WITHOUT a line — the caller positions it (validateDeck: the
20
+ * frontmatter `theme:` line; CLI/worker: stats.warnings).
21
+ */
22
+
23
+ import {
24
+ OFFICIAL_LAYOUT_DIAGS,
25
+ loadThemeLayouts,
26
+ loadUserLayouts,
27
+ resetUserLayouts,
28
+ } from './layout.mjs';
29
+ import { applyTheme, resolveTheme, themeContrastDiagnostics } from './theme.mjs';
30
+
31
+ /**
32
+ * @param {object} meta deck frontmatter (deck.meta)
33
+ * @param {object} [opts] { baseDir, themePath, defaultTheme } — themePath
34
+ * (CLI --theme) takes precedence over meta.theme;
35
+ * defaultTheme (host) applies only if nothing else names a
36
+ * theme
37
+ * @returns {{ diagnostics: Array, theme: object|null, themeFile: string|null }}
38
+ */
39
+ export function prepareDeckContext(
40
+ meta = {},
41
+ { baseDir = process.cwd(), themePath = null, defaultTheme = null } = {},
42
+ ) {
43
+ resetUserLayouts();
44
+ // official catalog (design/layouts/): loaded once at startup — a file that
45
+ // could not be read would signal a broken installation, on every deck
46
+ const diagnostics = [...OFFICIAL_LAYOUT_DIAGS];
47
+ const {
48
+ theme,
49
+ path: themeFile,
50
+ layoutsDir,
51
+ kitName,
52
+ diagnostics: themeDiags,
53
+ } = resolveTheme(meta, { baseDir, themePath, defaultTheme });
54
+ diagnostics.push(...themeDiags);
55
+ applyTheme(theme);
56
+ // kit layouts BEFORE the deck's own: a collision is reported on the deck's
57
+ // definition, attributed to the kit
58
+ if (layoutsDir) diagnostics.push(...loadThemeLayouts(layoutsDir, kitName));
59
+ diagnostics.push(...loadUserLayouts(baseDir));
60
+ if (theme) diagnostics.push(...themeContrastDiagnostics());
61
+ return { diagnostics, theme, themeFile };
62
+ }