@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,737 @@
1
+ /**
2
+ * Validation of a deck without generating it: positioned diagnostics (line in
3
+ * the source file) consumed by `lutrin validate`, the VS Code extension
4
+ * (underlines in the editor) and agents (`--json`).
5
+ *
6
+ * Severities: `error` (the rendering will not be the expected one), `warning`
7
+ * (probably not intended), `info` (automatic behaviour worth knowing about).
8
+ */
9
+
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+ import {
13
+ parseDeck,
14
+ ALERT_BLOCK_TYPES,
15
+ CONTAINERS,
16
+ CHART_TYPES,
17
+ ICON_COLORS,
18
+ ANIM_PRESETS,
19
+ ANIM_PRESET_ALIASES,
20
+ isKnownAnimateValue,
21
+ runsToText,
22
+ } from './parse.mjs';
23
+ import {
24
+ buildScenes,
25
+ blockHeight,
26
+ inferLayout,
27
+ LAYOUTS,
28
+ LAYOUT_SECTIONS,
29
+ layoutDef,
30
+ layoutParams,
31
+ layoutParamSchema,
32
+ officialLayouts,
33
+ userLayouts,
34
+ } from './layout.mjs';
35
+ import { hasLucideIcon, imageDims, imageWithinRoots, resolveImagePath } from './assets.mjs';
36
+ import { chartDataDiagnostics } from './chart.mjs';
37
+ import { LAYER_SHADES, PAGE, contentArea } from './tokens.mjs';
38
+ import { prepareDeckContext } from './context.mjs';
39
+ import { THEME_KEYS } from './theme.mjs';
40
+ import { closest } from './suggest.mjs';
41
+
42
+ /** Candidates for animation preset suggestions (names + French aliases). */
43
+ const ANIM_CANDIDATES = [...ANIM_PRESETS, ...ANIM_PRESET_ALIASES];
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // Walking the blocks of the IR (the :::… callouts nest blocks)
47
+ // ---------------------------------------------------------------------------
48
+
49
+ function* walkBlocks(slide) {
50
+ function* rec(blocks) {
51
+ for (const b of blocks) {
52
+ yield b;
53
+ if (b.type === 'alert') {
54
+ // only descend into what the callout renders: the content it drops is
55
+ // already reported one block up by ALERT_CONTENT_DROPPED — re-flagging
56
+ // it in cascade (nested callout) would produce nothing but noise
57
+ yield* rec(b.blocks.filter((x) => ALERT_BLOCK_TYPES.has(x.type)));
58
+ } else if (b.blocks) {
59
+ yield* rec(b.blocks);
60
+ }
61
+ }
62
+ }
63
+ for (const s of slide.sections) yield* rec(s.blocks);
64
+ }
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // Validation
68
+ // ---------------------------------------------------------------------------
69
+
70
+ /**
71
+ * @param {string} source the complete Markdown (DSL)
72
+ * @param {object} [opts] { baseDir } — the file's directory (resolution of
73
+ * images, theme and layouts/*.json); { themePath } —
74
+ * imposed kit (CLI flag --kit, wins over the
75
+ * frontmatter); { deck, scenes } — IR and scenes
76
+ * already computed by the host (extension worker) so
77
+ * parseDeck/buildScenes are not redone on every
78
+ * keystroke.
79
+ * @returns {Array<{severity:'error'|'warning'|'info', code:string, message:string, line:number, suggestion?:string}>}
80
+ */
81
+ export function validateDeck(
82
+ source,
83
+ {
84
+ baseDir = process.cwd(),
85
+ themePath = null,
86
+ defaultTheme = null,
87
+ imageRoots = [],
88
+ deck = null,
89
+ scenes = null,
90
+ } = {},
91
+ ) {
92
+ // trust roots for local images: the deck's directory + the project/vault
93
+ // roots declared by the host (containment — assets.mjs)
94
+ const imageTrustRoots = [baseDir, ...imageRoots];
95
+ const diags = [];
96
+ const push = (severity, code, message, line, suggestion) =>
97
+ diags.push({ severity, code, message, line: line ?? 1, ...(suggestion ? { suggestion } : {}) });
98
+
99
+ // ------ text scan: unknown directives -------------------------------------
100
+ // markdown-it-container silently ignores an unknown `:::name` (rendered as a
101
+ // paragraph) — only a scan of the source catches them. We skip the
102
+ // frontmatter and the inside of ```…``` blocks to avoid false positives.
103
+ // Leading BOM: `parseDeck` strips it on its side before parsing. This scan
104
+ // re-scans the SAME source; without the same stripping, a deck saved as
105
+ // UTF-8-BOM would make the two readings diverge (frontmatter and frontmatter
106
+ // keys shifted) and would position diagnostics beside the mark. The stripping
107
+ // happens AFTER the split, on the first line: a BOM only exists at the head
108
+ // of a file, and this scan must ask nothing of `source` other than `.split` —
109
+ // a hostile source would throw here, before the try/catch that turns an
110
+ // impossible parse into a diagnostic rather than a crash.
111
+ const lines = source.split(/\r?\n/);
112
+ if (typeof lines[0] === 'string') lines[0] = lines[0].replace(/^\uFEFF/, '');
113
+ let inFence = null;
114
+ let inFrontmatter = lines[0]?.trim() === '---' ? 'open' : null;
115
+ lines.forEach((raw, k) => {
116
+ const line = raw.trim();
117
+ if (inFrontmatter === 'open' && k > 0) {
118
+ if (line === '---') inFrontmatter = null;
119
+ return;
120
+ }
121
+ if (inFrontmatter === 'open' && k === 0) return;
122
+ const fence = line.match(/^(`{3,}|~{3,})/);
123
+ if (fence) {
124
+ if (!inFence) inFence = fence[1][0];
125
+ else if (fence[1][0] === inFence) inFence = null;
126
+ return;
127
+ }
128
+ if (inFence) return;
129
+ const dir = line.match(/^:{3,}\s*([A-Za-z][\w-]*)/);
130
+ // CASE-SENSITIVE comparison, like markdown-it-container when opening:
131
+ // `:::Info` opens no callout and renders as a literal paragraph.
132
+ // Normalizing here would judge as "known" what the engine ignores, and
133
+ // would neutralize this diagnostic exactly where it serves most — the
134
+ // casing mistake.
135
+ if (dir && !CONTAINERS.includes(dir[1])) {
136
+ // mermaid/math/chart are ``` fences in this DSL, not directives
137
+ const fenceLangs = ['mermaid', 'math', 'latex', 'chart'];
138
+ const fence = closest(dir[1], fenceLangs);
139
+ // only the case differs: name the cause, otherwise the author re-reads a
140
+ // correctly spelled word without seeing what is wrong
141
+ const cased = CONTAINERS.includes(dir[1].toLowerCase()) ? dir[1].toLowerCase() : null;
142
+ push(
143
+ 'error',
144
+ 'UNKNOWN_DIRECTIVE',
145
+ cased
146
+ ? `Unknown directive ":::${dir[1]}": directives are written in lowercase — ":::${cased}".`
147
+ : fence
148
+ ? `Unknown directive ":::${dir[1]}" — "${fence}" is written as a code block: \`\`\`${fence} … \`\`\`.`
149
+ : `Unknown directive ":::${dir[1]}" (directives: ${CONTAINERS.join(', ')}).`,
150
+ k + 1,
151
+ cased ?? (fence ? `\`\`\`${fence}` : closest(dir[1], CONTAINERS)),
152
+ );
153
+ }
154
+ });
155
+
156
+ // ------ IR -----------------------------------------------------------------
157
+ // Validation must never throw: a deck that cannot be parsed becomes a
158
+ // diagnostic, not a crash of the editor or of the CLI.
159
+ if (!deck) {
160
+ try {
161
+ deck = parseDeck(source);
162
+ } catch (e) {
163
+ push('error', 'PARSE_ERROR', `Could not parse the document: ${e?.message ?? e}`, 1);
164
+ return diags;
165
+ }
166
+ }
167
+
168
+ // Directives the parser could not attach to any slide (end of file, or `---`
169
+ // before any content). They have no effect: saying so here is the only trace
170
+ // the author will ever get. Reported BEFORE the early return of EMPTY_DECK —
171
+ // an empty deck is precisely the case where every directive is orphaned.
172
+ for (const d of deck.orphanDirectives ?? []) {
173
+ push(
174
+ 'warning',
175
+ 'ORPHAN_DIRECTIVE',
176
+ `The <!-- ${d.key}: … --> directive governs no slide: none opens after it (a directive applies to the slide surrounding it, whether it precedes or follows its "# heading"). It will have no effect.`,
177
+ d.line,
178
+ );
179
+ }
180
+
181
+ if (!deck.slides.length && !deck.meta.title) {
182
+ push(
183
+ 'warning',
184
+ 'EMPTY_DECK',
185
+ 'No slides: neither a frontmatter `title:`, nor a `# heading` in the body.',
186
+ 1,
187
+ );
188
+ return diags;
189
+ }
190
+
191
+ /** Line (1-based) of a frontmatter key, to position a diagnostic. */
192
+ const metaLine = (key) => {
193
+ if (lines[0]?.trim() !== '---') return 1;
194
+ for (let k = 1; k < lines.length && lines[k].trim() !== '---'; k++) {
195
+ if (new RegExp(`^${key}\\s*:`).test(lines[k])) return k + 1;
196
+ }
197
+ return 1;
198
+ };
199
+
200
+ // `animate:` in the frontmatter (whole deck) — the same check as the slide
201
+ // comment, positioned on the frontmatter line
202
+ if (deck.meta.animate != null && !isKnownAnimateValue(deck.meta.animate)) {
203
+ push(
204
+ 'warning',
205
+ 'UNKNOWN_ANIMATE',
206
+ `Unknown value "${deck.meta.animate}" for animate: (presets: ${ANIM_PRESETS.join(', ')} — or true/none).`,
207
+ metaLine('animate'),
208
+ closest(String(deck.meta.animate), ANIM_CANDIDATES) ?? undefined,
209
+ );
210
+ }
211
+
212
+ // ------ theme + user layouts -----------------------------------------------
213
+ // Loads layouts/*.json and applies the theme BEFORE the slide loop
214
+ // (UNKNOWN_LAYOUT reads the live registry) and before the geometric audits
215
+ // (the estimates must be the theme's). Scenes precomputed by the host were
216
+ // built in the same state by compileHtml.
217
+ const prep = prepareDeckContext(deck.meta, { baseDir, themePath, defaultTheme });
218
+ // anchor on the frontmatter line that DESIGNATES the kit — `kit:` today,
219
+ // `theme:` for decks written before. The KIT_* codes, like the THEME_* ones,
220
+ // all speak of that line: forgetting them would send the user back to line 1,
221
+ // where there is nothing to fix.
222
+ // metaLine returns 1 when the key is absent, and never 1 when it is present
223
+ // (line 1 is the opening "---"): 1 therefore means "no kit:"
224
+ const lineOfKit = metaLine('kit');
225
+ const kitLine = lineOfKit !== 1 ? lineOfKit : metaLine('theme');
226
+ for (const d of prep.diagnostics) {
227
+ const aboutKit = d.code.startsWith('THEME_') || d.code.startsWith('KIT_');
228
+ push(d.severity, d.code, d.message, aboutKit ? kitLine : 1, d.suggestion);
229
+ }
230
+
231
+ for (const slide of deck.slides) {
232
+ if (slide.layout && !LAYOUTS.includes(slide.layout)) {
233
+ push(
234
+ 'error',
235
+ 'UNKNOWN_LAYOUT',
236
+ `Unknown layout "${slide.layout}" (layouts: ${LAYOUTS.join(', ')}).`,
237
+ slide.layoutLine ?? slide.line,
238
+ closest(slide.layout, LAYOUTS),
239
+ );
240
+ }
241
+ // structured layouts: the number of ## sections must fit — name comparisons
242
+ // are made on the resolved BASE layout, so that a user alias
243
+ // (layouts/*.json) inherits the same exceptions
244
+ const expect = slide.layout && LAYOUT_SECTIONS[slide.layout];
245
+ const layoutBase = slide.layout ? (layoutDef(slide.layout)?.base ?? slide.layout) : null;
246
+ if (expect) {
247
+ const secs = slide.sections.filter((s) => s.heading || s.blocks.length);
248
+ // LEAD: in columns, what precedes the first "##" does not occupy a
249
+ // column — layout.mjs flows it full width above (see the `lead` of
250
+ // two-columns/three-columns, SAME condition here). Counting it made us
251
+ // announce "4 sections found: the surplus will be ignored" where nothing
252
+ // is ignored: a lying warning, worse than none.
253
+ const lead =
254
+ (layoutBase === 'two-columns' || layoutBase === 'three-columns') &&
255
+ secs.length &&
256
+ !secs[0].heading &&
257
+ secs.some((s) => s.heading);
258
+ const nSecs = secs.length - (lead ? 1 : 0);
259
+ const bulletLayers =
260
+ layoutBase === 'layers' &&
261
+ !slide.sections.some((s) => s.heading) &&
262
+ slide.sections.flatMap((s) => s.blocks).every((b) => b.type === 'bullets');
263
+ if (!bulletLayers && (nSecs < expect.min || nSecs > expect.max)) {
264
+ push(
265
+ 'warning',
266
+ 'LAYOUT_SECTIONS',
267
+ `The "${slide.layout}" layout expects ${
268
+ expect.min === expect.max ? expect.min : `${expect.min} to ${expect.max}`
269
+ } "##" sections (${nSecs} found${
270
+ layoutBase === 'layers' ? ', or a single bullet list — one item per layer' : ''
271
+ }): the surplus will be ignored, the shortfall will leave gaps.`,
272
+ slide.layoutLine ?? slide.line,
273
+ );
274
+ }
275
+ }
276
+ if (slide.animateUnknown != null) {
277
+ push(
278
+ 'warning',
279
+ 'UNKNOWN_ANIMATE',
280
+ `Unknown value "${slide.animateUnknown}" for <!-- animate: … --> (presets: ${ANIM_PRESETS.join(', ')} — or true/none).`,
281
+ slide.animateLine ?? slide.line,
282
+ closest(slide.animateUnknown, ANIM_CANDIDATES) ?? undefined,
283
+ );
284
+ }
285
+ // quote layout: layout.mjs renders an EMPTY scene when the slide has no
286
+ // block to quote (rather than crashing the renderers). It places, it does
287
+ // not speak: without this diagnostic, the author sees a bare slide and
288
+ // never learns why.
289
+ if (layoutBase === 'quote' && ![...walkBlocks(slide)].length) {
290
+ push(
291
+ 'warning',
292
+ 'QUOTE_EMPTY',
293
+ `The "${slide.layout}" layout features the FIRST block on the slide, but this slide has nothing to quote: the slide will be empty — add the quotation under the title.`,
294
+ slide.layoutLine ?? slide.line,
295
+ );
296
+ }
297
+ // layers layout: the `shades` parameter indexes the kit's shades
298
+ // (LAYER_SHADES). An index beyond them is CLAMPED to the lightest by
299
+ // layout.mjs — two layers then end up the same tint, which shows without
300
+ // explaining itself: it is the kit that lacks shades, not the deck.
301
+ if (layoutBase === 'layers' && LAYER_SHADES.length) {
302
+ const shades = layoutParams(slide.layout).shades;
303
+ const over = Array.isArray(shades)
304
+ ? [...new Set(shades.filter((s) => s > LAYER_SHADES.length - 1))]
305
+ : [];
306
+ if (over.length) {
307
+ push(
308
+ 'warning',
309
+ 'LAYERS_SHADE_MISSING',
310
+ `The "${slide.layout}" layout asks for shade ${over.join(', ')} (parameter "shades"), but the kit only provides ${LAYER_SHADES.length} layer shades — indices 0 to ${LAYER_SHADES.length - 1}: the layers concerned will fall back to the lightest.`,
311
+ slide.layoutLine ?? slide.line,
312
+ );
313
+ }
314
+ }
315
+ // metrics layout: past the cap (parameter `max`, 4 by default), the surplus
316
+ // is dropped without a trace — compare the resolved BASE layout (user
317
+ // aliases included), the effective cap of the alias
318
+ const nMetrics = [...walkBlocks(slide)].filter((b) => b.type === 'metric').length;
319
+ const effLayout = slide.layout ?? inferLayout(slide, 1);
320
+ if ((layoutDef(effLayout)?.base ?? effLayout) === 'metrics') {
321
+ const maxCards = layoutParams(effLayout).max ?? 4;
322
+ if (nMetrics > maxCards) {
323
+ push(
324
+ 'warning',
325
+ 'METRICS_DROPPED',
326
+ `${nMetrics} :::metric cards — the "${effLayout}" layout only displays ${maxCards}: ${
327
+ nMetrics - maxCards === 1
328
+ ? 'the last one will be dropped'
329
+ : `the last ${nMetrics - maxCards} will be dropped`
330
+ }. Spread them over two slides.`,
331
+ slide.line,
332
+ );
333
+ }
334
+ }
335
+ for (const b of walkBlocks(slide)) {
336
+ if (b.type === 'image' && !/^https?:/.test(b.src)) {
337
+ const file = resolveImagePath(baseDir, b.src);
338
+ if (!imageWithinRoots(file, imageTrustRoots)) {
339
+ push(
340
+ 'error',
341
+ 'IMAGE_PATH_ESCAPE',
342
+ `Image outside the deck's directory: ${b.src} — refused (it will not be embedded). An image must sit under the deck's directory or under a project/vault directory allowed by the editor.`,
343
+ b.line,
344
+ );
345
+ } else if (!fs.existsSync(file)) {
346
+ push(
347
+ 'warning',
348
+ 'MISSING_IMAGE',
349
+ `Image not found: ${b.src} (a placeholder will be displayed).`,
350
+ b.line,
351
+ );
352
+ }
353
+ }
354
+ if (b.type === 'icon' && hasLucideIcon(b.name) === false) {
355
+ push(
356
+ 'warning',
357
+ 'UNKNOWN_ICON',
358
+ `Lucide icon "${b.name}" not found — check the name on lucide.dev.`,
359
+ b.line,
360
+ );
361
+ }
362
+ if (b.type === 'code' && b.invalidChart) {
363
+ push(
364
+ 'warning',
365
+ 'INVALID_CHART',
366
+ `The \`chart\` specification could not be parsed — the block will be displayed as code. Expected: "type: ${[...CHART_TYPES].join('|')}", "categories: a, b", then "Series: v1, v2".`,
367
+ b.line,
368
+ );
369
+ }
370
+ // callouts: the renderers only render paragraphs and bullet lists — any
371
+ // other block is ignored, and the author must know it
372
+ if (b.type === 'alert') {
373
+ for (const inner of b.blocks) {
374
+ if (!ALERT_BLOCK_TYPES.has(inner.type)) {
375
+ push(
376
+ 'warning',
377
+ 'ALERT_CONTENT_DROPPED',
378
+ `The :::${b.kind} callout only renders paragraphs and bullet lists: the "${inner.type}" block will be ignored — move it out of the callout.`,
379
+ inner.line ?? b.line,
380
+ );
381
+ }
382
+ }
383
+ }
384
+ // quotation: only the text of paragraphs is kept — a list, a table or an
385
+ // image written inside are dropped by the parser, as for the callouts,
386
+ // and that must be said (parse.mjs sets `dropped`)
387
+ if (b.type === 'quote' && b.dropped?.length) {
388
+ for (const t of b.dropped) {
389
+ push(
390
+ 'warning',
391
+ 'QUOTE_CONTENT_DROPPED',
392
+ `A quotation only renders text: the "${t}" block it contains will be ignored — move it out of the quotation.`,
393
+ b.line,
394
+ );
395
+ }
396
+ }
397
+ // cartesian and radar: chart.mjs truncates each series to the number of
398
+ // categories BEFORE computing its scale (otherwise the surplus, never
399
+ // plotted, crushes the visible plot). What it rules out, it says here —
400
+ // the engine's rule: rendering places, validation speaks.
401
+ for (const d of chartDataDiagnostics(b))
402
+ push(d.severity, d.code, d.message, d.line ?? b.line);
403
+ // pie/doughnut: a single series of positive shares, truncated to the
404
+ // number of categories — the rest of the data is dropped at render time,
405
+ // with no trace other than this diagnostic (same windows as chart.mjs:
406
+ // the truncation applies BEFORE the clamp on negatives)
407
+ if (b.type === 'chart' && (b.chartType === 'pie' || b.chartType === 'doughnut')) {
408
+ if (b.series.length > 1) {
409
+ push(
410
+ 'warning',
411
+ 'CHART_DATA_IGNORED',
412
+ `Chart "${b.chartType}": ${b.series.length} series — only the first ("${b.series[0].name}") will be displayed. To compare series, use bar, line or radar.`,
413
+ b.line,
414
+ );
415
+ }
416
+ const extra = b.series[0].values.length - b.categories.length;
417
+ if (extra > 0) {
418
+ push(
419
+ 'warning',
420
+ 'CHART_DATA_IGNORED',
421
+ `Chart "${b.chartType}": the series "${b.series[0].name}" carries ${b.series[0].values.length} values for ${b.categories.length} categories — ${extra === 1 ? 'the last one will be dropped' : `the last ${extra} will be dropped`}.`,
422
+ b.line,
423
+ );
424
+ }
425
+ // a series shorter than the categories: the rendering no longer invents
426
+ // a 0 share for the categories without a value, but the author must
427
+ // know they will not appear (same windows as the truncation)
428
+ const missing = b.categories.length - b.series[0].values.length;
429
+ if (missing > 0) {
430
+ const orphans = b.categories.slice(b.series[0].values.length);
431
+ push(
432
+ 'warning',
433
+ 'CHART_DATA_IGNORED',
434
+ `Chart "${b.chartType}": ${b.categories.length} categories, but the series stops at value ${b.series[0].values.length} — ${missing === 1 ? `the category "${orphans[0]}" will have no share` : `the categories ${orphans.map((c) => `"${c}"`).join(', ')} will have no share`}.`,
435
+ b.line,
436
+ );
437
+ }
438
+ const shown = b.series[0].values.slice(0, b.categories.length);
439
+ const neg = shown.filter((v) => v < 0).length;
440
+ if (neg) {
441
+ push(
442
+ 'warning',
443
+ 'CHART_DATA_IGNORED',
444
+ `Chart "${b.chartType}": negative values are displayed as 0 (${neg} of them) — this type only represents positive shares; use bar or barh.`,
445
+ b.line,
446
+ );
447
+ }
448
+ }
449
+ }
450
+ }
451
+
452
+ // ------ art direction: suggested structured layout -------------------------
453
+ // reverse inference — structured layouts are never inferred, but some content
454
+ // betrays the intent (SWOT, before/after, dated milestones)
455
+ // NFD separates the letter from its diacritic, the character class removes
456
+ // the diacritics: "résumé" and "resume" then compare as identical.
457
+ // Targeting combining marks is the intention here, not a mistake.
458
+ const norm = (runs) =>
459
+ runsToText(runs)
460
+ .toLowerCase()
461
+ .normalize('NFD')
462
+ // biome-ignore lint/suspicious/noMisleadingCharacterClass: removal of diacritics after NFD
463
+ .replace(/[\u0300-\u036f]/g, '');
464
+ for (const slide of deck.slides) {
465
+ if (slide.layout) continue;
466
+ const heads = slide.sections.filter((s) => s.heading).map((s) => norm(s.heading));
467
+ if (heads.length < 2) continue;
468
+ const has = (re) => heads.some((h) => re.test(h));
469
+ // milestones: the date/step must OPEN the section heading ("2024",
470
+ // "Q1 2026", "Phase 2") — "Review of 2025" is not a milestone
471
+ const dated = heads.filter((h) =>
472
+ /^((19|20)\d{2}\b|[tq][1-4]\b|phase\s*\d|step\s*\d|milestone|week\s*\d|quarter\s*\d)/.test(h),
473
+ ).length;
474
+ let layout = null;
475
+ let why = null;
476
+ // swot: require the canonical order — the layout tints quadrants BY
477
+ // POSITION (success, danger, info, warning); suggesting swot on unordered
478
+ // sections would render "Threats" in green
479
+ const SWOT_ORDER = ['strengths', 'weaknesses', 'opportunities', 'threats'];
480
+ // the OFFICIAL layouts (design/layouts/) are suggested too — only if they
481
+ // are properly loaded in the registry (catalog intact)
482
+ const official = (name) => (LAYOUTS.includes(name) ? name : null);
483
+ if (heads.length === 4 && SWOT_ORDER.every((k, i) => heads[i].includes(k))) {
484
+ layout = 'swot';
485
+ why = 'a strengths / weaknesses / opportunities / threats matrix';
486
+ } else if (
487
+ heads.length === 2 &&
488
+ has(/^pros?\b/) &&
489
+ has(/^cons?\b(?![-–])/) &&
490
+ // "Pros for whom?" (a question) or "Con-artists" (hyphenated) are not a decision to weigh
491
+ !heads.some((h) => h.includes('?')) &&
492
+ official('pros-cons')
493
+ ) {
494
+ layout = 'pros-cons';
495
+ why = 'a decision to weigh (pros / cons)';
496
+ } else if (heads.length === 2 && has(/^before\b/) && has(/^after\b/)) {
497
+ layout = 'comparison';
498
+ why = 'a before / after comparison';
499
+ } else if (heads.length === 2 && has(/current|today/) && has(/target|goal|tomorrow|future/)) {
500
+ layout = 'comparison';
501
+ why = 'a current-state / target comparison';
502
+ } else if (
503
+ heads.length === 4 &&
504
+ heads.filter((h) => /probabilit|severity|likelihood/.test(h)).length >= 2 &&
505
+ // the layout tints BY POSITION (green → red): require ascending order —
506
+ // first quadrant benign, last critical — as for the canonical order of
507
+ // the SWOT
508
+ /low|minor/.test(heads[0]) &&
509
+ /critical|major|high|severe/.test(heads[3]) &&
510
+ official('risk-map')
511
+ ) {
512
+ layout = 'risk-map';
513
+ why = 'a risk map (probability / severity)';
514
+ } else if (dated >= Math.max(2, heads.length - 1)) {
515
+ layout = 'timeline';
516
+ why = 'dated milestones';
517
+ }
518
+ if (layout) {
519
+ push(
520
+ 'info',
521
+ 'LAYOUT_SUGGESTION',
522
+ `The "##" sections of this slide express ${why}: adding <!-- layout: ${layout} --> will display it with the dedicated layout.`,
523
+ slide.line,
524
+ layout,
525
+ );
526
+ }
527
+ }
528
+
529
+ // ------ geometric audits on the scenes --------------------------------------
530
+ try {
531
+ const allScenes = scenes ?? buildScenes(deck);
532
+
533
+ // pagination (density info)
534
+ const paginated = new Set();
535
+ for (const scene of allScenes) {
536
+ if (scene.continued && !paginated.has(scene.sourceLine)) {
537
+ paginated.add(scene.sourceLine);
538
+ push(
539
+ 'info',
540
+ 'SLIDE_PAGINATED',
541
+ `The slide "${String(scene.title ?? '').replace(/ \(cont\.\)$/, '')}" overflows: its content is split into "(cont.)" slides.`,
542
+ scene.sourceLine,
543
+ );
544
+ }
545
+ }
546
+
547
+ // overflow: estimated height vs region (unpaginated layouts: columns,
548
+ // panels, split — the author can only fix it by knowing about it).
549
+ // The lower bound is the bottom of the CONTENT AREA (672 px): that is where
550
+ // panels and columns end — not the footer (688 px).
551
+ const AUDITED = new Set([
552
+ 'para',
553
+ 'bullets',
554
+ 'heading',
555
+ 'code',
556
+ 'table',
557
+ 'alert',
558
+ 'quote',
559
+ 'math',
560
+ ]);
561
+ const ADVICE = {
562
+ table: 'split the table or remove columns',
563
+ bullets: 'shorten the bullets or spread them over two slides',
564
+ };
565
+ const area = contentArea();
566
+ const areaBottom = area.y + area.h;
567
+ for (const scene of allScenes) {
568
+ if (scene.master === 'cover' || scene.master === 'section') continue;
569
+ let flagged = 0;
570
+ for (const el of scene.elements) {
571
+ if (flagged >= 3 || !AUDITED.has(el.block.type)) continue;
572
+ const needed = blockHeight(el.block, el.region.w);
573
+ const overflow = Math.round(
574
+ Math.max(needed - el.region.h, el.region.y + Math.max(needed, el.region.h) - areaBottom),
575
+ );
576
+ if (overflow > 12) {
577
+ flagged++;
578
+ push(
579
+ 'warning',
580
+ 'BLOCK_OVERFLOW',
581
+ `The "${el.block.type}" block overflows its region by about ${overflow} px (${scene.layout} layout) — ${
582
+ ADVICE[el.block.type] ?? 'trim the content or switch layouts'
583
+ }.`,
584
+ el.block.line ?? scene.sourceLine,
585
+ );
586
+ }
587
+ }
588
+
589
+ // resolution: a local image stretched beyond its native size
590
+ const images = scene.elements
591
+ .filter((el) => el.block.type === 'image')
592
+ .concat(scene.image ? [{ block: scene.image, region: { w: PAGE.width } }] : []);
593
+ for (const el of images) {
594
+ const b = el.block;
595
+ if (/^https?:/.test(b.src)) continue;
596
+ const file = resolveImagePath(baseDir, b.src);
597
+ // an image that escapes: refused for embedding (IMAGE_PATH_ESCAPE
598
+ // already emitted) — nothing to audit about its resolution
599
+ if (!imageWithinRoots(file, imageTrustRoots)) continue;
600
+ if (!fs.existsSync(file)) continue;
601
+ const dims = imageDims(file);
602
+ if (!dims?.w) continue;
603
+ // actual displayed width: the renderer frames with "contain" (ratio
604
+ // preserved) — except the cover/background roles, stretched over the
605
+ // region
606
+ const cover = b.role === 'cover' || b.role === 'background';
607
+ const displayed =
608
+ cover || !dims.h || !el.region.h
609
+ ? el.region.w
610
+ : Math.min(el.region.w, (el.region.h * dims.w) / dims.h);
611
+ if (displayed > dims.w * 1.3) {
612
+ push(
613
+ 'info',
614
+ 'IMAGE_UPSCALED',
615
+ `Image "${b.src}", ${dims.w} px wide, displayed at about ${Math.round(displayed)} px — risk of blur: supply a larger image.`,
616
+ b.line ?? scene.sourceLine,
617
+ );
618
+ }
619
+ }
620
+ }
621
+ } catch (e) {
622
+ push('error', 'LAYOUT_ERROR', `Could not lay out the document: ${e?.message ?? e}`, 1);
623
+ }
624
+
625
+ return diags.sort((a, b) => a.line - b.line);
626
+ }
627
+
628
+ // ---------------------------------------------------------------------------
629
+ // Engine capabilities (for agents and autocompletion)
630
+ // ---------------------------------------------------------------------------
631
+
632
+ export function capabilities() {
633
+ // deep copy on output: the registry and the schemas are live module state —
634
+ // a host that mutated the result would corrupt every subsequent deck of the
635
+ // warm worker
636
+ return structuredClone({
637
+ layouts: LAYOUTS,
638
+ // official layouts of the design/layouts/ catalog (base + parameters +
639
+ // description) — never inferred, to be asked for by <!-- layout: … -->
640
+ officialLayouts: officialLayouts(),
641
+ // user layouts of the last prepared deck (layouts/*.json) — their full
642
+ // definition, so that the agent knows where each alias comes from
643
+ userLayouts: userLayouts(),
644
+ layoutSections: Object.fromEntries(
645
+ LAYOUTS.filter((l) => layoutDef(l)?.sections).map((l) => [l, layoutDef(l).sections]),
646
+ ),
647
+ // parameters of the built-in generators (review §3.3, step 3): to be set at
648
+ // the top level of a layouts/*.json — types, domains and defaults published
649
+ // so that agents discover them instead of inventing them
650
+ layoutParams: Object.fromEntries(
651
+ LAYOUTS.filter(
652
+ (l) => layoutDef(l)?.builtin && Object.keys(layoutParamSchema(l) ?? {}).length,
653
+ ).map((l) => [l, layoutParamSchema(l)]),
654
+ ),
655
+ directives: CONTAINERS,
656
+ chartTypes: [...CHART_TYPES],
657
+ iconColors: [...ICON_COLORS],
658
+ codeFences: ['mermaid', 'math', 'latex', 'tex', 'chart'],
659
+ comments: ['notes', 'layout', 'animate'],
660
+ animatePresets: [...ANIM_PRESETS],
661
+ frontmatter: ['title', 'subtitle', 'author', 'date', 'footer', 'animate', 'kit', 'assets'],
662
+ outputs: ['pptx', 'html'],
663
+ remoteImages:
664
+ '`![](https://…)` images are downloaded then embedded in the deliverable (the presentation has no ' +
665
+ 'network dependency). They land in the user cache ~/.cache/lutrin/remote/, shared between projects — ' +
666
+ "compiling writes nothing into the deck's directory. `assets: vendor` (frontmatter) or --vendor-assets " +
667
+ '(CLI, which wins) copies them into assets/remote/ next to the .md, for a self-contained directory ' +
668
+ 'that can be archived, handed over or versioned.',
669
+ vendor:
670
+ '"lutrin vendor <deck.md>" freezes ALL external dependencies in the deck\'s directory: remote images ' +
671
+ '(assets/remote/), already rendered Mermaid diagrams (assets/mermaid/ — the deck then compiles ' +
672
+ 'without @mermaid-js/mermaid-cli installed) and the resolved kit, fonts and logos included ' +
673
+ '(assets/kit/). The frontmatter is rewritten accordingly (assets: vendor, kit: ./assets/kit): the ' +
674
+ 'declaration stays explicit, no hidden level is added to the precedence of kits. The directory then ' +
675
+ 'compiles offline, on a machine with no kit installed — at the price of a freeze: updating the kit ' +
676
+ 'means running vendor again.',
677
+ theme: {
678
+ frontmatter:
679
+ 'kit: my-kit (name of an installed kit), kit: ./my-theme.json (file resolved relative to the ' +
680
+ "deck's directory) or kit: ./my-kit (directory carrying a kit.json); kit: none forces the default " +
681
+ 'theme. The CLI flag --kit wins; otherwise the project default via "lutrin": { "kit": … } of the ' +
682
+ 'nearest package.json. "theme:" is still accepted as a deprecated alias (diagnostic ' +
683
+ 'KIT_DEPRECATED_KEY).',
684
+ precedence:
685
+ '--kit (CLI) > frontmatter kit: > project default (package.json "lutrin".kit) > ' +
686
+ 'user default (config.json, see userConfig) > host default (extension) > generic theme.',
687
+ userConfig:
688
+ 'kit shared between projects: the "kit" field of ~/.config/lutrin/config.json (overridable by ' +
689
+ 'LUTRIN_CONFIG; XDG_CONFIG_HOME respected) — set with "config --kit <ref>". ' +
690
+ 'Kits installed in ~/.config/lutrin/kits/<name>/ are referenced by name from any project.',
691
+ reference:
692
+ 'design/themes/default.json (full mirror of the default theme, a template to copy)',
693
+ kit:
694
+ 'a kit carries a kit.json { name, version?, theme?: "./theme.json", layouts?: "./layouts" } at its ' +
695
+ 'root (defaults: ./theme.json and ./layouts if it exists) — fonts/logos resolved relative to ' +
696
+ 'theme.json, inside the kit; distributed as a directory or as a .deckkit archive',
697
+ keys: THEME_KEYS,
698
+ },
699
+ layoutsDir:
700
+ 'layouts/*.json next to the deck — { name, base (built-in or official layout), sections?: { min, max }, description?, …parameters of the base (see layoutParams) }',
701
+ diagnostics: [
702
+ 'PARSE_ERROR',
703
+ 'LAYOUT_ERROR',
704
+ 'EMPTY_DECK',
705
+ 'UNKNOWN_DIRECTIVE',
706
+ 'ORPHAN_DIRECTIVE',
707
+ 'UNKNOWN_LAYOUT',
708
+ 'LAYOUT_SECTIONS',
709
+ 'UNKNOWN_ANIMATE',
710
+ 'METRICS_DROPPED',
711
+ 'MISSING_IMAGE',
712
+ 'UNKNOWN_ICON',
713
+ 'INVALID_CHART',
714
+ 'SLIDE_PAGINATED',
715
+ 'BLOCK_OVERFLOW',
716
+ 'LAYOUT_SUGGESTION',
717
+ 'IMAGE_UPSCALED',
718
+ 'ALERT_CONTENT_DROPPED',
719
+ 'CHART_DATA_IGNORED',
720
+ 'QUOTE_EMPTY',
721
+ 'LAYERS_SHADE_MISSING',
722
+ 'THEME_NOT_FOUND',
723
+ 'THEME_INVALID',
724
+ 'THEME_UNKNOWN_KEY',
725
+ 'THEME_BAD_VALUE',
726
+ 'THEME_CONTRAST',
727
+ 'KIT_NOT_FOUND',
728
+ 'KIT_INVALID',
729
+ 'KIT_UNKNOWN_KEY',
730
+ 'KIT_BAD_VALUE',
731
+ 'KIT_DEPRECATED_KEY',
732
+ 'USER_CONFIG_INVALID',
733
+ 'LAYOUT_DEF_INVALID',
734
+ 'LAYOUT_DEF_ADJUSTED',
735
+ ],
736
+ });
737
+ }