@lutrin/core 1.0.0 → 1.1.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/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 provides the `lutrin` CLI, and serves as a library for the editor
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
- The package is not published on npm yet. From a clone of the repository:
15
+ To use the compiler as a library:
14
16
 
15
17
  ```bash
16
- npm install # at the root of the monorepo
17
- npx lutrin --help
18
+ npm install @lutrin/core
19
+ ```
18
20
 
19
- npm link -w lutrin # to call "lutrin" from anywhere
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.0.0",
3
+ "version": "1.1.0",
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 (install @mermaid-js/mermaid-cli for graphical rendering)`,
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: install @mermaid-js/mermaid-cli, then re-run vendor');
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
  }
@@ -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 → mmdc (optional), as PNG (PPTX) or SVG (HTML).
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);
@@ -709,7 +713,7 @@ export async function renderMath(tex, { scale = 3 } = {}) {
709
713
  }
710
714
 
711
715
  // ---------------------------------------------------------------------------
712
- // Mermaid (optional): PNG or SVG rendering via mmdc when it is available
716
+ // Mermaid: PNG or SVG rendering, in a browser found on the machine (or mmdc)
713
717
  // ---------------------------------------------------------------------------
714
718
 
715
719
  /** Mermaid theme aligned on the active theme: light surfaces, neutral rules,
@@ -778,7 +782,7 @@ export function renderMermaid(sourceText, tmpDir, idx, mmdc, { format = 'png' }
778
782
  fs.writeFileSync(cfg, JSON.stringify(mermaidConfig()));
779
783
  fs.writeFileSync(src, sourceText);
780
784
  const args = ['-i', src, '-o', out, '-b', 'transparent', '-c', cfg];
781
- if (format === 'png') args.push('-s', '3');
785
+ if (format === 'png') args.push('-s', String(MERMAID_PNG_SCALE));
782
786
  execFileSync(mmdc, args, { stdio: 'pipe', timeout: 60_000 });
783
787
  return fs.existsSync(out) ? out : null;
784
788
  } catch {
@@ -786,10 +790,82 @@ export function renderMermaid(sourceText, tmpDir, idx, mmdc, { format = 'png' }
786
790
  }
787
791
  }
788
792
 
793
+ /** Raster scale of the PNG output (both the browser child and mmdc's `-s`).
794
+ * Part of the PNG cache key: a diagram fills a slot of up to ~1180 px, so a
795
+ * 1× raster — as produced when `svgWidth` misread `width="100%"` — arrives
796
+ * blurry, and bumping the scale must orphan those files. */
797
+ export const MERMAID_PNG_SCALE = 3;
798
+
799
+ /** The Mermaid bundle shipped inside the package (see vendor/mermaid/README).
800
+ * Absent only from a broken install — in which case the browser path simply
801
+ * does not apply and the caller keeps its fallback. */
802
+ const MERMAID_BUNDLE = path.join(ROOT, 'vendor', 'mermaid', 'mermaid.min.js');
803
+
804
+ /** This file, spawned as a child process. */
805
+ const MERMAID_CHILD = path.join(ROOT, 'src', 'deck', 'mermaid-render.mjs');
806
+
807
+ /** Last diagnosis from the browser renderer, for the CLI to surface. A silent
808
+ * null told nobody whether the browser was missing, the source invalid, or
809
+ * Chrome unable to start — three fixes behind one symptom. */
810
+ let _mermaidError = null;
811
+ export const lastMermaidError = () => _mermaidError;
812
+
813
+ /**
814
+ * Renders a Mermaid diagram in a browser found on the machine, and returns the
815
+ * path of the produced file — or null, the caller keeping its fallback.
816
+ *
817
+ * Synchronous, like `renderMermaid()` above and for the same reason: both
818
+ * callers of `renderMermaidCached()` dispatch blocks in synchronous loops. The
819
+ * asynchrony lives in the child process (`mermaid-render.mjs`), which the
820
+ * timeout below bounds.
821
+ */
822
+ export function renderMermaidBrowser(sourceText, tmpDir, idx, { format = 'png' } = {}) {
823
+ const browser = findBrowser();
824
+ if (!browser) {
825
+ _mermaidError = 'no browser found — run `lutrin setup-mermaid`';
826
+ return null;
827
+ }
828
+ if (!fs.existsSync(MERMAID_BUNDLE)) {
829
+ _mermaidError = `the vendored Mermaid bundle is missing (${MERMAID_BUNDLE})`;
830
+ return null;
831
+ }
832
+ try {
833
+ const out = path.join(tmpDir, `diagram-${idx}.${format}`);
834
+ const request = path.join(tmpDir, `request-${idx}.json`);
835
+ fs.writeFileSync(
836
+ request,
837
+ JSON.stringify({
838
+ source: sourceText,
839
+ config: mermaidConfig(),
840
+ out,
841
+ browser: browser.path,
842
+ mermaidBundle: MERMAID_BUNDLE,
843
+ format,
844
+ scale: MERMAID_PNG_SCALE,
845
+ fontFiles: [FONT_FILES.regular, FONT_FILES.bold, FONT_FILES.italic].filter(Boolean),
846
+ defaultFontFamily: FONTS.body,
847
+ }),
848
+ );
849
+ // 60 s, as for mmdc: a cold browser launch costs a few seconds, and a
850
+ // diagram that has not rendered by then never will.
851
+ execFileSync(process.execPath, [MERMAID_CHILD, request], { stdio: 'pipe', timeout: 60_000 });
852
+ if (fs.existsSync(out)) {
853
+ _mermaidError = null;
854
+ return out;
855
+ }
856
+ _mermaidError = 'the renderer produced no file';
857
+ return null;
858
+ } catch (err) {
859
+ _mermaidError = (err?.stderr?.toString() || err?.message || String(err)).trim().split('\n')[0];
860
+ return null;
861
+ }
862
+ }
863
+
789
864
  /**
790
865
  * 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.
866
+ * the rendered file — indispensable to the live preview: rendering costs
867
+ * several seconds per diagram, and it is never run again for a source already
868
+ * seen.
793
869
  *
794
870
  * - disk cache `~/.cache/lutrin/mermaid/` (key: sha1 source+format+config),
795
871
  * shared between the CLI, watch and the VS Code extension, persistent
@@ -827,10 +903,10 @@ function mermaidCacheDir() {
827
903
  *
828
904
  * Consulted BEFORE the user cache: that is what allows a self-contained
829
905
  * 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. */
906
+ * is a hash of (source + format + theme config, plus the raster scale for
907
+ * PNGs), a file found there is by construction the exact rendering asked
908
+ * for — consulting this directory therefore adds no semantics, only one more
909
+ * source for content that is already determined. */
834
910
  export const mermaidVendorDir = (baseDir) => path.join(baseDir, 'assets', 'mermaid');
835
911
 
836
912
  /**
@@ -852,9 +928,18 @@ function svgUsableInHtml(file) {
852
928
  }
853
929
 
854
930
  export function renderMermaidCached(sourceText, { format = 'png', baseDir = null } = {}) {
931
+ // the raster scale keys the PNGs only: SVGs are scale-free, and including it
932
+ // there would orphan every diagram already vendored next to existing decks
855
933
  const key = `${crypto
856
934
  .createHash('sha1')
857
- .update(JSON.stringify({ s: sourceText, f: format, c: mermaidConfig() }))
935
+ .update(
936
+ JSON.stringify({
937
+ s: sourceText,
938
+ f: format,
939
+ c: mermaidConfig(),
940
+ ...(format === 'png' ? { px: MERMAID_PNG_SCALE } : {}),
941
+ }),
942
+ )
858
943
  .digest('hex')}.${format}`;
859
944
  // The FILE NAME depends only on the content (source + format + config) —
860
945
  // that is what makes a vendored directory readable by any Lutrin. The
@@ -879,13 +964,32 @@ export function renderMermaidCached(sourceText, { format = 'png', baseDir = null
879
964
  }
880
965
  }
881
966
 
967
+ // Two renderers, tried in order. mmdc first when it is there: someone who
968
+ // installed it asked for it, and it is the reference implementation. The
969
+ // browser is what makes a fresh machine work without installing anything —
970
+ // it needs no download, only a Chrome/Edge/Brave/Chromium already present.
882
971
  const mmdc = findMmdc();
883
- if (!mmdc) return null;
972
+ const browser = findBrowser();
973
+ // No renderer at all → null WITHOUT memoizing it: installing a browser (or
974
+ // mmdc) must take effect without restarting the preview worker. A negative
975
+ // entry is only ever recorded for a source a working renderer refused.
976
+ if (!mmdc && !browser) {
977
+ _mermaidError = 'no browser found — run `lutrin setup-mermaid`';
978
+ return null;
979
+ }
980
+ const renderers = [
981
+ mmdc ? (dir) => renderMermaid(sourceText, dir, 0, mmdc, { format }) : null,
982
+ browser ? (dir) => renderMermaidBrowser(sourceText, dir, 0, { format }) : null,
983
+ ].filter(Boolean);
884
984
 
885
985
  const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lutrin-mmd-'));
886
986
  let result = null;
887
987
  try {
888
- const out = renderMermaid(sourceText, tmpDir, 0, mmdc, { format });
988
+ let out = null;
989
+ for (const render of renderers) {
990
+ out = render(tmpDir);
991
+ if (out) break;
992
+ }
889
993
  if (out && (format !== 'svg' || svgUsableInHtml(out))) {
890
994
  // always written into the user cache, never into a vendored directory:
891
995
  // `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
+ }
@@ -28,6 +28,7 @@ import {
28
28
  LINE_HEIGHT,
29
29
  contentArea,
30
30
  } from './tokens.mjs';
31
+ import { isMarpDeck } from './marp.mjs';
31
32
  import { ALERT_BLOCK_TYPES, animateFlag, animatePreset, runsToText } from './parse.mjs';
32
33
  import { closest } from './suggest.mjs';
33
34
 
@@ -908,8 +909,10 @@ export function buildScenes(deck) {
908
909
  const deckAnimate = meta.animate != null && animateFlag(meta.animate);
909
910
  const deckPreset = meta.animate != null ? animatePreset(meta.animate) : null;
910
911
 
911
- // Implicit cover slide from the frontmatter
912
- if (meta.title) {
912
+ // Implicit cover slide from the frontmatter. Not for a Marp deck: there,
913
+ // `title:` is HTML metadata (Marp CLI) and the cover is the deck's own
914
+ // first slide — an implicit one would double it.
915
+ if (meta.title && !isMarpDeck(meta)) {
913
916
  scenes.push({
914
917
  master: 'cover',
915
918
  layout: 'cover',