@lerret/cli 0.1.9 → 0.1.11

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "studioVersion": "0.1.0",
3
- "builtAt": "2026-05-21T21:46:23.082Z",
3
+ "builtAt": "2026-05-21T22:29:17.659Z",
4
4
  "files": [
5
5
  "apple-touch-icon.png",
6
6
  "assets/asset-runtime-B5cPbIor.js",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lerret/cli",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
4
4
  "description": "The `lerret` design canvas CLI — a folder of plain React component files renders as a visual canvas. Includes the Vite dev server, headless export, and the bundled studio.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/export.js CHANGED
@@ -663,8 +663,15 @@ export function buildBaseFilename(artboard, extension) {
663
663
  /**
664
664
  * Compute the on-disk output path for one artboard.
665
665
  *
666
- * Structured (default): `<outDir>/<locationSegments>/<filename>`
667
- * Flat: `<outDir>/[<segments-joined-by---if-collision>]<filename>`
666
+ * Structured (default): `<outDir>/<page>[/<group>[/…]]/<filename>` per the PRD —
667
+ * the page name (derived from `artboard.pagePath`) is always present, so that
668
+ * a project with `landing/heroes/Card1.jsx` and `social/Banner.jsx` writes to
669
+ * `out/landing/heroes/Card1.png` and `out/social/Banner.png` (and assets named
670
+ * the same in different pages cannot collide).
671
+ *
672
+ * Flat: `<outDir>/[<segments-joined-by---if-collision>]<filename>` — flat mode
673
+ * never used the page prefix; we leave it as-is so existing `--flat` users see
674
+ * no path change.
668
675
  *
669
676
  * For flat layout, collision disambiguation needs to know whether OTHER items
670
677
  * in the same run would produce the same base filename. The caller supplies
@@ -676,7 +683,14 @@ export function buildBaseFilename(artboard, extension) {
676
683
  * Output root, absolute and using forward slashes.
677
684
  * @param {object} args.artboard
678
685
  * @param {string[]} args.artboard.locationSegments
679
- * Page/group chain — `[]` for an asset directly in a page.
686
+ * Group chain — `[]` for an asset directly in a page. The page level is
687
+ * derived separately from `artboard.pagePath`, not carried here (the studio's
688
+ * ZIP exporter shares this field and intentionally omits the page prefix —
689
+ * see packages/studio/src/export/zip.js).
690
+ * @param {string} [args.artboard.pagePath]
691
+ * Full LerretPath of the containing page. The basename becomes the top-level
692
+ * folder in structured mode. When absent (older callers / hand-crafted
693
+ * artboards in tests), the page level is omitted gracefully.
680
694
  * @param {string} args.filename
681
695
  * The base filename, already extension-suffixed.
682
696
  * @param {boolean} args.flat
@@ -699,14 +713,34 @@ export function buildOutputPath({ outDir, artboard, filename, flat, nameCount =
699
713
  return joinForward(outDir, filename);
700
714
  }
701
715
 
702
- if (segments.length === 0) {
716
+ const pageName = pageNameFromPagePath(artboard.pagePath);
717
+ const structuredSegs = pageName ? [pageName, ...segments] : segments;
718
+
719
+ if (structuredSegs.length === 0) {
703
720
  return joinForward(outDir, filename);
704
721
  }
705
722
 
706
- const safeSegs = segments.map(safeName);
723
+ const safeSegs = structuredSegs.map(safeName);
707
724
  return joinForward(outDir, ...safeSegs, filename);
708
725
  }
709
726
 
727
+ /**
728
+ * Extract the page-folder name from an Artboard's `pagePath`. The path is a
729
+ * forward-slash LerretPath like `/proj/.lerret/landing`; the basename is the
730
+ * page-folder name (`landing`). Returns `null` when the input is missing or
731
+ * unusable so callers can fall back gracefully.
732
+ *
733
+ * @param {unknown} pagePath
734
+ * @returns {string | null}
735
+ */
736
+ function pageNameFromPagePath(pagePath) {
737
+ if (typeof pagePath !== 'string' || pagePath.length === 0) return null;
738
+ const trimmed = pagePath.replace(/\/+$/, '');
739
+ const lastSlash = trimmed.lastIndexOf('/');
740
+ const name = lastSlash === -1 ? trimmed : trimmed.slice(lastSlash + 1);
741
+ return name.length > 0 ? name : null;
742
+ }
743
+
710
744
  /**
711
745
  * Join path segments using forward slashes, regardless of platform. The CLI
712
746
  * normalizes every path to the contract's forward-slash form at its boundary
@@ -1091,6 +1125,53 @@ function formatLocation(segments) {
1091
1125
  return segments.length === 0 ? '(top)' : segments.join('/');
1092
1126
  }
1093
1127
 
1128
+ /**
1129
+ * Group expanded artboard entries by their `pagePath`, preserving first-seen
1130
+ * order. Each entry is paired with its pre-computed base filename so the
1131
+ * downstream capture loop doesn't have to re-derive names.
1132
+ *
1133
+ * The studio renders one project-page at a time, so the CLI navigates the
1134
+ * URL hash to each page once and captures every artboard on that page before
1135
+ * moving on. The returned order is whatever order pages first appear in
1136
+ * `expanded` — which mirrors `collectArtboards`'s model-walk order (already
1137
+ * alphabetical, so a project's page-visit order is deterministic).
1138
+ *
1139
+ * @param {Array<{ artboard: object, variantName: string | undefined, domId: string }>} expanded
1140
+ * @param {string[]} baseFilenames Same length as `expanded`; the precomputed
1141
+ * filename for each entry.
1142
+ * @returns {Array<{
1143
+ * pagePath: string | null,
1144
+ * entries: Array<{ artboard: object, variantName: string | undefined, domId: string, filename: string }>
1145
+ * }>}
1146
+ * One group per distinct `pagePath`. `pagePath` is `null` only for hand-
1147
+ * crafted artboards in tests that omit the field — the orchestrator skips
1148
+ * navigation for that bucket and uses whatever the studio is showing.
1149
+ */
1150
+ export function groupEntriesByPage(expanded, baseFilenames) {
1151
+ /** @type {Map<string, Array<{ artboard: object, variantName: string | undefined, domId: string, filename: string }>>} */
1152
+ const byPath = new Map();
1153
+ const NULL_KEY = '__lerret_no_page__';
1154
+ for (let i = 0; i < expanded.length; i++) {
1155
+ const entry = expanded[i];
1156
+ const pagePath =
1157
+ entry.artboard && typeof entry.artboard.pagePath === 'string'
1158
+ ? entry.artboard.pagePath
1159
+ : null;
1160
+ const key = pagePath === null ? NULL_KEY : pagePath;
1161
+ const paired = { ...entry, filename: baseFilenames[i] };
1162
+ const bucket = byPath.get(key);
1163
+ if (bucket) {
1164
+ bucket.push(paired);
1165
+ } else {
1166
+ byPath.set(key, [paired]);
1167
+ }
1168
+ }
1169
+ return [...byPath.entries()].map(([key, entries]) => ({
1170
+ pagePath: key === NULL_KEY ? null : key,
1171
+ entries,
1172
+ }));
1173
+ }
1174
+
1094
1175
  /**
1095
1176
  * Run `@lerret/cli export`. Resolves the scope, boots Vite + Chromium, captures
1096
1177
  * each artboard, writes the result to disk, and returns an exit code.
@@ -1254,29 +1335,23 @@ export async function runExport(argv, deps = {}) {
1254
1335
 
1255
1336
  // Open a single page and navigate to the studio. We use one page for the
1256
1337
  // whole run — captureArtboard is independent per artboard and the studio
1257
- // already renders every artboard in the project on first load. Bringing
1258
- // a fresh page per artboard would re-bundle / re-fetch fonts each time.
1338
+ // pages share the same Vite/HMR session. A fresh page per artboard would
1339
+ // re-bundle / re-fetch fonts each time.
1340
+ //
1341
+ // The studio renders ONE project-page at a time (the dock's page picker
1342
+ // drives `ProjectStudio`'s hash route — see `packages/studio/src/project-
1343
+ // studio.jsx`). To capture artboards across every project page we group
1344
+ // by `pagePath`, navigate the URL hash to each page, wait for its first
1345
+ // slot to attach, then capture all of that page's artboards before
1346
+ // moving on. The first hash-set fires `hashchange` even when it matches
1347
+ // the default page, which keeps the navigation predictable.
1259
1348
  const context = await browser.newContext();
1260
1349
  const page = await context.newPage();
1261
1350
  await page.goto(url, { waitUntil: 'load', timeout: 60000 });
1262
1351
 
1263
- // Wait for the first expected artboard slot. Once one is in the DOM the
1264
- // studio has mounted; the rest of the project's slots follow in the same
1265
- // render. A timeout here means the studio could not load the project —
1266
- // surface that as a fatal error rather than silently producing zero
1267
- // captures.
1268
- const firstSelector = ARTBOARD_SELECTORS.slotByDataAttr(expanded[0].domId);
1269
- try {
1270
- await page.waitForSelector(firstSelector, { state: 'attached', timeout: 30000 });
1271
- } catch (err) {
1272
- process.stderr.write(
1273
- `@lerret/cli export: studio did not render any artboards within 30s ` +
1274
- `(${err && err.message ? err.message : String(err)}). The project may be empty or failed to load.\n`,
1275
- );
1276
- return 1;
1277
- }
1278
-
1279
- // 5. Build name counts for flat-mode disambiguation.
1352
+ // 5. Build name counts for flat-mode disambiguation. These are computed
1353
+ // across the WHOLE run so collisions are detected even when colliding
1354
+ // artboards live on different pages.
1280
1355
  /** @type {Map<string, number>} */
1281
1356
  const nameCounts = new Map();
1282
1357
  const baseFilenames = expanded.map((entry) =>
@@ -1286,73 +1361,132 @@ export async function runExport(argv, deps = {}) {
1286
1361
  nameCounts.set(name, (nameCounts.get(name) ?? 0) + 1);
1287
1362
  }
1288
1363
 
1289
- // 6. Capture each artboard. Failures are isolated a single bad
1290
- // capture is logged and the run continues. Unembedded fonts are
1291
- // aggregated across the run.
1364
+ // 6. Group entries by `pagePath` so we can navigate the studio to each
1365
+ // page once. Insertion order matters `collectArtboards` walks pages
1366
+ // in model order (alphabetical by the loader), so the resulting page
1367
+ // visit order is deterministic.
1368
+ const pageGroups = groupEntriesByPage(expanded, baseFilenames);
1369
+
1370
+ // 7. Capture each artboard, one page-batch at a time. Failures are
1371
+ // isolated — a single bad capture is logged and the run continues.
1372
+ // Unembedded fonts are aggregated across the run.
1292
1373
  const allUnembeddedFonts = new Set();
1293
1374
  /** @type {Array<{ artboard: object, reason: string }>} */
1294
1375
  const failures = [];
1295
1376
  let writtenCount = 0;
1296
-
1297
- for (let i = 0; i < expanded.length; i++) {
1298
- const entry = expanded[i];
1299
- const filename = baseFilenames[i];
1300
- const nameCount = nameCounts.get(filename) ?? 1;
1301
- const outputPath = buildOutputPath({
1302
- outDir: outDirAbs,
1303
- artboard: entry.artboard,
1304
- filename,
1305
- flat: flags.flat,
1306
- nameCount,
1307
- });
1308
-
1309
- const human = `${i + 1}/${expanded.length}`;
1310
- const label = `${formatLocation(entry.artboard.locationSegments)}/${entry.artboard.asset.name}${entry.variantName ? `#${entry.variantName}` : ''}`;
1311
- process.stdout.write(`[${human}] capturing ${label}\n`);
1312
-
1313
- let result;
1314
- try {
1315
- result = await captureInPage(page, entry.domId, flags.format);
1316
- } catch (err) {
1317
- result = {
1318
- ok: false,
1319
- error: err && err.message ? err.message : String(err),
1320
- };
1321
- }
1322
-
1323
- if (!result || !result.ok) {
1324
- const reason = (result && result.error) || 'unknown capture failure';
1325
- process.stderr.write(`[${human}] FAILED ${label}: ${reason}\n`);
1326
- failures.push({ artboard: entry.artboard, reason });
1327
- continue;
1377
+ let runIndex = 0;
1378
+
1379
+ for (const group of pageGroups) {
1380
+ // Navigate the studio to this page via the hash. `ProjectStudio`'s
1381
+ // `useHashRoute` listens on `hashchange`, so setting `location.hash`
1382
+ // re-renders `ProjectCanvas` for the matching page. When `pagePath` is
1383
+ // null (test artboards without a page hint) we skip navigation —
1384
+ // whatever the studio is currently showing serves the capture.
1385
+ if (group.pagePath !== null) {
1386
+ try {
1387
+ await page.evaluate((p) => {
1388
+ // eslint-disable-next-line no-undef
1389
+ window.location.hash = '#' + p;
1390
+ }, group.pagePath);
1391
+ } catch (err) {
1392
+ // A navigation failure for this page is fatal for the page batch
1393
+ // but not for the run — log every entry in this group as failed
1394
+ // and move on. We don't return 1 because other pages may succeed.
1395
+ const reason =
1396
+ `could not navigate to page ${group.pagePath}: ` +
1397
+ `${err && err.message ? err.message : String(err)}`;
1398
+ for (const entry of group.entries) {
1399
+ runIndex++;
1400
+ const human = `${runIndex}/${expanded.length}`;
1401
+ const label = `${formatLocation(entry.artboard.locationSegments)}/${entry.artboard.asset.name}${entry.variantName ? `#${entry.variantName}` : ''}`;
1402
+ process.stderr.write(`[${human}] FAILED ${label}: ${reason}\n`);
1403
+ failures.push({ artboard: entry.artboard, reason });
1404
+ }
1405
+ continue;
1406
+ }
1328
1407
  }
1329
1408
 
1330
- // Decode base64 Uint8Array and write the bytes to disk.
1331
- let bytes;
1409
+ // Wait for the first slot on this page to attach. If the studio fails
1410
+ // to render this page's artboards within the timeout, log every entry
1411
+ // in the batch as failed (with a clear reason) and move to the next
1412
+ // page rather than aborting — partial output is more useful than zero.
1413
+ const firstSelector = ARTBOARD_SELECTORS.slotByDataAttr(group.entries[0].domId);
1332
1414
  try {
1333
- const binary = Buffer.from(result.bytesB64, 'base64');
1334
- bytes = new Uint8Array(binary.buffer, binary.byteOffset, binary.byteLength);
1415
+ await page.waitForSelector(firstSelector, { state: 'attached', timeout: 30000 });
1335
1416
  } catch (err) {
1336
- const reason = `failed to decode capture bytes: ${err && err.message ? err.message : String(err)}`;
1337
- process.stderr.write(`[${human}] FAILED ${label}: ${reason}\n`);
1338
- failures.push({ artboard: entry.artboard, reason });
1417
+ const reason =
1418
+ `studio did not render page ${group.pagePath || '(default)'} within 30s ` +
1419
+ `(${err && err.message ? err.message : String(err)})`;
1420
+ for (const entry of group.entries) {
1421
+ runIndex++;
1422
+ const human = `${runIndex}/${expanded.length}`;
1423
+ const label = `${formatLocation(entry.artboard.locationSegments)}/${entry.artboard.asset.name}${entry.variantName ? `#${entry.variantName}` : ''}`;
1424
+ process.stderr.write(`[${human}] FAILED ${label}: ${reason}\n`);
1425
+ failures.push({ artboard: entry.artboard, reason });
1426
+ }
1339
1427
  continue;
1340
1428
  }
1341
1429
 
1342
- try {
1343
- await ensureDirFn(toLerretPath(dirname(outputPath)));
1344
- await writeBinary(outputPath, bytes);
1345
- writtenCount++;
1346
- process.stdout.write(`[${human}] wrote ${outputPath}\n`);
1347
- } catch (err) {
1348
- const reason = `write failed: ${err && err.message ? err.message : String(err)}`;
1349
- process.stderr.write(`[${human}] FAILED ${label}: ${reason}\n`);
1350
- failures.push({ artboard: entry.artboard, reason });
1351
- continue;
1352
- }
1430
+ for (const entry of group.entries) {
1431
+ runIndex++;
1432
+ const filename = entry.filename;
1433
+ const nameCount = nameCounts.get(filename) ?? 1;
1434
+ const outputPath = buildOutputPath({
1435
+ outDir: outDirAbs,
1436
+ artboard: entry.artboard,
1437
+ filename,
1438
+ flat: flags.flat,
1439
+ nameCount,
1440
+ });
1441
+
1442
+ const human = `${runIndex}/${expanded.length}`;
1443
+ const label = `${formatLocation(entry.artboard.locationSegments)}/${entry.artboard.asset.name}${entry.variantName ? `#${entry.variantName}` : ''}`;
1444
+ process.stdout.write(`[${human}] capturing ${label}\n`);
1445
+
1446
+ let result;
1447
+ try {
1448
+ result = await captureInPage(page, entry.domId, flags.format);
1449
+ } catch (err) {
1450
+ result = {
1451
+ ok: false,
1452
+ error: err && err.message ? err.message : String(err),
1453
+ };
1454
+ }
1455
+
1456
+ if (!result || !result.ok) {
1457
+ const reason = (result && result.error) || 'unknown capture failure';
1458
+ process.stderr.write(`[${human}] FAILED ${label}: ${reason}\n`);
1459
+ failures.push({ artboard: entry.artboard, reason });
1460
+ continue;
1461
+ }
1462
+
1463
+ // Decode base64 → Uint8Array and write the bytes to disk.
1464
+ let bytes;
1465
+ try {
1466
+ const binary = Buffer.from(result.bytesB64, 'base64');
1467
+ bytes = new Uint8Array(binary.buffer, binary.byteOffset, binary.byteLength);
1468
+ } catch (err) {
1469
+ const reason = `failed to decode capture bytes: ${err && err.message ? err.message : String(err)}`;
1470
+ process.stderr.write(`[${human}] FAILED ${label}: ${reason}\n`);
1471
+ failures.push({ artboard: entry.artboard, reason });
1472
+ continue;
1473
+ }
1353
1474
 
1354
- for (const font of result.unembeddedFonts || []) {
1355
- allUnembeddedFonts.add(font);
1475
+ try {
1476
+ await ensureDirFn(toLerretPath(dirname(outputPath)));
1477
+ await writeBinary(outputPath, bytes);
1478
+ writtenCount++;
1479
+ process.stdout.write(`[${human}] wrote ${outputPath}\n`);
1480
+ } catch (err) {
1481
+ const reason = `write failed: ${err && err.message ? err.message : String(err)}`;
1482
+ process.stderr.write(`[${human}] FAILED ${label}: ${reason}\n`);
1483
+ failures.push({ artboard: entry.artboard, reason });
1484
+ continue;
1485
+ }
1486
+
1487
+ for (const font of result.unembeddedFonts || []) {
1488
+ allUnembeddedFonts.add(font);
1489
+ }
1356
1490
  }
1357
1491
  }
1358
1492