@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.
- package/dist-studio/.bundle-stamp +1 -1
- package/package.json +1 -1
- package/src/export.js +213 -79
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lerret/cli",
|
|
3
|
-
"version": "0.1.
|
|
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>/<
|
|
667
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
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 =
|
|
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
|
-
//
|
|
1258
|
-
//
|
|
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
|
-
//
|
|
1264
|
-
//
|
|
1265
|
-
//
|
|
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.
|
|
1290
|
-
//
|
|
1291
|
-
//
|
|
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
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
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
|
-
//
|
|
1331
|
-
|
|
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
|
-
|
|
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 =
|
|
1337
|
-
|
|
1338
|
-
|
|
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
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
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
|
-
|
|
1355
|
-
|
|
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
|
|