@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,910 @@
1
+ /**
2
+ * External assets: remote images, Lucide icons, LaTeX equations, Mermaid
3
+ * diagrams.
4
+ *
5
+ * Common principle: everything that comes from elsewhere is first materialized
6
+ * as a local file (a copy is kept, and embedded in the deliverable); never a
7
+ * network dependency at the moment the presentation is opened.
8
+ *
9
+ * - remote images → downloaded into the user cache
10
+ * `~/.cache/lutrin/remote/` (shared across projects; compiling writes
11
+ * nothing into the source tree), or copied into `assets/remote/`
12
+ * next to the .md on explicit request — `assets: vendor` — when the
13
+ * deck directory must stay self-contained;
14
+ * - `lucide:` icons → SVG resolved from node_modules/lucide-static,
15
+ * otherwise from the user cache `~/.cache/lutrin/icons/lucide/`,
16
+ * otherwise downloaded from unpkg at the PINNED version (lucideVersion)
17
+ * and then cached — recolored (`iconSvg`) then rasterized
18
+ * to PNG when needed (`renderIcon`);
19
+ * - LaTeX equations → MathJax (tex → SVG, glyphs as paths:
20
+ * `mathSvg`) then PNG when needed (`renderMath`);
21
+ * - Mermaid diagrams → mmdc (optional), as PNG (PPTX) or SVG (HTML).
22
+ *
23
+ * Every asset therefore exists in two flavors: SVG (inlined as is by the HTML
24
+ * renderer) and a high-density PNG via @resvg/resvg-js (PowerPoint handles
25
+ * embedded SVG poorly, and not at all before 2019 — PNG is the safe format).
26
+ *
27
+ * Everything DOWNLOADED ends up embedded in the deliverable: that is what
28
+ * makes every URL in the deck a read primitive on the machine that compiles,
29
+ * and what justifies this module's guards — admitted scheme, public addresses
30
+ * only at every redirect hop (`fetchPublic`), bounded bodies, pinned CDN
31
+ * version. None of this is decorative caution: without these guards, a
32
+ * `![](http://169.254.169.254/…)` exfiltrates cloud credentials into a .pptx.
33
+ */
34
+
35
+ import fs from 'node:fs';
36
+ import os from 'node:os';
37
+ import path from 'node:path';
38
+ import crypto from 'node:crypto';
39
+ import dns from 'node:dns';
40
+ import { execFileSync } from 'node:child_process';
41
+ import { createRequire } from 'node:module';
42
+ import { fileURLToPath } from 'node:url';
43
+ import { COLORS, FONTS, FONT_FILES } from './tokens.mjs';
44
+
45
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
46
+ const require = createRequire(import.meta.url);
47
+
48
+ /** Root of an installed package, wherever the package manager hoisted it. */
49
+ function packageRoot(name) {
50
+ try {
51
+ return path.dirname(require.resolve(`${name}/package.json`));
52
+ } catch {
53
+ return null;
54
+ }
55
+ }
56
+
57
+ const LUCIDE_LOCAL = packageRoot('lucide-static')
58
+ ? path.join(packageRoot('lucide-static'), 'icons')
59
+ : path.join(ROOT, 'node_modules', 'lucide-static', 'icons');
60
+ // user cache (never inside the installed package: node_modules must stay
61
+ // read-only) — same root as the Mermaid cache
62
+ const LUCIDE_CACHE = path.join(userCacheRoot(), 'icons', 'lucide');
63
+
64
+ /**
65
+ * Version of `lucide-static` to ask the CDN for — the one THIS package
66
+ * declares in its dependencies, never `@latest`.
67
+ *
68
+ * `@latest` made a deck's rendering depend on the day the first compilation
69
+ * happened: the SVG served that day entered the user cache, which never
70
+ * expires, and two machines on the same team could inline two different
71
+ * drawings for the same icon. Pinning also means the downloaded version is
72
+ * the one `lucide-static` was installed against locally — the two sources of
73
+ * an icon (node_modules and CDN) no longer diverge.
74
+ *
75
+ * @returns {string|null} the exact version, or null if it cannot be determined
76
+ */
77
+ let _lucideVersion; // memoized: two reads of package.json per process are enough
78
+ function lucideVersion() {
79
+ if (_lucideVersion !== undefined) return _lucideVersion;
80
+ const semver = (v) =>
81
+ typeof v === 'string' ? (v.match(/\d+\.\d+\.\d+(?:-[\w.]+)?/)?.[0] ?? null) : null;
82
+ let found = null;
83
+ try {
84
+ // the declared range ("^1.24.0") reduced to its exact bound
85
+ const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8'));
86
+ found = semver(pkg?.dependencies?.['lucide-static'] ?? pkg?.devDependencies?.['lucide-static']);
87
+ } catch {
88
+ /* package.json could not be read: fall back on the installed package */
89
+ }
90
+ if (!found) {
91
+ try {
92
+ found = semver(
93
+ JSON.parse(
94
+ fs.readFileSync(path.join(packageRoot('lucide-static') ?? '', 'package.json'), 'utf8'),
95
+ )?.version,
96
+ );
97
+ } catch {
98
+ /* not installed either */
99
+ }
100
+ }
101
+ return (_lucideVersion = found);
102
+ }
103
+
104
+ /** Pinned CDN base, or null — in which case NOTHING is downloaded: better a
105
+ * missing icon (diagnostic, renderer fallback) than an SVG of undetermined
106
+ * origin inlined into the deliverable. */
107
+ function lucideCdn() {
108
+ const v = lucideVersion();
109
+ return v ? `https://unpkg.com/lucide-static@${v}/icons` : null;
110
+ }
111
+
112
+ /** Bound on the CDN response. A Lucide icon weighs a few hundred bytes;
113
+ * 64 KB leaves all the room in the world and shuts the door on a bulky
114
+ * error page — or on an endless response. */
115
+ export const LUCIDE_MAX_BYTES = 64 * 1024;
116
+
117
+ // ---------------------------------------------------------------------------
118
+ // SVG → PNG (resvg)
119
+ // ---------------------------------------------------------------------------
120
+
121
+ let _resvg = null;
122
+ async function resvg() {
123
+ // Test injection point: `@resvg/resvg-js` is a SINGLE-PLATFORM native
124
+ // binary once packaged (its prebuilds are twelve optionalDependencies, of
125
+ // which npm installs only the one for the building machine) — its absence
126
+ // on the end user's machine is the REAL case we must be able to reproduce,
127
+ // and that no ESM import mock reproduces cleanly. Documented nowhere but
128
+ // here: this is not a setting.
129
+ if (process.env.LUTRIN_NO_RASTER === '1') return null;
130
+ if (_resvg === null) {
131
+ try {
132
+ _resvg = (await import('@resvg/resvg-js')).Resvg;
133
+ } catch {
134
+ _resvg = false;
135
+ }
136
+ }
137
+ return _resvg || null;
138
+ }
139
+
140
+ /**
141
+ * Is the rasterizer usable on this machine?
142
+ *
143
+ * Exposed so the renderers can SAY so instead of silently falling back on a
144
+ * code block: a .pptx whose charts have been replaced by their specification
145
+ * in text compiles, exits with code 0, and is only discovered in the meeting
146
+ * room (RASTER_UNAVAILABLE diagnostic, pptx/render.mjs).
147
+ */
148
+ export async function rasterAvailable() {
149
+ return (await resvg()) !== null;
150
+ }
151
+
152
+ /**
153
+ * Resolves the path of a local image relative to the deck. markdown-it
154
+ * percent-encodes URLs (`My photo é.png` → `My%20photo%20%C3%A9.png`): if the
155
+ * path as given does not exist, its decoded form is tried — file names with
156
+ * accents or spaces, and `![[…]]` embeds translated by the hosts (Obsidian
157
+ * plugin).
158
+ */
159
+ export function resolveImagePath(baseDir, src) {
160
+ const direct = path.resolve(baseDir, src);
161
+ if (fs.existsSync(direct)) return direct;
162
+ try {
163
+ const decoded = path.resolve(baseDir, decodeURIComponent(src));
164
+ if (fs.existsSync(decoded)) return decoded;
165
+ } catch {
166
+ // invalid % sequence: the raw path is authoritative
167
+ }
168
+ return direct;
169
+ }
170
+
171
+ /**
172
+ * Does a resolved file fall under one of the trusted roots?
173
+ *
174
+ * The LOCAL counterpart of the SSRF guard. A local image is READ and then
175
+ * embedded (base64) in the deliverable: `![bg](/Users/victim/.ssh/id_rsa)` or
176
+ * `![bg](../../../.ssh/id_rsa)` in a deck received from a third party would
177
+ * exfiltrate an arbitrary file from the machine that compiles. Image sources
178
+ * are therefore confined to the deck directory, plus the project/vault roots
179
+ * the host declares (VS Code: the workspace directory; Obsidian: the vault
180
+ * root, so that attachments filed elsewhere stay readable).
181
+ *
182
+ * Same shape as `insideKit`/`within`: lexical check first (a path that escapes
183
+ * is refused whether it exists or not), then the real one (realpath) to close
184
+ * the symbolic links that would lead outside. Roots are dereferenced on both
185
+ * sides (macOS /var → /private/var, symlinked HOME).
186
+ */
187
+ export function imageWithinRoots(file, roots) {
188
+ const abs = path.resolve(file);
189
+ const bases = (Array.isArray(roots) ? roots : [roots])
190
+ .filter(Boolean)
191
+ .map((r) => path.resolve(r));
192
+ if (!bases.length) return false;
193
+ const within = (p, base) => {
194
+ const rel = path.relative(base, p);
195
+ return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
196
+ };
197
+ if (!bases.some((base) => within(abs, base))) return false; // lexical escape
198
+ try {
199
+ const real = fs.realpathSync(abs);
200
+ return bases.some((base) => {
201
+ let realBase;
202
+ try {
203
+ realBase = fs.realpathSync(base);
204
+ } catch {
205
+ realBase = base;
206
+ }
207
+ return within(real, realBase);
208
+ });
209
+ } catch {
210
+ return true; // file does not exist: the lexical escape is already ruled out (MISSING_IMAGE elsewhere)
211
+ }
212
+ }
213
+
214
+ /**
215
+ * Resolves a local image and CONFINES it to the trusted roots.
216
+ *
217
+ * `roots[0]` is the primary root (the deck directory) against which relative
218
+ * paths are resolved; the following roots only widen the confinement (an
219
+ * absolute path already under a project/vault root passes).
220
+ *
221
+ * @returns {string|null} confined absolute path, or null if the image escapes
222
+ */
223
+ export function resolveLocalImage(roots, src) {
224
+ const bases = (Array.isArray(roots) ? roots : [roots]).filter(Boolean);
225
+ if (!bases.length) return null;
226
+ const file = resolveImagePath(bases[0], src);
227
+ return imageWithinRoots(file, bases) ? file : null;
228
+ }
229
+
230
+ /** Intrinsic dimensions of a PNG or JPEG (null if the format is unknown) —
231
+ * the PPTX renderer's "contain" fit and validate's resolution audit. */
232
+ export function imageDims(file) {
233
+ let buf;
234
+ try {
235
+ buf = fs.readFileSync(file);
236
+ } catch {
237
+ return null;
238
+ }
239
+ if (buf.length > 24 && buf.readUInt32BE(0) === 0x89504e47) {
240
+ return { w: buf.readUInt32BE(16), h: buf.readUInt32BE(20) };
241
+ }
242
+ if (buf[0] === 0xff && buf[1] === 0xd8) {
243
+ let i = 2;
244
+ while (i < buf.length - 9) {
245
+ if (buf[i] !== 0xff) {
246
+ i++;
247
+ continue;
248
+ }
249
+ const marker = buf[i + 1];
250
+ if (marker === 0xd8 || (marker >= 0xd0 && marker <= 0xd9)) {
251
+ i += 2;
252
+ continue;
253
+ }
254
+ const len = buf.readUInt16BE(i + 2);
255
+ if (marker >= 0xc0 && marker <= 0xcf && ![0xc4, 0xc8, 0xcc].includes(marker)) {
256
+ return { h: buf.readUInt16BE(i + 5), w: buf.readUInt16BE(i + 7) };
257
+ }
258
+ i += 2 + len;
259
+ }
260
+ }
261
+ return null;
262
+ }
263
+
264
+ /** Rasterizes an SVG to PNG. @returns {{png:Buffer,w:number,h:number}|null}
265
+ * The active theme's .ttf files (FONT_FILES) are registered with resvg:
266
+ * without them, a chart whose SVG carries `font-family: <theme font>`
267
+ * (chart.mjs) would be rasterized with a fallback font while the same SVG,
268
+ * inlined in the HTML, displays in the theme font — HTML/.pptx parity
269
+ * broken. loadSystemFonts stays active for the families the theme does not
270
+ * supply. */
271
+ export async function svgToPng(svg, widthPx) {
272
+ const Resvg = await resvg();
273
+ if (!Resvg) return null;
274
+ try {
275
+ const fontFiles = [FONT_FILES.regular, FONT_FILES.bold, FONT_FILES.italic].filter(Boolean);
276
+ const r = new Resvg(svg, {
277
+ fitTo: { mode: 'width', value: Math.round(widthPx) },
278
+ font: { fontFiles, loadSystemFonts: true, defaultFontFamily: FONTS.body },
279
+ });
280
+ const img = r.render();
281
+ return { png: img.asPng(), w: img.width, h: img.height };
282
+ } catch {
283
+ return null;
284
+ }
285
+ }
286
+
287
+ // ---------------------------------------------------------------------------
288
+ // Remote images: local copy (user cache, or the project when "vendor")
289
+ // ---------------------------------------------------------------------------
290
+
291
+ const EXT_BY_MIME = {
292
+ 'image/png': '.png',
293
+ 'image/jpeg': '.jpg',
294
+ 'image/gif': '.gif',
295
+ 'image/webp': '.webp',
296
+ 'image/svg+xml': '.svg',
297
+ };
298
+
299
+ /** A stable, readable local file name for a URL. */
300
+ function localNameFor(url, ext) {
301
+ const hash = crypto.createHash('sha1').update(url).digest('hex').slice(0, 8);
302
+ const base =
303
+ path
304
+ .basename(new URL(url).pathname)
305
+ .replace(/\.[a-z0-9]+$/i, '')
306
+ .replace(/[^\w.-]+/g, '-')
307
+ .slice(0, 48) || 'image';
308
+ return `${base}-${hash}${ext}`;
309
+ }
310
+
311
+ /**
312
+ * Is a remote image copied INTO the project ("vendored") rather than into the
313
+ * user cache? Frontmatter `assets: vendor`, which the CLI flag
314
+ * `--vendor-assets` can force — same precedence as `--kit` over `kit:`.
315
+ *
316
+ * The default is the cache: compiling must write nothing into the source
317
+ * tree. Vendoring is the exception chosen when the deck directory must stay
318
+ * self-contained (archiving, handover, offline compilation elsewhere).
319
+ *
320
+ * `projet` is a deliberately FRENCH input alias of `project` — DSL input that
321
+ * an author types, not prose. Deleting it would silently stop recognizing
322
+ * `assets: projet`, with no diagnostic: `vendorRemoteAssets` would simply
323
+ * return false and the images would go to the cache instead of the project.
324
+ * Do not "translate" it away.
325
+ */
326
+ export const vendorRemoteAssets = (meta, override) =>
327
+ override ?? /^(vendor|projet|project)$/i.test(String(meta?.assets ?? '').trim());
328
+
329
+ /** Maximum size of a remote image. Generous for a real photograph (a 1920 px
330
+ * JPEG weighs ~600 KB, a PNG panorama a few MB), narrow against a URL that
331
+ * would point — by mistake or not — at an archive of several GB. */
332
+ export const REMOTE_IMAGE_MAX_BYTES = 25 * 1024 * 1024;
333
+
334
+ /**
335
+ * Reads the response body, refusing to go past the bound.
336
+ *
337
+ * `content-length` is only the server's announcement: it allows giving up
338
+ * before the transfer, but it is the running total during the read that
339
+ * actually protects — same reasoning as fetchKitArchive. Leaving the loop
340
+ * cancels the stream, so the socket is released without downloading the rest.
341
+ *
342
+ * @returns {Promise<Buffer|null>} null if the bound is crossed
343
+ */
344
+ async function readBounded(res, max = REMOTE_IMAGE_MAX_BYTES) {
345
+ const declared = Number(res.headers.get('content-length'));
346
+ if (Number.isFinite(declared) && declared > max) return null;
347
+ if (!res.body) return null;
348
+ const chunks = [];
349
+ let size = 0;
350
+ for await (const chunk of res.body) {
351
+ size += chunk.length;
352
+ if (size > max) return null;
353
+ chunks.push(chunk);
354
+ }
355
+ return Buffer.concat(chunks);
356
+ }
357
+
358
+ // ---------------------------------------------------------------------------
359
+ // SSRF guard: what we agree to go and fetch
360
+ // ---------------------------------------------------------------------------
361
+
362
+ /**
363
+ * The response body is written to disk and then EMBEDDED in the deliverable.
364
+ * An image URL is therefore — for whoever writes the deck, or for whoever
365
+ * managed to have one written — a read primitive on the machine that
366
+ * compiles: `http://169.254.169.254/…` on a cloud runner yields the IAM
367
+ * credentials, and `http://127.0.0.1:8080/` the victim's intranet, all of it
368
+ * exfiltrated in an innocuous-looking .pptx. Hence the refusal of private and
369
+ * local addresses.
370
+ *
371
+ * The judgement bears on the ADDRESSES, not on the name: `internal.example.com`
372
+ * pointing at 10.0.0.5 must be refused just like 10.0.0.5 itself. It is redone
373
+ * at EVERY redirect hop — a public URL that answers 302 towards an internal
374
+ * address is the obvious bypass, and the only way to close it is to follow the
375
+ * redirects by hand.
376
+ */
377
+ export const REMOTE_SCHEMES = new Set(['https:', 'http:']);
378
+ export const REMOTE_IMAGE_TIMEOUT_MS = 30_000;
379
+ export const REMOTE_MAX_REDIRECTS = 5;
380
+
381
+ /**
382
+ * A literal IP address that is private, local, or otherwise outside the public
383
+ * network?
384
+ *
385
+ * Over-inclusive by choice: anything that is not a recognizable public address
386
+ * is refused (`return true`), including what cannot be parsed. A missing image
387
+ * is a visible annoyance; a successful read of an internal network is not seen
388
+ * at all.
389
+ */
390
+ export function isPrivateAddress(ip) {
391
+ const addr = String(ip ?? '')
392
+ .trim()
393
+ .replace(/^\[|\]$/g, '')
394
+ .split('%')[0]
395
+ .toLowerCase();
396
+
397
+ // IPv4 "mapped" into IPv6: judge the v4 it carries, otherwise loopback
398
+ // walks back in disguised. TWO spellings to cover: the dotted form
399
+ // (::ffff:127.0.0.1) and the HEXADECIMAL form (::ffff:7f00:1) — the latter
400
+ // is what `new URL()` normalizes the host to, so it is THE one that arrives
401
+ // here in practice; recognizing only the former let
402
+ // http://[::ffff:127.0.0.1]/ through entirely.
403
+ const mappedDotted = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(addr);
404
+ if (mappedDotted) return isPrivateAddress(mappedDotted[1]);
405
+ const mappedHex = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(addr);
406
+ if (mappedHex) {
407
+ const [hi, lo] = [Number.parseInt(mappedHex[1], 16), Number.parseInt(mappedHex[2], 16)];
408
+ return isPrivateAddress(`${hi >> 8}.${hi & 255}.${lo >> 8}.${lo & 255}`);
409
+ }
410
+
411
+ if (/^\d{1,3}(\.\d{1,3}){3}$/.test(addr)) {
412
+ const o = addr.split('.').map(Number);
413
+ if (o.some((n) => !Number.isInteger(n) || n > 255)) return true; // could not be parsed
414
+ const [a, b] = o;
415
+ if (a === 0) return true; // 0.0.0.0/8 "this host"
416
+ if (a === 127) return true; // loopback
417
+ if (a === 10) return true; // private
418
+ if (a === 172 && b >= 16 && b <= 31) return true; // private
419
+ if (a === 192 && b === 168) return true; // private
420
+ if (a === 169 && b === 254) return true; // link-local + cloud metadata
421
+ if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT (RFC 6598)
422
+ if (a >= 224) return true; // multicast and reserved
423
+ return false;
424
+ }
425
+
426
+ if (!addr.includes(':')) return true; // neither IPv4 nor IPv6: unknown
427
+ if (addr === '::1' || addr === '::') return true;
428
+ if (/^f[cd]/.test(addr)) return true; // fc00::/7 unique local
429
+ if (/^fe[89ab]/.test(addr)) return true; // fe80::/10 link-local
430
+ if (/^ff/.test(addr)) return true; // multicast
431
+ return false;
432
+ }
433
+
434
+ /**
435
+ * Judges a URL BEFORE any transfer.
436
+ *
437
+ * DNS is resolved here so that the verdict bears on addresses. A name that
438
+ * CANNOT be resolved is refused: `fetch` goes through the same getaddrinfo
439
+ * (hosts file included), so it could not have reached anything either —
440
+ * refusing early only avoids having to trust a second resolution. What remains
441
+ * is a theoretical "DNS rebinding" window between this verdict and fetch's
442
+ * connection, which only a controlled socket would close.
443
+ *
444
+ * @returns {Promise<string|null>} the reason for the refusal, or null if the URL is admitted
445
+ */
446
+ export async function remoteUrlRefusal(raw) {
447
+ let u;
448
+ try {
449
+ u = new URL(raw);
450
+ } catch {
451
+ return `invalid URL: ${raw}`;
452
+ }
453
+ if (!REMOTE_SCHEMES.has(u.protocol))
454
+ return `protocol refused: ${u.protocol}// — only http and https are downloaded (${raw})`;
455
+
456
+ const host = u.hostname.replace(/^\[|\]$/g, '');
457
+ let addrs;
458
+ try {
459
+ addrs = await dns.promises.lookup(host, { all: true });
460
+ } catch {
461
+ return `unresolvable host: ${host}`;
462
+ }
463
+ if (!addrs.length) return `host with no address: ${host}`;
464
+ const privateAddr = addrs.find((a) => isPrivateAddress(a.address));
465
+ if (privateAddr) return `private or local address refused: ${host} → ${privateAddr.address}`;
466
+ return null;
467
+ }
468
+
469
+ /** Releases the socket of a hop we will not read (redirect, HTTP error). */
470
+ async function discardBody(res) {
471
+ try {
472
+ if (typeof res.body?.cancel === 'function') await res.body.cancel();
473
+ else if (typeof res.body?.return === 'function') await res.body.return();
474
+ } catch {
475
+ /* body already closed */
476
+ }
477
+ }
478
+
479
+ /**
480
+ * `fetch` with redirects followed BY HAND, every hop judged again by
481
+ * remoteUrlRefusal. `redirect: 'follow'` would delegate the chain to undici,
482
+ * which does not know our policy: the first hop would be checked and every
483
+ * following one left free.
484
+ *
485
+ * @returns {Promise<Response|null>} the final response, body unread
486
+ */
487
+ async function fetchPublic(url) {
488
+ let current = url;
489
+ for (let hop = 0; hop <= REMOTE_MAX_REDIRECTS; hop++) {
490
+ if (await remoteUrlRefusal(current)) return null;
491
+ const res = await fetch(current, {
492
+ redirect: 'manual',
493
+ signal: AbortSignal.timeout(REMOTE_IMAGE_TIMEOUT_MS),
494
+ });
495
+ const location = res.headers.get('location');
496
+ if (res.status >= 300 && res.status < 400 && location) {
497
+ await discardBody(res); // nothing from this hop enters the deliverable
498
+ let next;
499
+ try {
500
+ next = new URL(location, current).toString();
501
+ } catch {
502
+ return null;
503
+ }
504
+ current = next;
505
+ continue;
506
+ }
507
+ if (!res.ok) {
508
+ await discardBody(res);
509
+ return null;
510
+ }
511
+ return res;
512
+ }
513
+ return null; // too many redirects
514
+ }
515
+
516
+ /** Destination directory for remote images, according to the chosen mode. */
517
+ export function remoteDir(baseDir, vendor) {
518
+ return vendor ? path.join(baseDir, 'assets', 'remote') : path.join(userCacheRoot(), 'remote');
519
+ }
520
+
521
+ /**
522
+ * Downloads a remote image and returns its local path — or null (offline,
523
+ * 404…): the caller keeps its "placeholder" fallback.
524
+ *
525
+ * `vendor: true` writes into `<baseDir>/assets/remote/` (a versionable copy,
526
+ * next to the .md); otherwise into `~/.cache/lutrin/remote/`, shared across
527
+ * projects and without side effects on the sources — the normal case.
528
+ */
529
+ export async function fetchRemoteImage(url, baseDir, { vendor = false } = {}) {
530
+ const dir = remoteDir(baseDir, vendor);
531
+ // cache: a file already downloaded for this URL, whatever its extension
532
+ const hash = crypto.createHash('sha1').update(url).digest('hex').slice(0, 8);
533
+ if (fs.existsSync(dir)) {
534
+ const hit = fs.readdirSync(dir).find((f) => f.includes(`-${hash}.`));
535
+ if (hit) return path.join(dir, hit);
536
+ }
537
+ // vendor mode exists to make the deck directory self-contained: if the image
538
+ // is already in the user cache, copy it instead of downloading it again
539
+ if (vendor) {
540
+ const cached = await fetchRemoteImage(url, baseDir, { vendor: false });
541
+ if (cached) {
542
+ try {
543
+ fs.mkdirSync(dir, { recursive: true });
544
+ const file = path.join(dir, path.basename(cached));
545
+ fs.copyFileSync(cached, file);
546
+ return file;
547
+ } catch {
548
+ return cached; // read-only directory: the cache will do
549
+ }
550
+ }
551
+ return null;
552
+ }
553
+ try {
554
+ const res = await fetchPublic(url);
555
+ if (!res) return null; // scheme, private address, redirects, HTTP error
556
+ const mime = (res.headers.get('content-type') ?? '').split(';')[0].trim();
557
+ const ext = EXT_BY_MIME[mime] ?? path.extname(new URL(url).pathname) ?? '.png';
558
+ if (!EXT_BY_MIME[mime] && !/^image\//.test(mime) && !/\.(png|jpe?g|gif|webp|svg)$/i.test(url))
559
+ return null;
560
+ const buf = await readBounded(res);
561
+ if (!buf) return null;
562
+ fs.mkdirSync(dir, { recursive: true });
563
+ const file = path.join(dir, localNameFor(url, ext || '.png'));
564
+ fs.writeFileSync(file, buf);
565
+ return file;
566
+ } catch {
567
+ return null;
568
+ }
569
+ }
570
+
571
+ // ---------------------------------------------------------------------------
572
+ // Lucide icons
573
+ // ---------------------------------------------------------------------------
574
+
575
+ /**
576
+ * Source SVG of an icon: node_modules → local cache → CDN (then cached).
577
+ *
578
+ * What the CDN returns is inlined in the produced HTML and rasterized into the
579
+ * .pptx, then kept FOREVER in the user cache. Three guards, therefore, before
580
+ * writing anything at all: pinned version (lucideCdn), bounded body
581
+ * (LUCIDE_MAX_BYTES), and checked shape — an HTML error page or a proxy
582
+ * interstitial must not freeze into the cache under the name of an icon, where
583
+ * nothing would ever come to correct it.
584
+ */
585
+ async function lucideSvg(name) {
586
+ const safe = name.toLowerCase().replace(/[^a-z0-9-]/g, '');
587
+ if (!safe) return null;
588
+ for (const dir of [LUCIDE_LOCAL, LUCIDE_CACHE]) {
589
+ const f = path.join(dir, `${safe}.svg`);
590
+ if (fs.existsSync(f)) return fs.readFileSync(f, 'utf8');
591
+ }
592
+ const base = lucideCdn();
593
+ if (!base) return null;
594
+ try {
595
+ const res = await fetch(`${base}/${safe}.svg`, { signal: AbortSignal.timeout(15_000) });
596
+ if (!res.ok) return null;
597
+ const buf = await readBounded(res, LUCIDE_MAX_BYTES);
598
+ if (!buf) return null;
599
+ const svg = buf.toString('utf8');
600
+ if (!/^\s*<svg[\s>]/i.test(svg)) return null;
601
+ fs.mkdirSync(LUCIDE_CACHE, { recursive: true });
602
+ fs.writeFileSync(path.join(LUCIDE_CACHE, `${safe}.svg`), svg);
603
+ return svg;
604
+ } catch {
605
+ return null;
606
+ }
607
+ }
608
+
609
+ /**
610
+ * Offline check of an icon name (for `validate`): true / false according to
611
+ * local presence, null if it cannot be checked without the network
612
+ * (lucide-static absent and the icon never cached).
613
+ */
614
+ export function hasLucideIcon(name) {
615
+ const safe = name.toLowerCase().replace(/[^a-z0-9-]/g, '');
616
+ if (!safe) return false;
617
+ for (const dir of [LUCIDE_LOCAL, LUCIDE_CACHE]) {
618
+ if (fs.existsSync(path.join(dir, `${safe}.svg`))) return true;
619
+ }
620
+ return fs.existsSync(LUCIDE_LOCAL) ? false : null;
621
+ }
622
+
623
+ /** Allowed icon colors (brand) — default: primary green.
624
+ * Read at call time so as to follow a theme applied by applyTheme(). */
625
+ const iconColors = () => ({
626
+ primary: COLORS.primary,
627
+ neutral: COLORS.neutralPrimary,
628
+ secondary: COLORS.neutralSecondary,
629
+ white: COLORS.ground,
630
+ });
631
+
632
+ /** SVG of a recolored Lucide icon (brand ink). @returns {string|null} */
633
+ export async function iconSvg(name, { color = 'primary' } = {}) {
634
+ const svg = await lucideSvg(name);
635
+ if (!svg) return null;
636
+ const palette = iconColors();
637
+ const hex = palette[color] ?? palette.primary;
638
+ return svg.replace(/currentColor/g, `#${hex}`);
639
+ }
640
+
641
+ /**
642
+ * Renders a Lucide icon as a recolored PNG.
643
+ * @returns {{png:Buffer,w:number,h:number}|null}
644
+ */
645
+ export async function renderIcon(name, { color = 'primary', rasterPx = 384 } = {}) {
646
+ const svg = await iconSvg(name, { color });
647
+ return svg ? svgToPng(svg, rasterPx) : null;
648
+ }
649
+
650
+ // ---------------------------------------------------------------------------
651
+ // LaTeX → SVG → PNG (MathJax, optional like Mermaid)
652
+ // ---------------------------------------------------------------------------
653
+
654
+ let _mathjax = null;
655
+ async function mathDocument() {
656
+ if (_mathjax === null) {
657
+ try {
658
+ const { mathjax } = await import('mathjax-full/js/mathjax.js');
659
+ const { TeX } = await import('mathjax-full/js/input/tex.js');
660
+ const { SVG } = await import('mathjax-full/js/output/svg.js');
661
+ const { liteAdaptor } = await import('mathjax-full/js/adaptors/liteAdaptor.js');
662
+ const { RegisterHTMLHandler } = await import('mathjax-full/js/handlers/html.js');
663
+ const { AllPackages } = await import('mathjax-full/js/input/tex/AllPackages.js');
664
+ const adaptor = liteAdaptor();
665
+ RegisterHTMLHandler(adaptor);
666
+ const doc = mathjax.document('', {
667
+ InputJax: new TeX({ packages: AllPackages }),
668
+ OutputJax: new SVG({ fontCache: 'local' }),
669
+ });
670
+ _mathjax = { doc, adaptor };
671
+ } catch {
672
+ _mathjax = false;
673
+ }
674
+ }
675
+ return _mathjax || null;
676
+ }
677
+
678
+ const EX_TO_PX = 9; // 1ex ≈ 9 px for a presentation body text size
679
+
680
+ /**
681
+ * Renders a LaTeX equation as SVG (the brand's neutral-primary ink).
682
+ * @returns {{svg:string,displayW:number,displayH:number}|null}
683
+ */
684
+ export async function mathSvg(tex) {
685
+ const mj = await mathDocument();
686
+ if (!mj) return null;
687
+ try {
688
+ const node = mj.doc.convert(tex, { display: true });
689
+ let svg = mj.adaptor.innerHTML(node);
690
+ const wEx = Number.parseFloat(svg.match(/width="([\d.]+)ex"/)?.[1] ?? '0');
691
+ const hEx = Number.parseFloat(svg.match(/height="([\d.]+)ex"/)?.[1] ?? '0');
692
+ if (!wEx || !hEx || /data-mjx-error/.test(svg)) return null;
693
+ svg = svg.replace(/currentColor/g, `#${COLORS.neutralPrimary}`);
694
+ return { svg, displayW: wEx * EX_TO_PX, displayH: hEx * EX_TO_PX };
695
+ } catch {
696
+ return null;
697
+ }
698
+ }
699
+
700
+ /**
701
+ * Renders a LaTeX equation as PNG (the brand's neutral-primary ink).
702
+ * @returns {{png:Buffer,w:number,h:number,displayW:number,displayH:number}|null}
703
+ */
704
+ export async function renderMath(tex, { scale = 3 } = {}) {
705
+ const m = await mathSvg(tex);
706
+ if (!m) return null;
707
+ const out = await svgToPng(m.svg, m.displayW * scale);
708
+ return out ? { ...out, displayW: m.displayW, displayH: m.displayH } : null;
709
+ }
710
+
711
+ // ---------------------------------------------------------------------------
712
+ // Mermaid (optional): PNG or SVG rendering via mmdc when it is available
713
+ // ---------------------------------------------------------------------------
714
+
715
+ /** Mermaid theme aligned on the active theme: light surfaces, neutral rules,
716
+ * primary as the only accent. Built at call time so as to follow a theme
717
+ * applied by applyTheme() — and since the config enters the disk cache key,
718
+ * two themes never share their PNGs (and a config change invalidates the old
719
+ * cache by itself: that is what made it possible to switch htmlLabels off
720
+ * without a purge).
721
+ *
722
+ * `htmlLabels: false` is NOT an aesthetic choice. By default, mmdc puts every
723
+ * node and edge label inside
724
+ * `<foreignObject><div>…</div></foreignObject>` — HTML inside SVG. The HTML
725
+ * renderer's sanitizer strips `foreignObject` ALONG WITH its content (a sound
726
+ * security rule: it is the entry point for arbitrary HTML into an inlined
727
+ * SVG), and every diagram therefore displayed as a series of empty rectangles
728
+ * in the standalone HTML, the VS Code webview and the Obsidian shadow DOM —
729
+ * while the .pptx, rasterized by mmdc itself, carried its labels. A silent
730
+ * HTML/PPTX divergence. Switched off here, mermaid emits native SVG `<text>`,
731
+ * which the sanitizer lets through. */
732
+ export const mermaidConfig = () => ({
733
+ theme: 'base',
734
+ htmlLabels: false,
735
+ flowchart: { htmlLabels: false },
736
+ class: { htmlLabels: false },
737
+ themeVariables: {
738
+ primaryColor: `#${COLORS.highlightLight}`,
739
+ primaryBorderColor: `#${COLORS.primary}`,
740
+ primaryTextColor: `#${COLORS.neutralPrimary}`,
741
+ lineColor: `#${COLORS.neutralSecondary}`,
742
+ secondaryColor: `#${COLORS.underground1}`,
743
+ tertiaryColor: `#${COLORS.ground}`,
744
+ fontFamily: `${FONTS.body}, Helvetica, Arial, sans-serif`,
745
+ fontSize: '16px',
746
+ },
747
+ });
748
+
749
+ let _mmdc; // memoized: the binary is looked up once per process
750
+ export function findMmdc() {
751
+ if (_mmdc !== undefined) return _mmdc;
752
+ // node_modules/.bin, walking up from the package (workspace hoisting)
753
+ for (let dir = ROOT; ; dir = path.dirname(dir)) {
754
+ const local = path.join(dir, 'node_modules', '.bin', 'mmdc');
755
+ if (fs.existsSync(local)) return (_mmdc = local);
756
+ if (path.dirname(dir) === dir) break;
757
+ }
758
+ try {
759
+ return (_mmdc = execFileSync('which', ['mmdc'], { encoding: 'utf8' }).trim() || null);
760
+ } catch {
761
+ return (_mmdc = null);
762
+ }
763
+ }
764
+
765
+ /**
766
+ * Renders a Mermaid diagram into `tmpDir` (PNG for the PPTX, SVG for the HTML)
767
+ * and returns the path of the produced file — or null (mmdc absent, invalid
768
+ * source…): the caller keeps its "source as a code block" fallback.
769
+ */
770
+ export function renderMermaid(sourceText, tmpDir, idx, mmdc, { format = 'png' } = {}) {
771
+ if (!mmdc) return null;
772
+ try {
773
+ const src = path.join(tmpDir, `diagram-${idx}.mmd`);
774
+ const out = path.join(tmpDir, `diagram-${idx}.${format}`);
775
+ const cfg = path.join(tmpDir, 'mermaid-config.json');
776
+ // always rewritten: a reused tmpDir must not serve up the config of
777
+ // another theme a second time
778
+ fs.writeFileSync(cfg, JSON.stringify(mermaidConfig()));
779
+ fs.writeFileSync(src, sourceText);
780
+ const args = ['-i', src, '-o', out, '-b', 'transparent', '-c', cfg];
781
+ if (format === 'png') args.push('-s', '3');
782
+ execFileSync(mmdc, args, { stdio: 'pipe', timeout: 60_000 });
783
+ return fs.existsSync(out) ? out : null;
784
+ } catch {
785
+ return null;
786
+ }
787
+ }
788
+
789
+ /**
790
+ * Renders a Mermaid diagram through a persistent cache and returns the path of
791
+ * the rendered file — indispensable to the live preview: mmdc costs several
792
+ * seconds per diagram, and it is never run again for a source already seen.
793
+ *
794
+ * - disk cache `~/.cache/lutrin/mermaid/` (key: sha1 source+format+config),
795
+ * shared between the CLI, watch and the VS Code extension, persistent
796
+ * across sessions;
797
+ * - memory cache in front of the disk, including **negative** entries
798
+ * (invalid source → null memorized, otherwise every keystroke on a broken
799
+ * diagram would pay for mmdc again);
800
+ * - mmdc absent → null without caching (it may be installed afterwards).
801
+ */
802
+ const MERMAID_MEM = new Map();
803
+
804
+ /** Root of the user cache: LUTRIN_CACHE, otherwise XDG_CACHE_HOME, otherwise
805
+ * ~/.cache/lutrin. Declared as a function (hoisted): LUCIDE_CACHE evaluates it
806
+ * at module load time.
807
+ *
808
+ * `MTL_DECK_CACHE` is still honored as a fallback (the tool used to be called
809
+ * mtl-deck), like MTL_DECK_CONFIG in theme.mjs. The old `~/.cache/mtl-deck`,
810
+ * however, is NOT migrated: a cache regenerates itself, and moving it would
811
+ * cost more than letting it grow old. */
812
+ function userCacheRoot() {
813
+ return (
814
+ process.env.LUTRIN_CACHE ||
815
+ process.env.MTL_DECK_CACHE ||
816
+ (process.env.XDG_CACHE_HOME
817
+ ? path.join(process.env.XDG_CACHE_HOME, 'lutrin')
818
+ : path.join(os.homedir(), '.cache', 'lutrin'))
819
+ );
820
+ }
821
+
822
+ function mermaidCacheDir() {
823
+ return path.join(userCacheRoot(), 'mermaid');
824
+ }
825
+
826
+ /** Rendered diagrams kept next to the deck (see `lutrin vendor`).
827
+ *
828
+ * Consulted BEFORE the user cache: that is what allows a self-contained
829
+ * directory to display on a machine where mmdc is not installed. Since the key
830
+ * is a hash of (source + format + theme config), a file found there is by
831
+ * construction the exact rendering asked for — consulting this directory
832
+ * therefore adds no semantics, only one more source for content that is
833
+ * already determined. */
834
+ export const mermaidVendorDir = (baseDir) => path.join(baseDir, 'assets', 'mermaid');
835
+
836
+ /**
837
+ * Is an SVG whose labels are locked inside `<foreignObject>` unusable in HTML?
838
+ * Yes: the sanitizer strips them along with their content, and nothing would be
839
+ * left but mute rectangles.
840
+ *
841
+ * Defense in depth behind `htmlLabels: false` — an mmdc of a version that
842
+ * ignored the option, or an SVG vendored by a Lutrin predating the fix, would
843
+ * still produce a diagram without a single word. Better then to return null:
844
+ * the caller falls back on its source as a code block, which can be read.
845
+ */
846
+ function svgUsableInHtml(file) {
847
+ try {
848
+ return !/<foreignobject[\s>]/i.test(fs.readFileSync(file, 'utf8'));
849
+ } catch {
850
+ return false;
851
+ }
852
+ }
853
+
854
+ export function renderMermaidCached(sourceText, { format = 'png', baseDir = null } = {}) {
855
+ const key = `${crypto
856
+ .createHash('sha1')
857
+ .update(JSON.stringify({ s: sourceText, f: format, c: mermaidConfig() }))
858
+ .digest('hex')}.${format}`;
859
+ // The FILE NAME depends only on the content (source + format + config) —
860
+ // that is what makes a vendored directory readable by any Lutrin. The
861
+ // MEMOIZATION key, on the other hand, must also carry baseDir: the verdict
862
+ // depends on the file found in the deck's `assets/mermaid/`, hence on the
863
+ // deck. Without that, a vendored SVG refused (foreignObject) in a first deck
864
+ // condemned the perfectly sound SVG of a second deck compiled in the same
865
+ // process — exactly what the preview worker does, deck after deck.
866
+ const memKey = crypto
867
+ .createHash('sha1')
868
+ .update(JSON.stringify({ k: key, b: baseDir }))
869
+ .digest('hex');
870
+ if (MERMAID_MEM.has(memKey)) return MERMAID_MEM.get(memKey);
871
+
872
+ for (const dir of [baseDir ? mermaidVendorDir(baseDir) : null, mermaidCacheDir()]) {
873
+ if (!dir) continue;
874
+ const found = path.join(dir, key);
875
+ if (fs.existsSync(found)) {
876
+ const ok = format !== 'svg' || svgUsableInHtml(found);
877
+ MERMAID_MEM.set(memKey, ok ? found : null);
878
+ return ok ? found : null;
879
+ }
880
+ }
881
+
882
+ const mmdc = findMmdc();
883
+ if (!mmdc) return null;
884
+
885
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lutrin-mmd-'));
886
+ let result = null;
887
+ try {
888
+ const out = renderMermaid(sourceText, tmpDir, 0, mmdc, { format });
889
+ if (out && (format !== 'svg' || svgUsableInHtml(out))) {
890
+ // always written into the user cache, never into a vendored directory:
891
+ // `lutrin vendor` alone decides what enters the project
892
+ const cached = path.join(mermaidCacheDir(), key);
893
+ fs.mkdirSync(mermaidCacheDir(), { recursive: true });
894
+ fs.copyFileSync(out, cached);
895
+ result = cached;
896
+ }
897
+ } finally {
898
+ fs.rmSync(tmpDir, { recursive: true, force: true });
899
+ }
900
+ MERMAID_MEM.set(memKey, result);
901
+ return result;
902
+ }
903
+
904
+ /** Writes a PNG buffer to a temporary file and returns its path. */
905
+ export function writeTmpPng(dir, name, buf) {
906
+ fs.mkdirSync(dir, { recursive: true });
907
+ const f = path.join(dir, `${name}.png`);
908
+ fs.writeFileSync(f, buf);
909
+ return f;
910
+ }