@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.
- package/README.md +15 -6
- package/package.json +4 -3
- package/src/cli.mjs +81 -2
- package/src/deck/assets.mjs +121 -13
- package/src/deck/browser.mjs +269 -0
- package/src/deck/layout.mjs +5 -2
- package/src/deck/marp.mjs +324 -0
- package/src/deck/mermaid-render.mjs +147 -0
- package/src/deck/parse.mjs +172 -3
- package/src/deck/validate.mjs +83 -22
- package/src/html/render.mjs +13 -3
- package/src/pptx/fonts.mjs +145 -7
- package/src/pptx/render.mjs +52 -14
- package/vendor/mermaid/LICENSE +21 -0
- package/vendor/mermaid/README.md +44 -0
- package/vendor/mermaid/mermaid.min.js +3587 -0
package/src/deck/layout.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|