@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,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
|
+
* - `` → image role;
|
|
20
|
+
* - `` → remote image, downloaded and copied locally;
|
|
21
|
+
* - `` → 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
|
+
* ``: 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  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
|
+
* " $$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
|
+
}
|