@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,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
|
+
* `` 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: `` or
|
|
176
|
+
* `` 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
|
+
}
|