@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.
- package/LICENSE +21 -0
- package/dist-studio/.bundle-stamp +34 -0
- package/dist-studio/assets/asset-runtime-MFjDKvQD.js +129 -0
- package/dist-studio/assets/cli-project-source-9dNA_gVa.js +1 -0
- package/dist-studio/assets/dev-harness-BH6a8T7l.js +18 -0
- package/dist-studio/assets/hosted-project-source-dVGq_8c6.js +135 -0
- package/dist-studio/assets/index-BNmJ8c2t.css +1 -0
- package/dist-studio/assets/index-EslqdOhg.js +10 -0
- package/dist-studio/assets/leaf-marker-command.png +0 -0
- package/dist-studio/assets/leaf-marker-comment-box.png +0 -0
- package/dist-studio/assets/leaf-marker-homescreen.png +0 -0
- package/dist-studio/assets/leafmarker-icon-dark-128.png +0 -0
- package/dist-studio/assets/leafmarker-logo-transparent.png +0 -0
- package/dist-studio/assets/leafmarker-logo.png +0 -0
- package/dist-studio/assets/lerret-logo.png +0 -0
- package/dist-studio/assets/lerret-wordmark.svg +3 -0
- package/dist-studio/assets/logo-angular.svg +1 -0
- package/dist-studio/assets/logo-claude.svg +7 -0
- package/dist-studio/assets/logo-codex.svg +1 -0
- package/dist-studio/assets/logo-cursor.svg +1 -0
- package/dist-studio/assets/logo-javascript.svg +1 -0
- package/dist-studio/assets/logo-react.svg +1 -0
- package/dist-studio/assets/logo-svelte.svg +1 -0
- package/dist-studio/assets/logo-vue.svg +1 -0
- package/dist-studio/assets/open-folder-D5OR7eLb.js +8 -0
- package/dist-studio/assets/project-studio-BjNaIuRb.js +795 -0
- package/dist-studio/assets/project-studio-CKuMOMsC.css +1 -0
- package/dist-studio/assets/superwhisper-logo.png +0 -0
- package/dist-studio/index.html +47 -0
- package/dist-studio/module-sw.js +275 -0
- package/package.json +51 -0
- package/src/dev.js +373 -0
- package/src/export.js +1386 -0
- package/src/fs/node-backend.js +631 -0
- package/src/lerret.js +143 -0
- package/src/resolve-project.js +178 -0
- package/src/vite-plugin-lerret-project.js +986 -0
- 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.
|