@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,776 @@
1
+ /**
2
+ * Front end of the compiler: enriched Markdown → presentation IR.
3
+ *
4
+ * Markdown is only an input DSL; past this stage, only the IR
5
+ * (deck → slides → sections → blocks) travels through the pipeline. It is the
6
+ * canonical representation that layout analysis, pagination and the render
7
+ * engines all work on.
8
+ *
9
+ * DSL rules:
10
+ * - simple YAML frontmatter (title, subtitle, author, date, footer);
11
+ * - `# H1` or `---` opens a new slide;
12
+ * - `## H2` opens a section (a potential slot: column, panel…);
13
+ * - `:::info|success|warning|danger` → semantic callout;
14
+ * - `:::metric` (value then label) → metric card; a final line starting
15
+ * with `↑ ↗ ↓ ↘ →` (or `+`/`-` in front of a figure, or `=`) becomes the
16
+ * **trend** — up, down or flat. The color follows the direction
17
+ * (up = positive); suffix `(+)` / `(-)` to invert it when a fall is good
18
+ * news (costs, incidents);
19
+ * - `![cover|background|left|right](img)` → image role;
20
+ * - `![alt](https://…)` → remote image, downloaded and copied locally;
21
+ * - `![color?](lucide:name)` → Lucide icon (color: primary by default);
22
+ * - ```mermaid → diagram block;
23
+ * - ```math (or ```latex) → LaTeX equation; `$$…$$` alone in a paragraph
24
+ * works too;
25
+ * - ```chart → native chart (bar, barh, line, area, pie, doughnut, radar);
26
+ * - `<!-- notes: … -->` → presenter notes;
27
+ * - `<!-- layout: … -->` → imposed layout;
28
+ *
29
+ * A directive (`layout`, `notes`, `animate`) governs the slide that surrounds
30
+ * it, and it may be written BEFORE that slide's `# H1`: announcing the layout
31
+ * then the title is the natural order — and the only possible one when you
32
+ * want to read the layout first. A directive met outside any slide is
33
+ * therefore held pending and applied to the next slide that opens. If none
34
+ * opens (end of file, or a `---` separator before any content), it has
35
+ * nothing to govern: it comes out in `orphanDirectives` so that validation
36
+ * says so, rather than vanishing without a word.
37
+ * - `<!-- animate -->` → progressive reveal of the slide's content
38
+ * (`animate: true` in the frontmatter for the whole deck,
39
+ * `<!-- animate: none -->` to exclude one slide); a value can impose the
40
+ * PowerPoint effect for the whole slide — `<!-- animate: fade -->`,
41
+ * `wipe`, `zoom`, `appear` — otherwise the effect is chosen per block
42
+ * type (see anim.mjs).
43
+ */
44
+
45
+ import MarkdownIt from 'markdown-it';
46
+ import container from 'markdown-it-container';
47
+
48
+ export const CONTAINERS = ['info', 'success', 'warning', 'danger', 'metric'];
49
+
50
+ /** Block types a :::info/success/warning/danger callout knows how to render
51
+ * (single source of truth: both renderers, the layout's height estimation
52
+ * and validation all refer to it). Any other block is ignored at render time
53
+ * — with no height reserved, and reported through ALERT_CONTENT_DROPPED. */
54
+ export const ALERT_BLOCK_TYPES = new Set(['para', 'bullets']);
55
+ const IMAGE_ROLES = new Set(['cover', 'background', 'left', 'right']);
56
+ export const ICON_COLORS = new Set(['primary', 'neutral', 'secondary', 'white']);
57
+ export const CHART_TYPES = new Set(['bar', 'barh', 'line', 'area', 'pie', 'doughnut', 'radar']);
58
+
59
+ /** Blocks a list item can carry and that a bullet cannot contain (a list's IR
60
+ * knows nothing but runs): they are re-read as blocks in their own right and
61
+ * reinserted in their place in the block sequence.
62
+ *
63
+ * `heading_open` is deliberately ABSENT from it: in this DSL a `#`/`##` is
64
+ * not a content block but a slide/section SEPARATOR. Re-reading it as a block
65
+ * would split the slide on a heading indented under a bullet — the heading's
66
+ * text must stay a bullet, as it always has. */
67
+ const ITEM_NESTED_BLOCKS = new Set(['table_open', 'fence', 'code_block', 'blockquote_open']);
68
+
69
+ function buildMd() {
70
+ const md = new MarkdownIt({ html: true, linkify: true, typographer: false });
71
+ for (const name of CONTAINERS) md.use(container, name);
72
+ return md;
73
+ }
74
+
75
+ /**
76
+ * A deck may *start* with `---` without having any frontmatter: the `---` is
77
+ * then a horizontal rule, and the block delimited by the next `---` is
78
+ * content — the first slide. The two must therefore be told apart.
79
+ *
80
+ * The test happens in two stages, deliberately asymmetric:
81
+ * - the **first** non-empty line must be a *strict* `key:` form — not
82
+ * indented, with no markdown block opener (`> * + - #`). It is that
83
+ * strict predicate which guards against the false positive: "> A quote:
84
+ * with a colon", an indented list or indented code all contain colons but
85
+ * never open a frontmatter;
86
+ * - the **following** lines are judged with a *tolerant* predicate (empty,
87
+ * `#` comment, indented continuation, or `key:`), so that a real
88
+ * frontmatter is not rejected wholesale because of a single unusual
89
+ * line — an accented key, a dotted key, an empty value, nested YAML, a
90
+ * YAML list, a comment.
91
+ *
92
+ * Detection is therefore "all or nothing" on the *block*, but key extraction
93
+ * stays line by line: a line that cannot be read is skipped without costing
94
+ * the others.
95
+ */
96
+ const FM_KEY_STRICT = /^[^\s>*+\-#:][^:]*:(?:\s|$)/;
97
+ const FM_LINE_LOOSE = /^\s*$|^\s*#|^\s+\S|^[^\s:][^:]*:(?:\s|$)/;
98
+
99
+ export function looksLikeFrontmatter(block) {
100
+ const lines = block.split(/\r?\n/);
101
+ const first = lines.findIndex((l) => l.trim() !== '');
102
+ if (first === -1) return false; // empty block: two `---` in a row
103
+ if (!FM_KEY_STRICT.test(lines[first])) return false;
104
+ return lines.slice(first + 1).every((l) => FM_LINE_LOOSE.test(l));
105
+ }
106
+
107
+ /** Minimal YAML frontmatter: flat `key: value`, delimited by `---`.
108
+ * `lineOffset`: lines consumed by the frontmatter, used to bring markdown-it
109
+ * positions (relative to the body) back to the source file. */
110
+ function splitFrontmatter(src) {
111
+ const m = src.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
112
+ if (!m || !looksLikeFrontmatter(m[1])) return { meta: {}, body: src, lineOffset: 0 };
113
+ const meta = {};
114
+ for (const line of m[1].split(/\r?\n/)) {
115
+ const kv = line.match(/^(\w[\w-]*)\s*:\s*(.+?)\s*$/);
116
+ if (kv) meta[kv[1]] = kv[2].replace(/^["']|["']$/g, '');
117
+ }
118
+ return { meta, body: src.slice(m[0].length), lineOffset: (m[0].match(/\n/g) ?? []).length };
119
+ }
120
+
121
+ /** Accumulates a markdown-it inline token into `runs`, keeping the emphasis
122
+ * state `state` up to date. The state is carried by the caller: it therefore
123
+ * survives an image that cuts the paragraph into two fragments. */
124
+ function pushRun(runs, t, state) {
125
+ switch (t.type) {
126
+ case 'strong_open':
127
+ state.bold++;
128
+ break;
129
+ case 'strong_close':
130
+ state.bold--;
131
+ break;
132
+ case 'em_open':
133
+ state.italic++;
134
+ break;
135
+ case 'em_close':
136
+ state.italic--;
137
+ break;
138
+ case 'link_open':
139
+ state.link = t.attrGet('href');
140
+ break;
141
+ case 'link_close':
142
+ state.link = null;
143
+ break;
144
+ case 'code_inline':
145
+ runs.push({ text: t.content, code: true, bold: state.bold > 0, italic: state.italic > 0 });
146
+ break;
147
+ case 'softbreak':
148
+ case 'hardbreak':
149
+ runs.push({ text: ' ', soft: true });
150
+ break;
151
+ case 'image':
152
+ // outside a paragraph (list, cell, heading) an image has no block to go
153
+ // into; inside one, inlineParagraphBlocks() intercepts it first.
154
+ break;
155
+ case 'text':
156
+ default:
157
+ if (t.content) {
158
+ runs.push({
159
+ text: t.content,
160
+ bold: state.bold > 0 || undefined,
161
+ italic: state.italic > 0 || undefined,
162
+ link: state.link || undefined,
163
+ });
164
+ }
165
+ }
166
+ }
167
+
168
+ /** Flattens markdown-it's inline children into styled "runs". */
169
+ function inlineRuns(token) {
170
+ const runs = [];
171
+ const state = { bold: 0, italic: 0, link: null };
172
+ for (const t of token.children ?? []) pushRun(runs, t, state);
173
+ return runs;
174
+ }
175
+
176
+ export const runsToText = (runs) => runs.map((r) => r.text).join('');
177
+
178
+ /** markdown-it `image` token → `image` or `icon` block.
179
+ * `![role](…)`: the alt carries the role when it names one, otherwise the
180
+ * alternative text; `lucide:`/`icon:` switches to an icon. */
181
+ function imageBlock(img) {
182
+ const alt = img.content ?? '';
183
+ const src = img.attrGet('src') ?? '';
184
+ const icon = src.match(/^(?:lucide|icon):(.+)$/i);
185
+ if (icon) {
186
+ return {
187
+ type: 'icon',
188
+ name: icon[1].trim().toLowerCase(),
189
+ color: ICON_COLORS.has(alt) ? alt : 'primary',
190
+ };
191
+ }
192
+ return {
193
+ type: 'image',
194
+ src,
195
+ role: IMAGE_ROLES.has(alt) ? alt : 'auto',
196
+ alt: IMAGE_ROLES.has(alt) ? '' : alt,
197
+ };
198
+ }
199
+
200
+ /** Trims the edge whitespace of a fragment — the whitespace left behind when
201
+ * a neighbouring image is pulled out. A `code_inline`'s content is never
202
+ * touched. */
203
+ function trimEdgeRuns(runs) {
204
+ const blank = (r) => !r.code && !/\S/.test(r.text ?? '');
205
+ const out = runs.slice();
206
+ while (out.length && blank(out[0])) out.shift();
207
+ while (out.length && blank(out[out.length - 1])) out.pop();
208
+ if (!out.length) return out;
209
+ if (!out[0].code) out[0] = { ...out[0], text: out[0].text.replace(/^\s+/, '') };
210
+ const last = out.length - 1;
211
+ if (!out[last].code) out[last] = { ...out[last], text: out[last].text.replace(/\s+$/, '') };
212
+ return out;
213
+ }
214
+
215
+ /** A paragraph's text fragment → `para` block, or `math` when the fragment is
216
+ * an isolated `$$…$$`. An empty fragment produces no block. */
217
+ function paraFromRuns(runs) {
218
+ const trimmed = trimEdgeRuns(runs);
219
+ if (!trimmed.length) return null;
220
+ const math = runsToText(trimmed)
221
+ .trim()
222
+ .match(/^\$\$([\s\S]+)\$\$$/);
223
+ if (math) return { type: 'math', source: math[1].trim() };
224
+ return { type: 'para', runs: trimmed };
225
+ }
226
+
227
+ /**
228
+ * Splits a paragraph's inline content into blocks, in source order.
229
+ *
230
+ * An image is no longer kept on the sole condition of being alone in its
231
+ * paragraph: it always becomes a block, and the text around it stays a
232
+ * paragraph — an image sharing its paragraph with text, or two images side by
233
+ * side, are no longer silently thrown away.
234
+ *
235
+ * The emphasis state crosses images: "**bold ![](a.png) more**" renders both
236
+ * fragments in bold, and a link surrounding an image keeps carrying the text
237
+ * that follows it. Equation detection applies fragment by fragment, so that
238
+ * "![](a.png) $$x^2$$" stays an equation.
239
+ */
240
+ function inlineParagraphBlocks(token) {
241
+ const blocks = [];
242
+ const state = { bold: 0, italic: 0, link: null };
243
+ let runs = [];
244
+ const flush = () => {
245
+ const b = paraFromRuns(runs);
246
+ if (b) blocks.push(b);
247
+ runs = [];
248
+ };
249
+ for (const t of token.children ?? []) {
250
+ if (t.type === 'image') {
251
+ flush();
252
+ blocks.push(imageBlock(t));
253
+ continue;
254
+ }
255
+ pushRun(runs, t, state);
256
+ }
257
+ flush();
258
+ return blocks;
259
+ }
260
+
261
+ /**
262
+ * `chart` specification — a line-by-line format, deliberately minimal:
263
+ *
264
+ * type: bar (bar, barh, line, area, pie, doughnut, radar)
265
+ * categories: Q1, Q2, Q3
266
+ * Sales: 120, 150, 180
267
+ * Costs: 80, 90, 95
268
+ *
269
+ * Each "Name: v1, v2, …" line is a series (decimals with a point).
270
+ * Invalid specification → null: the caller falls back to a code block, and
271
+ * the user sees their source as written rather than a broken slide.
272
+ */
273
+ function parseChartSpec(source) {
274
+ const spec = { chartType: 'bar', categories: [], series: [] };
275
+ for (const raw of source.split('\n')) {
276
+ const line = raw.trim();
277
+ if (!line || line.startsWith('#')) continue;
278
+ const m = line.match(/^([^:]+):\s*(.*)$/);
279
+ if (!m) return null;
280
+ const key = m[1].trim();
281
+ const val = m[2].trim();
282
+ const lower = key.toLowerCase();
283
+ if (lower === 'type') {
284
+ if (!CHART_TYPES.has(val.toLowerCase())) return null;
285
+ spec.chartType = val.toLowerCase();
286
+ } else if (lower === 'categories' || lower === 'catégories') {
287
+ // `catégories` is a deliberately FRENCH input alias of the DSL key, kept
288
+ // for the same reason as PRESET_ALIASES below: it is a value an author
289
+ // types, not prose. Do not "translate" it away — see the note there.
290
+ spec.categories = val
291
+ .split(',')
292
+ .map((s) => s.trim())
293
+ .filter(Boolean);
294
+ } else {
295
+ // `Number('')` is 0: a series with no values ("Sales:"), a hole
296
+ // ("12, , 18") or a trailing comma ("12, 18,") therefore read as silent
297
+ // zeros. An empty cell invalidates the specification — the author sees
298
+ // their source and a diagnostic, never an invented zero.
299
+ const parts = val.split(',').map((s) => s.trim());
300
+ if (parts.some((s) => !s)) return null;
301
+ const values = parts.map(Number);
302
+ // `Number.isNaN` let ±Infinity through ("1e999", "Infinity")
303
+ if (values.some((v) => !Number.isFinite(v))) return null;
304
+ spec.series.push({ name: key, values });
305
+ }
306
+ }
307
+ if (!spec.series.length) return null;
308
+ if (!spec.categories.length) {
309
+ spec.categories = spec.series[0].values.map((_, k) => String(k + 1));
310
+ }
311
+ return spec;
312
+ }
313
+
314
+ /**
315
+ * Trend line of a `:::metric` card: direction (up, down, flat) + text. The
316
+ * direction comes from the first character (`↑ ↗ ↓ ↘ →`, or `+`/`-` in front
317
+ * of a figure, or `=`). The default sentiment follows the direction (up =
318
+ * positive, down = negative, flat = neutral); a `(+)` or `(-)` suffix inverts
319
+ * it — a fall in incidents is good news. Returns null if the line is not a
320
+ * trend.
321
+ */
322
+ export function parseTrend(text) {
323
+ const m = text.match(/^(?:([↑↗])|([↓↘])|(→)|(=)|(?=[+\-−]\s?\d)([+\-−]))\s*(.*)$/u);
324
+ if (!m) return null;
325
+ const dir = m[1]
326
+ ? 'up'
327
+ : m[2] || m[5] === '-' || m[5] === '−'
328
+ ? 'down'
329
+ : m[3]
330
+ ? 'flat'
331
+ : m[4]
332
+ ? 'flat'
333
+ : 'up';
334
+ // the +/− sign is part of the data ("+12 %"); the arrows and "=" are not
335
+ let label = (m[5] ? m[5] + m[6] : m[6]).trim();
336
+ let sentiment = dir === 'up' ? 'positive' : dir === 'down' ? 'negative' : 'neutral';
337
+ const override = label.match(/\s*\((\+|-)\)\s*$/);
338
+ if (override) {
339
+ sentiment = override[1] === '+' ? 'positive' : 'negative';
340
+ label = label.slice(0, override.index).trim();
341
+ }
342
+ return { dir, sentiment, text: label };
343
+ }
344
+
345
+ function parseComment(html) {
346
+ const m = html.match(/<!--\s*(notes|layout|animate)\s*(?::\s*([\s\S]*?))?\s*-->/);
347
+ return m ? { key: m[1], value: (m[2] ?? '').trim() } : null;
348
+ }
349
+
350
+ /** `<!-- animate -->`, `animate: true`… → true; `none|false|off|non|0` → false. */
351
+ export const animateFlag = (value) =>
352
+ !/^(none|false|off|non|0)$/i.test(String(value ?? '').trim() || 'true');
353
+
354
+ /** Reveal effects a slide can impose (`<!-- animate: fade -->`). French
355
+ * aliases are accepted; single source of truth for validation and for
356
+ * `capabilities()`. With no value, the effect is chosen per block type. */
357
+ export const ANIM_PRESETS = ['appear', 'fade', 'wipe', 'zoom'];
358
+ /** The four non-canonical keys below are DELIBERATELY FRENCH: they are DSL
359
+ * input values an author types (`<!-- animate: fondu -->`), not prose, and
360
+ * the alias table is the only thing that makes them work. "Correcting"
361
+ * `fondu: 'fade'` into `fade: 'fade'` breaks such decks with no compile
362
+ * error — `animateFlag` still returns true while `animatePreset` returns
363
+ * null, so the imposed effect is lost in silence and no diagnostic is
364
+ * emitted. `apparaître` also carries a circumflex beside the unaccented
365
+ * `apparaitre`; the lookup lowercases but does no Unicode normalization, so
366
+ * an NFC/NFD shift would half-break the table. Leave this object as it is. */
367
+ const PRESET_ALIASES = {
368
+ appear: 'appear',
369
+ apparaitre: 'appear',
370
+ apparaître: 'appear',
371
+ fade: 'fade',
372
+ fondu: 'fade',
373
+ wipe: 'wipe',
374
+ balayage: 'wipe',
375
+ zoom: 'zoom',
376
+ };
377
+
378
+ /** `animate` value → normalized effect name, or null (boolean or unknown). */
379
+ export const animatePreset = (value) =>
380
+ PRESET_ALIASES[
381
+ String(value ?? '')
382
+ .trim()
383
+ .toLowerCase()
384
+ ] ?? null;
385
+
386
+ /** Aliases (French) accepted alongside the canonical names — for validation's
387
+ * "did you mean" suggestions. */
388
+ export const ANIM_PRESET_ALIASES = Object.keys(PRESET_ALIASES).filter(
389
+ (k) => PRESET_ALIASES[k] !== k,
390
+ );
391
+
392
+ /** `animate` values that are neither a boolean nor a known effect (for the
393
+ * "did you mean" validation). */
394
+ const ANIM_FLAGS = /^(|true|false|none|off|on|non|oui|yes|0|1)$/i;
395
+ export const isKnownAnimateValue = (value) =>
396
+ ANIM_FLAGS.test(String(value ?? '').trim()) || animatePreset(value) !== null;
397
+
398
+ function newSlide() {
399
+ return {
400
+ title: null,
401
+ titleRuns: null,
402
+ layout: null,
403
+ animate: null,
404
+ notes: [],
405
+ sections: [{ heading: null, blocks: [] }],
406
+ };
407
+ }
408
+
409
+ /**
410
+ * Converts markdown-it's token stream into the IR.
411
+ * @returns {{ meta: object, slides: object[] }}
412
+ */
413
+ export function parseDeck(source) {
414
+ // A leading U+FEFF (Windows Notepad, PowerShell `>` redirection) precedes
415
+ // the frontmatter's `---`: without stripping it, the frontmatter is not
416
+ // recognized at all — the metadata is lost and the block ends up in the body
417
+ // as a ghost slide. CI covers windows-latest; the BOM does not cost a line,
418
+ // so source positions stay correct.
419
+ const { meta, body, lineOffset } = splitFrontmatter(source.replace(/^\uFEFF/, ''));
420
+ const tokens = buildMd().parse(body, {});
421
+ // 1-based, in the source file (frontmatter included)
422
+ const lineOf = (t) => (t?.map ? t.map[0] + lineOffset + 1 : undefined);
423
+
424
+ const slides = [];
425
+ let slide = null;
426
+
427
+ /** Directives read outside any slide, pending the next one that opens. */
428
+ let pending = [];
429
+ const orphanDirectives = [];
430
+
431
+ /** Applies a `<!-- key: value -->` directive to a slide. */
432
+ function applyDirective(target, c) {
433
+ if (c.key === 'notes') target.notes.push(c.value);
434
+ else if (c.key === 'layout') {
435
+ target.layout = c.value;
436
+ target.layoutLine = c.line;
437
+ } else if (c.key === 'animate') {
438
+ target.animate = animateFlag(c.value);
439
+ const preset = animatePreset(c.value);
440
+ if (preset) target.animatePreset = preset;
441
+ if (!isKnownAnimateValue(c.value)) {
442
+ target.animateUnknown = c.value;
443
+ target.animateLine = c.line;
444
+ }
445
+ }
446
+ }
447
+
448
+ const ensureSlide = () => {
449
+ if (!slide) {
450
+ slide = newSlide();
451
+ // directives written between the separator and the title govern THIS
452
+ // slide: apply them in source order, as if they had been written just
453
+ // after the `# H1`
454
+ for (const c of pending) applyDirective(slide, c);
455
+ pending = [];
456
+ }
457
+ return slide;
458
+ };
459
+ const curSection = () => slide.sections[slide.sections.length - 1];
460
+ const pushSlide = () => {
461
+ if (slide) slides.push(slide);
462
+ slide = null;
463
+ };
464
+ /** A `---` (or the end of the file) with no slide opened since: the pending
465
+ * directives govern nothing. The engine does not keep quiet about what
466
+ * failed — they come back out for validation. */
467
+ const dropPending = () => {
468
+ orphanDirectives.push(...pending);
469
+ pending = [];
470
+ };
471
+
472
+ let i = 0;
473
+ const n = tokens.length;
474
+
475
+ /** Consumes a `:::name` container's tokens and returns its blocks. */
476
+ function collectUntil(closeType) {
477
+ const blocks = [];
478
+ while (i < n && tokens[i].type !== closeType) {
479
+ const b = readBlock();
480
+ if (b) blocks.push(...(Array.isArray(b) ? b : [b]));
481
+ }
482
+ i++; // skip the *_close
483
+ return blocks;
484
+ }
485
+
486
+ /** Reads a block starting at tokens[i]; advances i. May return null.
487
+ * Every block returned carries `line` (position in the source file). */
488
+ function readBlock() {
489
+ const line = lineOf(tokens[i]);
490
+ const b = readBlockAt();
491
+ if (b && line != null) {
492
+ for (const x of Array.isArray(b) ? b : [b]) x.line ??= line;
493
+ }
494
+ return b;
495
+ }
496
+
497
+ function readBlockAt() {
498
+ const t = tokens[i];
499
+
500
+ // Lists (bulleted or numbered), with nesting.
501
+ //
502
+ // An item can carry something other than text: a table, a code block, a
503
+ // quotation, a callout. Collecting every `inline` token indiscriminately
504
+ // destroyed them — a table's cells became fake bullets, and a code block,
505
+ // which emits no `inline` at all, vanished without a trace. The engine
506
+ // only drops content at the registry's stated boundaries: those blocks are
507
+ // therefore re-read as they are and reinserted IN THEIR PLACE in source
508
+ // order — the list is split at the insertion point (bullets before, block,
509
+ // bullets after) — rather than lost (or merely reported).
510
+ // Only the `inline` tokens of an item's paragraph become bullets.
511
+ if (t.type === 'bullet_list_open' || t.type === 'ordered_list_open') {
512
+ const ordered = t.type === 'ordered_list_open';
513
+ const close = ordered ? 'ordered_list_close' : 'bullet_list_close';
514
+ const out = [];
515
+ let items = [];
516
+ let itemsLine = null;
517
+ // rank of the next top-level item: a numbered list cut in two by a
518
+ // nested block must not restart at "1." after the block. `startAt` is
519
+ // the same convention as the split performed by pagination.
520
+ let rank = 1;
521
+ /** Closes the current list chunk to let a block through. */
522
+ const flushBullets = () => {
523
+ if (!items.length) return;
524
+ const b = { type: 'bullets', ordered, items };
525
+ if (ordered && rank > 1) b.startAt = rank;
526
+ if (itemsLine != null) b.line = itemsLine;
527
+ rank += items.filter((it) => !it.level).length;
528
+ out.push(b);
529
+ items = [];
530
+ itemsLine = null;
531
+ };
532
+ let depth = 0;
533
+ let para = 0;
534
+ i++;
535
+ while (i < n) {
536
+ const u = tokens[i];
537
+ if (
538
+ (u.type === 'bullet_list_open' || u.type === 'ordered_list_open') &&
539
+ depth >= 0 &&
540
+ u.level > t.level
541
+ ) {
542
+ depth++;
543
+ i++;
544
+ continue;
545
+ }
546
+ if (u.type === close && u.level === t.level) {
547
+ i++;
548
+ break;
549
+ }
550
+ if (u.type === 'bullet_list_close' || u.type === 'ordered_list_close') {
551
+ depth--;
552
+ i++;
553
+ continue;
554
+ }
555
+ // A heading indented under a bullet does NOT open a section: its text
556
+ // stays a bullet. It is therefore counted as an item paragraph, or
557
+ // else the `para > 0` below would make it vanish without a trace.
558
+ if (/^(paragraph|heading)_(open|close)$/.test(u.type)) {
559
+ para += u.type.endsWith('_open') ? 1 : -1;
560
+ i++;
561
+ continue;
562
+ }
563
+ if (ITEM_NESTED_BLOCKS.has(u.type) || /^container_\w+_open$/.test(u.type)) {
564
+ // the bullets already read come out BEFORE the block: source order is
565
+ // real, and a table illustrating point 1 does not land after point 3
566
+ flushBullets();
567
+ // readBlock() consumes the whole block: its `inline` tokens (cells,
568
+ // quotation…) can no longer be mistaken for bullets
569
+ const b = readBlock();
570
+ if (b) out.push(...(Array.isArray(b) ? b : [b]));
571
+ continue;
572
+ }
573
+ if (u.type === 'inline' && para > 0) {
574
+ // nesting level: roughly (token level - base level) / 2
575
+ const lvl = Math.max(0, Math.floor((u.level - t.level - 2) / 2));
576
+ items.push({ runs: inlineRuns(u), level: Math.min(lvl, 2) });
577
+ itemsLine ??= lineOf(u);
578
+ }
579
+ i++;
580
+ }
581
+ flushBullets();
582
+ // an empty list stays an empty list (downstream expects the block)
583
+ if (!out.length) return { type: 'bullets', ordered, items: [] };
584
+ return out.length === 1 ? out[0] : out;
585
+ }
586
+
587
+ switch (t.type) {
588
+ case 'paragraph_open': {
589
+ const inline = tokens[i + 1];
590
+ i += 3; // open, inline, close
591
+ if (inline?.type !== 'inline') return null;
592
+ // images and text become a sequence of blocks, in source order
593
+ const blocks = inlineParagraphBlocks(inline);
594
+ if (!blocks.length) return null;
595
+ return blocks.length === 1 ? blocks[0] : blocks;
596
+ }
597
+ case 'heading_open': {
598
+ const depth = Number(t.tag.slice(1));
599
+ const inline = tokens[i + 1];
600
+ i += 3;
601
+ const runs = inline?.type === 'inline' ? inlineRuns(inline) : [];
602
+ return { type: 'heading', depth, runs };
603
+ }
604
+ case 'fence': {
605
+ i++;
606
+ const lang = (t.info || '').trim().split(/\s+/)[0].toLowerCase();
607
+ if (lang === 'mermaid') return { type: 'mermaid', source: t.content };
608
+ if (lang === 'math' || lang === 'latex' || lang === 'tex')
609
+ return { type: 'math', source: t.content.trim() };
610
+ if (lang === 'chart') {
611
+ const spec = parseChartSpec(t.content);
612
+ if (spec) return { type: 'chart', ...spec };
613
+ // spec could not be parsed → code stays visible, never a broken slide
614
+ return { type: 'code', lang, source: t.content.replace(/\n$/, ''), invalidChart: true };
615
+ }
616
+ return { type: 'code', lang, source: t.content.replace(/\n$/, '') };
617
+ }
618
+ case 'code_block': {
619
+ i++;
620
+ return { type: 'code', lang: '', source: t.content.replace(/\n$/, '') };
621
+ }
622
+ case 'blockquote_open': {
623
+ i++;
624
+ const inner = collectUntil('blockquote_close');
625
+ const paras = inner.filter((b) => b.type === 'para');
626
+ // What is not a paragraph (list, table, image) cannot go inside a
627
+ // quotation: the dropped TYPES are kept so that validation says so
628
+ // (QUOTE_CONTENT_DROPPED), instead of a silent loss.
629
+ const dropped = [...new Set(inner.filter((b) => b.type !== 'para').map((b) => b.type))];
630
+ // Convention: a last paragraph starting with "—" = attribution.
631
+ let cite = null;
632
+ if (paras.length > 1) {
633
+ const last = runsToText(paras[paras.length - 1].runs);
634
+ if (/^[—–-]\s*/.test(last)) {
635
+ cite = last.replace(/^[—–-]\s*/, '');
636
+ paras.pop();
637
+ }
638
+ }
639
+ return {
640
+ type: 'quote',
641
+ runs: paras.flatMap((p, k) => (k ? [{ text: ' ' }, ...p.runs] : p.runs)),
642
+ cite,
643
+ ...(dropped.length ? { dropped } : {}),
644
+ };
645
+ }
646
+ case 'table_open': {
647
+ const header = [];
648
+ const rows = [];
649
+ let inHead = false;
650
+ let row = null;
651
+ i++;
652
+ while (i < n && tokens[i].type !== 'table_close') {
653
+ const u = tokens[i];
654
+ if (u.type === 'thead_open') inHead = true;
655
+ else if (u.type === 'thead_close') inHead = false;
656
+ else if (u.type === 'tr_open') row = [];
657
+ else if (u.type === 'tr_close') (inHead ? header : rows).push(row);
658
+ else if (u.type === 'inline') row.push(inlineRuns(u));
659
+ i++;
660
+ }
661
+ i++;
662
+ return { type: 'table', header: header[0] ?? [], rows };
663
+ }
664
+ case 'html_block': {
665
+ i++;
666
+ const c = parseComment(t.content);
667
+ if (c) {
668
+ const directive = { ...c, line: lineOf(t) };
669
+ if (slide) applyDirective(slide, directive);
670
+ else pending.push(directive);
671
+ }
672
+ return null;
673
+ }
674
+ case 'hr': {
675
+ i++;
676
+ return { type: 'hr' };
677
+ }
678
+ default: {
679
+ // :::name containers
680
+ const cm = t.type.match(/^container_(\w+)_open$/);
681
+ if (cm) {
682
+ i++;
683
+ const kind = cm[1];
684
+ const inner = collectUntil(`container_${kind}_close`);
685
+ if (kind === 'metric') {
686
+ // 1st line = value, the rest = label — whether the lines are
687
+ // separate paragraphs or split by a plain soft break
688
+ const lines = [];
689
+ for (const p of inner.filter((b) => b.type === 'para')) {
690
+ let cur = [];
691
+ for (const r of p.runs) {
692
+ if (r.soft) {
693
+ lines.push(cur);
694
+ cur = [];
695
+ } else cur.push(r);
696
+ }
697
+ lines.push(cur);
698
+ }
699
+ const filled = lines.filter((l) => l.length);
700
+ // last line read as a trend (↑ +12 %…) if it has the shape of one
701
+ let trend = null;
702
+ if (filled.length > 1) {
703
+ trend = parseTrend(runsToText(filled[filled.length - 1]).trim());
704
+ if (trend) filled.pop();
705
+ }
706
+ return {
707
+ type: 'metric',
708
+ value: filled[0] ? runsToText(filled[0]).trim() : '',
709
+ label: filled
710
+ .slice(1)
711
+ .map((l) => runsToText(l).trim())
712
+ .join(' '),
713
+ ...(trend ? { trend } : {}),
714
+ };
715
+ }
716
+ return { type: 'alert', kind, blocks: inner };
717
+ }
718
+ i++; // unhandled token
719
+ return null;
720
+ }
721
+ }
722
+ }
723
+
724
+ while (i < n) {
725
+ const t = tokens[i];
726
+
727
+ if (t.type === 'hr') {
728
+ i++;
729
+ pushSlide();
730
+ // a directive still pending here precedes a `---` with no slide having
731
+ // opened in between: it governed nothing
732
+ dropPending();
733
+ continue;
734
+ }
735
+
736
+ const read = readBlock();
737
+ if (!read) continue;
738
+
739
+ // a paragraph mixing text and images returns several blocks: all of them
740
+ // are placed, in source order
741
+ for (const block of Array.isArray(read) ? read : [read]) {
742
+ if (block.type === 'heading' && block.depth === 1) {
743
+ pushSlide();
744
+ ensureSlide();
745
+ slide.titleRuns = block.runs;
746
+ slide.title = runsToText(block.runs);
747
+ slide.line ??= block.line;
748
+ continue;
749
+ }
750
+
751
+ ensureSlide();
752
+ slide.line ??= block.line;
753
+ if (block.type === 'heading' && block.depth === 2) {
754
+ // new section (potential slot)
755
+ if (curSection().heading !== null || curSection().blocks.length) {
756
+ slide.sections.push({ heading: block.runs, blocks: [] });
757
+ } else {
758
+ curSection().heading = block.runs;
759
+ }
760
+ continue;
761
+ }
762
+ if (block.type === 'hr') continue;
763
+ curSection().blocks.push(block);
764
+ }
765
+ }
766
+ pushSlide();
767
+ dropPending();
768
+
769
+ return {
770
+ meta,
771
+ slides: slides.filter((s) => s.title || s.sections.some((x) => x.blocks.length || x.heading)),
772
+ // key absent when there is nothing to report: the IR of a healthy deck
773
+ // does not change shape for an anomaly it does not have
774
+ ...(orphanDirectives.length ? { orphanDirectives } : {}),
775
+ };
776
+ }