@lerret/cli 0.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.
Files changed (38) hide show
  1. package/LICENSE +21 -0
  2. package/dist-studio/.bundle-stamp +34 -0
  3. package/dist-studio/assets/asset-runtime-MFjDKvQD.js +129 -0
  4. package/dist-studio/assets/cli-project-source-9dNA_gVa.js +1 -0
  5. package/dist-studio/assets/dev-harness-BH6a8T7l.js +18 -0
  6. package/dist-studio/assets/hosted-project-source-dVGq_8c6.js +135 -0
  7. package/dist-studio/assets/index-BNmJ8c2t.css +1 -0
  8. package/dist-studio/assets/index-EslqdOhg.js +10 -0
  9. package/dist-studio/assets/leaf-marker-command.png +0 -0
  10. package/dist-studio/assets/leaf-marker-comment-box.png +0 -0
  11. package/dist-studio/assets/leaf-marker-homescreen.png +0 -0
  12. package/dist-studio/assets/leafmarker-icon-dark-128.png +0 -0
  13. package/dist-studio/assets/leafmarker-logo-transparent.png +0 -0
  14. package/dist-studio/assets/leafmarker-logo.png +0 -0
  15. package/dist-studio/assets/lerret-logo.png +0 -0
  16. package/dist-studio/assets/lerret-wordmark.svg +3 -0
  17. package/dist-studio/assets/logo-angular.svg +1 -0
  18. package/dist-studio/assets/logo-claude.svg +7 -0
  19. package/dist-studio/assets/logo-codex.svg +1 -0
  20. package/dist-studio/assets/logo-cursor.svg +1 -0
  21. package/dist-studio/assets/logo-javascript.svg +1 -0
  22. package/dist-studio/assets/logo-react.svg +1 -0
  23. package/dist-studio/assets/logo-svelte.svg +1 -0
  24. package/dist-studio/assets/logo-vue.svg +1 -0
  25. package/dist-studio/assets/open-folder-D5OR7eLb.js +8 -0
  26. package/dist-studio/assets/project-studio-BjNaIuRb.js +795 -0
  27. package/dist-studio/assets/project-studio-CKuMOMsC.css +1 -0
  28. package/dist-studio/assets/superwhisper-logo.png +0 -0
  29. package/dist-studio/index.html +47 -0
  30. package/dist-studio/module-sw.js +275 -0
  31. package/package.json +51 -0
  32. package/src/dev.js +373 -0
  33. package/src/export.js +1386 -0
  34. package/src/fs/node-backend.js +631 -0
  35. package/src/lerret.js +143 -0
  36. package/src/resolve-project.js +178 -0
  37. package/src/vite-plugin-lerret-project.js +986 -0
  38. package/src/watcher.js +214 -0
