@codepress/design-kit 0.4.1 → 0.5.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/README.md +25 -6
- package/package.json +1 -1
- package/schema/design-kit.config.schema.json +6 -1
- package/src/capture-pages.mjs +158 -19
- package/src/capture-stories.mjs +43 -4
- package/src/config.mjs +1 -0
- package/src/index.d.ts +12 -1
- package/src/index.mjs +1 -1
- package/src/kit.mjs +24 -6
- package/src/pages.mjs +92 -16
package/README.md
CHANGED
|
@@ -275,6 +275,15 @@ export returns (`async ({ browser, baseUrl, config }) => state`).
|
|
|
275
275
|
`slots` names the regions a mockup replaces: the first selector that matches
|
|
276
276
|
a visible, non-empty element gets `data-kit-slot="<name>"`, and `main` is
|
|
277
277
|
required. A page without a `main` slot is a gap, not a frame.
|
|
278
|
+
- `source`: names this config's pages when more than one config captures
|
|
279
|
+
pages into one kit, such as a web app and a desktop app's windows. A capture
|
|
280
|
+
with a `source` rewrites only its own pages' frames and gap rows and leaves
|
|
281
|
+
every other source's in place; its stylesheet is `css/routes--<source>.css`.
|
|
282
|
+
Without one (the default), a capture replaces every page in the kit. Page
|
|
283
|
+
names must be unique across sources: a name another source already captured
|
|
284
|
+
into the kit is a config error that names both sources (exit 2), checked
|
|
285
|
+
before the capture starts and again before it writes. To move a page from
|
|
286
|
+
one source to another, rebuild the kit without the old source's rows.
|
|
278
287
|
- `widths`: the sizes every page is captured at (default 1280×800 and 390×844).
|
|
279
288
|
- `shellReady`: a selector the app's frame shows before any page content.
|
|
280
289
|
- `layout`: `{selector, attribute, default?}` reads the layout the app chose,
|
|
@@ -288,8 +297,10 @@ value>]` then replaces `slots`.
|
|
|
288
297
|
kit's `fonts/` folder (`copyFonts: false` keeps them out); `fontsPath` says
|
|
289
298
|
which URL path holds them when not every font file is one.
|
|
290
299
|
- `hooks`: a module exporting `context(context, info)` (may return a cleanup)
|
|
291
|
-
and `page(page, info)` (
|
|
292
|
-
routing mock data
|
|
300
|
+
and `page(page, info)` (runs before the page opens; may return
|
|
301
|
+
`{beforeReady, readyFailed, beforeSerialize}`), for routing mock data,
|
|
302
|
+
clicking through to a view (`beforeReady` runs once `shellReady` shows,
|
|
303
|
+
before `ready` is awaited) or adding checks.
|
|
293
304
|
- `timeoutMs`, `startTimeoutMs`, `fixedTime`.
|
|
294
305
|
|
|
295
306
|
## Commands
|
|
@@ -384,17 +395,21 @@ design-kit/
|
|
|
384
395
|
html/story--<id>@<w>.html, html/route--<name>@<w>.html
|
|
385
396
|
css/stories.css every rule the Storybook preview loaded, deduplicated
|
|
386
397
|
css/routes.css every rule the captured pages loaded, deduplicated
|
|
398
|
+
css/routes--<source>.css the same, for the pages of one page source
|
|
387
399
|
fonts/ the fonts both use
|
|
388
|
-
gaps.json [{id|component|route, reason, detail?}]
|
|
400
|
+
gaps.json [{id|component|route, reason, detail?, source?}]
|
|
389
401
|
kit.mjs the tool, copied into every kit so tool and data match
|
|
390
402
|
```
|
|
391
403
|
|
|
392
404
|
A row is `{id, kind: "story"|"route", title, component?, file, width, height,
|
|
393
|
-
bytes, slots?, source_file?, body_class?, html_attrs?,
|
|
405
|
+
bytes, slots?, source_file?, source?, css?, body_class?, html_attrs?,
|
|
406
|
+
body_attrs?}`; a page row from a page source carries `source` and its
|
|
407
|
+
stylesheet in `css`. Ids are
|
|
394
408
|
`story:<storybook-id>@<width>` and `route:<name>@<width>`; file names use `--`
|
|
395
409
|
instead of `:`, so the kit uploads as a CI artifact as it is. Always find a
|
|
396
410
|
row's markup through its `file`. `manifest.css` may name another stylesheet
|
|
397
|
-
per source (`{stories, routes}`).
|
|
411
|
+
per source (`{stories, routes}`). With page sources, `manifest.sources.routes`
|
|
412
|
+
keeps the kit's totals and adds `page_sources.<source>` with each one's own.
|
|
398
413
|
|
|
399
414
|
## `kit.mjs`
|
|
400
415
|
|
|
@@ -440,7 +455,11 @@ await capturePages(config, {
|
|
|
440
455
|
|
|
441
456
|
`capturePages` also takes `forbidStrings` and `stripOrigins` (more origins to
|
|
442
457
|
remove from markup, such as a mock API's) and a `session` object whose
|
|
443
|
-
`interrupted` flag stops it between pages.
|
|
458
|
+
`interrupted` flag stops it between pages. A harness that starts its own
|
|
459
|
+
servers or builds before calling `capturePages` can call
|
|
460
|
+
`assertPageSources(config, out)` first: it throws the same `ConfigError`
|
|
461
|
+
(`exitCode` 2) for a page another source already captured into `out`, before
|
|
462
|
+
anything starts.
|
|
444
463
|
|
|
445
464
|
## Contributing and license
|
|
446
465
|
|
package/package.json
CHANGED
|
@@ -245,6 +245,11 @@
|
|
|
245
245
|
"required": ["pages"],
|
|
246
246
|
"description": "`design-kit capture pages`: real pages of the running app, through Playwright.",
|
|
247
247
|
"properties": {
|
|
248
|
+
"source": {
|
|
249
|
+
"type": "string",
|
|
250
|
+
"pattern": "^[a-z0-9][a-z0-9-]*$",
|
|
251
|
+
"description": "Names this page source (such as \"web\" or \"terminal\") so several apps capture into one kit: a capture then rewrites only its own source's frames, rows, stylesheet (css/routes--<source>.css) and gap rows. Page names must be unique across sources; a name another source already captured into the kit is a config error. Without it, a capture replaces every page in the kit."
|
|
252
|
+
},
|
|
248
253
|
"baseUrl": {
|
|
249
254
|
"type": "string",
|
|
250
255
|
"pattern": "^https?://",
|
|
@@ -282,7 +287,7 @@
|
|
|
282
287
|
"hooks": {
|
|
283
288
|
"type": "string",
|
|
284
289
|
"minLength": 1,
|
|
285
|
-
"description": "A module exporting optional `context(context, info)` and `page(page, info)` hooks."
|
|
290
|
+
"description": "A module exporting optional `context(context, info)` and `page(page, info)` hooks. `page` may return `{ beforeReady, readyFailed, beforeSerialize }`: `beforeReady()` runs after the shell shows and before the ready selector is awaited, for a page reached by a click."
|
|
286
291
|
},
|
|
287
292
|
"widths": {
|
|
288
293
|
"type": "array",
|
package/src/capture-pages.mjs
CHANGED
|
@@ -32,9 +32,14 @@ import {
|
|
|
32
32
|
kitRouteId,
|
|
33
33
|
mergeGaps,
|
|
34
34
|
mergeManifest,
|
|
35
|
+
mergeRoutes,
|
|
35
36
|
normalizeReactIds,
|
|
37
|
+
ownsRouteRow,
|
|
36
38
|
rewriteCssUrls,
|
|
39
|
+
routeCssFile,
|
|
40
|
+
routeRowPage,
|
|
37
41
|
selectPages,
|
|
42
|
+
sourceCollisions,
|
|
38
43
|
} from "./pages.mjs";
|
|
39
44
|
import { ConfigError } from "./config.mjs";
|
|
40
45
|
import { loadPlaywright } from "./playwright.mjs";
|
|
@@ -310,6 +315,9 @@ async function capturePage(context, page, viewport, run) {
|
|
|
310
315
|
.first()
|
|
311
316
|
.waitFor({ state: "visible" });
|
|
312
317
|
}
|
|
318
|
+
// A page reached by a click or a key, not by its path alone (a settings
|
|
319
|
+
// panel, a drawer), gets there here.
|
|
320
|
+
await pageHooks.beforeReady?.();
|
|
313
321
|
const ready = tab.locator(page.ready).first();
|
|
314
322
|
try {
|
|
315
323
|
await ready.waitFor({ state: "visible" });
|
|
@@ -366,6 +374,47 @@ function themeClassesOf(theme) {
|
|
|
366
374
|
return theme?.light ? [dark, theme.light] : [dark, "light"];
|
|
367
375
|
}
|
|
368
376
|
|
|
377
|
+
/**
|
|
378
|
+
* Throws a ConfigError (exit 2) when a page the config declares is already in
|
|
379
|
+
* the kit at `out` (a routes.json row or a gaps.json page row) from another
|
|
380
|
+
* page source. Page names are unique across sources, and every declared page
|
|
381
|
+
* counts, whatever `--only` selects. `capturePages` calls it before starting
|
|
382
|
+
* anything; a harness that starts its own servers first can call it earlier.
|
|
383
|
+
*
|
|
384
|
+
* @param {object} config A loaded config (`loadConfig`).
|
|
385
|
+
* @param {string} out The kit folder.
|
|
386
|
+
*/
|
|
387
|
+
export function assertPageSources(config, out) {
|
|
388
|
+
const settings = config.pages;
|
|
389
|
+
if (!settings) return;
|
|
390
|
+
const source = settings.source ?? null;
|
|
391
|
+
const pageNames = new Set(settings.pages.map((page) => page.name));
|
|
392
|
+
const clashes = sourceCollisions(
|
|
393
|
+
[
|
|
394
|
+
...asArray(readJson(path.join(out, "routes.json"))),
|
|
395
|
+
...asArray(readJson(path.join(out, "gaps.json"))),
|
|
396
|
+
],
|
|
397
|
+
{ source, pageNames },
|
|
398
|
+
);
|
|
399
|
+
if (clashes.length) {
|
|
400
|
+
throw new ConfigError(
|
|
401
|
+
clashes
|
|
402
|
+
.map(
|
|
403
|
+
({ name, other }) =>
|
|
404
|
+
`page "${name}" is declared by page source "${source}" (${config.file}) and source "${other}" already captured it into ${out}; page names must be unique across sources`,
|
|
405
|
+
)
|
|
406
|
+
.join("\n"),
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/** A page's markup carries a forbidStrings value. */
|
|
412
|
+
class ForbiddenStringError extends Error {
|
|
413
|
+
constructor() {
|
|
414
|
+
super("the page's markup carries a forbidStrings value");
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
369
418
|
/**
|
|
370
419
|
* Captures the configured pages into `out`.
|
|
371
420
|
*
|
|
@@ -382,13 +431,6 @@ function themeClassesOf(theme) {
|
|
|
382
431
|
* Returns 0, or 130 when interrupted (nothing is written then). Throws,
|
|
383
432
|
* writing nothing, when any page's markup carries a forbidStrings value.
|
|
384
433
|
*/
|
|
385
|
-
/** A page's markup carries a forbidStrings value. */
|
|
386
|
-
class ForbiddenStringError extends Error {
|
|
387
|
-
constructor() {
|
|
388
|
-
super("the page's markup carries a forbidStrings value");
|
|
389
|
-
}
|
|
390
|
-
}
|
|
391
|
-
|
|
392
434
|
export async function capturePages(config, options = {}) {
|
|
393
435
|
const settings = config.pages;
|
|
394
436
|
if (!settings) {
|
|
@@ -411,6 +453,11 @@ export async function capturePages(config, options = {}) {
|
|
|
411
453
|
only: options.only,
|
|
412
454
|
ready: options.ready,
|
|
413
455
|
});
|
|
456
|
+
// Before anything starts: a page this source declares that the kit already
|
|
457
|
+
// holds from another source.
|
|
458
|
+
assertPageSources(config, out);
|
|
459
|
+
const source = settings.source ?? null;
|
|
460
|
+
const pageNames = new Set(settings.pages.map((page) => page.name));
|
|
414
461
|
if (!options.baseUrl && !settings.baseUrl && !settings.startCommand) {
|
|
415
462
|
throw new ConfigError(
|
|
416
463
|
"pages.baseUrl, or pages.startCommand with pages.waitUrl, is required (or pass --base-url)",
|
|
@@ -610,6 +657,7 @@ export async function capturePages(config, options = {}) {
|
|
|
610
657
|
bytes: Buffer.byteLength(html),
|
|
611
658
|
slots: [...capture.slots].sort(),
|
|
612
659
|
...(page.sourceFile ? { source_file: page.sourceFile } : {}),
|
|
660
|
+
...(source ? { source, css: routeCssFile(source) } : {}),
|
|
613
661
|
path: page.path,
|
|
614
662
|
body_class: capture.bodyAttrs.class ?? "",
|
|
615
663
|
html_attrs: capture.htmlAttrs,
|
|
@@ -628,7 +676,12 @@ export async function capturePages(config, options = {}) {
|
|
|
628
676
|
.split("\n")
|
|
629
677
|
.slice(0, 3)
|
|
630
678
|
.join(" ");
|
|
631
|
-
gaps.push({
|
|
679
|
+
gaps.push({
|
|
680
|
+
id,
|
|
681
|
+
route: page.name,
|
|
682
|
+
...(source ? { source } : {}),
|
|
683
|
+
reason,
|
|
684
|
+
});
|
|
632
685
|
log(`gap ${id}: ${reason}`);
|
|
633
686
|
} finally {
|
|
634
687
|
await cleanup?.().catch?.(() => {});
|
|
@@ -661,6 +714,8 @@ export async function capturePages(config, options = {}) {
|
|
|
661
714
|
fonts: fontFiles,
|
|
662
715
|
theme: config.theme,
|
|
663
716
|
configDir: config.configDir,
|
|
717
|
+
source,
|
|
718
|
+
pageNames,
|
|
664
719
|
});
|
|
665
720
|
timings.total_s = (performance.now() - startedAt) / 1000;
|
|
666
721
|
const cpu = process.cpuUsage();
|
|
@@ -674,21 +729,89 @@ export async function capturePages(config, options = {}) {
|
|
|
674
729
|
return 0;
|
|
675
730
|
}
|
|
676
731
|
|
|
677
|
-
|
|
732
|
+
/**
|
|
733
|
+
* Writes this capture into the kit at `out`. Without a `source` it replaces
|
|
734
|
+
* every route file, row and gap row (the stories' stay). With one it
|
|
735
|
+
* replaces only what `ownsRouteRow` gives it, so another page source's
|
|
736
|
+
* frames, rows, stylesheet and gap rows stay.
|
|
737
|
+
*/
|
|
738
|
+
export function writePagesKit(
|
|
678
739
|
out,
|
|
679
|
-
{
|
|
740
|
+
{
|
|
741
|
+
rows,
|
|
742
|
+
files,
|
|
743
|
+
css,
|
|
744
|
+
gaps,
|
|
745
|
+
needles,
|
|
746
|
+
pages,
|
|
747
|
+
widths,
|
|
748
|
+
fonts,
|
|
749
|
+
theme,
|
|
750
|
+
configDir,
|
|
751
|
+
source = null,
|
|
752
|
+
pageNames = new Set(pages.map((page) => page.name)),
|
|
753
|
+
},
|
|
680
754
|
) {
|
|
755
|
+
const owns = (row) => ownsRouteRow(row, { source, pageNames });
|
|
756
|
+
const routesPath = path.join(out, "routes.json");
|
|
757
|
+
const existingRows = readJson(routesPath);
|
|
758
|
+
// Checked again before anything is removed: another capture may have
|
|
759
|
+
// written the kit since this one started (captures into one kit are meant
|
|
760
|
+
// to run one after another).
|
|
761
|
+
const late = sourceCollisions(
|
|
762
|
+
[
|
|
763
|
+
...asArray(existingRows),
|
|
764
|
+
...asArray(readJson(path.join(out, "gaps.json"))),
|
|
765
|
+
],
|
|
766
|
+
{ source, pageNames },
|
|
767
|
+
);
|
|
768
|
+
if (late.length)
|
|
769
|
+
throw new ConfigError(
|
|
770
|
+
late
|
|
771
|
+
.map(
|
|
772
|
+
({ name, other }) =>
|
|
773
|
+
`page "${name}" was captured into ${out} by page source "${other}" while source "${source}" was capturing; nothing was written`,
|
|
774
|
+
)
|
|
775
|
+
.join("\n"),
|
|
776
|
+
);
|
|
681
777
|
fs.mkdirSync(path.join(out, "html"), { recursive: true });
|
|
682
778
|
fs.mkdirSync(path.join(out, "css"), { recursive: true });
|
|
683
|
-
// This half's files only: earlier page captures go, story files stay.
|
|
684
|
-
|
|
685
|
-
|
|
779
|
+
// This half's files only: earlier page captures go, story files stay. A
|
|
780
|
+
// sourced capture removes only the files of the rows it replaces.
|
|
781
|
+
if (!source) {
|
|
782
|
+
for (const name of fs.readdirSync(path.join(out, "html")))
|
|
783
|
+
if (isRouteHtmlFile(name)) fs.rmSync(path.join(out, "html", name));
|
|
784
|
+
for (const name of fs.readdirSync(path.join(out, "css")))
|
|
785
|
+
if (/^routes--.+\.css$/.test(name))
|
|
786
|
+
fs.rmSync(path.join(out, "css", name));
|
|
787
|
+
} else {
|
|
788
|
+
const stale = new Set(
|
|
789
|
+
(Array.isArray(existingRows) ? existingRows : [])
|
|
790
|
+
.filter((row) => row && typeof row === "object" && owns(row))
|
|
791
|
+
.map((row) => row.file)
|
|
792
|
+
.filter(
|
|
793
|
+
(file) =>
|
|
794
|
+
typeof file === "string" &&
|
|
795
|
+
/^html\/route[-:]/.test(file) &&
|
|
796
|
+
!file.includes(".."),
|
|
797
|
+
),
|
|
798
|
+
);
|
|
799
|
+
// A file of one of this source's pages with no row (a kit cut short).
|
|
800
|
+
for (const name of fs.readdirSync(path.join(out, "html"))) {
|
|
801
|
+
if (!isRouteHtmlFile(name)) continue;
|
|
802
|
+
const page = name
|
|
803
|
+
.replace(/^route(?:--|:)/, "")
|
|
804
|
+
.replace(/@\d+\.html$/, "");
|
|
805
|
+
if (pageNames.has(page)) stale.add(`html/${name}`);
|
|
806
|
+
}
|
|
807
|
+
for (const file of stale) fs.rmSync(path.join(out, file), { force: true });
|
|
808
|
+
}
|
|
686
809
|
|
|
687
|
-
const sortedRows =
|
|
810
|
+
const sortedRows = mergeRoutes(existingRows, rows, owns);
|
|
688
811
|
const written = [
|
|
689
812
|
...[...files.entries()].sort(byKey(([file]) => file)),
|
|
690
813
|
["routes.json", kitJson(sortedRows)],
|
|
691
|
-
[
|
|
814
|
+
[routeCssFile(source), css],
|
|
692
815
|
];
|
|
693
816
|
for (const [file, text] of written) {
|
|
694
817
|
if (findForbidden(text, needles))
|
|
@@ -710,24 +833,40 @@ function writePagesKit(
|
|
|
710
833
|
fs.copyFileSync(new URL("./kit.mjs", import.meta.url), tool);
|
|
711
834
|
|
|
712
835
|
const gapsPath = path.join(out, "gaps.json");
|
|
713
|
-
|
|
714
|
-
gapsPath,
|
|
715
|
-
|
|
836
|
+
const mergedGaps = mergeGaps(
|
|
837
|
+
readJson(gapsPath),
|
|
838
|
+
gaps.filter(isRouteGap),
|
|
839
|
+
owns,
|
|
716
840
|
);
|
|
841
|
+
fs.writeFileSync(gapsPath, kitJson(mergedGaps));
|
|
717
842
|
|
|
843
|
+
const routeGaps = mergedGaps.filter(isRouteGap);
|
|
718
844
|
const manifestPath = path.join(out, "manifest.json");
|
|
719
845
|
const manifest = mergeManifest(readJson(manifestPath), {
|
|
720
846
|
sha: gitSha(configDir),
|
|
721
847
|
builtAt: new Date().toISOString(),
|
|
722
|
-
captured:
|
|
848
|
+
captured: rows.length,
|
|
723
849
|
failed: gaps.length,
|
|
724
850
|
pages: pages.map((page) => page.name),
|
|
725
851
|
widths: widths.map((viewport) => viewport.width),
|
|
726
852
|
theme,
|
|
853
|
+
source,
|
|
854
|
+
total: {
|
|
855
|
+
captured: sortedRows.length,
|
|
856
|
+
failed: routeGaps.length,
|
|
857
|
+
pages: [
|
|
858
|
+
...new Set([...sortedRows, ...routeGaps].map(routeRowPage)),
|
|
859
|
+
].sort(),
|
|
860
|
+
widths: [
|
|
861
|
+
...new Set(sortedRows.map((row) => row.width).filter(Number.isInteger)),
|
|
862
|
+
].sort((a, b) => b - a),
|
|
863
|
+
},
|
|
727
864
|
});
|
|
728
865
|
fs.writeFileSync(manifestPath, kitJson(manifest));
|
|
729
866
|
}
|
|
730
867
|
|
|
868
|
+
const asArray = (value) => (Array.isArray(value) ? value : []);
|
|
869
|
+
|
|
731
870
|
function readJson(file) {
|
|
732
871
|
try {
|
|
733
872
|
return JSON.parse(fs.readFileSync(file, "utf8"));
|
package/src/capture-stories.mjs
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* Needs Playwright's Chromium (`npx playwright install chromium`).
|
|
11
11
|
*/
|
|
12
12
|
import { execFileSync, spawnSync } from "node:child_process";
|
|
13
|
+
import { createHash } from "node:crypto";
|
|
13
14
|
import {
|
|
14
15
|
copyFileSync,
|
|
15
16
|
createReadStream,
|
|
@@ -62,6 +63,28 @@ export const kitId = (storyId, width) => `story:${storyId}@${width}`;
|
|
|
62
63
|
export const kitFile = (id) => `html/${id.replace(/^story:/, "story--")}.html`;
|
|
63
64
|
export const storyIdOf = (id) => id.replace(/^story:/, "").replace(/@\d+$/, "");
|
|
64
65
|
|
|
66
|
+
/**
|
|
67
|
+
* Holds each distinct sheet once. A capture's sheets come back as shared
|
|
68
|
+
* `{key, rules}` entries: an equal key and equal rules give the same entry,
|
|
69
|
+
* so a run keeps one copy of the CSS nearly every story loads instead of one
|
|
70
|
+
* per capture. A linked sheet is keyed by its path, so two captures can share
|
|
71
|
+
* a key with different rules; each keeps its own, and the kit chooses between
|
|
72
|
+
* them exactly as it did before.
|
|
73
|
+
*/
|
|
74
|
+
export function sheetStore() {
|
|
75
|
+
const byKey = new Map(); // key -> Map(rules digest -> entry)
|
|
76
|
+
return (sheets) =>
|
|
77
|
+
sheets.map(({ key, rules }) => {
|
|
78
|
+
const digest = createHash("sha256")
|
|
79
|
+
.update(JSON.stringify(rules))
|
|
80
|
+
.digest("base64");
|
|
81
|
+
let variants = byKey.get(key);
|
|
82
|
+
if (!variants) byKey.set(key, (variants = new Map()));
|
|
83
|
+
if (!variants.has(digest)) variants.set(digest, { key, rules });
|
|
84
|
+
return variants.get(digest);
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
65
88
|
/**
|
|
66
89
|
* Sheets in a cascade-respecting order: each capture's sheets keep their
|
|
67
90
|
* document order, and a sheet first seen later is placed after the sheet
|
|
@@ -974,6 +997,15 @@ export async function captureStories(config, options = {}) {
|
|
|
974
997
|
const captures = new Map(); // kit id -> {story, viewport, result}
|
|
975
998
|
const failures = [];
|
|
976
999
|
const attempts = []; // every capture's timing, kept or not
|
|
1000
|
+
// Nearly every story loads the same sheets, a few hundred KB of rules
|
|
1001
|
+
// each; kept results share one copy of each (see sheetStore).
|
|
1002
|
+
const keepSheetsOnce = sheetStore();
|
|
1003
|
+
// What the timings report needs from a capture, without its result.
|
|
1004
|
+
const attempt = (capture) => {
|
|
1005
|
+
const record = { timing: capture.timing, failure: capture.failure };
|
|
1006
|
+
attempts.push(record);
|
|
1007
|
+
return record;
|
|
1008
|
+
};
|
|
977
1009
|
let done = 0;
|
|
978
1010
|
await pool(targets, concurrency, async (story) => {
|
|
979
1011
|
const first = await captureOne({
|
|
@@ -986,7 +1018,7 @@ export async function captureStories(config, options = {}) {
|
|
|
986
1018
|
families,
|
|
987
1019
|
fixedTime,
|
|
988
1020
|
});
|
|
989
|
-
|
|
1021
|
+
const firstAttempt = attempt(first);
|
|
990
1022
|
let viewports = plannedViewports(story.title, null, planning);
|
|
991
1023
|
const results = new Map([[planning.viewport.width, first]]);
|
|
992
1024
|
if (first.result) {
|
|
@@ -994,7 +1026,7 @@ export async function captureStories(config, options = {}) {
|
|
|
994
1026
|
viewports = plannedViewports(story.title, declared, planning);
|
|
995
1027
|
if (declared) {
|
|
996
1028
|
results.delete(planning.viewport.width);
|
|
997
|
-
|
|
1029
|
+
firstAttempt.discarded = true;
|
|
998
1030
|
}
|
|
999
1031
|
}
|
|
1000
1032
|
for (const viewport of viewports) {
|
|
@@ -1009,7 +1041,7 @@ export async function captureStories(config, options = {}) {
|
|
|
1009
1041
|
families,
|
|
1010
1042
|
fixedTime,
|
|
1011
1043
|
});
|
|
1012
|
-
|
|
1044
|
+
attempt(capture);
|
|
1013
1045
|
results.set(viewport.width, capture);
|
|
1014
1046
|
}
|
|
1015
1047
|
const capture = results.get(viewport.width);
|
|
@@ -1017,7 +1049,14 @@ export async function captureStories(config, options = {}) {
|
|
|
1017
1049
|
if (capture.failure) {
|
|
1018
1050
|
failures.push({ id, ...capture.failure });
|
|
1019
1051
|
} else {
|
|
1020
|
-
captures.set(id, {
|
|
1052
|
+
captures.set(id, {
|
|
1053
|
+
story,
|
|
1054
|
+
viewport,
|
|
1055
|
+
result: {
|
|
1056
|
+
...capture.result,
|
|
1057
|
+
sheets: keepSheetsOnce(capture.result.sheets),
|
|
1058
|
+
},
|
|
1059
|
+
});
|
|
1021
1060
|
}
|
|
1022
1061
|
}
|
|
1023
1062
|
done += 1;
|
package/src/config.mjs
CHANGED
package/src/index.d.ts
CHANGED
|
@@ -36,6 +36,8 @@ export interface DesignKitConfig {
|
|
|
36
36
|
};
|
|
37
37
|
stories?: Record<string, unknown>;
|
|
38
38
|
pages?: {
|
|
39
|
+
/** The page source this config captures; null replaces every page. */
|
|
40
|
+
source: string | null;
|
|
39
41
|
baseUrl?: string;
|
|
40
42
|
fixedTime: string;
|
|
41
43
|
widths: Size[];
|
|
@@ -63,8 +65,10 @@ export function captureStories(
|
|
|
63
65
|
},
|
|
64
66
|
): Promise<{ out: string; rows: number; failed: number }>;
|
|
65
67
|
|
|
66
|
-
/** Returned by a `page` hook;
|
|
68
|
+
/** Returned by a `page` hook; each runs inside the capture of one page. */
|
|
67
69
|
export interface PageHookResult {
|
|
70
|
+
/** After the shell shows, before the ready selector: reach the page by a click. */
|
|
71
|
+
beforeReady?(): void | Promise<void>;
|
|
68
72
|
/** The ready selector never appeared; may throw a clearer error. */
|
|
69
73
|
readyFailed?(): void | Promise<void>;
|
|
70
74
|
/** Runs just before serialization, in place of the optional screenshot. */
|
|
@@ -112,6 +116,13 @@ export function capturePages(
|
|
|
112
116
|
options?: CapturePagesOptions,
|
|
113
117
|
): Promise<number>;
|
|
114
118
|
|
|
119
|
+
/**
|
|
120
|
+
* Throws a ConfigError (exitCode 2) when a page the config declares is already
|
|
121
|
+
* in the kit at `out` from another page source. `capturePages` calls it before
|
|
122
|
+
* starting anything; a harness that starts its own servers can call it first.
|
|
123
|
+
*/
|
|
124
|
+
export function assertPageSources(config: DesignKitConfig, out: string): void;
|
|
125
|
+
|
|
115
126
|
/** What to look for per forbidden value: as written, a token's long segments, and escaped. */
|
|
116
127
|
export function forbiddenNeedles(values: string[]): string[];
|
|
117
128
|
/** The first needle `text` contains, or null. */
|
package/src/index.mjs
CHANGED
|
@@ -12,6 +12,6 @@ export {
|
|
|
12
12
|
validate,
|
|
13
13
|
} from "./config.mjs";
|
|
14
14
|
export { captureStories, playwrightVersion } from "./capture-stories.mjs";
|
|
15
|
-
export { capturePages } from "./capture-pages.mjs";
|
|
15
|
+
export { assertPageSources, capturePages } from "./capture-pages.mjs";
|
|
16
16
|
export { findForbidden, forbiddenNeedles } from "./pages.mjs";
|
|
17
17
|
export { compose, find, loadKit, run as runKit } from "./kit.mjs";
|
package/src/kit.mjs
CHANGED
|
@@ -67,6 +67,21 @@ function cssFile(kit, source) {
|
|
|
67
67
|
: `css/${source}.css`;
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
/**
|
|
71
|
+
* The stylesheet a row needs: a route row of a page source names its own
|
|
72
|
+
* (`css`, inside the kit's css folder), any other row its source's.
|
|
73
|
+
*/
|
|
74
|
+
function rowCssFile(kit, row) {
|
|
75
|
+
const source = rowSource(row);
|
|
76
|
+
if (
|
|
77
|
+
source === "routes" &&
|
|
78
|
+
typeof row.css === "string" &&
|
|
79
|
+
/^css\/[^/]+\.css$/.test(row.css)
|
|
80
|
+
)
|
|
81
|
+
return row.css;
|
|
82
|
+
return cssFile(kit, source);
|
|
83
|
+
}
|
|
84
|
+
|
|
70
85
|
/** A row's markup, found only through its `file` (never derived from the id). */
|
|
71
86
|
function snippetPath(kit, row) {
|
|
72
87
|
if (typeof row.file !== "string" || !row.file) {
|
|
@@ -693,7 +708,7 @@ function expandSnippets(kit, html, used, budget, depth = 0) {
|
|
|
693
708
|
const id = element.attrs.id;
|
|
694
709
|
if (!id) throw new KitError("<kit-snippet> without an id attribute");
|
|
695
710
|
const row = requireRow(kit, id, "<kit-snippet id>");
|
|
696
|
-
used.add(
|
|
711
|
+
used.add(rowCssFile(kit, row));
|
|
697
712
|
let markup = readSnippet(kit, row);
|
|
698
713
|
budget.left -= markup.length;
|
|
699
714
|
if (budget.left < 0) {
|
|
@@ -839,17 +854,20 @@ export function compose(
|
|
|
839
854
|
if (theme !== "light" && theme !== "dark") {
|
|
840
855
|
throw new KitError(`unknown theme "${theme}". Use light or dark.`);
|
|
841
856
|
}
|
|
842
|
-
const used = new Set([
|
|
857
|
+
const used = new Set([rowCssFile(kit, row)]);
|
|
843
858
|
const filled = fillSlots(kit, row, readSnippet(kit, row), slots);
|
|
844
859
|
const body = expandSnippets(kit, filled, used, {
|
|
845
860
|
left: maxBytes - filled.length,
|
|
846
861
|
max: maxBytes,
|
|
847
862
|
});
|
|
848
863
|
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
864
|
+
// Stories' sheet first, then the route sheets, as before page sources.
|
|
865
|
+
const storiesCss = cssFile(kit, "stories");
|
|
866
|
+
const css = [...used]
|
|
867
|
+
.sort((a, b) =>
|
|
868
|
+
a === storiesCss ? -1 : b === storiesCss ? 1 : a < b ? -1 : a > b ? 1 : 0,
|
|
869
|
+
)
|
|
870
|
+
.map((name) => {
|
|
853
871
|
const file = join(kit.dir, name);
|
|
854
872
|
if (!existsSync(file)) {
|
|
855
873
|
throw new KitError(`the kit has no ${name} for ${frame}`);
|
package/src/pages.mjs
CHANGED
|
@@ -9,8 +9,16 @@
|
|
|
9
9
|
* (id `route:<name>@<width>`; no ":" in paths)
|
|
10
10
|
* routes.json one row per capture
|
|
11
11
|
* css/routes.css every rule the captured pages loaded, deduped
|
|
12
|
+
* (css/routes--<source>.css for a page source)
|
|
12
13
|
* gaps.json shared; this half owns rows with `route`
|
|
13
14
|
* or an id starting `route:`
|
|
15
|
+
*
|
|
16
|
+
* Page sources: a config whose `pages.source` names one (such as `web` or
|
|
17
|
+
* `terminal`) rewrites only its own rows, files, stylesheet and gap rows, so
|
|
18
|
+
* several apps capture into one kit. Its rows carry `source` and `css`. Rows
|
|
19
|
+
* with no `source` (a capture without one, or a kit from before sources)
|
|
20
|
+
* belong to a sourced capture only when it declares their page name. A
|
|
21
|
+
* capture without a source owns every route row, as before sources existed.
|
|
14
22
|
*/
|
|
15
23
|
|
|
16
24
|
export function kitRouteId(name, width) {
|
|
@@ -228,19 +236,75 @@ export function isRouteGap(row) {
|
|
|
228
236
|
return row.route != null || (row.id ?? "").startsWith("route:");
|
|
229
237
|
}
|
|
230
238
|
|
|
231
|
-
/**
|
|
232
|
-
export function
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
239
|
+
/** The page a route row or route gap row is for. */
|
|
240
|
+
export function routeRowPage(row) {
|
|
241
|
+
if (row.route != null) return String(row.route);
|
|
242
|
+
return String(row.id ?? "")
|
|
243
|
+
.replace(/^route:/, "")
|
|
244
|
+
.replace(/@\d+$/, "");
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** The stylesheet a page source's rows use: `css/routes.css` without one. */
|
|
248
|
+
export function routeCssFile(source) {
|
|
249
|
+
return source ? `css/routes--${source}.css` : "css/routes.css";
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Whether a capture replaces an existing route row (or route gap row): a
|
|
254
|
+
* capture without a source replaces every one; a sourced capture replaces
|
|
255
|
+
* its own source's rows and unsourced rows of a page it declares.
|
|
256
|
+
*/
|
|
257
|
+
export function ownsRouteRow(row, { source = null, pageNames = new Set() }) {
|
|
258
|
+
if (!source) return true;
|
|
259
|
+
if (row.source != null) return row.source === source;
|
|
260
|
+
return pageNames.has(routeRowPage(row));
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Pages this capture declares that the kit already holds from another
|
|
265
|
+
* source, as `{ name, other }` (one per page): page names are unique across
|
|
266
|
+
* sources, since a row id is `route:<name>@<width>` whatever its source.
|
|
267
|
+
*/
|
|
268
|
+
export function sourceCollisions(rows, { source = null, pageNames }) {
|
|
269
|
+
if (!source) return [];
|
|
270
|
+
const found = new Map();
|
|
271
|
+
for (const row of rows) {
|
|
272
|
+
if (!row || typeof row !== "object" || row.source == null) continue;
|
|
273
|
+
if (row.source === source) continue;
|
|
274
|
+
const name = routeRowPage(row);
|
|
275
|
+
if (pageNames.has(name) && !found.has(name))
|
|
276
|
+
found.set(name, String(row.source));
|
|
277
|
+
}
|
|
278
|
+
return [...found].map(([name, other]) => ({ name, other }));
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Replaces this half's rows in an existing gaps.json, keeping the stories'
|
|
283
|
+
* and, for a sourced capture, other sources' route rows (`owns` says which
|
|
284
|
+
* route rows this capture replaces; default every one).
|
|
285
|
+
*/
|
|
286
|
+
export function mergeGaps(existing, mine, owns = () => true) {
|
|
287
|
+
const rows = Array.isArray(existing)
|
|
288
|
+
? existing.filter((row) => row && typeof row === "object")
|
|
237
289
|
: [];
|
|
290
|
+
const kept = rows.filter((row) => !isRouteGap(row));
|
|
291
|
+
const otherRoutes = rows.filter((row) => isRouteGap(row) && !owns(row));
|
|
238
292
|
return [
|
|
239
293
|
...kept,
|
|
240
|
-
...[...mine].sort(
|
|
294
|
+
...[...otherRoutes, ...mine].sort(
|
|
295
|
+
byKey((row) => row.id ?? row.route ?? ""),
|
|
296
|
+
),
|
|
241
297
|
];
|
|
242
298
|
}
|
|
243
299
|
|
|
300
|
+
/** routes.json after a capture: other sources' rows kept, this run's added, by id. */
|
|
301
|
+
export function mergeRoutes(existing, mine, owns = () => true) {
|
|
302
|
+
const kept = Array.isArray(existing)
|
|
303
|
+
? existing.filter((row) => row && typeof row === "object" && !owns(row))
|
|
304
|
+
: [];
|
|
305
|
+
return [...kept, ...mine].sort(byKey((row) => row.id ?? ""));
|
|
306
|
+
}
|
|
307
|
+
|
|
244
308
|
const asObject = (value) =>
|
|
245
309
|
value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
246
310
|
|
|
@@ -249,18 +313,30 @@ const asObject = (value) =>
|
|
|
249
313
|
* `sources.routes` and `counts.routes` are replaced, the other sources are
|
|
250
314
|
* kept, `counts.failed` is the sum of every source's `failed`, and
|
|
251
315
|
* `sha`/`built_at` (and `theme`, when configured) go to the last writer.
|
|
316
|
+
*
|
|
317
|
+
* A sourced capture (`run.source`) records its own numbers under
|
|
318
|
+
* `sources.routes.page_sources[<source>]`, keeps the other page sources'
|
|
319
|
+
* entries, and makes `sources.routes` the whole kit's: `run.total` gives the
|
|
320
|
+
* captured and failed counts, pages and widths across every page source.
|
|
252
321
|
*/
|
|
253
322
|
export function mergeManifest(existing, run) {
|
|
254
323
|
const manifest = asObject(existing);
|
|
255
|
-
const
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
pages: run.pages,
|
|
261
|
-
widths: run.widths,
|
|
262
|
-
},
|
|
324
|
+
const own = {
|
|
325
|
+
captured: run.captured,
|
|
326
|
+
failed: run.failed,
|
|
327
|
+
pages: run.pages,
|
|
328
|
+
widths: run.widths,
|
|
263
329
|
};
|
|
330
|
+
const routes = run.source
|
|
331
|
+
? {
|
|
332
|
+
...(run.total ?? own),
|
|
333
|
+
page_sources: {
|
|
334
|
+
...asObject(asObject(asObject(manifest.sources).routes).page_sources),
|
|
335
|
+
[run.source]: own,
|
|
336
|
+
},
|
|
337
|
+
}
|
|
338
|
+
: own;
|
|
339
|
+
const sources = { ...asObject(manifest.sources), routes };
|
|
264
340
|
const failed = Object.values(sources).reduce(
|
|
265
341
|
(sum, source) => sum + (Number(asObject(source).failed) || 0),
|
|
266
342
|
0,
|
|
@@ -271,7 +347,7 @@ export function mergeManifest(existing, run) {
|
|
|
271
347
|
sha: run.sha,
|
|
272
348
|
built_at: run.builtAt,
|
|
273
349
|
sources,
|
|
274
|
-
counts: { ...asObject(manifest.counts), routes:
|
|
350
|
+
counts: { ...asObject(manifest.counts), routes: routes.captured, failed },
|
|
275
351
|
...(run.theme ? { theme: run.theme } : {}),
|
|
276
352
|
};
|
|
277
353
|
}
|