@codepress/design-kit 0.4.2 → 0.6.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 +45 -10
- 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 +257 -46
- 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
|
@@ -252,7 +252,12 @@ temporary folder that is removed afterwards.
|
|
|
252
252
|
- `viewport`: each story's first capture, unless the story sets
|
|
253
253
|
`parameters.viewport.defaultViewport` (default 1280×800).
|
|
254
254
|
- `widths`: more captures for stories whose title matches the `titles`
|
|
255
|
-
regular expression.
|
|
255
|
+
regular expression. A story that only works at some widths (a phone-only
|
|
256
|
+
drawer whose `play` opens what the desktop layout never shows) lists its
|
|
257
|
+
own with `parameters: { designKit: { widths: [390] } }` (numbers or
|
|
258
|
+
`{width, height}`): it is captured at exactly those, and a width it leaves
|
|
259
|
+
out is neither a row nor a gap. A failure at a width it lists is still a
|
|
260
|
+
gap, and an empty or malformed list is ignored.
|
|
256
261
|
- `fonts`: `{dir, faces: [{family, file, weight?, style?, variable?,
|
|
257
262
|
fallback?}]}` gives every story these faces (`file` is a name glob in `dir`;
|
|
258
263
|
`variable` sets a CSS variable on `:root` for apps that read their font from
|
|
@@ -261,6 +266,11 @@ fallback?}]}` gives every story these faces (`file` is a name glob in `dir`;
|
|
|
261
266
|
- `fixedTime`: the instant `Date` returns (default `2026-01-01T12:00:00.000Z`).
|
|
262
267
|
- `gaps`: lists the component files no story covers in `gaps.json`.
|
|
263
268
|
`componentGlob` is relative to `root`; stories, tests and specs never count.
|
|
269
|
+
A file with nothing to show (a context provider, a controller, a hook
|
|
270
|
+
wrapper) says so with a comment, `// design-kit: no-story <reason>` (a block
|
|
271
|
+
or JSX comment works too): its row has reason `exempt` and the reason as
|
|
272
|
+
`detail`, so a report can list it apart. A marker with no reason exempts
|
|
273
|
+
nothing; the row stays `no story`.
|
|
264
274
|
|
|
265
275
|
### `pages`
|
|
266
276
|
|
|
@@ -275,6 +285,15 @@ export returns (`async ({ browser, baseUrl, config }) => state`).
|
|
|
275
285
|
`slots` names the regions a mockup replaces: the first selector that matches
|
|
276
286
|
a visible, non-empty element gets `data-kit-slot="<name>"`, and `main` is
|
|
277
287
|
required. A page without a `main` slot is a gap, not a frame.
|
|
288
|
+
- `source`: names this config's pages when more than one config captures
|
|
289
|
+
pages into one kit, such as a web app and a desktop app's windows. A capture
|
|
290
|
+
with a `source` rewrites only its own pages' frames and gap rows and leaves
|
|
291
|
+
every other source's in place; its stylesheet is `css/routes--<source>.css`.
|
|
292
|
+
Without one (the default), a capture replaces every page in the kit. Page
|
|
293
|
+
names must be unique across sources: a name another source already captured
|
|
294
|
+
into the kit is a config error that names both sources (exit 2), checked
|
|
295
|
+
before the capture starts and again before it writes. To move a page from
|
|
296
|
+
one source to another, rebuild the kit without the old source's rows.
|
|
278
297
|
- `widths`: the sizes every page is captured at (default 1280×800 and 390×844).
|
|
279
298
|
- `shellReady`: a selector the app's frame shows before any page content.
|
|
280
299
|
- `layout`: `{selector, attribute, default?}` reads the layout the app chose,
|
|
@@ -288,8 +307,10 @@ value>]` then replaces `slots`.
|
|
|
288
307
|
kit's `fonts/` folder (`copyFonts: false` keeps them out); `fontsPath` says
|
|
289
308
|
which URL path holds them when not every font file is one.
|
|
290
309
|
- `hooks`: a module exporting `context(context, info)` (may return a cleanup)
|
|
291
|
-
and `page(page, info)` (
|
|
292
|
-
routing mock data
|
|
310
|
+
and `page(page, info)` (runs before the page opens; may return
|
|
311
|
+
`{beforeReady, readyFailed, beforeSerialize}`), for routing mock data,
|
|
312
|
+
clicking through to a view (`beforeReady` runs once `shellReady` shows,
|
|
313
|
+
before `ready` is awaited) or adding checks.
|
|
293
314
|
- `timeoutMs`, `startTimeoutMs`, `fixedTime`.
|
|
294
315
|
|
|
295
316
|
## Commands
|
|
@@ -351,12 +372,15 @@ both with the same `--out`.
|
|
|
351
372
|
Exit codes: 0 done, 1 a capture could not run (no Storybook build, the app
|
|
352
373
|
did not start, a forbidden string) or `init` found the repository already set
|
|
353
374
|
up, 2 a config or usage error. A story or page that fails is a
|
|
354
|
-
`gaps.json` row and does not fail the run.
|
|
375
|
+
`gaps.json` row and does not fail the run. That includes a story that holds
|
|
376
|
+
the page's main thread: every wait on its page has a timer outside the page,
|
|
377
|
+
so it becomes a `timeout` gap instead of stalling the run.
|
|
355
378
|
|
|
356
379
|
Operational knobs: `DESIGN_KIT_CONCURRENCY` (browser pages at once, default
|
|
357
380
|
4), `DESIGN_KIT_STORY_TIMEOUT_MS` (render and, separately, settle budget per
|
|
358
381
|
story, default 15000), `DESIGN_KIT_QUIET_MS` (how long the DOM must stay
|
|
359
|
-
unchanged, default 600), `
|
|
382
|
+
unchanged, default 600), `DESIGN_KIT_PARAMS_READ_MS` (how long a failed capture
|
|
383
|
+
waits to read the story's `designKit.widths`, default 1000), `DESIGN_KIT_TIMINGS_FILE` (per-phase capture times),
|
|
360
384
|
`DESIGN_KIT_STOP_TIMEOUT_MS` (how long stopping the app may take).
|
|
361
385
|
|
|
362
386
|
## Capture rules
|
|
@@ -367,7 +391,10 @@ unchanged, default 600), `DESIGN_KIT_TIMINGS_FILE` (per-phase capture times),
|
|
|
367
391
|
- A story is captured after Storybook's `storyRendered` event (its `play`
|
|
368
392
|
function has finished), fonts, frame and image loads, no request in flight,
|
|
369
393
|
and six frames plus `DESIGN_KIT_QUIET_MS` with no DOM change. A story that
|
|
370
|
-
throws, never renders or never stops changing is a gap.
|
|
394
|
+
throws, never renders or never stops changing is a gap. A story driven by a
|
|
395
|
+
live timer or a looping script can tell it is being captured from
|
|
396
|
+
`window.__DESIGN_KIT_CAPTURE__ === true`, set before any page script runs,
|
|
397
|
+
and render one still state of it instead.
|
|
371
398
|
- Attributes are serialized in sorted order, and React `useId` values are
|
|
372
399
|
renumbered in pages, so the markup depends on the final state, not on timing.
|
|
373
400
|
- Story captures only reach the local Storybook server. Same-origin images
|
|
@@ -384,17 +411,21 @@ design-kit/
|
|
|
384
411
|
html/story--<id>@<w>.html, html/route--<name>@<w>.html
|
|
385
412
|
css/stories.css every rule the Storybook preview loaded, deduplicated
|
|
386
413
|
css/routes.css every rule the captured pages loaded, deduplicated
|
|
414
|
+
css/routes--<source>.css the same, for the pages of one page source
|
|
387
415
|
fonts/ the fonts both use
|
|
388
|
-
gaps.json [{id|component|route, reason, detail?}]
|
|
416
|
+
gaps.json [{id|component|route, reason, detail?, source?}]
|
|
389
417
|
kit.mjs the tool, copied into every kit so tool and data match
|
|
390
418
|
```
|
|
391
419
|
|
|
392
420
|
A row is `{id, kind: "story"|"route", title, component?, file, width, height,
|
|
393
|
-
bytes, slots?, source_file?, body_class?, html_attrs?,
|
|
421
|
+
bytes, slots?, source_file?, source?, css?, body_class?, html_attrs?,
|
|
422
|
+
body_attrs?}`; a page row from a page source carries `source` and its
|
|
423
|
+
stylesheet in `css`. Ids are
|
|
394
424
|
`story:<storybook-id>@<width>` and `route:<name>@<width>`; file names use `--`
|
|
395
425
|
instead of `:`, so the kit uploads as a CI artifact as it is. Always find a
|
|
396
426
|
row's markup through its `file`. `manifest.css` may name another stylesheet
|
|
397
|
-
per source (`{stories, routes}`).
|
|
427
|
+
per source (`{stories, routes}`). With page sources, `manifest.sources.routes`
|
|
428
|
+
keeps the kit's totals and adds `page_sources.<source>` with each one's own.
|
|
398
429
|
|
|
399
430
|
## `kit.mjs`
|
|
400
431
|
|
|
@@ -440,7 +471,11 @@ await capturePages(config, {
|
|
|
440
471
|
|
|
441
472
|
`capturePages` also takes `forbidStrings` and `stripOrigins` (more origins to
|
|
442
473
|
remove from markup, such as a mock API's) and a `session` object whose
|
|
443
|
-
`interrupted` flag stops it between pages.
|
|
474
|
+
`interrupted` flag stops it between pages. A harness that starts its own
|
|
475
|
+
servers or builds before calling `capturePages` can call
|
|
476
|
+
`assertPageSources(config, out)` first: it throws the same `ConfigError`
|
|
477
|
+
(`exitCode` 2) for a page another source already captured into `out`, before
|
|
478
|
+
anything starts.
|
|
444
479
|
|
|
445
480
|
## Contributing and license
|
|
446
481
|
|
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
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* DESIGN_KIT_CONCURRENCY caps the browser pages working at once (default 4);
|
|
7
7
|
* DESIGN_KIT_STORY_TIMEOUT_MS bounds each capture (default 15000);
|
|
8
8
|
* DESIGN_KIT_QUIET_MS is how long the DOM must stay unchanged (default 600);
|
|
9
|
+
* DESIGN_KIT_PARAMS_READ_MS bounds reading a failed story's widths (default 1000);
|
|
9
10
|
* DESIGN_KIT_TIMINGS_FILE, when set, receives the per-phase time breakdown.
|
|
10
11
|
* Needs Playwright's Chromium (`npx playwright install chromium`).
|
|
11
12
|
*/
|
|
@@ -45,6 +46,38 @@ const QUIET_FRAMES = 6;
|
|
|
45
46
|
*/
|
|
46
47
|
const quietMs =
|
|
47
48
|
Number.parseInt(process.env.DESIGN_KIT_QUIET_MS ?? "600", 10) || 0;
|
|
49
|
+
/**
|
|
50
|
+
* DESIGN_KIT_PARAMS_READ_MS: how long a failed capture waits to read the
|
|
51
|
+
* story's `designKit.widths` from the preview (default 1000).
|
|
52
|
+
*/
|
|
53
|
+
const paramsReadMs =
|
|
54
|
+
Number.parseInt(process.env.DESIGN_KIT_PARAMS_READ_MS ?? "1000", 10) || 1000;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The console prefix a story's page announces its own widths with at
|
|
58
|
+
* storyPrepared. A console message reaches node even when the story holds
|
|
59
|
+
* the main thread right after, which a read of the page never would.
|
|
60
|
+
*/
|
|
61
|
+
const WIDTHS_ANNOUNCEMENT = "design-kit:widths ";
|
|
62
|
+
|
|
63
|
+
/** A page that stopped answering: its main thread is held by the story. */
|
|
64
|
+
class Unresponsive extends Error {}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Settles with `promise`, or rejects with Unresponsive after `ms`.
|
|
68
|
+
* Playwright's evaluate has no timeout, and one set in the page cannot fire
|
|
69
|
+
* while a story holds the page's main thread, so every wait on a story's
|
|
70
|
+
* page goes through this node-side timer. The timer is cleared either way.
|
|
71
|
+
*/
|
|
72
|
+
function bounded(promise, ms, detail) {
|
|
73
|
+
let timer;
|
|
74
|
+
return Promise.race([
|
|
75
|
+
promise,
|
|
76
|
+
new Promise((_, fail) => {
|
|
77
|
+
timer = setTimeout(() => fail(new Unresponsive(detail)), ms);
|
|
78
|
+
}),
|
|
79
|
+
]).finally(() => clearTimeout(timer));
|
|
80
|
+
}
|
|
48
81
|
|
|
49
82
|
/** Storybook's built-in viewport names (minimal set plus the iPhones in use). */
|
|
50
83
|
const NAMED_VIEWPORTS = {
|
|
@@ -151,15 +184,50 @@ export function mergeManifest(existing, { sha, builtAt, stories, theme }) {
|
|
|
151
184
|
};
|
|
152
185
|
}
|
|
153
186
|
|
|
187
|
+
/**
|
|
188
|
+
* A story's own widths, from `parameters.designKit.widths`: numbers or
|
|
189
|
+
* `{width, height}` entries. Anything else, or an empty list, is no list,
|
|
190
|
+
* so a malformed parameter never drops a story.
|
|
191
|
+
*/
|
|
192
|
+
export function ownWidths(raw) {
|
|
193
|
+
if (!Array.isArray(raw)) return null;
|
|
194
|
+
const list = [];
|
|
195
|
+
for (const entry of raw) {
|
|
196
|
+
const width = typeof entry === "number" ? entry : entry?.width;
|
|
197
|
+
const height = typeof entry === "number" ? undefined : entry?.height;
|
|
198
|
+
if (!Number.isInteger(width) || width <= 0) continue;
|
|
199
|
+
if (list.some((planned) => planned.width === width)) continue;
|
|
200
|
+
list.push(
|
|
201
|
+
Number.isInteger(height) && height > 0 ? { width, height } : { width },
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
return list.length ? list : null;
|
|
205
|
+
}
|
|
206
|
+
|
|
154
207
|
/**
|
|
155
208
|
* Which captures a story gets: its declared viewport or the default, plus
|
|
156
209
|
* each configured width whose `titles` pattern matches the story's title.
|
|
210
|
+
* A story that lists its own widths (`parameters.designKit.widths`, see
|
|
211
|
+
* ownWidths) gets exactly those, each with its listed height, else the
|
|
212
|
+
* height the declared viewport, default or a configured width has for it,
|
|
213
|
+
* else the default height.
|
|
157
214
|
*/
|
|
158
215
|
export function plannedViewports(
|
|
159
216
|
title,
|
|
160
217
|
declared,
|
|
161
218
|
{ viewport = DEFAULT_VIEWPORT, widths = [] } = {},
|
|
219
|
+
own = null,
|
|
162
220
|
) {
|
|
221
|
+
if (own?.length) {
|
|
222
|
+
const known = [declared, viewport, ...widths].filter(Boolean);
|
|
223
|
+
return own.map(({ width, height }) => ({
|
|
224
|
+
width,
|
|
225
|
+
height:
|
|
226
|
+
height ??
|
|
227
|
+
known.find((size) => size.width === width && size.height)?.height ??
|
|
228
|
+
viewport.height,
|
|
229
|
+
}));
|
|
230
|
+
}
|
|
163
231
|
const first = declared ?? viewport;
|
|
164
232
|
const list = [first];
|
|
165
233
|
for (const extra of widths) {
|
|
@@ -276,7 +344,7 @@ export function fontCss(base, fonts, files = fontFiles(fonts)) {
|
|
|
276
344
|
}
|
|
277
345
|
|
|
278
346
|
/** Runs before any page script: a seeded Math.random, the font CSS, and a channel tap. */
|
|
279
|
-
function pageInit({ css, families }) {
|
|
347
|
+
function pageInit({ css, families, width, announcement }) {
|
|
280
348
|
let seed = 0x2f6b9a1d;
|
|
281
349
|
Math.random = () => {
|
|
282
350
|
seed = (seed + 0x6d2b79f5) | 0;
|
|
@@ -297,6 +365,9 @@ function pageInit({ css, families }) {
|
|
|
297
365
|
}
|
|
298
366
|
const kit = { outcome: null, detail: "", pending: 0 };
|
|
299
367
|
window.__kit = kit;
|
|
368
|
+
// Public: a story with live data or a running animation can render a
|
|
369
|
+
// still state of it when this is set (README "Capture rules").
|
|
370
|
+
window.__DESIGN_KIT_CAPTURE__ = true;
|
|
300
371
|
// In-flight requests, so settling can wait for them rather than a timer.
|
|
301
372
|
const track = (promise) => {
|
|
302
373
|
kit.pending += 1;
|
|
@@ -345,6 +416,25 @@ function pageInit({ css, families }) {
|
|
|
345
416
|
finish("threw", `play: ${describe(e)}`),
|
|
346
417
|
);
|
|
347
418
|
value.on("storyMissing", () => finish("missing"));
|
|
419
|
+
// A story that lists its own widths and leaves this one out is not
|
|
420
|
+
// captured here: no render, no play, no gap (ownWidths).
|
|
421
|
+
value.on("storyPrepared", (event) => {
|
|
422
|
+
const own = event?.parameters?.designKit?.widths;
|
|
423
|
+
if (!Array.isArray(own)) return;
|
|
424
|
+
kit.widths = own;
|
|
425
|
+
try {
|
|
426
|
+
console.debug(`${announcement}${JSON.stringify(own)}`);
|
|
427
|
+
} catch {
|
|
428
|
+
// A list that cannot be serialized is read from __kit instead.
|
|
429
|
+
}
|
|
430
|
+
// The same rule as ownWidths: positive integer widths only, and no
|
|
431
|
+
// usable width means no list, so a malformed one never skips.
|
|
432
|
+
const listed = own
|
|
433
|
+
.map((entry) => (typeof entry === "number" ? entry : entry?.width))
|
|
434
|
+
.filter((value) => Number.isInteger(value) && value > 0);
|
|
435
|
+
if (listed.length && !listed.includes(width))
|
|
436
|
+
finish("skipped", `not listed in designKit.widths`);
|
|
437
|
+
});
|
|
348
438
|
},
|
|
349
439
|
});
|
|
350
440
|
window.__kitLoaded = new WeakSet();
|
|
@@ -442,6 +532,7 @@ async function extract() {
|
|
|
442
532
|
".sb-preparing-story,.sb-preparing-docs,.sb-nopreview,.sb-errordisplay,#storybook-docs,script,noscript,template,link,[data-kit-inject]";
|
|
443
533
|
const story = window.__STORYBOOK_PREVIEW__?.currentRender?.story;
|
|
444
534
|
const parameters = story?.parameters ?? {};
|
|
535
|
+
const widths = parameters.designKit?.widths ?? null;
|
|
445
536
|
const viewport = parameters.viewport ?? {};
|
|
446
537
|
let declared = null;
|
|
447
538
|
const name = viewport.defaultViewport;
|
|
@@ -577,6 +668,7 @@ async function extract() {
|
|
|
577
668
|
html,
|
|
578
669
|
sheets,
|
|
579
670
|
declared,
|
|
671
|
+
widths,
|
|
580
672
|
bodyClass,
|
|
581
673
|
empty: !rootHasContent && !portalHasContent,
|
|
582
674
|
};
|
|
@@ -638,7 +730,12 @@ async function freshPage({
|
|
|
638
730
|
const page = await context.newPage();
|
|
639
731
|
page.on("pageerror", () => {});
|
|
640
732
|
await page.clock.setFixedTime(new Date(fixedTime));
|
|
641
|
-
await page.addInitScript(pageInit, {
|
|
733
|
+
await page.addInitScript(pageInit, {
|
|
734
|
+
css,
|
|
735
|
+
families,
|
|
736
|
+
width: viewport.width,
|
|
737
|
+
announcement: WIDTHS_ANNOUNCEMENT,
|
|
738
|
+
});
|
|
642
739
|
return { context, page, cdp: await context.newCDPSession(page) };
|
|
643
740
|
}
|
|
644
741
|
|
|
@@ -659,6 +756,20 @@ async function captureTimed(
|
|
|
659
756
|
}));
|
|
660
757
|
const { context, page, cdp } = slot;
|
|
661
758
|
let reusable = false;
|
|
759
|
+
// The widths this story announced, heard even if its page then stops
|
|
760
|
+
// answering (WIDTHS_ANNOUNCEMENT).
|
|
761
|
+
let announced;
|
|
762
|
+
const hear = (message) => {
|
|
763
|
+
if (message.type() !== "debug") return;
|
|
764
|
+
const text = message.text();
|
|
765
|
+
if (!text.startsWith(WIDTHS_ANNOUNCEMENT)) return;
|
|
766
|
+
try {
|
|
767
|
+
announced = JSON.parse(text.slice(WIDTHS_ANNOUNCEMENT.length));
|
|
768
|
+
} catch {
|
|
769
|
+
// Not ours after all; the bounded read below still runs.
|
|
770
|
+
}
|
|
771
|
+
};
|
|
772
|
+
page.on("console", hear);
|
|
662
773
|
try {
|
|
663
774
|
await cdp.send("Storage.clearDataForOrigin", {
|
|
664
775
|
origin,
|
|
@@ -673,18 +784,50 @@ async function captureTimed(
|
|
|
673
784
|
`${origin}/iframe.html?id=${encodeURIComponent(story.id)}&viewMode=story&globals=a11y.manual:!true`,
|
|
674
785
|
{ waitUntil: "domcontentloaded", timeout },
|
|
675
786
|
);
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
}
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
787
|
+
// The node-side timer bounds the wait too: Playwright's own timeout never
|
|
788
|
+
// fires while a story holds the page's main thread.
|
|
789
|
+
const notRendered = {
|
|
790
|
+
outcome: "timeout",
|
|
791
|
+
detail: `no storyRendered within ${timeout}ms`,
|
|
792
|
+
};
|
|
793
|
+
const outcome = await bounded(
|
|
794
|
+
page
|
|
795
|
+
.waitForFunction(() => window.__kit?.outcome, null, {
|
|
796
|
+
timeout: Math.max(1000, deadline - Date.now()),
|
|
797
|
+
polling: 50,
|
|
798
|
+
})
|
|
799
|
+
.then(() => page.evaluate(() => ({ ...window.__kit }))),
|
|
800
|
+
Math.max(1000, deadline - Date.now()) + 1000,
|
|
801
|
+
).catch(() => notRendered);
|
|
686
802
|
if (outcome.outcome !== "rendered") {
|
|
803
|
+
// The story's own widths: from storyPrepared, or read from the preview
|
|
804
|
+
// when this Storybook sends no parameters with it.
|
|
805
|
+
// Bounded: a story that holds the main thread (the usual reason for a
|
|
806
|
+
// timeout) would never answer, and the lane would never finish.
|
|
807
|
+
const own = ownWidths(
|
|
808
|
+
outcome.widths ??
|
|
809
|
+
announced ??
|
|
810
|
+
(await bounded(
|
|
811
|
+
page.evaluate(
|
|
812
|
+
() =>
|
|
813
|
+
window.__STORYBOOK_PREVIEW__?.currentRender?.story?.parameters
|
|
814
|
+
?.designKit?.widths ?? null,
|
|
815
|
+
),
|
|
816
|
+
paramsReadMs,
|
|
817
|
+
).catch(() => null)),
|
|
818
|
+
);
|
|
819
|
+
// Left out of the story's own widths: not a capture and not a gap.
|
|
820
|
+
// Any other failure stays one.
|
|
821
|
+
if (
|
|
822
|
+
outcome.outcome === "skipped" ||
|
|
823
|
+
(own && !own.some((entry) => entry.width === viewport.width))
|
|
824
|
+
) {
|
|
825
|
+
// A page that timed out may still be held: never park it.
|
|
826
|
+
reusable = outcome.outcome !== "timeout";
|
|
827
|
+
return { skipped: true, widths: own };
|
|
828
|
+
}
|
|
687
829
|
return {
|
|
830
|
+
widths: own,
|
|
688
831
|
failure: {
|
|
689
832
|
reason: outcome.outcome === "missing" ? "missing" : outcome.outcome,
|
|
690
833
|
detail: outcome.detail,
|
|
@@ -701,7 +844,12 @@ async function captureTimed(
|
|
|
701
844
|
// guests one after another. Settle the guests, then the host, until a
|
|
702
845
|
// round brings no new frame and no host change.
|
|
703
846
|
const settleBy = Date.now() + timeout;
|
|
704
|
-
|
|
847
|
+
// `settle` gives up in the page after `timeout`; a second more covers the
|
|
848
|
+
// round trip, so only a page that stopped answering reaches the bound.
|
|
849
|
+
const unanswered = `page stopped answering after storyRendered (${timeout}ms)`;
|
|
850
|
+
const settleOn = (target) =>
|
|
851
|
+
bounded(target.evaluate(settle, quiet), timeout + 1000, unanswered);
|
|
852
|
+
let settled = (await settleOn(page)).settled;
|
|
705
853
|
let known = new Set();
|
|
706
854
|
while (settled) {
|
|
707
855
|
const guests = page
|
|
@@ -711,12 +859,16 @@ async function captureTimed(
|
|
|
711
859
|
if (!fresh && known.size === 0) break;
|
|
712
860
|
known = new Set(guests);
|
|
713
861
|
const results = await Promise.all(
|
|
714
|
-
// A frame that navigated away or detached has nothing left to wait
|
|
862
|
+
// A frame that navigated away or detached has nothing left to wait
|
|
863
|
+
// for; one that stopped answering is a timeout like its host.
|
|
715
864
|
guests.map((frame) =>
|
|
716
|
-
frame
|
|
865
|
+
settleOn(frame).catch((error) => {
|
|
866
|
+
if (error instanceof Unresponsive) throw error;
|
|
867
|
+
return { settled: true };
|
|
868
|
+
}),
|
|
717
869
|
),
|
|
718
870
|
);
|
|
719
|
-
const host = await page
|
|
871
|
+
const host = await settleOn(page);
|
|
720
872
|
settled =
|
|
721
873
|
results.every((result) => result.settled) &&
|
|
722
874
|
host.settled &&
|
|
@@ -732,7 +884,11 @@ async function captureTimed(
|
|
|
732
884
|
};
|
|
733
885
|
}
|
|
734
886
|
step("serialize");
|
|
735
|
-
const result = await
|
|
887
|
+
const result = await bounded(
|
|
888
|
+
page.evaluate(extract),
|
|
889
|
+
timeout,
|
|
890
|
+
`page stopped answering while serializing (${timeout}ms)`,
|
|
891
|
+
);
|
|
736
892
|
// An inline sheet is keyed by its text, which resolved its url()s against
|
|
737
893
|
// this run's server; the port must not reach the kit.
|
|
738
894
|
for (const sheet of result.sheets)
|
|
@@ -746,38 +902,48 @@ async function captureTimed(
|
|
|
746
902
|
};
|
|
747
903
|
}
|
|
748
904
|
reusable = true;
|
|
749
|
-
return { result };
|
|
905
|
+
return { result, widths: ownWidths(result.widths ?? outcome.widths) };
|
|
750
906
|
} catch (error) {
|
|
751
907
|
return {
|
|
752
908
|
failure: {
|
|
753
|
-
reason:
|
|
909
|
+
reason:
|
|
910
|
+
error instanceof Unresponsive || /Timeout/i.test(String(error))
|
|
911
|
+
? "timeout"
|
|
912
|
+
: "threw",
|
|
754
913
|
detail: String(error?.message ?? error)
|
|
755
914
|
.split("\n")[0]
|
|
756
915
|
.slice(0, 500),
|
|
757
916
|
},
|
|
758
917
|
};
|
|
759
918
|
} finally {
|
|
919
|
+
page.off("console", hear);
|
|
760
920
|
step("close");
|
|
761
921
|
const parked =
|
|
762
922
|
reusable &&
|
|
763
923
|
parkedCount() < parkLimit &&
|
|
764
|
-
(await
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
924
|
+
(await bounded(
|
|
925
|
+
page
|
|
926
|
+
// Session storage lives with the tab, not the origin's data, so it
|
|
927
|
+
// is cleared on the story's own page before leaving it.
|
|
928
|
+
.evaluate(() => sessionStorage.clear())
|
|
929
|
+
.then(() => page.goto("about:blank", { timeout }))
|
|
930
|
+
.then(() =>
|
|
931
|
+
page.evaluate(() => {
|
|
932
|
+
window.name = "";
|
|
933
|
+
}),
|
|
934
|
+
),
|
|
935
|
+
timeout,
|
|
936
|
+
)
|
|
774
937
|
.then(() => true)
|
|
775
938
|
.catch(() => false));
|
|
776
939
|
if (parked) {
|
|
777
940
|
const list = idlePages.get(key) ?? [];
|
|
778
941
|
list.push(slot);
|
|
779
942
|
idlePages.set(key, list);
|
|
780
|
-
} else
|
|
943
|
+
} else {
|
|
944
|
+
// A context whose page is held is left to browser.close() at the end.
|
|
945
|
+
await bounded(context.close(), timeout).catch(() => {});
|
|
946
|
+
}
|
|
781
947
|
}
|
|
782
948
|
}
|
|
783
949
|
|
|
@@ -829,11 +995,32 @@ function storybookStories(buildDir) {
|
|
|
829
995
|
.sort((a, b) => (a.id < b.id ? -1 : 1));
|
|
830
996
|
}
|
|
831
997
|
|
|
998
|
+
/**
|
|
999
|
+
* The reason a `design-kit: no-story <reason>` comment gives (a `//`, block or
|
|
1000
|
+
* JSX comment anywhere in the file), or null when there is none or its reason
|
|
1001
|
+
* is empty: a bare marker exempts nothing.
|
|
1002
|
+
*/
|
|
1003
|
+
export function noStoryReason(source) {
|
|
1004
|
+
const marker =
|
|
1005
|
+
/(?:\/\/|\/\*|^[ \t]*\*)[ \t]*design-kit:[ \t]*no-story\b([^\n]*)/gm;
|
|
1006
|
+
for (const match of String(source).matchAll(marker)) {
|
|
1007
|
+
const reason = match[1]
|
|
1008
|
+
.replace(/\*\/.*$/, "")
|
|
1009
|
+
.replace(/[}\s]+$/, "")
|
|
1010
|
+
.trim();
|
|
1011
|
+
if (reason) return reason;
|
|
1012
|
+
}
|
|
1013
|
+
return null;
|
|
1014
|
+
}
|
|
1015
|
+
|
|
832
1016
|
/**
|
|
833
1017
|
* Component files nothing documents: files under `gaps.sourceRoot` whose path
|
|
834
1018
|
* from the Storybook project matches `gaps.componentGlob`, minus stories,
|
|
835
1019
|
* tests and specs, that no story names as its component or import. Paths in
|
|
836
|
-
* the rows are relative to the config file's folder.
|
|
1020
|
+
* the rows are relative to the config file's folder. A file carrying a
|
|
1021
|
+
* `design-kit: no-story <reason>` comment (a context, a controller, a hook
|
|
1022
|
+
* wrapper) is an `exempt` row with that reason as its detail, listed apart
|
|
1023
|
+
* so a person can check the reason; with an empty reason it stays `no story`.
|
|
837
1024
|
*/
|
|
838
1025
|
export function componentsWithoutStories(stories, { gaps, root, configDir }) {
|
|
839
1026
|
const documented = new Set(
|
|
@@ -855,10 +1042,13 @@ export function componentsWithoutStories(stories, { gaps, root, configDir }) {
|
|
|
855
1042
|
} else if (!/\.(stories|test|spec)\.[cm]?[jt]sx?$/.test(name.name)) {
|
|
856
1043
|
const rel = relative(root, path).split(sep).join("/");
|
|
857
1044
|
if (matchFile(pattern, rel) && !documented.has(rel)) {
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
1045
|
+
const component = relative(configDir, path).split(sep).join("/");
|
|
1046
|
+
const exempt = noStoryReason(readFileSync(path, "utf8"));
|
|
1047
|
+
out.push(
|
|
1048
|
+
exempt
|
|
1049
|
+
? { component, reason: "exempt", detail: exempt }
|
|
1050
|
+
: { component, reason: "no story" },
|
|
1051
|
+
);
|
|
862
1052
|
}
|
|
863
1053
|
}
|
|
864
1054
|
}
|
|
@@ -1002,7 +1192,11 @@ export async function captureStories(config, options = {}) {
|
|
|
1002
1192
|
const keepSheetsOnce = sheetStore();
|
|
1003
1193
|
// What the timings report needs from a capture, without its result.
|
|
1004
1194
|
const attempt = (capture) => {
|
|
1005
|
-
const record = {
|
|
1195
|
+
const record = {
|
|
1196
|
+
timing: capture.timing,
|
|
1197
|
+
failure: capture.failure,
|
|
1198
|
+
skipped: capture.skipped,
|
|
1199
|
+
};
|
|
1006
1200
|
attempts.push(record);
|
|
1007
1201
|
return record;
|
|
1008
1202
|
};
|
|
@@ -1019,16 +1213,28 @@ export async function captureStories(config, options = {}) {
|
|
|
1019
1213
|
fixedTime,
|
|
1020
1214
|
});
|
|
1021
1215
|
const firstAttempt = attempt(first);
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1216
|
+
// The story's declared viewport and own widths are known once it has
|
|
1217
|
+
// loaded at the default one; that first capture is kept only when the
|
|
1218
|
+
// plan still has the default viewport in it.
|
|
1219
|
+
const declared = first.result ? viewportFor(first.result.declared) : null;
|
|
1220
|
+
const viewports = plannedViewports(
|
|
1221
|
+
story.title,
|
|
1222
|
+
declared,
|
|
1223
|
+
planning,
|
|
1224
|
+
first.widths,
|
|
1225
|
+
);
|
|
1226
|
+
const results = new Map();
|
|
1227
|
+
if (
|
|
1228
|
+
!first.skipped &&
|
|
1229
|
+
!declared &&
|
|
1230
|
+
viewports.some(
|
|
1231
|
+
(viewport) =>
|
|
1232
|
+
viewport.width === planning.viewport.width &&
|
|
1233
|
+
viewport.height === planning.viewport.height,
|
|
1234
|
+
)
|
|
1235
|
+
) {
|
|
1236
|
+
results.set(planning.viewport.width, first);
|
|
1237
|
+
} else if (!first.skipped) firstAttempt.discarded = true;
|
|
1032
1238
|
for (const viewport of viewports) {
|
|
1033
1239
|
if (!results.has(viewport.width)) {
|
|
1034
1240
|
const capture = await captureOne({
|
|
@@ -1046,6 +1252,7 @@ export async function captureStories(config, options = {}) {
|
|
|
1046
1252
|
}
|
|
1047
1253
|
const capture = results.get(viewport.width);
|
|
1048
1254
|
const id = kitId(story.id, viewport.width);
|
|
1255
|
+
if (capture.skipped) continue;
|
|
1049
1256
|
if (capture.failure) {
|
|
1050
1257
|
failures.push({ id, ...capture.failure });
|
|
1051
1258
|
} else {
|
|
@@ -1236,6 +1443,7 @@ export async function captureStories(config, options = {}) {
|
|
|
1236
1443
|
const timings = {
|
|
1237
1444
|
...timingBreakdown(attempts),
|
|
1238
1445
|
discarded: attempts.filter((a) => a.discarded).length,
|
|
1446
|
+
skipped: attempts.filter((a) => a.skipped).length,
|
|
1239
1447
|
lanes: concurrency,
|
|
1240
1448
|
quiet_ms: quietMs,
|
|
1241
1449
|
timeout_ms: timeout,
|
|
@@ -1255,6 +1463,9 @@ export async function captureStories(config, options = {}) {
|
|
|
1255
1463
|
return { out, rows: rows.length, failed: storyGaps.length };
|
|
1256
1464
|
} finally {
|
|
1257
1465
|
await browser?.close().catch(() => {});
|
|
1466
|
+
// Parked pages belong to this browser; a later capture in the same
|
|
1467
|
+
// process must not pick one up.
|
|
1468
|
+
idlePages.clear();
|
|
1258
1469
|
server?.close();
|
|
1259
1470
|
if (ownedBuild) rmSync(ownedBuild, { recursive: true, force: true });
|
|
1260
1471
|
}
|
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
|
}
|