package/src/export.js ADDED
@@ -0,0 +1,1386 @@
1
+ // `lerret export` — headless capture of a project's artboards to image files
2
+ // (FR37, FR38).
3
+ //
4
+ // Renders every artboard in scope through the EXACT same `captureArtboard`
5
+ // path the studio uses, so a `lerret export` run produces a
6
+ // pixel-faithful match to what the same project produces from a click in the
7
+ // in-studio export buttons. The mechanism is:
8
+ //
9
+ // 1. Resolve the project — either from the optional `[path]` positional, or
10
+ // by walking up `.lerret/` from the caller's CWD.
11
+ // If `[path]` points at a page or group folder inside `.lerret/`, that
12
+ // becomes the export scope; if it points at the project root, the whole
13
+ // project is the scope.
14
+ // 2. Load the project model (`scan()` via the Node `FilesystemAccess`
15
+ // backend), then call `collectArtboards(model, scope)` to
16
+ // pick which artboards to capture.
17
+ // 3. Boot a Vite dev server programmatically against the studio source plus
18
+ // the same `vite-plugin-lerret-project` `lerret dev` uses — exactly the
19
+ // runtime that serves the studio so the project is mounted there.
20
+ // 4. Launch a headless Chromium through Playwright. Prefer the system
21
+ // `chrome`/`msedge` channel so `npx`-style invocations stay light; fall
22
+ // back to a bundled `playwright` browser if it has been installed; print
23
+ // a clear, actionable message if neither is available.
24
+ // 5. Navigate to the studio URL, wait for the first artboard slot to
25
+ // appear, then for each artboard call `captureArtboard` INSIDE the page
26
+ // via `page.evaluate` (the same module the studio bundles). Each blob is
27
+ // transferred back to Node as a Uint8Array and written to disk under
28
+ // `--out` using either the structured (default) or `--flat` layout.
29
+ //
30
+ // FR39 adds two override flags:
31
+ // --data <path> JSON or .js file; overrides the data tier (tier 1) in
32
+ // `resolveProps` for each artboard in this run.
33
+ // --config <path> JSON or .js file; is deep-merged into the cascade
34
+ // (using `computeCascadedConfig` semantics) so every
35
+ // folder's effective config is overridden for this run.
36
+ //
37
+ // Both override paths are resolved relative to the caller's CWD. They are
38
+ // loaded BEFORE the Vite server starts; any missing-file or parse error
39
+ // aborts the run immediately (exit 1) so the user gets clear feedback before
40
+ // spending time booting Chromium.
41
+ //
42
+ // Neither override is ever written back to the user's `.lerret/` (NFR13).
43
+ // The loaded values flow into `lerretProjectPlugin` as in-memory constructor
44
+ // options; the plugin exposes them via the `virtual:lerret-project` module's
45
+ // `overrides` export so the studio runtime can apply them during rendering.
46
+ //
47
+ // Output:
48
+ // Structured (default): `<out>/<page>[/<group>[/…]]/<asset.name>[-<variant>].<ext>`
49
+ // Flat (`--flat`): `<out>/<asset.name>[-<variant>].<ext>` (collisions
50
+ // disambiguated by joining locationSegments with `-`).
51
+ //
52
+ // Exit code policy:
53
+ // 0 — every selected artboard was captured and written (a per-artboard
54
+ // failure that the run continued past is logged and still 0; the run
55
+ // did not abort).
56
+ // 1 — fatal: no project resolved, no artboards in scope, output dir could
57
+ // not be created, Vite failed to start, or no Chromium could be launched.
58
+ // Also 1 when a --data / --config file is missing or unparseable.
59
+ //
60
+ // Failure isolation: an individual artboard capture failure is reported but
61
+ // does NOT abort the run; remaining artboards still write. Unembedded fonts
62
+ // across all captures are aggregated and named in the final summary.
63
+ //
64
+ // Separation invariant (NFR13): the CLI never writes into the user's
65
+ // `.lerret/`. All output lands under `--out`, which defaults to a fresh
66
+ // `./lerret-export` directory relative to the CWD. Override values supplied
67
+ // via --data / --config are kept entirely in memory for the duration of the
68
+ // run and are discarded on exit.
69
+
70
+ import { parseArgs } from 'node:util';
71
+ import { dirname, resolve as resolvePath } from 'node:path';
72
+ import { fileURLToPath, pathToFileURL } from 'node:url';
73
+
74
+ import { scan, collectArtboards } from '@lerret/core';
75
+
76
+ import {
77
+ createNodeBackend,
78
+ ensureDir,
79
+ pathExists,
80
+ realpathOfExistingPrefix,
81
+ realpathOrSelf,
82
+ readTextFile,
83
+ } from './fs/node-backend.js';
84
+ import { resolveProject } from './resolve-project.js';
85
+ import { resolveStudioRoot } from './dev.js';
86
+ import { lerretProjectPlugin } from './vite-plugin-lerret-project.js';
87
+
88
+ // ─────────────────────────────────────────────────────────────────────────────
89
+ // Public types
90
+ // ─────────────────────────────────────────────────────────────────────────────
91
+
92
+ /**
93
+ * The argv shape `parseArgs` produces for `lerret export`.
94
+ *
95
+ * @typedef {object} ExportFlags
96
+ * @property {string | undefined} pathArg
97
+ * The optional positional argument — a project root, or a page/group folder
98
+ * inside `.lerret/`. `undefined` means "walk up from CWD".
99
+ * @property {'png' | 'jpg'} format
100
+ * Output image format. Defaults to `'png'`.
101
+ * @property {string} out
102
+ * Output directory. Defaults to `./lerret-export` relative to CWD.
103
+ * @property {boolean} flat
104
+ * When true, all images are written directly into `out` with collision
105
+ * disambiguation. When false (default), `out` receives nested folders
106
+ * mirroring the project's page/group hierarchy.
107
+ * @property {string | undefined} data
108
+ * Path to a JSON or .js file whose contents override the data tier (tier 1)
109
+ * of `resolveProps` for every artboard in this run (FR39).
110
+ * Resolved relative to CWD. `undefined` → no data override.
111
+ * @property {string | undefined} config
112
+ * Path to a JSON or .js file whose contents are deep-merged into the
113
+ * cascaded config for every folder in this run (FR39).
114
+ * Resolved relative to CWD. `undefined` → no config override.
115
+ * @property {boolean} help
116
+ */
117
+
118
+ /**
119
+ * Loaded override pair — the result of calling `loadOverrideFiles`.
120
+ *
121
+ * @typedef {object} OverrideFiles
122
+ * @property {Record<string, unknown> | undefined} dataOverride
123
+ * The parsed data override object, or `undefined` when `--data` was not
124
+ * supplied.
125
+ * @property {Record<string, unknown> | undefined} configOverride
126
+ * The parsed config override object, or `undefined` when `--config` was not
127
+ * supplied.
128
+ */
129
+
130
+ /**
131
+ * @typedef {object} ScopeResolution
132
+ * @property {boolean} found
133
+ * True when both project root AND scope path resolve to a node in the model.
134
+ * @property {string} [projectRoot] Absolute, forward-slash project root path.
135
+ * @property {string} [lerretDir] Absolute, forward-slash `.lerret/` path.
136
+ * @property {string | null} [scopePath]
137
+ * The `LerretPath` to pass to `collectArtboards` — `null` for "whole project",
138
+ * or a page/group path. Only meaningful when `found === true`.
139
+ * @property {'project' | 'page' | 'group'} [scopeKind]
140
+ * How the scope was classified. Useful for the start-of-run log.
141
+ * @property {object} [model]
142
+ * The scanned project model — populated on `found === true` so the caller
143
+ * does not have to scan twice. Untyped here (it is `ProjectNode` from
144
+ * `@lerret/core`) to keep this CLI module's JSDoc imports minimal.
145
+ * @property {string} [error]
146
+ * Human-readable failure reason when `found === false`.
147
+ */
148
+
149
+ // ─────────────────────────────────────────────────────────────────────────────
150
+ // Constants
151
+ // ─────────────────────────────────────────────────────────────────────────────
152
+
153
+ /**
154
+ * Default output directory (created relative to CWD) when `--out` is omitted.
155
+ * Chosen to be obvious, non-magical, and clearly distinct from any project-
156
+ * internal folder — the CLI never writes inside `.lerret/` (NFR13).
157
+ *
158
+ * @type {string}
159
+ */
160
+ export const DEFAULT_OUT_DIR = './lerret-export';
161
+
162
+ /**
163
+ * Default image format when `--format` is omitted. Matches the studio's per-
164
+ * artboard PNG button and the bulk-export panel default.
165
+ *
166
+ * @type {'png'}
167
+ */
168
+ export const DEFAULT_FORMAT = 'png';
169
+
170
+ /**
171
+ * Selectors used to find an artboard in the rendered studio. The canvas
172
+ * marks each artboard slot with `data-dc-slot="<id>"` where `<id>` is the
173
+ * asset path (primary export) or `<asset.path>#<variantName>` (variant). The
174
+ * inner `.dc-card` is the actual element `captureArtboard` rasterizes — the
175
+ * same DOM node the studio's per-artboard PNG button uses.
176
+ *
177
+ * @type {{ slotByDataAttr: (id: string) => string, innerCardSelector: string }}
178
+ */
179
+ export const ARTBOARD_SELECTORS = {
180
+ slotByDataAttr: (id) =>
181
+ `[data-dc-slot=${JSON.stringify(id)}]`,
182
+ innerCardSelector: '.dc-card',
183
+ };
184
+
185
+ // ─────────────────────────────────────────────────────────────────────────────
186
+ // Argument parsing
187
+ // ─────────────────────────────────────────────────────────────────────────────
188
+
189
+ /**
190
+ * Print `lerret export`'s usage banner.
191
+ *
192
+ * @returns {void}
193
+ */
194
+ function printUsage() {
195
+ const lines = [
196
+ 'lerret export — render a project (or page/group) headlessly to image files.',
197
+ '',
198
+ 'Usage: lerret export [path] [options]',
199
+ '',
200
+ 'Arguments:',
201
+ ' path Project root, or a page/group folder inside `.lerret/`.',
202
+ ' Omitted: walk up from CWD to find the nearest project.',
203
+ '',
204
+ 'Options:',
205
+ ` --format <fmt> Image format — png (default) or jpg.`,
206
+ ` --out <dir> Output directory (default: ${DEFAULT_OUT_DIR}).`,
207
+ ' --flat Write all images directly under --out; default is',
208
+ ' nested folders mirroring page/group hierarchy.',
209
+ ' --data <path> JSON or .js file whose contents override the data tier',
210
+ ' (tier 1) for every artboard in this run. Resolved',
211
+ ' relative to CWD. Missing / invalid file → exit 1.',
212
+ ' --config <path> JSON or .js file deep-merged into the cascaded config',
213
+ ' for this run. Resolved relative',
214
+ ' to CWD. Missing / invalid file → exit 1.',
215
+ ' -h, --help Show this help.',
216
+ ];
217
+ process.stdout.write(lines.join('\n') + '\n');
218
+ }
219
+
220
+ /**
221
+ * Parse `lerret export`'s argv. A separate function so tests can verify flag
222
+ * handling without booting Vite or Playwright.
223
+ *
224
+ * @param {string[]} argv Argv slice after the `export` subcommand.
225
+ * @returns {{ flags: ExportFlags | null, error: string | null }}
226
+ * `error` is set when parsing fails — the caller prints it and the usage
227
+ * banner. On success the caller acts on `flags`.
228
+ */
229
+ export function parseExportArgs(argv) {
230
+ let parsed;
231
+ try {
232
+ parsed = parseArgs({
233
+ args: argv,
234
+ options: {
235
+ format: { type: 'string' },
236
+ out: { type: 'string' },
237
+ flat: { type: 'boolean' },
238
+ data: { type: 'string' },
239
+ config: { type: 'string' },
240
+ help: { type: 'boolean', short: 'h' },
241
+ },
242
+ // Reject unknown flags — surface a typo as a usage error rather than
243
+ // silently ignoring it.
244
+ strict: true,
245
+ // The optional `[path]` is the only allowed positional.
246
+ allowPositionals: true,
247
+ });
248
+ } catch (err) {
249
+ return {
250
+ flags: null,
251
+ error: err && err.message ? err.message : String(err),
252
+ };
253
+ }
254
+
255
+ const values = parsed.values || {};
256
+ const positionals = parsed.positionals || [];
257
+
258
+ // Only zero or one positional argument is accepted. The first is the
259
+ // `[path]`; anything beyond it is a usage error.
260
+ if (positionals.length > 1) {
261
+ return {
262
+ flags: null,
263
+ error: `unexpected extra arguments: ${positionals.slice(1).join(' ')}`,
264
+ };
265
+ }
266
+
267
+ // Format: defaults to 'png'. `jpeg` is accepted as an alias for `jpg` for
268
+ // parity with `resolveFormat` used by the studio; both normalize to the
269
+ // 'jpg' string here so downstream code is straightforward.
270
+ let format = DEFAULT_FORMAT;
271
+ if (typeof values.format === 'string') {
272
+ const f = values.format.toLowerCase();
273
+ if (f === 'png') {
274
+ format = 'png';
275
+ } else if (f === 'jpg' || f === 'jpeg') {
276
+ format = 'jpg';
277
+ } else {
278
+ return {
279
+ flags: null,
280
+ error: `--format: unsupported value "${values.format}" (expected png or jpg)`,
281
+ };
282
+ }
283
+ }
284
+
285
+ const out = typeof values.out === 'string' ? values.out : DEFAULT_OUT_DIR;
286
+ const flat = values.flat === true;
287
+ const pathArg = positionals.length === 1 ? positionals[0] : undefined;
288
+ const data = typeof values.data === 'string' ? values.data : undefined;
289
+ const config = typeof values.config === 'string' ? values.config : undefined;
290
+
291
+ return {
292
+ flags: {
293
+ pathArg,
294
+ format,
295
+ out,
296
+ flat,
297
+ data,
298
+ config,
299
+ help: !!values.help,
300
+ },
301
+ error: null,
302
+ };
303
+ }
304
+
305
+ // ─────────────────────────────────────────────────────────────────────────────
306
+ // Scope resolution
307
+ // ─────────────────────────────────────────────────────────────────────────────
308
+
309
+ /**
310
+ * Normalize an OS path to the forward-slash form `core` and the
311
+ * `FilesystemAccess` contract use. Pure helper — no fs access.
312
+ *
313
+ * @param {string} osPath
314
+ * @returns {string}
315
+ */
316
+ function toLerretPath(osPath) {
317
+ return osPath.replaceAll('\\', '/');
318
+ }
319
+
320
+ /**
321
+ * Locate the page or group node within the loaded model whose `path` equals
322
+ * `target`. Walks the tree depth-first and returns the matching node + its
323
+ * kind (`'page'` or `'group'`), or null when no match is found.
324
+ *
325
+ * Exposed for tests so they can verify the project/page/group classification
326
+ * without booting the rest of the export pipeline.
327
+ *
328
+ * @param {object} model A scanned project model (`ProjectNode`).
329
+ * @param {string} target The `LerretPath` to look up.
330
+ * @returns {{ kind: 'page' | 'group' } | null}
331
+ */
332
+ export function findModelNode(model, target) {
333
+ if (!model || typeof target !== 'string') return null;
334
+ for (const page of model.pages || []) {
335
+ if (page.path === target) return { kind: 'page' };
336
+ const groupHit = findGroupRecursive(page.groups || [], target);
337
+ if (groupHit) return groupHit;
338
+ }
339
+ return null;
340
+ }
341
+
342
+ /**
343
+ * Recursive helper for {@link findModelNode}.
344
+ *
345
+ * @param {Array<object>} groups
346
+ * @param {string} target
347
+ * @returns {{ kind: 'group' } | null}
348
+ */
349
+ function findGroupRecursive(groups, target) {
350
+ for (const group of groups) {
351
+ if (group.path === target) return { kind: 'group' };
352
+ const hit = findGroupRecursive(group.groups || [], target);
353
+ if (hit) return hit;
354
+ }
355
+ return null;
356
+ }
357
+
358
+ /**
359
+ * Resolve the project, the scope path within it, and the kind of that scope.
360
+ *
361
+ * The PRD's path-argument flow:
362
+ * - `pathArg` omitted → walk up from `cwd`.
363
+ * - `pathArg` is a project root (directly contains `.lerret/`) → scope =
364
+ * whole project (`scopePath = null`).
365
+ * - `pathArg` is inside an ancestor's `.lerret/` → scope = page or group at
366
+ * that path. The project root is the nearest ancestor with `.lerret/`.
367
+ *
368
+ * Returns a tagged-union-like result `{ found, ... }` so the caller can branch
369
+ * cleanly. Tests inject a stub `fs` backend to keep this offline.
370
+ *
371
+ * @param {object} opts
372
+ * @param {string | undefined} opts.pathArg
373
+ * @param {string} opts.cwd
374
+ * The working directory used as the walk-up starting point when `pathArg` is
375
+ * omitted. Always an absolute, native-separator path.
376
+ * @param {import('@lerret/core').FilesystemAccess} [opts.fs]
377
+ * The filesystem backend to read through. Defaults to a fresh Node backend.
378
+ * @returns {Promise<ScopeResolution>}
379
+ */
380
+ export async function resolveScope({ pathArg, cwd, fs = createNodeBackend() }) {
381
+ // Step 1: figure out the start dir for project detection.
382
+ //
383
+ // When pathArg is given we resolve it to an absolute path and start the
384
+ // walk-up there. The walk-up call still does the right thing whether the
385
+ // user passed a project root or a sub-folder — `resolveProject` finds the
386
+ // nearest ancestor that owns `.lerret/`.
387
+ const absoluteStart = pathArg
388
+ ? toLerretPath(resolvePath(cwd, pathArg))
389
+ : toLerretPath(resolvePath(cwd));
390
+
391
+ const projectResolution = await resolveProject(absoluteStart, fs);
392
+ if (!projectResolution.found) {
393
+ return {
394
+ found: false,
395
+ error:
396
+ `no \`.lerret/\` project found from ${absoluteStart} — ` +
397
+ 'pass a project path or run from inside a project directory',
398
+ };
399
+ }
400
+
401
+ // Canonicalize both ends so the path-arg comparisons below work even when
402
+ // the user supplied a symlinked path (the classic macOS `/tmp` →
403
+ // `/private/tmp` gotcha that `lerret dev` also has to handle).
404
+ const projectRoot = toLerretPath(realpathOrSelf(projectResolution.projectRoot));
405
+ const lerretDir = toLerretPath(realpathOrSelf(projectResolution.lerretDir));
406
+
407
+ // Step 2: load the project model so we can classify pathArg against the
408
+ // tree. A `scan` failure is fatal — without a model we cannot collect any
409
+ // artboards. We `scan` from the canonicalized `lerretDir` so the model's
410
+ // node paths are canonical too — they then compare equal to the canonical
411
+ // pathArg below regardless of which symlink hop the caller used.
412
+ let model;
413
+ try {
414
+ model = await scan(fs, lerretDir);
415
+ } catch (err) {
416
+ return {
417
+ found: false,
418
+ projectRoot,
419
+ lerretDir,
420
+ error: `project scan failed: ${err && err.message ? err.message : String(err)}`,
421
+ };
422
+ }
423
+
424
+ // Step 3: classify the scope.
425
+ //
426
+ // When no pathArg was given, OR the pathArg equals the project root /
427
+ // `.lerret/`, the scope is the whole project. Otherwise the absolute
428
+ // pathArg must match a page or group path inside the model. We do an
429
+ // exact-string match (after realpath-resolving so symlinks-in-paths don't
430
+ // foil the lookup), keeping the rule simple and easy to predict.
431
+ if (!pathArg) {
432
+ return {
433
+ found: true,
434
+ projectRoot,
435
+ lerretDir,
436
+ scopePath: null,
437
+ scopeKind: 'project',
438
+ model,
439
+ };
440
+ }
441
+
442
+ const pathArgAbs = toLerretPath(realpathOrSelf(resolvePath(cwd, pathArg)));
443
+
444
+ // Project root or `.lerret/` itself — whole project.
445
+ if (pathArgAbs === projectRoot || pathArgAbs === lerretDir) {
446
+ return {
447
+ found: true,
448
+ projectRoot,
449
+ lerretDir,
450
+ scopePath: null,
451
+ scopeKind: 'project',
452
+ model,
453
+ };
454
+ }
455
+
456
+ // Anywhere outside `.lerret/` cannot be a page or group.
457
+ if (!pathArgAbs.startsWith(lerretDir + '/')) {
458
+ return {
459
+ found: false,
460
+ projectRoot,
461
+ lerretDir,
462
+ error:
463
+ `path "${pathArg}" is not the project root nor inside ` +
464
+ `\`.lerret/\` — only project / page / group folders are valid scopes`,
465
+ };
466
+ }
467
+
468
+ // Must match a page or group node in the loaded model.
469
+ const hit = findModelNode(model, pathArgAbs);
470
+ if (!hit) {
471
+ return {
472
+ found: false,
473
+ projectRoot,
474
+ lerretDir,
475
+ error:
476
+ `path "${pathArg}" does not match any page or group in the project ` +
477
+ 'model — verify the folder is a known page / group (folders that ' +
478
+ 'begin with `_` are excluded)',
479
+ };
480
+ }
481
+
482
+ return {
483
+ found: true,
484
+ projectRoot,
485
+ lerretDir,
486
+ scopePath: pathArgAbs,
487
+ scopeKind: hit.kind,
488
+ model,
489
+ };
490
+ }
491
+
492
+ // ─────────────────────────────────────────────────────────────────────────────
493
+ // Override-file loading (FR39)
494
+ // ─────────────────────────────────────────────────────────────────────────────
495
+ //
496
+ // Both --data and --config accept either a JSON file (loaded via `readFile` +
497
+ // `JSON.parse`) or a `.js` / `.mjs` file (loaded via `dynamic import()` with
498
+ // `file://` URL, default export consumed). The file path is always resolved
499
+ // relative to the caller's CWD before loading.
500
+ //
501
+ // Any failure (file not found, invalid JSON, non-object default export, …) is
502
+ // returned as `{ ok: false, error }` so the caller can emit a clear message
503
+ // and exit 1 BEFORE starting Vite or Playwright (fail fast, fail cheap).
504
+ //
505
+ // Neither loaded value is ever written anywhere — both are kept in memory for
506
+ // the duration of the run and discarded on process exit (NFR13).
507
+
508
+ /**
509
+ * Load a single override file (JSON or .js) and return its parsed value.
510
+ *
511
+ * @param {string} filePath Absolute path to the override file.
512
+ * @returns {Promise<{ ok: true, value: Record<string, unknown> } | { ok: false, error: string }>}
513
+ */
514
+ export async function loadOverrideFile(filePath) {
515
+ const isJs =
516
+ filePath.endsWith('.js') ||
517
+ filePath.endsWith('.mjs') ||
518
+ filePath.endsWith('.cjs');
519
+
520
+ if (isJs) {
521
+ // Dynamic import via a file:// URL so Node resolves the path correctly
522
+ // regardless of CWD and the import doesn't cache-bust the entire module graph.
523
+ let mod;
524
+ try {
525
+ mod = await import(pathToFileURL(filePath).href);
526
+ } catch (err) {
527
+ const cause = err instanceof Error ? err.message : String(err);
528
+ return {
529
+ ok: false,
530
+ error: `could not import override file "${filePath}": ${cause}`,
531
+ };
532
+ }
533
+ // Expect the default export to be a plain object.
534
+ const value = mod && mod.default !== undefined ? mod.default : mod;
535
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
536
+ return {
537
+ ok: false,
538
+ error:
539
+ `override file "${filePath}": default export must be a plain object ` +
540
+ `(got ${value === null ? 'null' : Array.isArray(value) ? 'array' : typeof value})`,
541
+ };
542
+ }
543
+ return { ok: true, value: /** @type {Record<string, unknown>} */ (value) };
544
+ }
545
+
546
+ // JSON path — readTextFile then JSON.parse.
547
+ let raw;
548
+ try {
549
+ raw = await readTextFile(filePath);
550
+ } catch (err) {
551
+ const cause = err instanceof Error ? err.message : String(err);
552
+ return {
553
+ ok: false,
554
+ error: `override file "${filePath}" not found or unreadable: ${cause}`,
555
+ };
556
+ }
557
+
558
+ let parsed;
559
+ try {
560
+ parsed = JSON.parse(raw);
561
+ } catch (err) {
562
+ const cause = err instanceof Error ? err.message : String(err);
563
+ return {
564
+ ok: false,
565
+ error: `override file "${filePath}" contains invalid JSON: ${cause}`,
566
+ };
567
+ }
568
+
569
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
570
+ return {
571
+ ok: false,
572
+ error:
573
+ `override file "${filePath}": top-level JSON value must be a plain object ` +
574
+ `(got ${parsed === null ? 'null' : Array.isArray(parsed) ? 'array' : typeof parsed})`,
575
+ };
576
+ }
577
+
578
+ return { ok: true, value: /** @type {Record<string, unknown>} */ (parsed) };
579
+ }
580
+
581
+ /**
582
+ * Load the `--data` and `--config` override files (if supplied), resolving
583
+ * their paths relative to `cwd`. Returns the loaded values, or `{ ok: false }`
584
+ * with an actionable error on the first failure so the caller can emit it and
585
+ * exit 1 before booting Vite.
586
+ *
587
+ * @param {object} opts
588
+ * @param {string | undefined} opts.dataPath Raw `--data` flag value (or undefined).
589
+ * @param {string | undefined} opts.configPath Raw `--config` flag value (or undefined).
590
+ * @param {string} opts.cwd Caller's working directory (for path resolution).
591
+ * @returns {Promise<
592
+ * { ok: true, overrides: OverrideFiles } |
593
+ * { ok: false, error: string }
594
+ * >}
595
+ */
596
+ export async function loadOverrideFiles({ dataPath, configPath, cwd }) {
597
+ /** @type {Record<string, unknown> | undefined} */
598
+ let dataOverride;
599
+ /** @type {Record<string, unknown> | undefined} */
600
+ let configOverride;
601
+
602
+ if (dataPath !== undefined) {
603
+ const absPath = resolvePath(cwd, dataPath);
604
+ const result = await loadOverrideFile(absPath);
605
+ if (!result.ok) return { ok: false, error: result.error };
606
+ dataOverride = result.value;
607
+ }
608
+
609
+ if (configPath !== undefined) {
610
+ const absPath = resolvePath(cwd, configPath);
611
+ const result = await loadOverrideFile(absPath);
612
+ if (!result.ok) return { ok: false, error: result.error };
613
+ configOverride = result.value;
614
+ }
615
+
616
+ return { ok: true, overrides: { dataOverride, configOverride } };
617
+ }
618
+
619
+ // ─────────────────────────────────────────────────────────────────────────────
620
+ // Output path naming
621
+ // ─────────────────────────────────────────────────────────────────────────────
622
+
623
+ /**
624
+ * Strip characters that are illegal in common OS file systems and collapse
625
+ * internal whitespace. Mirrors the `safeName` used by the studio's single-
626
+ * export filename builder so the CLI's filenames are predictable
627
+ * even when an asset name contains odd characters.
628
+ *
629
+ * @param {string} text
630
+ * @returns {string}
631
+ */
632
+ function safeName(text) {
633
+ return (
634
+ (text || 'artboard')
635
+ .toString()
636
+ .replace(/[\\/:*?"<>|\x00-\x1F]+/g, '') // eslint-disable-line no-control-regex
637
+ .replace(/\s+/g, ' ')
638
+ .trim()
639
+ .slice(0, 120) || 'artboard'
640
+ );
641
+ }
642
+
643
+ /**
644
+ * Build a base filename from one artboard record (asset name + optional
645
+ * variant + extension). Matches the bulk-export naming convention from
646
+ * `studio/export/zip.js`: `<asset.name>[-<variant>].<ext>`.
647
+ *
648
+ * @param {object} artboard An `Artboard` from `collectArtboards`,
649
+ * optionally enriched with a `variantName`.
650
+ * @param {{ name: string }} artboard.asset
651
+ * @param {string} [artboard.variantName]
652
+ * @param {string} extension e.g. `'png'`.
653
+ * @returns {string} e.g. `'HeroBanner.png'` or `'BadgeVariants-Ghost.png'`.
654
+ */
655
+ export function buildBaseFilename(artboard, extension) {
656
+ const base = safeName(artboard.asset.name);
657
+ const variant = artboard.variantName;
658
+ const stem = variant && variant !== 'default' ? `${base}-${safeName(variant)}` : base;
659
+ return `${stem}.${extension}`;
660
+ }
661
+
662
+ /**
663
+ * Compute the on-disk output path for one artboard.
664
+ *
665
+ * Structured (default): `<outDir>/<locationSegments>/<filename>`
666
+ * Flat: `<outDir>/[<segments-joined-by---if-collision>]<filename>`
667
+ *
668
+ * For flat layout, collision disambiguation needs to know whether OTHER items
669
+ * in the same run would produce the same base filename. The caller supplies
670
+ * `nameCount` — the number of items in the run sharing this base filename —
671
+ * so this function stays pure.
672
+ *
673
+ * @param {object} args
674
+ * @param {string} args.outDir
675
+ * Output root, absolute and using forward slashes.
676
+ * @param {object} args.artboard
677
+ * @param {string[]} args.artboard.locationSegments
678
+ * Page/group chain — `[]` for an asset directly in a page.
679
+ * @param {string} args.filename
680
+ * The base filename, already extension-suffixed.
681
+ * @param {boolean} args.flat
682
+ * @param {number} [args.nameCount=1]
683
+ * How many items in the current export run share `filename`. Only meaningful
684
+ * when `flat === true`. When `> 1`, the locationSegments are joined with `-`
685
+ * and prepended to disambiguate.
686
+ * @returns {string} The full output file path.
687
+ */
688
+ export function buildOutputPath({ outDir, artboard, filename, flat, nameCount = 1 }) {
689
+ const segments = Array.isArray(artboard.locationSegments)
690
+ ? artboard.locationSegments
691
+ : [];
692
+
693
+ if (flat) {
694
+ if (nameCount > 1 && segments.length > 0) {
695
+ const prefix = segments.map(safeName).join('-');
696
+ return joinForward(outDir, `${prefix}-${filename}`);
697
+ }
698
+ return joinForward(outDir, filename);
699
+ }
700
+
701
+ if (segments.length === 0) {
702
+ return joinForward(outDir, filename);
703
+ }
704
+
705
+ const safeSegs = segments.map(safeName);
706
+ return joinForward(outDir, ...safeSegs, filename);
707
+ }
708
+
709
+ /**
710
+ * Join path segments using forward slashes, regardless of platform. The CLI
711
+ * normalizes every path to the contract's forward-slash form at its boundary
712
+ * so we don't need `node:path.join`'s native-separator behavior here.
713
+ *
714
+ * @param {...string} parts
715
+ * @returns {string}
716
+ */
717
+ function joinForward(...parts) {
718
+ return parts
719
+ .map((p, i) => (i === 0 ? p.replace(/\/+$/, '') : p.replace(/^\/+|\/+$/g, '')))
720
+ .filter((p) => p.length > 0)
721
+ .join('/');
722
+ }
723
+
724
+ // ─────────────────────────────────────────────────────────────────────────────
725
+ // Variant expansion
726
+ // ─────────────────────────────────────────────────────────────────────────────
727
+ //
728
+ // `collectArtboards` returns ONE entry per asset. Each asset's `meta.variants`
729
+ // (FR10) lists the named-export variants the asset contributes — including
730
+ // `'default'` for the primary export. The CLI must expand the variant list
731
+ // into one capture-per-variant so a component with three variants writes
732
+ // three image files. We do this expansion server-side (here) and pass the
733
+ // per-variant id to the page so the existing studio-side DOM (which renders
734
+ // one DCArtboard per variant with `data-dc-slot=<asset.path>#<variantName>`)
735
+ // can be selected directly.
736
+
737
+ /**
738
+ * Expand each `Artboard` from `collectArtboards` into one record per
739
+ * rendered DOM artboard, enriched with the per-variant id used to look up
740
+ * the slot in the page.
741
+ *
742
+ * Resolution rules (mirror the studio's runtime):
743
+ * - If the asset's parsed `meta.variants` is a non-empty list, emit one
744
+ * record per variant. The `domId` is `<asset.path>#<variantName>` unless
745
+ * the variant is the primary `default` AND it is the only variant — in
746
+ * which case `domId` is the bare `asset.path` (matching `vite-runtime.js`).
747
+ * - If `meta.variants` is empty or absent, emit ONE record with
748
+ * `variantName === undefined` and `domId === asset.path`.
749
+ *
750
+ * @param {Array<object>} artboards `Artboard[]` from `collectArtboards`.
751
+ * @returns {Array<{ artboard: object, variantName: string | undefined, domId: string }>}
752
+ */
753
+ export function expandArtboardVariants(artboards) {
754
+ const out = [];
755
+ for (const artboard of artboards) {
756
+ const variants = readAssetVariants(artboard.asset);
757
+ if (variants.length === 0) {
758
+ out.push({ artboard, variantName: undefined, domId: artboard.asset.path });
759
+ continue;
760
+ }
761
+ // Single-default-only is rendered as the primary; the studio's
762
+ // `vite-runtime.js` emits the bare path in that case.
763
+ if (variants.length === 1 && variants[0] === 'default') {
764
+ out.push({ artboard, variantName: undefined, domId: artboard.asset.path });
765
+ continue;
766
+ }
767
+ for (const variantName of variants) {
768
+ const isPrimary = variantName === 'default';
769
+ const domId = isPrimary
770
+ ? artboard.asset.path
771
+ : `${artboard.asset.path}#${variantName}`;
772
+ // Enrich the artboard clone with the variantName so filename derivation
773
+ // produces `<name>-<variant>` for non-primary variants.
774
+ const enriched = { ...artboard, variantName: isPrimary ? undefined : variantName };
775
+ out.push({ artboard: enriched, variantName: isPrimary ? undefined : variantName, domId });
776
+ }
777
+ }
778
+ return out;
779
+ }
780
+
781
+ /**
782
+ * Read the variant list off an asset's parsed `meta`. Falls back to `[]` for
783
+ * any asset whose meta has not been parsed (markdown documents, assets the
784
+ * loader could not statically introspect — those will render a single primary
785
+ * artboard at runtime). Conservative on shape so a future meta tweak does not
786
+ * break the CLI.
787
+ *
788
+ * @param {object} asset
789
+ * @returns {string[]} An array of variant names (may include `'default'`).
790
+ */
791
+ function readAssetVariants(asset) {
792
+ if (!asset || !asset.meta) return [];
793
+ const v = asset.meta.variants;
794
+ if (!Array.isArray(v)) return [];
795
+ return v
796
+ .map((entry) => {
797
+ if (typeof entry === 'string') return entry;
798
+ if (entry && typeof entry === 'object' && typeof entry.name === 'string') {
799
+ return entry.name;
800
+ }
801
+ return null;
802
+ })
803
+ .filter((name) => typeof name === 'string' && name.length > 0);
804
+ }
805
+
806
+ // ─────────────────────────────────────────────────────────────────────────────
807
+ // Playwright launch — system Chrome first, bundled fallback, clear error
808
+ // ─────────────────────────────────────────────────────────────────────────────
809
+
810
+ /**
811
+ * Attempt to launch a headless Chromium. Strategy:
812
+ *
813
+ * 1. Try `playwright-core`'s `chromium.launch({ channel: 'chrome' })`. If
814
+ * the user has Google Chrome / Chromium / Edge installed in a standard
815
+ * location, this succeeds without downloading anything — keeping `npx
816
+ * lerret export` light, per the architecture decision.
817
+ * 2. If the channel launch fails, try `playwright` (the full package,
818
+ * which ships its bundled browser when installed). Only present when
819
+ * the user opts in by installing the full `playwright` package.
820
+ * 3. If neither works, throw a clear error explaining BOTH paths the user
821
+ * can take to make a browser available.
822
+ *
823
+ * The dynamic `import()` of each package fails clearly if the package is not
824
+ * installed — we map that to a friendly message and never leak a stack trace.
825
+ *
826
+ * @returns {Promise<{ browser: object, launchedVia: string }>}
827
+ * `browser` is a Playwright `Browser` instance (caller must `close()` it).
828
+ * `launchedVia` describes the path taken, for the start-of-run log.
829
+ */
830
+ export async function launchHeadlessBrowser() {
831
+ /** @type {Error | null} */
832
+ let systemErr = null;
833
+ /** @type {Error | null} */
834
+ let bundledErr = null;
835
+
836
+ // 1. Prefer system Chrome via playwright-core.
837
+ let coreMod;
838
+ try {
839
+ coreMod = await import('playwright-core');
840
+ } catch (err) {
841
+ coreMod = null;
842
+ systemErr = err instanceof Error ? err : new Error(String(err));
843
+ }
844
+
845
+ if (coreMod && coreMod.chromium && typeof coreMod.chromium.launch === 'function') {
846
+ try {
847
+ const browser = await coreMod.chromium.launch({
848
+ headless: true,
849
+ channel: 'chrome',
850
+ });
851
+ return { browser, launchedVia: 'system Chrome (playwright-core, channel:chrome)' };
852
+ } catch (err) {
853
+ systemErr = err instanceof Error ? err : new Error(String(err));
854
+ }
855
+ }
856
+
857
+ // 2. Fall back to the bundled browser shipped by the full `playwright`
858
+ // package (user opt-in install).
859
+ let fullMod;
860
+ try {
861
+ fullMod = await import('playwright');
862
+ } catch (err) {
863
+ fullMod = null;
864
+ bundledErr = err instanceof Error ? err : new Error(String(err));
865
+ }
866
+
867
+ if (fullMod && fullMod.chromium && typeof fullMod.chromium.launch === 'function') {
868
+ try {
869
+ const browser = await fullMod.chromium.launch({ headless: true });
870
+ return { browser, launchedVia: 'bundled Chromium (playwright)' };
871
+ } catch (err) {
872
+ bundledErr = err instanceof Error ? err : new Error(String(err));
873
+ }
874
+ }
875
+
876
+ // 3. Neither worked — print one actionable message.
877
+ const lines = [
878
+ 'Could not launch a headless Chromium for the export run.',
879
+ '',
880
+ 'You have two options:',
881
+ ' • Install Google Chrome (recommended — Lerret prefers a system browser to keep `npx` light).',
882
+ ' • Install the full `playwright` package to download a bundled Chromium:',
883
+ ' npm install -g playwright && npx playwright install chromium',
884
+ '',
885
+ 'Last attempt details:',
886
+ ` system Chrome: ${systemErr ? systemErr.message : 'not attempted'}`,
887
+ ` bundled: ${bundledErr ? bundledErr.message : 'not attempted'}`,
888
+ ];
889
+ throw new Error(lines.join('\n'));
890
+ }
891
+
892
+ // ─────────────────────────────────────────────────────────────────────────────
893
+ // Vite server boot — programmatic, headless (no browser open)
894
+ // ─────────────────────────────────────────────────────────────────────────────
895
+
896
+ /**
897
+ * Boot a Vite dev server programmatically — same plugin / fs.allow shape as
898
+ * `lerret dev`, but with `server.open = false` and an undefined port (Vite
899
+ * picks a free one). The returned `address` is the URL to navigate to.
900
+ *
901
+ * @param {object} opts
902
+ * @param {string} opts.projectRoot
903
+ * @param {string} opts.lerretDir
904
+ * @param {Record<string, unknown> | undefined} [opts.dataOverride]
905
+ * Optional in-memory data override from `--data`. Forwarded to
906
+ * the plugin so the virtual module exposes it; the studio runtime merges it
907
+ * at tier 1 of `resolveProps`. Never written to disk (NFR13).
908
+ * @param {Record<string, unknown> | undefined} [opts.configOverride]
909
+ * Optional in-memory config override from `--config`. Deep-merged
910
+ * into the cascade server-side by the plugin. Never written to disk (NFR13).
911
+ * @returns {Promise<{ server: import('vite').ViteDevServer, url: string }>}
912
+ */
913
+ export async function bootViteServer({ projectRoot, lerretDir, dataOverride, configOverride }) {
914
+ const studioRoot = resolveStudioRoot();
915
+ const vite = await import('vite');
916
+ const { createServer, searchForWorkspaceRoot } = vite;
917
+ const workspaceRoot = searchForWorkspaceRoot(studioRoot);
918
+
919
+ // Whether we are serving from the pre-built CLI bundle or from source.
920
+ // When pre-built, skip the React plugin — JSX is already compiled.
921
+ const cliDir = resolvePath(dirname(fileURLToPath(import.meta.url)), '..');
922
+ const isPreBuilt = pathExists(resolvePath(cliDir, 'dist-studio', 'index.html')) &&
923
+ studioRoot === resolvePath(cliDir, 'dist-studio');
924
+
925
+ const plugins = [
926
+ lerretProjectPlugin({
927
+ projectRoot: toLerretPath(realpathOrSelf(projectRoot)),
928
+ lerretDir: toLerretPath(realpathOrSelf(lerretDir)),
929
+ dataOverride,
930
+ configOverride,
931
+ }),
932
+ ];
933
+ if (!isPreBuilt) {
934
+ const reactPlugin = (await import('@vitejs/plugin-react')).default;
935
+ plugins.unshift(reactPlugin());
936
+ }
937
+
938
+ const server = await createServer({
939
+ configFile: false,
940
+ root: studioRoot,
941
+ plugins,
942
+ server: {
943
+ open: false, // headless — never open a real browser
944
+ fs: {
945
+ allow: [studioRoot, workspaceRoot],
946
+ },
947
+ // Suppress the long warning Vite prints when a host-check would be
948
+ // helpful — we're talking to ourselves on localhost.
949
+ host: '127.0.0.1',
950
+ },
951
+ // Lower the log level so a non-interactive run is not flooded with
952
+ // Vite's HMR chatter; the CLI prints its own per-artboard progress.
953
+ logLevel: 'warn',
954
+ });
955
+
956
+ await server.listen();
957
+ const addr = server.httpServer && server.httpServer.address();
958
+ if (!addr || typeof addr === 'string' || !addr.port) {
959
+ await server.close().catch(() => {});
960
+ throw new Error('Vite dev server did not bind to a TCP address');
961
+ }
962
+ const url = `http://127.0.0.1:${addr.port}`;
963
+ return { server, url };
964
+ }
965
+
966
+ // ─────────────────────────────────────────────────────────────────────────────
967
+ // Per-artboard capture inside the page
968
+ // ─────────────────────────────────────────────────────────────────────────────
969
+
970
+ /**
971
+ * Run the studio-bundled `captureArtboard` for one artboard, INSIDE the
972
+ * headless page, and transfer the resulting bytes back to Node as a base64
973
+ * string. (Playwright's `evaluate` cannot directly serialize Blobs across the
974
+ * Node/browser bridge — base64 is the universal escape hatch.)
975
+ *
976
+ * Locates the artboard DOM node by its `data-dc-slot=<domId>` attribute, then
977
+ * invokes the EXACT same `captureArtboard` module the studio uses for its
978
+ * per-artboard PNG/JPG buttons. Every font-embedding path inside that module
979
+ * runs unchanged, so a headless capture is pixel-faithful to a studio click.
980
+ *
981
+ * Returns `{ ok: true, bytesB64, unembeddedFonts }` on success, or
982
+ * `{ ok: false, error }` on any in-page failure. The function never throws —
983
+ * the caller treats `ok: false` as a per-artboard failure and continues.
984
+ *
985
+ * @param {object} page A Playwright `Page` instance.
986
+ * @param {string} domId The artboard's `data-dc-slot` id (asset path, or
987
+ * `<asset.path>#<variantName>` for a variant).
988
+ * @param {'png' | 'jpg'} format
989
+ * @returns {Promise<{ ok: boolean, bytesB64?: string, unembeddedFonts?: string[], error?: string }>}
990
+ */
991
+ export async function evaluateCaptureInPage(page, domId, format) {
992
+ // The capture import path inside the studio's served bundle. Vite serves
993
+ // the studio source so we can `import()` the same module the studio uses.
994
+ //
995
+ // The arrow below executes INSIDE the Chromium page — `document`, `btoa`,
996
+ // and dynamic `import()` of a `/src/…` URL are browser-side and the lint
997
+ // (which sees this file as Node-only) cannot tell. The targeted disable
998
+ // covers only that callback.
999
+ return await page.evaluate(
1000
+ /* eslint-disable no-undef */
1001
+ async ({ domId, format, slotSelector, innerCardSelector }) => {
1002
+ try {
1003
+ const slot = document.querySelector(slotSelector);
1004
+ if (!slot) return { ok: false, error: `slot not found for "${domId}"` };
1005
+ const card = slot.querySelector(innerCardSelector);
1006
+ if (!card) return { ok: false, error: `inner card not found inside slot "${domId}"` };
1007
+
1008
+ // Dynamic import of the studio's capture module — served by Vite.
1009
+ // The studio's main bundle loads it eagerly for the per-artboard
1010
+ // PNG button; importing it here is essentially a cache hit.
1011
+ const mod = await import('/src/export/capture.js');
1012
+ const { blob, unembeddedFonts } = await mod.captureArtboard(card, { format });
1013
+
1014
+ // Transfer the blob as base64 — Playwright `evaluate` serializes
1015
+ // primitives but cannot pass Blobs across the bridge.
1016
+ const buf = await blob.arrayBuffer();
1017
+ const bytes = new Uint8Array(buf);
1018
+ let binary = '';
1019
+ const chunk = 0x8000;
1020
+ for (let i = 0; i < bytes.length; i += chunk) {
1021
+ binary += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk));
1022
+ }
1023
+ return {
1024
+ ok: true,
1025
+ bytesB64: btoa(binary),
1026
+ unembeddedFonts: unembeddedFonts || [],
1027
+ };
1028
+ } catch (err) {
1029
+ return {
1030
+ ok: false,
1031
+ error: err && err.message ? err.message : String(err),
1032
+ };
1033
+ }
1034
+ },
1035
+ /* eslint-enable no-undef */
1036
+ {
1037
+ domId,
1038
+ format,
1039
+ slotSelector: ARTBOARD_SELECTORS.slotByDataAttr(domId),
1040
+ innerCardSelector: ARTBOARD_SELECTORS.innerCardSelector,
1041
+ },
1042
+ );
1043
+ }
1044
+
1045
+ // ─────────────────────────────────────────────────────────────────────────────
1046
+ // Orchestrator
1047
+ // ─────────────────────────────────────────────────────────────────────────────
1048
+
1049
+ /**
1050
+ * Internal: format an artboard's location for the start-of-run progress log.
1051
+ *
1052
+ * @param {string[]} segments
1053
+ * @returns {string}
1054
+ */
1055
+ function formatLocation(segments) {
1056
+ return segments.length === 0 ? '(top)' : segments.join('/');
1057
+ }
1058
+
1059
+ /**
1060
+ * Run `lerret export`. Resolves the scope, boots Vite + Chromium, captures
1061
+ * each artboard, writes the result to disk, and returns an exit code.
1062
+ *
1063
+ * @param {string[]} argv Argv slice after the `export` subcommand.
1064
+ * @param {object} [deps] Injectable dependencies for tests.
1065
+ * @param {typeof launchHeadlessBrowser} [deps.launchBrowser]
1066
+ * @param {typeof bootViteServer} [deps.bootServer]
1067
+ * @param {typeof evaluateCaptureInPage} [deps.captureInPage]
1068
+ * @param {(filePath: string, bytes: Uint8Array) => Promise<void>} [deps.writeBinary]
1069
+ * @param {(dir: string) => Promise<void>} [deps.ensureDir]
1070
+ * @param {() => string} [deps.getCwd] Returns the working directory.
1071
+ * @param {typeof loadOverrideFiles} [deps.loadOverrides]
1072
+ * Override the file-loading function (for tests that don't want real fs reads).
1073
+ * @returns {Promise<number>} Exit code.
1074
+ */
1075
+ export async function runExport(argv, deps = {}) {
1076
+ const { flags, error } = parseExportArgs(argv);
1077
+ if (error) {
1078
+ process.stderr.write(`lerret export: ${error}\n\n`);
1079
+ printUsage();
1080
+ return 1;
1081
+ }
1082
+
1083
+ if (flags.help) {
1084
+ printUsage();
1085
+ return 0;
1086
+ }
1087
+
1088
+ const getCwd = deps.getCwd || (() => process.cwd());
1089
+ const cwd = getCwd();
1090
+
1091
+ // 0. Load --data / --config override files. This happens BEFORE
1092
+ // scope resolution and before Vite is booted so that a bad override path
1093
+ // or malformed JSON produces a fast, cheap, actionable error. Neither
1094
+ // value is ever written to disk (NFR13).
1095
+ const loadOverridesFn = deps.loadOverrides || loadOverrideFiles;
1096
+ const overrideResult = await loadOverridesFn({
1097
+ dataPath: flags.data,
1098
+ configPath: flags.config,
1099
+ cwd,
1100
+ });
1101
+ if (!overrideResult.ok) {
1102
+ process.stderr.write(`lerret export: ${overrideResult.error}\n`);
1103
+ return 1;
1104
+ }
1105
+ const { dataOverride, configOverride } = overrideResult.overrides;
1106
+
1107
+ // 1. Resolve project + scope.
1108
+ const scope = await resolveScope({ pathArg: flags.pathArg, cwd });
1109
+ if (!scope.found) {
1110
+ process.stderr.write(`lerret export: ${scope.error}\n`);
1111
+ return 1;
1112
+ }
1113
+
1114
+ // 2. Pick the artboards in scope and expand variants.
1115
+ let baseArtboards;
1116
+ try {
1117
+ baseArtboards = collectArtboards(scope.model, scope.scopePath);
1118
+ } catch (err) {
1119
+ process.stderr.write(`lerret export: ${err && err.message ? err.message : String(err)}\n`);
1120
+ return 1;
1121
+ }
1122
+
1123
+ const expanded = expandArtboardVariants(baseArtboards);
1124
+ if (expanded.length === 0) {
1125
+ process.stderr.write(
1126
+ `lerret export: no artboards found in scope (${scope.scopeKind}). Nothing to export.\n`,
1127
+ );
1128
+ return 1;
1129
+ }
1130
+
1131
+ // 3. Prepare the output directory.
1132
+ //
1133
+ // We canonicalize the OUT path so the NFR13 "no writes into `.lerret/`"
1134
+ // check is not foiled by symlinks (`/tmp` → `/private/tmp` on macOS is the
1135
+ // textbook gotcha). The path likely does not yet exist on disk —
1136
+ // `realpathOfExistingPrefix` walks up to the deepest ancestor that does
1137
+ // exist, canonicalizes it, and re-attaches the leaf components verbatim.
1138
+ const outDirAbs = realpathOfExistingPrefix(resolvePath(cwd, flags.out));
1139
+ // Refuse to write into the user's `.lerret/` — the separation invariant
1140
+ // (NFR13) is enforced at the CLI boundary.
1141
+ if (
1142
+ outDirAbs === scope.lerretDir ||
1143
+ outDirAbs.startsWith(scope.lerretDir + '/')
1144
+ ) {
1145
+ process.stderr.write(
1146
+ `lerret export: refusing to write into the project's \`.lerret/\` directory ` +
1147
+ `(${scope.lerretDir}). Pick an --out directory outside the project's .lerret/ tree.\n`,
1148
+ );
1149
+ return 1;
1150
+ }
1151
+
1152
+ const ensureDirFn = deps.ensureDir || ensureDir;
1153
+ try {
1154
+ await ensureDirFn(outDirAbs);
1155
+ } catch (err) {
1156
+ process.stderr.write(
1157
+ `lerret export: could not create output directory ${outDirAbs}: ` +
1158
+ `${err && err.message ? err.message : String(err)}\n`,
1159
+ );
1160
+ return 1;
1161
+ }
1162
+
1163
+ // 4. Boot Vite + Playwright. Either can fail with a clear, actionable
1164
+ // message; both must be torn down on success or partial failure.
1165
+ const overrideNote =
1166
+ dataOverride !== undefined && configOverride !== undefined
1167
+ ? ' [--data + --config overrides active]'
1168
+ : dataOverride !== undefined
1169
+ ? ' [--data override active]'
1170
+ : configOverride !== undefined
1171
+ ? ' [--config override active]'
1172
+ : '';
1173
+ process.stdout.write(
1174
+ `lerret export: project ${scope.projectRoot}\n` +
1175
+ `lerret export: scope ${scope.scopeKind}${scope.scopePath ? ` (${scope.scopePath})` : ''}\n` +
1176
+ `lerret export: ${expanded.length} artboard${expanded.length === 1 ? '' : 's'} to capture (${flags.format})${overrideNote}\n` +
1177
+ `lerret export: writing to ${outDirAbs}${flags.flat ? ' (flat layout)' : ''}\n`,
1178
+ );
1179
+
1180
+ const bootServer = deps.bootServer || bootViteServer;
1181
+ const launchBrowser = deps.launchBrowser || launchHeadlessBrowser;
1182
+ const captureInPage = deps.captureInPage || evaluateCaptureInPage;
1183
+ const writeBinary = deps.writeBinary || writeBinaryToDisk;
1184
+
1185
+ /** @type {{ close: () => Promise<unknown> } | null} */
1186
+ let server = null;
1187
+ /** @type {{ close: () => Promise<unknown> } | null} */
1188
+ let browser = null;
1189
+
1190
+ try {
1191
+ let url;
1192
+ try {
1193
+ const booted = await bootServer({
1194
+ projectRoot: scope.projectRoot,
1195
+ lerretDir: scope.lerretDir,
1196
+ dataOverride,
1197
+ configOverride,
1198
+ });
1199
+ server = booted.server;
1200
+ url = booted.url;
1201
+ } catch (err) {
1202
+ process.stderr.write(
1203
+ `lerret export: Vite dev server failed to start: ` +
1204
+ `${err && err.message ? err.message : String(err)}\n`,
1205
+ );
1206
+ return 1;
1207
+ }
1208
+
1209
+ try {
1210
+ const launched = await launchBrowser();
1211
+ browser = launched.browser;
1212
+ process.stdout.write(`lerret export: ${launched.launchedVia}\n`);
1213
+ } catch (err) {
1214
+ process.stderr.write(
1215
+ `lerret export: ${err && err.message ? err.message : String(err)}\n`,
1216
+ );
1217
+ return 1;
1218
+ }
1219
+
1220
+ // Open a single page and navigate to the studio. We use one page for the
1221
+ // whole run — captureArtboard is independent per artboard and the studio
1222
+ // already renders every artboard in the project on first load. Bringing
1223
+ // a fresh page per artboard would re-bundle / re-fetch fonts each time.
1224
+ const context = await browser.newContext();
1225
+ const page = await context.newPage();
1226
+ await page.goto(url, { waitUntil: 'load', timeout: 60000 });
1227
+
1228
+ // Wait for the first expected artboard slot. Once one is in the DOM the
1229
+ // studio has mounted; the rest of the project's slots follow in the same
1230
+ // render. A timeout here means the studio could not load the project —
1231
+ // surface that as a fatal error rather than silently producing zero
1232
+ // captures.
1233
+ const firstSelector = ARTBOARD_SELECTORS.slotByDataAttr(expanded[0].domId);
1234
+ try {
1235
+ await page.waitForSelector(firstSelector, { state: 'attached', timeout: 30000 });
1236
+ } catch (err) {
1237
+ process.stderr.write(
1238
+ `lerret export: studio did not render any artboards within 30s ` +
1239
+ `(${err && err.message ? err.message : String(err)}). The project may be empty or failed to load.\n`,
1240
+ );
1241
+ return 1;
1242
+ }
1243
+
1244
+ // 5. Build name counts for flat-mode disambiguation.
1245
+ /** @type {Map<string, number>} */
1246
+ const nameCounts = new Map();
1247
+ const baseFilenames = expanded.map((entry) =>
1248
+ buildBaseFilename(entry.artboard, flags.format),
1249
+ );
1250
+ for (const name of baseFilenames) {
1251
+ nameCounts.set(name, (nameCounts.get(name) ?? 0) + 1);
1252
+ }
1253
+
1254
+ // 6. Capture each artboard. Failures are isolated — a single bad
1255
+ // capture is logged and the run continues. Unembedded fonts are
1256
+ // aggregated across the run.
1257
+ const allUnembeddedFonts = new Set();
1258
+ /** @type {Array<{ artboard: object, reason: string }>} */
1259
+ const failures = [];
1260
+ let writtenCount = 0;
1261
+
1262
+ for (let i = 0; i < expanded.length; i++) {
1263
+ const entry = expanded[i];
1264
+ const filename = baseFilenames[i];
1265
+ const nameCount = nameCounts.get(filename) ?? 1;
1266
+ const outputPath = buildOutputPath({
1267
+ outDir: outDirAbs,
1268
+ artboard: entry.artboard,
1269
+ filename,
1270
+ flat: flags.flat,
1271
+ nameCount,
1272
+ });
1273
+
1274
+ const human = `${i + 1}/${expanded.length}`;
1275
+ const label = `${formatLocation(entry.artboard.locationSegments)}/${entry.artboard.asset.name}${entry.variantName ? `#${entry.variantName}` : ''}`;
1276
+ process.stdout.write(`[${human}] capturing ${label}\n`);
1277
+
1278
+ let result;
1279
+ try {
1280
+ result = await captureInPage(page, entry.domId, flags.format);
1281
+ } catch (err) {
1282
+ result = {
1283
+ ok: false,
1284
+ error: err && err.message ? err.message : String(err),
1285
+ };
1286
+ }
1287
+
1288
+ if (!result || !result.ok) {
1289
+ const reason = (result && result.error) || 'unknown capture failure';
1290
+ process.stderr.write(`[${human}] FAILED ${label}: ${reason}\n`);
1291
+ failures.push({ artboard: entry.artboard, reason });
1292
+ continue;
1293
+ }
1294
+
1295
+ // Decode base64 → Uint8Array and write the bytes to disk.
1296
+ let bytes;
1297
+ try {
1298
+ const binary = Buffer.from(result.bytesB64, 'base64');
1299
+ bytes = new Uint8Array(binary.buffer, binary.byteOffset, binary.byteLength);
1300
+ } catch (err) {
1301
+ const reason = `failed to decode capture bytes: ${err && err.message ? err.message : String(err)}`;
1302
+ process.stderr.write(`[${human}] FAILED ${label}: ${reason}\n`);
1303
+ failures.push({ artboard: entry.artboard, reason });
1304
+ continue;
1305
+ }
1306
+
1307
+ try {
1308
+ await ensureDirFn(toLerretPath(dirname(outputPath)));
1309
+ await writeBinary(outputPath, bytes);
1310
+ writtenCount++;
1311
+ process.stdout.write(`[${human}] wrote ${outputPath}\n`);
1312
+ } catch (err) {
1313
+ const reason = `write failed: ${err && err.message ? err.message : String(err)}`;
1314
+ process.stderr.write(`[${human}] FAILED ${label}: ${reason}\n`);
1315
+ failures.push({ artboard: entry.artboard, reason });
1316
+ continue;
1317
+ }
1318
+
1319
+ for (const font of result.unembeddedFonts || []) {
1320
+ allUnembeddedFonts.add(font);
1321
+ }
1322
+ }
1323
+
1324
+ // 7. Summary.
1325
+ const summaryLines = [
1326
+ '',
1327
+ `lerret export: wrote ${writtenCount} of ${expanded.length} image${expanded.length === 1 ? '' : 's'} to ${outDirAbs}`,
1328
+ ];
1329
+ if (failures.length > 0) {
1330
+ summaryLines.push(
1331
+ `lerret export: ${failures.length} artboard${failures.length === 1 ? '' : 's'} failed (see messages above)`,
1332
+ );
1333
+ }
1334
+ if (allUnembeddedFonts.size > 0) {
1335
+ summaryLines.push(
1336
+ `lerret export: fonts not embedded: ${[...allUnembeddedFonts].sort().join(', ')}`,
1337
+ );
1338
+ }
1339
+ process.stdout.write(summaryLines.join('\n') + '\n');
1340
+
1341
+ // Exit code is 0 when at least one artboard was written and the run
1342
+ // completed without a fatal error; a fully failed run (zero writes)
1343
+ // exits non-zero so CI surfaces it.
1344
+ return writtenCount === 0 ? 1 : 0;
1345
+ } finally {
1346
+ // Clean teardown — Playwright first, then Vite. Either may throw on a
1347
+ // half-initialized state during an early-exit; swallow so the finally
1348
+ // never masks the original error.
1349
+ if (browser) {
1350
+ try {
1351
+ await browser.close();
1352
+ } catch {
1353
+ // teardown best-effort
1354
+ }
1355
+ }
1356
+ if (server) {
1357
+ try {
1358
+ await server.close();
1359
+ } catch {
1360
+ // teardown best-effort
1361
+ }
1362
+ }
1363
+ }
1364
+ }
1365
+
1366
+ // ─────────────────────────────────────────────────────────────────────────────
1367
+ // Binary file writer (default implementation, injectable for tests)
1368
+ // ─────────────────────────────────────────────────────────────────────────────
1369
+
1370
+ /**
1371
+ * Default implementation of "write these bytes to this file". Delegates to the
1372
+ * Node backend's atomic `writeFile` (with `WriteFileOptions` using
1373
+ * `encoding: 'binary'`). Tests can override via `runExport`'s `deps.writeBinary`.
1374
+ *
1375
+ * @param {string} filePath Absolute, forward-slash file path.
1376
+ * @param {Uint8Array} bytes
1377
+ * @returns {Promise<void>}
1378
+ */
1379
+ async function writeBinaryToDisk(filePath, bytes) {
1380
+ const backend = createNodeBackend();
1381
+ await backend.writeFile(filePath, bytes, { encoding: 'binary' });
1382
+ }
1383
+
1384
+ // `runExport` is the only programmatic entry from this module; the top-level
1385
+ // `lerret.js` dispatch is what users hit at the shell. No auto-invocation guard
1386
+ // here — running this file standalone would compete with the dispatcher.