@lutrin/core 1.0.0 → 1.1.1

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.
@@ -28,6 +28,7 @@ import {
28
28
  LINE_HEIGHT,
29
29
  contentArea,
30
30
  } from './tokens.mjs';
31
+ import { isMarpDeck } from './marp.mjs';
31
32
  import { ALERT_BLOCK_TYPES, animateFlag, animatePreset, runsToText } from './parse.mjs';
32
33
  import { closest } from './suggest.mjs';
33
34
 
@@ -908,8 +909,10 @@ export function buildScenes(deck) {
908
909
  const deckAnimate = meta.animate != null && animateFlag(meta.animate);
909
910
  const deckPreset = meta.animate != null ? animatePreset(meta.animate) : null;
910
911
 
911
- // Implicit cover slide from the frontmatter
912
- if (meta.title) {
912
+ // Implicit cover slide from the frontmatter. Not for a Marp deck: there,
913
+ // `title:` is HTML metadata (Marp CLI) and the cover is the deck's own
914
+ // first slide — an implicit one would double it.
915
+ if (meta.title && !isMarpDeck(meta)) {
913
916
  scenes.push({
914
917
  master: 'cover',
915
918
  layout: 'cover',
@@ -0,0 +1,324 @@
1
+ /**
2
+ * Marp dialect (Marpit + Marp Core) — helpers of the front end.
3
+ *
4
+ * A deck opting in with `marp: true` in its frontmatter is read with Marp
5
+ * semantics instead of the lutrin DSL:
6
+ * - slides split on `---` only (`# H1` does not split; `headingDivider`
7
+ * restores heading splits when the deck asks for it);
8
+ * - the first `#`/`##` of a slide is its title, later `##` open sections;
9
+ * - an HTML comment is a presenter note, unless it carries directives;
10
+ * - `![bg …]` images become the slide background (or a split side), and
11
+ * the Marp sizing/filter keywords of an alt are consumed;
12
+ * - fragmented lists (`*` bullets, `1)` items) animate their slide;
13
+ * - `<!-- fit -->` in a heading is removed (fitting is the engine's job).
14
+ *
15
+ * Marp directives with no lutrin equivalent are COLLECTED, not lost: the
16
+ * parser returns them in `deck.marpIgnored` and validation reports each one
17
+ * (MARP_DIRECTIVE_IGNORED, info). Purely cosmetic directives that lutrin's
18
+ * engine already decides on its own (class, paginate, math, lang…) are
19
+ * accepted in silence. The lutrin extensions — `<!-- layout: … -->`,
20
+ * `<!-- notes: … -->`, `<!-- animate -->`, `kit:` and the `:::` callouts —
21
+ * keep working inside a Marp deck.
22
+ */
23
+
24
+ /** Marp global directives (deck-wide; also the Marp CLI metadata keys). */
25
+ export const MARP_GLOBAL = new Set([
26
+ 'theme',
27
+ 'style',
28
+ 'headingDivider',
29
+ 'lang',
30
+ 'size',
31
+ 'math',
32
+ 'marp',
33
+ 'title',
34
+ 'description',
35
+ 'keywords',
36
+ 'url',
37
+ 'image',
38
+ ]);
39
+
40
+ /** Marp local directives (per-slide, inherited; `_` prefix = spot). */
41
+ export const MARP_LOCAL = new Set([
42
+ 'paginate',
43
+ 'header',
44
+ 'footer',
45
+ 'class',
46
+ 'backgroundColor',
47
+ 'backgroundImage',
48
+ 'backgroundPosition',
49
+ 'backgroundRepeat',
50
+ 'backgroundSize',
51
+ 'color',
52
+ ]);
53
+
54
+ /** lutrin's own comment directives — valid in a Marp deck too (extension). */
55
+ const LUTRIN_COMMENT_KEYS = new Set(['notes', 'layout', 'animate']);
56
+
57
+ /** `marp: true` (frontmatter) opts the deck into the Marp dialect. */
58
+ export const isMarpDeck = (meta) => /^true$/i.test(String(meta?.marp ?? '').trim());
59
+
60
+ /**
61
+ * Sorts a Marp directive into what the engine does with it:
62
+ * - 'lutrin' — layout/notes/animate, routed through the normal machinery;
63
+ * - 'footer' — mapped onto the deck footer (meta.footer, last one wins);
64
+ * - 'divider' — headingDivider, changes how slides split;
65
+ * - 'silent' — accepted without effect NOR noise: the engine already
66
+ * decides pagination, classes, math rendering… on its own;
67
+ * - 'ignored' — no lutrin equivalent; reported by MARP_DIRECTIVE_IGNORED.
68
+ * Spot directives (`_key`) classify like their base key, except `_footer`,
69
+ * which would need a per-slide footer lutrin does not have.
70
+ */
71
+ export function classifyMarpDirective(key) {
72
+ const spot = key.startsWith('_');
73
+ const base = spot ? key.slice(1) : key;
74
+ if (LUTRIN_COMMENT_KEYS.has(base)) return spot ? 'ignored' : 'lutrin';
75
+ if (base === 'footer') return spot ? 'ignored' : 'footer';
76
+ // `_headingDivider` does not exist in Marp (the `_` prefix only applies to
77
+ // LOCAL directives): consumed in silence, like Marp Core does with an
78
+ // unknown key mixed into a directive comment
79
+ if (base === 'headingDivider') return spot ? 'silent' : 'divider';
80
+ // pagination, section classes, math engine, language and the HTML metadata
81
+ // (title, description…) are things the engine or the renderer already
82
+ // handles its own way: accepting them costs nothing and warns about nothing
83
+ if (
84
+ [
85
+ 'paginate',
86
+ 'class',
87
+ 'math',
88
+ 'lang',
89
+ 'marp',
90
+ 'title',
91
+ 'subtitle',
92
+ 'author',
93
+ 'date',
94
+ 'description',
95
+ 'keywords',
96
+ 'url',
97
+ 'image',
98
+ ].includes(base)
99
+ ) {
100
+ return 'silent';
101
+ }
102
+ if (MARP_LOCAL.has(base)) return 'ignored';
103
+ if (MARP_GLOBAL.has(base)) return 'ignored';
104
+ return 'silent';
105
+ }
106
+
107
+ const DIRECTIVE_KEY = /^(_?[A-Za-z][\w-]*)\s*:\s*(.*?)\s*$/;
108
+ /** The lutrin comment grammar, valid on a comment's WHOLE body: it accepts a
109
+ * bare `<!-- animate -->` and a multiline `<!-- notes: … -->`, which the
110
+ * strict line-by-line reading below would misread as prose. */
111
+ const LUTRIN_COMMENT = /^\s*(notes|layout|animate)\s*(?::\s*([\s\S]*?))?\s*$/;
112
+
113
+ const isKnownKey = (key) => {
114
+ // `_` only exists on Marp's LOCAL directives; anything else spot-prefixed
115
+ // is not a directive at all — the comment stays a note, like in Marpit
116
+ if (key.startsWith('_')) return MARP_LOCAL.has(key.slice(1));
117
+ return MARP_LOCAL.has(key) || MARP_GLOBAL.has(key) || LUTRIN_COMMENT_KEYS.has(key);
118
+ };
119
+
120
+ /** One comment body → its directives, or null when the comment is prose. */
121
+ function commentDirectives(body) {
122
+ const lutrin = body.match(LUTRIN_COMMENT);
123
+ if (lutrin) return [{ key: lutrin[1], value: (lutrin[2] ?? '').trim() }];
124
+ const lines = body.split(/\r?\n/).filter((l) => l.trim() !== '');
125
+ const pairs = lines.map((l) => l.trim().match(DIRECTIVE_KEY));
126
+ if (!lines.length || !pairs.every(Boolean) || !pairs.some((m) => isKnownKey(m[1]))) return null;
127
+ return pairs.map((m) => ({ key: m[1], value: m[2].replace(/^["']|["']$/g, '') }));
128
+ }
129
+
130
+ /**
131
+ * Reads an html_block in Marp mode: `{ directives, notes }`, each possibly
132
+ * empty. Each comment of the block is classified ON ITS OWN, per the Marp
133
+ * contract: every non-empty line `key: value` with at least one known key
134
+ * (Marp or lutrin) → directives; any other comment → a presenter note,
135
+ * whitespace-trimmed, line breaks preserved. Returns null when the block is
136
+ * not made of comments (raw HTML: ignored, as in the lutrin DSL, except
137
+ * `<style>` which is reported as the `style` directive it is).
138
+ */
139
+ export function parseMarpComment(html) {
140
+ if (/^\s*<style[\s>]/i.test(html))
141
+ return { directives: [{ key: 'style', value: '' }], notes: [] };
142
+ const comments = [...html.matchAll(/<!--([\s\S]*?)-->/g)];
143
+ if (!comments.length) return null;
144
+ // anything outside the comments (real markup) disqualifies the block
145
+ if (/\S/.test(html.replace(/<!--[\s\S]*?-->/g, ''))) return null;
146
+ const directives = [];
147
+ const notes = [];
148
+ for (const m of comments) {
149
+ const parsed = commentDirectives(m[1]);
150
+ if (parsed) {
151
+ directives.push(...parsed);
152
+ continue;
153
+ }
154
+ const note = m[1]
155
+ .split(/\r?\n/)
156
+ .map((l) => l.trim())
157
+ .join('\n')
158
+ .trim();
159
+ if (note) notes.push(note);
160
+ }
161
+ return { directives, notes };
162
+ }
163
+
164
+ /**
165
+ * `headingDivider: 2` splits before every heading of level <= 2;
166
+ * `headingDivider: [1, 3]` only at the exact levels listed. Returns a Set of
167
+ * levels, or null when the value cannot be read (the deck then splits on
168
+ * `---` only, like a value-less Marp deck).
169
+ */
170
+ export function parseHeadingDivider(value) {
171
+ const raw = String(value ?? '').trim();
172
+ const list = raw.match(/^\[([^\]]*)\]$/);
173
+ const levels = new Set();
174
+ if (list) {
175
+ for (const part of list[1].split(',')) {
176
+ const n = Number(part.trim());
177
+ if (Number.isInteger(n) && n >= 1 && n <= 6) levels.add(n);
178
+ }
179
+ return levels.size ? levels : null;
180
+ }
181
+ const n = Number(raw);
182
+ if (!Number.isInteger(n) || n < 1 || n > 6) return null;
183
+ for (let k = 1; k <= n; k++) levels.add(k);
184
+ return levels;
185
+ }
186
+
187
+ /**
188
+ * Interprets the frontmatter of a Marp deck, in place. `theme:` is moved to
189
+ * `marpTheme` so that it never reaches kit resolution — a Marp theme name is
190
+ * not a lutrin kit, and leaving it would turn `theme: gaia` into a fatal
191
+ * KIT_NOT_FOUND. Returns the heading divider and the directives to report.
192
+ */
193
+ export function marpMeta(meta) {
194
+ const ignored = [];
195
+ if (meta.theme != null) {
196
+ meta.marpTheme = meta.theme;
197
+ delete meta.theme;
198
+ ignored.push({ key: 'theme', value: meta.marpTheme });
199
+ }
200
+ let divider = null;
201
+ if (meta.headingDivider != null) divider = parseHeadingDivider(meta.headingDivider);
202
+ for (const [key, value] of Object.entries(meta)) {
203
+ if (key === 'headingDivider' || key === 'marpTheme') continue;
204
+ if (key === 'size') {
205
+ // only worth a report when it asks for something else than the engine's
206
+ // fixed 16:9 canvas
207
+ if (!/^16\s*:\s*9$/.test(String(value).trim())) ignored.push({ key, value });
208
+ continue;
209
+ }
210
+ if (classifyMarpDirective(key) === 'ignored') ignored.push({ key, value });
211
+ }
212
+ return { divider, ignored };
213
+ }
214
+
215
+ const BG_SIZE = /^(fit|contain|cover|auto|\d+(?:\.\d+)?%)$/;
216
+ const DIMENSION = /^(?:w|h|width|height):\S+$/;
217
+ const FILTER =
218
+ /^(blur|brightness|contrast|drop-shadow|grayscale|hue-rotate|invert|opacity|saturate|sepia)(?::\S+)?$/;
219
+ const SPLIT_SIDE = /^(left|right)(?::\d+(?:\.\d+)?%)?$/;
220
+
221
+ /**
222
+ * Maps the Marp alt grammar of an image block, in place. `![bg …]` becomes
223
+ * the slide background (role `background`), `bg left`/`bg right` the image
224
+ * side of a split slide; sizing keywords and CSS filters are consumed — the
225
+ * engine sizes and styles on its own. Whatever remains is the alt text.
226
+ */
227
+ export function marpImage(block) {
228
+ if (block.type !== 'image') return block;
229
+ const words = (block.alt ?? '').split(/\s+/).filter(Boolean);
230
+ // like Marpit, `bg` is recognized at ANY position among the alt keywords
231
+ const bgIdx = words.indexOf('bg');
232
+ const bg = bgIdx !== -1;
233
+ if (bg) words.splice(bgIdx, 1);
234
+ let side = null;
235
+ const rest = [];
236
+ for (const w of words) {
237
+ if (bg) {
238
+ const s = w.match(SPLIT_SIDE);
239
+ if (s) {
240
+ side = s[1];
241
+ continue;
242
+ }
243
+ if (w === 'vertical' || BG_SIZE.test(w)) continue;
244
+ }
245
+ if (DIMENSION.test(w) || FILTER.test(w)) continue;
246
+ rest.push(w);
247
+ }
248
+ if (bg) block.role = side ?? 'background';
249
+ block.alt = rest.join(' ');
250
+ return block;
251
+ }
252
+
253
+ /** Removes inline HTML comments (`# <!-- fit --> Title`, a comment written
254
+ * mid paragraph) from a run list. A code run is never touched. Each removed
255
+ * comment body is handed to `onComment` — Marpit reads directives and notes
256
+ * in inline comments too, they must not vanish. The `fit` marker is the one
257
+ * inline comment that is pure markup: it is dropped without a callback. */
258
+ export function stripInlineComments(runs, onComment) {
259
+ const out = [];
260
+ for (const r of runs ?? []) {
261
+ if (r.code || !r.text?.includes('<!--')) {
262
+ out.push(r);
263
+ continue;
264
+ }
265
+ const text = r.text.replace(/<!--([\s\S]*?)-->/g, (_, body) => {
266
+ if (onComment && !/^\s*fit\s*$/.test(body)) onComment(body);
267
+ return '';
268
+ });
269
+ if (/\S/.test(text) || r.soft) out.push({ ...r, text });
270
+ }
271
+ // the comment leaves its separating space behind ("<!-- fit --> Title"):
272
+ // trim the edges so the title does not start with a blank
273
+ if (out.length && !out[0].code) out[0] = { ...out[0], text: out[0].text.replace(/^\s+/, '') };
274
+ const last = out.length - 1;
275
+ if (last >= 0 && !out[last].code)
276
+ out[last] = { ...out[last], text: out[last].text.replace(/\s+$/, '') };
277
+ return out.filter((r) => r.code || r.soft || r.text !== '' || r.link);
278
+ }
279
+
280
+ /**
281
+ * Marp post-reading of the blocks a readBlock() returned: images remapped
282
+ * (`![bg …]`), inline comments stripped from the texts and routed through
283
+ * `onComment` (directives and notes live in inline comments too), paragraphs
284
+ * emptied by the stripping dropped. `fragmented` flags survive only on
285
+ * top-level bullet blocks — the slide loop turns them into the slide's
286
+ * animation.
287
+ */
288
+ export function marpBlocks(read, onComment) {
289
+ const blocks = Array.isArray(read) ? read : [read];
290
+ const out = [];
291
+ for (const b of blocks) {
292
+ switch (b.type) {
293
+ case 'image':
294
+ out.push(marpImage(b));
295
+ continue;
296
+ case 'para':
297
+ case 'quote': {
298
+ b.runs = stripInlineComments(b.runs, onComment);
299
+ if (b.runs.length) out.push(b);
300
+ continue;
301
+ }
302
+ case 'heading':
303
+ b.runs = stripInlineComments(b.runs, onComment);
304
+ out.push(b);
305
+ continue;
306
+ case 'bullets':
307
+ b.items = b.items
308
+ .map((it) => ({ ...it, runs: stripInlineComments(it.runs, onComment) }))
309
+ .filter((it) => it.runs.length);
310
+ out.push(b);
311
+ continue;
312
+ case 'alert':
313
+ // a fragmented list nested in a callout reveals with the callout:
314
+ // the flag must not leak into the IR
315
+ for (const inner of b.blocks) delete inner.fragmented;
316
+ out.push(b);
317
+ continue;
318
+ default:
319
+ out.push(b);
320
+ }
321
+ }
322
+ if (!out.length) return null;
323
+ return out.length === 1 && !Array.isArray(read) ? out[0] : out;
324
+ }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Renders one Mermaid diagram to SVG, in a browser, as a CHILD PROCESS.
3
+ *
4
+ * Not a library function on purpose. `renderMermaidCached()` is synchronous,
5
+ * and both its callers are too — `htmlMermaid()` (html/render.mjs) and
6
+ * `addMermaid()` (pptx/render.mjs) sit deep inside synchronous block-dispatch
7
+ * loops. Puppeteer is asynchronous. Turning the whole rendering chain async to
8
+ * accommodate it would touch every layer down to the CLI, for a rendering that
9
+ * is cached to disk and therefore runs approximately never.
10
+ *
11
+ * So this file is spawned with `execFileSync(process.execPath, …)`, exactly as
12
+ * `mmdc` was — the caller keeps a blocking call, the timeout, the process
13
+ * isolation the preview worker already relies on (worker.mjs, which exists so
14
+ * that a browser launch never blocks the editor's renderer), and a crash here
15
+ * cannot take the compiler with it.
16
+ *
17
+ * Contract — argv[2] is a JSON file holding the request (see below); writes the
18
+ * rendered file to `out` and exits 0, or writes a diagnosis to stderr and exits
19
+ * non-zero. Nothing goes to stdout: the parent reads only the exit code.
20
+ *
21
+ * The PNG rasterization happens HERE rather than in the parent, though
22
+ * `svgToPng()` already exists there: it is async, and the whole point of this
23
+ * child is to hand the parent one blocking call that yields a finished file.
24
+ * Fonts therefore travel in the request — the child must not import the theme.
25
+ */
26
+
27
+ import fs from 'node:fs';
28
+
29
+ const [, , requestFile] = process.argv;
30
+
31
+ if (!requestFile) {
32
+ console.error('usage: node mermaid-render.mjs <request.json>');
33
+ process.exit(2);
34
+ }
35
+
36
+ const {
37
+ source,
38
+ config,
39
+ out,
40
+ browser: executablePath,
41
+ mermaidBundle,
42
+ format = 'svg',
43
+ scale = 3,
44
+ fontFiles = [],
45
+ defaultFontFamily,
46
+ } = JSON.parse(fs.readFileSync(requestFile, 'utf8'));
47
+
48
+ /** Intrinsic width of the produced SVG, in px — the base the PNG scale
49
+ * multiplies. Mermaid states it on the root element; the viewBox is the
50
+ * fallback, and 800 the last resort (a diagram is never rendered at a
51
+ * width of zero because an attribute moved). `width="100%"` (mermaid's
52
+ * useMaxWidth default) is NOT an intrinsic width — reading its `100` used
53
+ * to rasterize every diagram 300 px wide, blurry once stretched into its
54
+ * slot; a percentage falls through to the viewBox. */
55
+ function svgWidth(svg) {
56
+ const attr = /<svg[^>]*\swidth="([\d.]+)(?:px)?"/i.exec(svg);
57
+ if (attr) return Number(attr[1]);
58
+ const box = /<svg[^>]*\sviewBox="[\d.-]+ [\d.-]+ ([\d.]+)/i.exec(svg);
59
+ return box ? Number(box[1]) : 800;
60
+ }
61
+
62
+ /**
63
+ * Shuts the browser down without ever hanging on it.
64
+ *
65
+ * `browser.close()` alone is NOT enough, and this cost an afternoon: driving a
66
+ * full Chrome (as opposed to the headless shell), `launch()` returns in about a
67
+ * second and `close()` then never resolves — the diagram was already written to
68
+ * disk, the process simply refused to die, and the parent's 60 s timeout turned
69
+ * a successful render into a failure. Chrome is asked politely, given five
70
+ * seconds, and killed.
71
+ *
72
+ * The render is finished by the time this runs, so nothing is lost by being
73
+ * brutal here.
74
+ */
75
+ async function shutdown(browser) {
76
+ if (!browser) return;
77
+ const proc = browser.process();
78
+ try {
79
+ await Promise.race([browser.close(), new Promise((r) => setTimeout(r, 5000))]);
80
+ } catch {
81
+ /* closing is best-effort: the kill below is what actually guarantees it */
82
+ }
83
+ try {
84
+ proc?.kill('SIGKILL');
85
+ } catch {
86
+ /* already gone */
87
+ }
88
+ }
89
+
90
+ let browser;
91
+ try {
92
+ const { default: puppeteer } = await import('puppeteer-core');
93
+
94
+ browser = await puppeteer.launch({
95
+ executablePath,
96
+ headless: true,
97
+ // --no-sandbox: the child renders a diagram from the deck being compiled,
98
+ // on the author's own machine, in a browser that loads no remote origin —
99
+ // and without it every containerised or root run (CI images, devcontainers)
100
+ // fails to start at all. The sandbox would be guarding against content we
101
+ // already trust enough to compile.
102
+ args: ['--no-sandbox', '--disable-dev-shm-usage'],
103
+ });
104
+
105
+ const page = await browser.newPage();
106
+ // about:blank with no network: the bundle is injected from disk, and the
107
+ // diagram source never leaves the machine.
108
+ await page.setContent('<!doctype html><html><body><div id="container"></div></body></html>');
109
+ await page.addScriptTag({ path: mermaidBundle });
110
+
111
+ const svg = await page.evaluate(
112
+ async (src, cfg) =>
113
+ // eslint-disable-next-line no-undef
114
+ {
115
+ // eslint-disable-next-line no-undef
116
+ mermaid.initialize({ startOnLoad: false, ...cfg });
117
+ // eslint-disable-next-line no-undef
118
+ const { svg } = await mermaid.render('lutrin-diagram', src);
119
+ return svg;
120
+ },
121
+ source,
122
+ config,
123
+ );
124
+
125
+ if (!svg || !/^\s*<svg/i.test(svg)) throw new Error('mermaid returned no SVG');
126
+
127
+ if (format === 'png') {
128
+ const { Resvg } = await import('@resvg/resvg-js');
129
+ const img = new Resvg(svg, {
130
+ fitTo: { mode: 'width', value: Math.max(1, Math.round(svgWidth(svg) * scale)) },
131
+ font: { fontFiles, loadSystemFonts: true, defaultFontFamily },
132
+ }).render();
133
+ fs.writeFileSync(out, img.asPng());
134
+ } else {
135
+ fs.writeFileSync(out, svg);
136
+ }
137
+
138
+ await shutdown(browser);
139
+ process.exit(0);
140
+ } catch (err) {
141
+ // The message matters: a broken diagram source and a browser that will not
142
+ // start are the same "null" to the caller, and only this line tells them
143
+ // apart when someone finally goes looking.
144
+ console.error(err?.message ?? String(err));
145
+ await shutdown(browser);
146
+ process.exit(1);
147
+ }