@lerret/cli 0.1.10 → 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-21T22:09:03.487Z",
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.10",
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
@@ -1125,6 +1125,53 @@ function formatLocation(segments) {
1125
1125
  return segments.length === 0 ? '(top)' : segments.join('/');
1126
1126
  }
1127
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
+
1128
1175
  /**
1129
1176
  * Run `@lerret/cli export`. Resolves the scope, boots Vite + Chromium, captures
1130
1177
  * each artboard, writes the result to disk, and returns an exit code.
@@ -1288,29 +1335,23 @@ export async function runExport(argv, deps = {}) {
1288
1335
 
1289
1336
  // Open a single page and navigate to the studio. We use one page for the
1290
1337
  // whole run — captureArtboard is independent per artboard and the studio
1291
- // already renders every artboard in the project on first load. Bringing
1292
- // 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.
1293
1348
  const context = await browser.newContext();
1294
1349
  const page = await context.newPage();
1295
1350
  await page.goto(url, { waitUntil: 'load', timeout: 60000 });
1296
1351
 
1297
- // Wait for the first expected artboard slot. Once one is in the DOM the
1298
- // studio has mounted; the rest of the project's slots follow in the same
1299
- // render. A timeout here means the studio could not load the project —
1300
- // surface that as a fatal error rather than silently producing zero
1301
- // captures.
1302
- const firstSelector = ARTBOARD_SELECTORS.slotByDataAttr(expanded[0].domId);
1303
- try {
1304
- await page.waitForSelector(firstSelector, { state: 'attached', timeout: 30000 });
1305
- } catch (err) {
1306
- process.stderr.write(
1307
- `@lerret/cli export: studio did not render any artboards within 30s ` +
1308
- `(${err && err.message ? err.message : String(err)}). The project may be empty or failed to load.\n`,
1309
- );
1310
- return 1;
1311
- }
1312
-
1313
- // 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.
1314
1355
  /** @type {Map<string, number>} */
1315
1356
  const nameCounts = new Map();
1316
1357
  const baseFilenames = expanded.map((entry) =>
@@ -1320,73 +1361,132 @@ export async function runExport(argv, deps = {}) {
1320
1361
  nameCounts.set(name, (nameCounts.get(name) ?? 0) + 1);
1321
1362
  }
1322
1363
 
1323
- // 6. Capture each artboard. Failures are isolated a single bad
1324
- // capture is logged and the run continues. Unembedded fonts are
1325
- // 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.
1326
1373
  const allUnembeddedFonts = new Set();
1327
1374
  /** @type {Array<{ artboard: object, reason: string }>} */
1328
1375
  const failures = [];
1329
1376
  let writtenCount = 0;
1330
-
1331
- for (let i = 0; i < expanded.length; i++) {
1332
- const entry = expanded[i];
1333
- const filename = baseFilenames[i];
1334
- const nameCount = nameCounts.get(filename) ?? 1;
1335
- const outputPath = buildOutputPath({
1336
- outDir: outDirAbs,
1337
- artboard: entry.artboard,
1338
- filename,
1339
- flat: flags.flat,
1340
- nameCount,
1341
- });
1342
-
1343
- const human = `${i + 1}/${expanded.length}`;
1344
- const label = `${formatLocation(entry.artboard.locationSegments)}/${entry.artboard.asset.name}${entry.variantName ? `#${entry.variantName}` : ''}`;
1345
- process.stdout.write(`[${human}] capturing ${label}\n`);
1346
-
1347
- let result;
1348
- try {
1349
- result = await captureInPage(page, entry.domId, flags.format);
1350
- } catch (err) {
1351
- result = {
1352
- ok: false,
1353
- error: err && err.message ? err.message : String(err),
1354
- };
1355
- }
1356
-
1357
- if (!result || !result.ok) {
1358
- const reason = (result && result.error) || 'unknown capture failure';
1359
- process.stderr.write(`[${human}] FAILED ${label}: ${reason}\n`);
1360
- failures.push({ artboard: entry.artboard, reason });
1361
- 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
+ }
1362
1407
  }
1363
1408
 
1364
- // Decode base64 Uint8Array and write the bytes to disk.
1365
- 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);
1366
1414
  try {
1367
- const binary = Buffer.from(result.bytesB64, 'base64');
1368
- bytes = new Uint8Array(binary.buffer, binary.byteOffset, binary.byteLength);
1415
+ await page.waitForSelector(firstSelector, { state: 'attached', timeout: 30000 });
1369
1416
  } catch (err) {
1370
- const reason = `failed to decode capture bytes: ${err && err.message ? err.message : String(err)}`;
1371
- process.stderr.write(`[${human}] FAILED ${label}: ${reason}\n`);
1372
- 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
+ }
1373
1427
  continue;
1374
1428
  }
1375
1429
 
1376
- try {
1377
- await ensureDirFn(toLerretPath(dirname(outputPath)));
1378
- await writeBinary(outputPath, bytes);
1379
- writtenCount++;
1380
- process.stdout.write(`[${human}] wrote ${outputPath}\n`);
1381
- } catch (err) {
1382
- const reason = `write failed: ${err && err.message ? err.message : String(err)}`;
1383
- process.stderr.write(`[${human}] FAILED ${label}: ${reason}\n`);
1384
- failures.push({ artboard: entry.artboard, reason });
1385
- continue;
1386
- }
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
+ }
1387
1462
 
1388
- for (const font of result.unembeddedFonts || []) {
1389
- allUnembeddedFonts.add(font);
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
+ }
1474
+
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
+ }
1390
1490
  }
1391
1491
  }
1392
1492