@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/README.md
CHANGED
|
@@ -2,23 +2,32 @@
|
|
|
2
2
|
|
|
3
3
|
The engine behind [Lutrin](../../README.md): enriched Markdown (a DSL) →
|
|
4
4
|
PowerPoint `.pptx` or standalone HTML, with the page layout decided by the
|
|
5
|
-
engine. It
|
|
6
|
-
hosts (VS Code extension, Obsidian plugin).
|
|
5
|
+
engine. It carries the CLI implementation and serves as a library for the
|
|
6
|
+
editor hosts (VS Code extension, Obsidian plugin). The command itself is
|
|
7
|
+
published separately as [`lutrin`](https://www.npmjs.com/package/lutrin), a
|
|
8
|
+
thin entry point that depends on this package.
|
|
7
9
|
|
|
8
10
|
Neither output format is privileged: both renderers consume the **same**
|
|
9
11
|
geometric **scene**, in pixels on a 1280 × 720 grid.
|
|
10
12
|
|
|
11
13
|
## Installation
|
|
12
14
|
|
|
13
|
-
|
|
15
|
+
To use the compiler as a library:
|
|
14
16
|
|
|
15
17
|
```bash
|
|
16
|
-
npm install
|
|
17
|
-
|
|
18
|
+
npm install @lutrin/core
|
|
19
|
+
```
|
|
18
20
|
|
|
19
|
-
|
|
21
|
+
```js
|
|
22
|
+
import { compileHtml } from '@lutrin/core';
|
|
23
|
+
import { parseDeck } from '@lutrin/core/parse';
|
|
24
|
+
import { validateDeck } from '@lutrin/core/validate';
|
|
20
25
|
```
|
|
21
26
|
|
|
27
|
+
For the command line, install [`lutrin`](https://www.npmjs.com/package/lutrin)
|
|
28
|
+
instead — or just `npx lutrin`. From a clone of the repository,
|
|
29
|
+
`npm link -w lutrin` puts the command on your PATH.
|
|
30
|
+
|
|
22
31
|
Node ≥ 22 (`engines.node`). The sources are executed as they are (ESM) — no
|
|
23
32
|
build step.
|
|
24
33
|
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lutrin/core",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.1",
|
|
4
4
|
"description": "Markdown → PowerPoint / HTML presentation compiler, themable: a generic default theme, organization brand kits distributed as .deckkit archives.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
7
7
|
"test": "node --import ./test/setup.mjs --test \"test/*.test.mjs\""
|
|
8
8
|
},
|
|
9
|
-
"files": ["src", "design", "README.md"],
|
|
9
|
+
"files": ["src", "design", "vendor", "README.md"],
|
|
10
10
|
"publishConfig": {
|
|
11
11
|
"access": "public"
|
|
12
12
|
},
|
|
@@ -29,7 +29,8 @@
|
|
|
29
29
|
"markdown-it": "^14.3.0",
|
|
30
30
|
"markdown-it-container": "^4.0.0",
|
|
31
31
|
"mathjax-full": "^3.2.1",
|
|
32
|
-
"pptxgenjs": "4.0.1"
|
|
32
|
+
"pptxgenjs": "4.0.1",
|
|
33
|
+
"puppeteer-core": "^25.3.0"
|
|
33
34
|
},
|
|
34
35
|
"peerDependencies": {
|
|
35
36
|
"@mermaid-js/mermaid-cli": "^11.16.0"
|
package/src/cli.mjs
CHANGED
|
@@ -84,6 +84,7 @@ const COMMANDS = [
|
|
|
84
84
|
'config',
|
|
85
85
|
'kit',
|
|
86
86
|
'vendor',
|
|
87
|
+
'setup-mermaid',
|
|
87
88
|
];
|
|
88
89
|
|
|
89
90
|
const USAGE = `Usage:
|
|
@@ -98,6 +99,7 @@ const USAGE = `Usage:
|
|
|
98
99
|
lutrin kit remove <name>
|
|
99
100
|
lutrin kit create <directory> [-o <file.deckkit>]
|
|
100
101
|
lutrin capabilities [<input.md>] [--kit <kit|file.json|directory>] [--json]
|
|
102
|
+
lutrin setup-mermaid [--yes]
|
|
101
103
|
lutrin --version | --help`;
|
|
102
104
|
|
|
103
105
|
/** Help that was ASKED FOR is an answer (stdout, exit code 0); usage recalled
|
|
@@ -163,6 +165,9 @@ const FLAG_SPECS = {
|
|
|
163
165
|
// real context (installed kit, layouts/ next to the deck) and not just that
|
|
164
166
|
// of the bare engine.
|
|
165
167
|
capabilities: { ...FLAGS_KIT, json: 'boolean' },
|
|
168
|
+
// `--yes` is what authorizes the ~200 MB download: a browser never arrives
|
|
169
|
+
// on a machine because someone ran a diagnostic.
|
|
170
|
+
'setup-mermaid': { yes: 'boolean' },
|
|
166
171
|
};
|
|
167
172
|
|
|
168
173
|
/**
|
|
@@ -456,7 +461,7 @@ async function cmdBuild(argv) {
|
|
|
456
461
|
if (stats.mermaidTotal && stats.mermaidRendered < stats.mermaidTotal) {
|
|
457
462
|
const missing = stats.mermaidTotal - stats.mermaidRendered;
|
|
458
463
|
console.log(
|
|
459
|
-
` ${missing} Mermaid diagram${missing > 1 ? 's' : ''} rendered as a text fallback
|
|
464
|
+
` ${missing} Mermaid diagram${missing > 1 ? 's' : ''} rendered as a text fallback — run \`lutrin setup-mermaid\` to diagnose`,
|
|
460
465
|
);
|
|
461
466
|
}
|
|
462
467
|
if (stats.remoteTotal) {
|
|
@@ -992,7 +997,7 @@ async function cmdVendor(argv) {
|
|
|
992
997
|
}
|
|
993
998
|
for (const f of r.frontmatter) console.log(` frontmatter: ${f}`);
|
|
994
999
|
if (r.mermaid.done < r.mermaid.total)
|
|
995
|
-
console.log(' → diagrams not rendered:
|
|
1000
|
+
console.log(' → diagrams not rendered: run `lutrin setup-mermaid`, then re-run vendor');
|
|
996
1001
|
if (r.images.done < r.images.total)
|
|
997
1002
|
console.log(' → images not downloaded: check the URL or the connection, then re-run vendor');
|
|
998
1003
|
// self-containment is announced ONLY if nothing was left aside: until now the
|
|
@@ -1004,6 +1009,77 @@ async function cmdVendor(argv) {
|
|
|
1004
1009
|
);
|
|
1005
1010
|
}
|
|
1006
1011
|
|
|
1012
|
+
// ---------------------------------------------------------------------------
|
|
1013
|
+
// setup-mermaid
|
|
1014
|
+
// ---------------------------------------------------------------------------
|
|
1015
|
+
|
|
1016
|
+
/**
|
|
1017
|
+
* Reports on the Mermaid rendering chain and, if nothing can drive it, offers
|
|
1018
|
+
* to download a browser.
|
|
1019
|
+
*
|
|
1020
|
+
* The command exists because the failure it fixes is invisible: a diagram that
|
|
1021
|
+
* cannot be rendered degrades to a readable code block, which is the right
|
|
1022
|
+
* behaviour and looks, to the author, exactly like a compiler that does not do
|
|
1023
|
+
* diagrams. This says out loud what is installed and what is missing.
|
|
1024
|
+
*
|
|
1025
|
+
* The download stays opt-in — `--yes` or an answered prompt. Nothing here runs
|
|
1026
|
+
* during a build.
|
|
1027
|
+
*/
|
|
1028
|
+
async function cmdSetupMermaid(argv) {
|
|
1029
|
+
const args = parseArgs(argv, FLAG_SPECS['setup-mermaid']);
|
|
1030
|
+
const { findBrowser, downloadHeadlessShell, browserCacheDir } = await import(
|
|
1031
|
+
'./deck/browser.mjs'
|
|
1032
|
+
);
|
|
1033
|
+
const { findMmdc, renderMermaidCached } = await import('./deck/assets.mjs');
|
|
1034
|
+
|
|
1035
|
+
const mmdc = findMmdc();
|
|
1036
|
+
let browser = findBrowser({ refresh: true });
|
|
1037
|
+
|
|
1038
|
+
console.log('Mermaid rendering:');
|
|
1039
|
+
console.log(` browser: ${browser ? `${browser.path} (${browser.source})` : 'none found'}`);
|
|
1040
|
+
console.log(` mermaid-cli: ${mmdc ?? 'not installed (optional — the browser is enough)'}`);
|
|
1041
|
+
|
|
1042
|
+
if (!browser) {
|
|
1043
|
+
if (!args.yes) {
|
|
1044
|
+
console.log(
|
|
1045
|
+
[
|
|
1046
|
+
'',
|
|
1047
|
+
'No Chrome, Edge, Brave or Chromium was found, and Mermaid needs a browser',
|
|
1048
|
+
'to measure the text it lays out.',
|
|
1049
|
+
'',
|
|
1050
|
+
'Installing any of those is the lightest fix. Otherwise lutrin can download',
|
|
1051
|
+
`chrome-headless-shell (~200 MB) into ${browserCacheDir()}.`,
|
|
1052
|
+
'',
|
|
1053
|
+
'Re-run with --yes to download it.',
|
|
1054
|
+
].join('\n'),
|
|
1055
|
+
);
|
|
1056
|
+
process.exitCode = 1;
|
|
1057
|
+
return;
|
|
1058
|
+
}
|
|
1059
|
+
console.log(`\nDownloading chrome-headless-shell into ${browserCacheDir()} …`);
|
|
1060
|
+
const exe = await downloadHeadlessShell((line) => console.log(line));
|
|
1061
|
+
console.log(`✓ browser installed: ${exe}`);
|
|
1062
|
+
browser = findBrowser({ refresh: true });
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
// A found browser is not a working one — an install can be broken, a
|
|
1066
|
+
// container can lack the shared libraries Chrome links against. Render a
|
|
1067
|
+
// diagram and say so, rather than promising on the strength of a file
|
|
1068
|
+
// existing.
|
|
1069
|
+
process.stdout.write('\nRendering a test diagram … ');
|
|
1070
|
+
const out = renderMermaidCached('flowchart LR\n A[Lutrin] --> B[Mermaid]', { format: 'svg' });
|
|
1071
|
+
if (out) {
|
|
1072
|
+
console.log('✓');
|
|
1073
|
+
console.log('\nMermaid diagrams will render. Nothing else to install.');
|
|
1074
|
+
return;
|
|
1075
|
+
}
|
|
1076
|
+
const { lastMermaidError } = await import('./deck/assets.mjs');
|
|
1077
|
+
console.log('✖');
|
|
1078
|
+
console.log(`\nThe browser was found but could not render: ${lastMermaidError() ?? 'unknown'}`);
|
|
1079
|
+
console.log('Set LUTRIN_BROWSER to another browser, or re-run with --yes to download one.');
|
|
1080
|
+
process.exitCode = 1;
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1007
1083
|
// ---------------------------------------------------------------------------
|
|
1008
1084
|
// capabilities
|
|
1009
1085
|
// ---------------------------------------------------------------------------
|
|
@@ -1101,6 +1177,9 @@ try {
|
|
|
1101
1177
|
case 'capabilities':
|
|
1102
1178
|
cmdCapabilities(rest2);
|
|
1103
1179
|
break;
|
|
1180
|
+
case 'setup-mermaid':
|
|
1181
|
+
await cmdSetupMermaid(rest2);
|
|
1182
|
+
break;
|
|
1104
1183
|
default:
|
|
1105
1184
|
usage();
|
|
1106
1185
|
}
|
package/src/deck/assets.mjs
CHANGED
|
@@ -18,7 +18,10 @@
|
|
|
18
18
|
* to PNG when needed (`renderIcon`);
|
|
19
19
|
* - LaTeX equations → MathJax (tex → SVG, glyphs as paths:
|
|
20
20
|
* `mathSvg`) then PNG when needed (`renderMath`);
|
|
21
|
-
* - Mermaid diagrams →
|
|
21
|
+
* - Mermaid diagrams → a browser already on the machine driven by
|
|
22
|
+
* puppeteer-core (`browser.mjs`) over the Mermaid bundle vendored in
|
|
23
|
+
* `vendor/mermaid/`, or `mmdc` when it happens to be installed — as PNG
|
|
24
|
+
* (PPTX) or SVG (HTML).
|
|
22
25
|
*
|
|
23
26
|
* Every asset therefore exists in two flavors: SVG (inlined as is by the HTML
|
|
24
27
|
* renderer) and a high-density PNG via @resvg/resvg-js (PowerPoint handles
|
|
@@ -41,6 +44,7 @@ import { execFileSync } from 'node:child_process';
|
|
|
41
44
|
import { createRequire } from 'node:module';
|
|
42
45
|
import { fileURLToPath } from 'node:url';
|
|
43
46
|
import { COLORS, FONTS, FONT_FILES } from './tokens.mjs';
|
|
47
|
+
import { findBrowser } from './browser.mjs';
|
|
44
48
|
|
|
45
49
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
46
50
|
const require = createRequire(import.meta.url);
|
|
@@ -638,11 +642,15 @@ export async function iconSvg(name, { color = 'primary' } = {}) {
|
|
|
638
642
|
return svg.replace(/currentColor/g, `#${hex}`);
|
|
639
643
|
}
|
|
640
644
|
|
|
645
|
+
/** Icon raster density (px) — shared with the callers that separate the SVG
|
|
646
|
+
* lookup from the rasterization to tell the two failures apart. */
|
|
647
|
+
export const ICON_RASTER_PX = 384;
|
|
648
|
+
|
|
641
649
|
/**
|
|
642
650
|
* Renders a Lucide icon as a recolored PNG.
|
|
643
651
|
* @returns {{png:Buffer,w:number,h:number}|null}
|
|
644
652
|
*/
|
|
645
|
-
export async function renderIcon(name, { color = 'primary', rasterPx =
|
|
653
|
+
export async function renderIcon(name, { color = 'primary', rasterPx = ICON_RASTER_PX } = {}) {
|
|
646
654
|
const svg = await iconSvg(name, { color });
|
|
647
655
|
return svg ? svgToPng(svg, rasterPx) : null;
|
|
648
656
|
}
|
|
@@ -709,7 +717,7 @@ export async function renderMath(tex, { scale = 3 } = {}) {
|
|
|
709
717
|
}
|
|
710
718
|
|
|
711
719
|
// ---------------------------------------------------------------------------
|
|
712
|
-
// Mermaid
|
|
720
|
+
// Mermaid: PNG or SVG rendering, in a browser found on the machine (or mmdc)
|
|
713
721
|
// ---------------------------------------------------------------------------
|
|
714
722
|
|
|
715
723
|
/** Mermaid theme aligned on the active theme: light surfaces, neutral rules,
|
|
@@ -778,7 +786,7 @@ export function renderMermaid(sourceText, tmpDir, idx, mmdc, { format = 'png' }
|
|
|
778
786
|
fs.writeFileSync(cfg, JSON.stringify(mermaidConfig()));
|
|
779
787
|
fs.writeFileSync(src, sourceText);
|
|
780
788
|
const args = ['-i', src, '-o', out, '-b', 'transparent', '-c', cfg];
|
|
781
|
-
if (format === 'png') args.push('-s',
|
|
789
|
+
if (format === 'png') args.push('-s', String(MERMAID_PNG_SCALE));
|
|
782
790
|
execFileSync(mmdc, args, { stdio: 'pipe', timeout: 60_000 });
|
|
783
791
|
return fs.existsSync(out) ? out : null;
|
|
784
792
|
} catch {
|
|
@@ -786,10 +794,82 @@ export function renderMermaid(sourceText, tmpDir, idx, mmdc, { format = 'png' }
|
|
|
786
794
|
}
|
|
787
795
|
}
|
|
788
796
|
|
|
797
|
+
/** Raster scale of the PNG output (both the browser child and mmdc's `-s`).
|
|
798
|
+
* Part of the PNG cache key: a diagram fills a slot of up to ~1180 px, so a
|
|
799
|
+
* 1× raster — as produced when `svgWidth` misread `width="100%"` — arrives
|
|
800
|
+
* blurry, and bumping the scale must orphan those files. */
|
|
801
|
+
export const MERMAID_PNG_SCALE = 3;
|
|
802
|
+
|
|
803
|
+
/** The Mermaid bundle shipped inside the package (see vendor/mermaid/README).
|
|
804
|
+
* Absent only from a broken install — in which case the browser path simply
|
|
805
|
+
* does not apply and the caller keeps its fallback. */
|
|
806
|
+
const MERMAID_BUNDLE = path.join(ROOT, 'vendor', 'mermaid', 'mermaid.min.js');
|
|
807
|
+
|
|
808
|
+
/** This file, spawned as a child process. */
|
|
809
|
+
const MERMAID_CHILD = path.join(ROOT, 'src', 'deck', 'mermaid-render.mjs');
|
|
810
|
+
|
|
811
|
+
/** Last diagnosis from the browser renderer, for the CLI to surface. A silent
|
|
812
|
+
* null told nobody whether the browser was missing, the source invalid, or
|
|
813
|
+
* Chrome unable to start — three fixes behind one symptom. */
|
|
814
|
+
let _mermaidError = null;
|
|
815
|
+
export const lastMermaidError = () => _mermaidError;
|
|
816
|
+
|
|
817
|
+
/**
|
|
818
|
+
* Renders a Mermaid diagram in a browser found on the machine, and returns the
|
|
819
|
+
* path of the produced file — or null, the caller keeping its fallback.
|
|
820
|
+
*
|
|
821
|
+
* Synchronous, like `renderMermaid()` above and for the same reason: both
|
|
822
|
+
* callers of `renderMermaidCached()` dispatch blocks in synchronous loops. The
|
|
823
|
+
* asynchrony lives in the child process (`mermaid-render.mjs`), which the
|
|
824
|
+
* timeout below bounds.
|
|
825
|
+
*/
|
|
826
|
+
export function renderMermaidBrowser(sourceText, tmpDir, idx, { format = 'png' } = {}) {
|
|
827
|
+
const browser = findBrowser();
|
|
828
|
+
if (!browser) {
|
|
829
|
+
_mermaidError = 'no browser found — run `lutrin setup-mermaid`';
|
|
830
|
+
return null;
|
|
831
|
+
}
|
|
832
|
+
if (!fs.existsSync(MERMAID_BUNDLE)) {
|
|
833
|
+
_mermaidError = `the vendored Mermaid bundle is missing (${MERMAID_BUNDLE})`;
|
|
834
|
+
return null;
|
|
835
|
+
}
|
|
836
|
+
try {
|
|
837
|
+
const out = path.join(tmpDir, `diagram-${idx}.${format}`);
|
|
838
|
+
const request = path.join(tmpDir, `request-${idx}.json`);
|
|
839
|
+
fs.writeFileSync(
|
|
840
|
+
request,
|
|
841
|
+
JSON.stringify({
|
|
842
|
+
source: sourceText,
|
|
843
|
+
config: mermaidConfig(),
|
|
844
|
+
out,
|
|
845
|
+
browser: browser.path,
|
|
846
|
+
mermaidBundle: MERMAID_BUNDLE,
|
|
847
|
+
format,
|
|
848
|
+
scale: MERMAID_PNG_SCALE,
|
|
849
|
+
fontFiles: [FONT_FILES.regular, FONT_FILES.bold, FONT_FILES.italic].filter(Boolean),
|
|
850
|
+
defaultFontFamily: FONTS.body,
|
|
851
|
+
}),
|
|
852
|
+
);
|
|
853
|
+
// 60 s, as for mmdc: a cold browser launch costs a few seconds, and a
|
|
854
|
+
// diagram that has not rendered by then never will.
|
|
855
|
+
execFileSync(process.execPath, [MERMAID_CHILD, request], { stdio: 'pipe', timeout: 60_000 });
|
|
856
|
+
if (fs.existsSync(out)) {
|
|
857
|
+
_mermaidError = null;
|
|
858
|
+
return out;
|
|
859
|
+
}
|
|
860
|
+
_mermaidError = 'the renderer produced no file';
|
|
861
|
+
return null;
|
|
862
|
+
} catch (err) {
|
|
863
|
+
_mermaidError = (err?.stderr?.toString() || err?.message || String(err)).trim().split('\n')[0];
|
|
864
|
+
return null;
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
|
|
789
868
|
/**
|
|
790
869
|
* Renders a Mermaid diagram through a persistent cache and returns the path of
|
|
791
|
-
* the rendered file — indispensable to the live preview:
|
|
792
|
-
* seconds per diagram, and it is never run again for a source already
|
|
870
|
+
* the rendered file — indispensable to the live preview: rendering costs
|
|
871
|
+
* several seconds per diagram, and it is never run again for a source already
|
|
872
|
+
* seen.
|
|
793
873
|
*
|
|
794
874
|
* - disk cache `~/.cache/lutrin/mermaid/` (key: sha1 source+format+config),
|
|
795
875
|
* shared between the CLI, watch and the VS Code extension, persistent
|
|
@@ -827,10 +907,10 @@ function mermaidCacheDir() {
|
|
|
827
907
|
*
|
|
828
908
|
* Consulted BEFORE the user cache: that is what allows a self-contained
|
|
829
909
|
* directory to display on a machine where mmdc is not installed. Since the key
|
|
830
|
-
* is a hash of (source + format + theme config
|
|
831
|
-
* construction the exact rendering asked
|
|
832
|
-
* therefore adds no semantics, only one more
|
|
833
|
-
* already determined. */
|
|
910
|
+
* is a hash of (source + format + theme config, plus the raster scale for
|
|
911
|
+
* PNGs), a file found there is by construction the exact rendering asked
|
|
912
|
+
* for — consulting this directory therefore adds no semantics, only one more
|
|
913
|
+
* source for content that is already determined. */
|
|
834
914
|
export const mermaidVendorDir = (baseDir) => path.join(baseDir, 'assets', 'mermaid');
|
|
835
915
|
|
|
836
916
|
/**
|
|
@@ -852,9 +932,18 @@ function svgUsableInHtml(file) {
|
|
|
852
932
|
}
|
|
853
933
|
|
|
854
934
|
export function renderMermaidCached(sourceText, { format = 'png', baseDir = null } = {}) {
|
|
935
|
+
// the raster scale keys the PNGs only: SVGs are scale-free, and including it
|
|
936
|
+
// there would orphan every diagram already vendored next to existing decks
|
|
855
937
|
const key = `${crypto
|
|
856
938
|
.createHash('sha1')
|
|
857
|
-
.update(
|
|
939
|
+
.update(
|
|
940
|
+
JSON.stringify({
|
|
941
|
+
s: sourceText,
|
|
942
|
+
f: format,
|
|
943
|
+
c: mermaidConfig(),
|
|
944
|
+
...(format === 'png' ? { px: MERMAID_PNG_SCALE } : {}),
|
|
945
|
+
}),
|
|
946
|
+
)
|
|
858
947
|
.digest('hex')}.${format}`;
|
|
859
948
|
// The FILE NAME depends only on the content (source + format + config) —
|
|
860
949
|
// that is what makes a vendored directory readable by any Lutrin. The
|
|
@@ -879,13 +968,32 @@ export function renderMermaidCached(sourceText, { format = 'png', baseDir = null
|
|
|
879
968
|
}
|
|
880
969
|
}
|
|
881
970
|
|
|
971
|
+
// Two renderers, tried in order. mmdc first when it is there: someone who
|
|
972
|
+
// installed it asked for it, and it is the reference implementation. The
|
|
973
|
+
// browser is what makes a fresh machine work without installing anything —
|
|
974
|
+
// it needs no download, only a Chrome/Edge/Brave/Chromium already present.
|
|
882
975
|
const mmdc = findMmdc();
|
|
883
|
-
|
|
976
|
+
const browser = findBrowser();
|
|
977
|
+
// No renderer at all → null WITHOUT memoizing it: installing a browser (or
|
|
978
|
+
// mmdc) must take effect without restarting the preview worker. A negative
|
|
979
|
+
// entry is only ever recorded for a source a working renderer refused.
|
|
980
|
+
if (!mmdc && !browser) {
|
|
981
|
+
_mermaidError = 'no browser found — run `lutrin setup-mermaid`';
|
|
982
|
+
return null;
|
|
983
|
+
}
|
|
984
|
+
const renderers = [
|
|
985
|
+
mmdc ? (dir) => renderMermaid(sourceText, dir, 0, mmdc, { format }) : null,
|
|
986
|
+
browser ? (dir) => renderMermaidBrowser(sourceText, dir, 0, { format }) : null,
|
|
987
|
+
].filter(Boolean);
|
|
884
988
|
|
|
885
989
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lutrin-mmd-'));
|
|
886
990
|
let result = null;
|
|
887
991
|
try {
|
|
888
|
-
|
|
992
|
+
let out = null;
|
|
993
|
+
for (const render of renderers) {
|
|
994
|
+
out = render(tmpDir);
|
|
995
|
+
if (out) break;
|
|
996
|
+
}
|
|
889
997
|
if (out && (format !== 'svg' || svgUsableInHtml(out))) {
|
|
890
998
|
// always written into the user cache, never into a vendored directory:
|
|
891
999
|
// `lutrin vendor` alone decides what enters the project
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Finding a browser — the layout engine Mermaid needs.
|
|
3
|
+
*
|
|
4
|
+
* Mermaid measures the text it lays out (that is how it sizes nodes and routes
|
|
5
|
+
* edges), so it cannot render without a real layout engine. The historical way
|
|
6
|
+
* to give it one was `@mermaid-js/mermaid-cli`, which pulls Puppeteer and
|
|
7
|
+
* downloads a Chrome: 405 MB of `node_modules` plus ~540 MB of browser, on
|
|
8
|
+
* every install. That price is exactly why it stayed an optional peer
|
|
9
|
+
* dependency nobody installed — and therefore why diagrams degraded to a text
|
|
10
|
+
* fallback on every fresh machine, silently.
|
|
11
|
+
*
|
|
12
|
+
* The observation this module rests on: a machine that compiles a presentation
|
|
13
|
+
* almost always already HAS a browser. Driving that one costs `puppeteer-core`
|
|
14
|
+
* (28 MB, and, unlike `puppeteer`, it never downloads anything) instead of
|
|
15
|
+
* three quarters of a gigabyte.
|
|
16
|
+
*
|
|
17
|
+
* Order of preference, most explicit first:
|
|
18
|
+
*
|
|
19
|
+
* 1. `LUTRIN_BROWSER` — the escape hatch: an unusual install, a sandbox, a
|
|
20
|
+
* pinned build. Never second-guessed, and a wrong path is reported rather
|
|
21
|
+
* than silently skipped, otherwise setting the variable at all would be
|
|
22
|
+
* indistinguishable from not setting it;
|
|
23
|
+
* 2. `PUPPETEER_EXECUTABLE_PATH` — the same intent, spelled the way the
|
|
24
|
+
* surrounding ecosystem already spells it (containers, CI images);
|
|
25
|
+
* 3. `~/.cache/lutrin/browser/` — what `lutrin setup-mermaid` downloads when
|
|
26
|
+
* the machine really has no browser. Preferred over a system install
|
|
27
|
+
* because it is the one WE provisioned, at a version we know renders;
|
|
28
|
+
* 4. the system browsers, in the order below.
|
|
29
|
+
*
|
|
30
|
+
* Chrome, Edge, Brave and Chromium are all Chromium: any of them drives the
|
|
31
|
+
* CDP endpoint puppeteer-core speaks. Firefox and Safari are deliberately
|
|
32
|
+
* absent — Safari has no CDP at all, and Firefox's support does not cover what
|
|
33
|
+
* mermaid's rendering needs.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import fs from 'node:fs';
|
|
37
|
+
import os from 'node:os';
|
|
38
|
+
import path from 'node:path';
|
|
39
|
+
import { execFileSync } from 'node:child_process';
|
|
40
|
+
|
|
41
|
+
/** Root of the user cache — same resolution as assets.mjs (LUTRIN_CACHE,
|
|
42
|
+
* MTL_DECK_CACHE for the tool's former name, XDG, then ~/.cache/lutrin).
|
|
43
|
+
* Duplicated rather than imported: assets.mjs imports THIS module, and the
|
|
44
|
+
* cycle would be paid on every load for six lines. */
|
|
45
|
+
function userCacheRoot() {
|
|
46
|
+
return (
|
|
47
|
+
process.env.LUTRIN_CACHE ||
|
|
48
|
+
process.env.MTL_DECK_CACHE ||
|
|
49
|
+
(process.env.XDG_CACHE_HOME
|
|
50
|
+
? path.join(process.env.XDG_CACHE_HOME, 'lutrin')
|
|
51
|
+
: path.join(os.homedir(), '.cache', 'lutrin'))
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Where `lutrin setup-mermaid` puts the browser it downloads. */
|
|
56
|
+
export const browserCacheDir = () => path.join(userCacheRoot(), 'browser');
|
|
57
|
+
|
|
58
|
+
/** Candidate system paths, per platform, in order of preference. */
|
|
59
|
+
function systemCandidates() {
|
|
60
|
+
const {
|
|
61
|
+
HOME = '',
|
|
62
|
+
LOCALAPPDATA = '',
|
|
63
|
+
PROGRAMFILES = '',
|
|
64
|
+
'PROGRAMFILES(X86)': PF86 = '',
|
|
65
|
+
} = process.env;
|
|
66
|
+
|
|
67
|
+
if (process.platform === 'darwin')
|
|
68
|
+
return [
|
|
69
|
+
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
70
|
+
`${HOME}/Applications/Google Chrome.app/Contents/MacOS/Google Chrome`,
|
|
71
|
+
'/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
|
|
72
|
+
'/Applications/Brave Browser.app/Contents/MacOS/Brave Browser',
|
|
73
|
+
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
if (process.platform === 'win32')
|
|
77
|
+
return [
|
|
78
|
+
`${PROGRAMFILES}\\Google\\Chrome\\Application\\chrome.exe`,
|
|
79
|
+
`${PF86}\\Google\\Chrome\\Application\\chrome.exe`,
|
|
80
|
+
`${LOCALAPPDATA}\\Google\\Chrome\\Application\\chrome.exe`,
|
|
81
|
+
`${PROGRAMFILES}\\Microsoft\\Edge\\Application\\msedge.exe`,
|
|
82
|
+
`${PF86}\\Microsoft\\Edge\\Application\\msedge.exe`,
|
|
83
|
+
`${PROGRAMFILES}\\BraveSoftware\\Brave-Browser\\Application\\brave.exe`,
|
|
84
|
+
];
|
|
85
|
+
|
|
86
|
+
// Linux and the other unices: absolute paths first (a container often has the
|
|
87
|
+
// binary without a populated PATH), then the names, resolved below.
|
|
88
|
+
return [
|
|
89
|
+
'/usr/bin/google-chrome',
|
|
90
|
+
'/usr/bin/google-chrome-stable',
|
|
91
|
+
'/usr/bin/chromium',
|
|
92
|
+
'/usr/bin/chromium-browser',
|
|
93
|
+
'/usr/bin/microsoft-edge',
|
|
94
|
+
'/usr/bin/brave-browser',
|
|
95
|
+
'/snap/bin/chromium',
|
|
96
|
+
'google-chrome',
|
|
97
|
+
'google-chrome-stable',
|
|
98
|
+
'chromium',
|
|
99
|
+
'chromium-browser',
|
|
100
|
+
'microsoft-edge',
|
|
101
|
+
'brave-browser',
|
|
102
|
+
];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Resolves a bare command name through PATH. `which`/`where` rather than a
|
|
106
|
+
* hand-rolled PATH walk, so that shims and symlinks resolve the way the shell
|
|
107
|
+
* would. */
|
|
108
|
+
function onPath(name) {
|
|
109
|
+
try {
|
|
110
|
+
const out = execFileSync(process.platform === 'win32' ? 'where' : 'which', [name], {
|
|
111
|
+
encoding: 'utf8',
|
|
112
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
113
|
+
});
|
|
114
|
+
// `where` can return several lines; the first is the one that would run
|
|
115
|
+
const first = out.split(/\r?\n/)[0].trim();
|
|
116
|
+
return first && fs.existsSync(first) ? first : null;
|
|
117
|
+
} catch {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const EXE_NAMES = new Set([
|
|
123
|
+
'chrome-headless-shell',
|
|
124
|
+
'chrome-headless-shell.exe',
|
|
125
|
+
'chrome',
|
|
126
|
+
'chrome.exe',
|
|
127
|
+
]);
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The browser previously downloaded into the user cache, or null.
|
|
131
|
+
*
|
|
132
|
+
* A bounded depth-first walk rather than a hard-coded path: `@puppeteer/browsers`
|
|
133
|
+
* nests its installs as `<cache>/chrome-headless-shell/<platform>-<build>/…`,
|
|
134
|
+
* and on macOS the executable sits inside a further `.app` bundle. Spelling
|
|
135
|
+
* that shape out here would make the lookup a hostage to a layout the
|
|
136
|
+
* downloader is free to change; four levels of `readdir` over a directory that
|
|
137
|
+
* holds at most a couple of browsers costs nothing.
|
|
138
|
+
*
|
|
139
|
+
* Directory entries are visited newest-name-last-first so that a cache holding
|
|
140
|
+
* two versions yields the recent one — the one the last `setup-mermaid` asked
|
|
141
|
+
* for.
|
|
142
|
+
*/
|
|
143
|
+
function cachedBrowser(dir = browserCacheDir(), depth = 0) {
|
|
144
|
+
if (depth > 4) return null;
|
|
145
|
+
let entries;
|
|
146
|
+
try {
|
|
147
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
148
|
+
} catch {
|
|
149
|
+
return null; // no cache directory yet
|
|
150
|
+
}
|
|
151
|
+
const sorted = entries.sort((a, b) => b.name.localeCompare(a.name));
|
|
152
|
+
for (const entry of sorted)
|
|
153
|
+
if (!entry.isDirectory() && EXE_NAMES.has(entry.name)) return path.join(dir, entry.name);
|
|
154
|
+
for (const entry of sorted) {
|
|
155
|
+
if (!entry.isDirectory()) continue;
|
|
156
|
+
const found = cachedBrowser(path.join(dir, entry.name), depth + 1);
|
|
157
|
+
if (found) return found;
|
|
158
|
+
}
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
let _browser; // memoized: the lookup stats a dozen paths, once per process
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Path of a usable browser, or null — in which case the caller keeps its text
|
|
166
|
+
* fallback and the CLI points at `lutrin setup-mermaid`.
|
|
167
|
+
*
|
|
168
|
+
* @param {object} [opts]
|
|
169
|
+
* @param {boolean} [opts.refresh] recompute instead of returning the memo —
|
|
170
|
+
* `setup-mermaid` needs it after downloading a browser into the cache.
|
|
171
|
+
* @returns {{path:string,source:string}|null} `source` names where it came
|
|
172
|
+
* from, so the CLI can say WHICH browser it is about to drive.
|
|
173
|
+
*/
|
|
174
|
+
export function findBrowser({ refresh = false } = {}) {
|
|
175
|
+
if (!refresh && _browser !== undefined) return _browser;
|
|
176
|
+
|
|
177
|
+
for (const [envVar, label] of [
|
|
178
|
+
['LUTRIN_BROWSER', 'LUTRIN_BROWSER'],
|
|
179
|
+
['PUPPETEER_EXECUTABLE_PATH', 'PUPPETEER_EXECUTABLE_PATH'],
|
|
180
|
+
]) {
|
|
181
|
+
const value = process.env[envVar];
|
|
182
|
+
if (!value) continue;
|
|
183
|
+
// An explicit setting that does not resolve is a configuration error, not
|
|
184
|
+
// an absence: fall through to autodetection, but say so — a silent skip
|
|
185
|
+
// here is a support ticket that starts with "but I set the variable".
|
|
186
|
+
if (fs.existsSync(value)) return (_browser = { path: value, source: label });
|
|
187
|
+
console.warn(`lutrin: ${label}="${value}" does not exist — looking for a browser elsewhere`);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const cached = cachedBrowser();
|
|
191
|
+
if (cached) return (_browser = { path: cached, source: 'downloaded by lutrin setup-mermaid' });
|
|
192
|
+
|
|
193
|
+
for (const candidate of systemCandidates()) {
|
|
194
|
+
const resolved = path.isAbsolute(candidate)
|
|
195
|
+
? fs.existsSync(candidate)
|
|
196
|
+
? candidate
|
|
197
|
+
: null
|
|
198
|
+
: onPath(candidate);
|
|
199
|
+
if (resolved) return (_browser = { path: resolved, source: 'installed on the system' });
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return (_browser = null);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Test seam: forget the memoized lookup. */
|
|
206
|
+
export function resetBrowserCache() {
|
|
207
|
+
_browser = undefined;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** The build `setup-mermaid` downloads when the machine has no browser at all.
|
|
211
|
+
* chrome-headless-shell rather than a full Chrome: ~200 MB against ~350 MB,
|
|
212
|
+
* measured, and it is the only mode we ever drive. Pinned, so that two
|
|
213
|
+
* machines provisioned months apart render the same diagram identically. */
|
|
214
|
+
export const HEADLESS_SHELL_VERSION = '150.0.7871.24';
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Downloads chrome-headless-shell into `~/.cache/lutrin/browser/` and returns
|
|
218
|
+
* its path.
|
|
219
|
+
*
|
|
220
|
+
* Only ever called by `lutrin setup-mermaid`, and only after the user has said
|
|
221
|
+
* yes: a compile never downloads a browser behind anyone's back — that silent
|
|
222
|
+
* ~1 GB is the whole reason mermaid rendering was broken by default in the
|
|
223
|
+
* first place.
|
|
224
|
+
*
|
|
225
|
+
* `@puppeteer/browsers` ships with puppeteer-core, so the downloader costs no
|
|
226
|
+
* extra dependency.
|
|
227
|
+
*
|
|
228
|
+
* @param {(text:string)=>void} [onProgress]
|
|
229
|
+
* @returns {Promise<string>} path of the executable
|
|
230
|
+
*/
|
|
231
|
+
export async function downloadHeadlessShell(onProgress = () => {}) {
|
|
232
|
+
const { install, resolveBuildId, detectBrowserPlatform, Browser } = await import(
|
|
233
|
+
'@puppeteer/browsers'
|
|
234
|
+
);
|
|
235
|
+
const cacheDir = browserCacheDir();
|
|
236
|
+
fs.mkdirSync(cacheDir, { recursive: true });
|
|
237
|
+
|
|
238
|
+
const platform = detectBrowserPlatform();
|
|
239
|
+
if (!platform) throw new Error(`unsupported platform: ${process.platform}/${process.arch}`);
|
|
240
|
+
|
|
241
|
+
// The pin is a starting point, not a contract with the CDN: a build that has
|
|
242
|
+
// been rotated out resolves to the current one for that channel rather than
|
|
243
|
+
// failing the setup outright.
|
|
244
|
+
let buildId = HEADLESS_SHELL_VERSION;
|
|
245
|
+
try {
|
|
246
|
+
buildId = await resolveBuildId(Browser.CHROMEHEADLESSSHELL, platform, buildId);
|
|
247
|
+
} catch {
|
|
248
|
+
buildId = await resolveBuildId(Browser.CHROMEHEADLESSSHELL, platform, 'stable');
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
let lastPercent = -1;
|
|
252
|
+
const installed = await install({
|
|
253
|
+
browser: Browser.CHROMEHEADLESSSHELL,
|
|
254
|
+
buildId,
|
|
255
|
+
cacheDir,
|
|
256
|
+
downloadProgressCallback: (downloaded, total) => {
|
|
257
|
+
if (!total) return;
|
|
258
|
+
const percent = Math.floor((downloaded / total) * 100);
|
|
259
|
+
// every 5 %: enough to show life, few enough not to flood a CI log
|
|
260
|
+
if (percent >= lastPercent + 5) {
|
|
261
|
+
lastPercent = percent;
|
|
262
|
+
onProgress(` downloading chrome-headless-shell ${buildId} — ${percent}%`);
|
|
263
|
+
}
|
|
264
|
+
},
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
resetBrowserCache();
|
|
268
|
+
return installed.executablePath;
|
|
269
|
+
}
|