@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 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)` (may return `{readyFailed, beforeSerialize}`), for
292
- routing mock data or adding checks.
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), `DESIGN_KIT_TIMINGS_FILE` (per-phase capture times),
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?, body_attrs?}`. Ids are
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@codepress/design-kit",
3
- "version": "0.4.2",
3
+ "version": "0.6.0",
4
4
  "description": "Capture your Storybook and real app pages as a static design kit, and compose self-contained mockups from it",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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",
@@ -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({ id, route: page.name, reason });
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
- function writePagesKit(
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
- { rows, files, css, gaps, needles, pages, widths, fonts, theme, configDir },
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
- for (const name of fs.readdirSync(path.join(out, "html")))
685
- if (isRouteHtmlFile(name)) fs.rmSync(path.join(out, "html", name));
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 = [...rows].sort(byKey((row) => row.id));
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
- ["css/routes.css", css],
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
- fs.writeFileSync(
714
- gapsPath,
715
- kitJson(mergeGaps(readJson(gapsPath), gaps.filter(isRouteGap))),
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: sortedRows.length,
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"));
@@ -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, { css, families });
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
- const outcome = await page
677
- .waitForFunction(() => window.__kit?.outcome, null, {
678
- timeout: Math.max(1000, deadline - Date.now()),
679
- polling: 50,
680
- })
681
- .then(() => page.evaluate(() => ({ ...window.__kit })))
682
- .catch(() => ({
683
- outcome: "timeout",
684
- detail: `no storyRendered within ${timeout}ms`,
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
- let settled = (await page.evaluate(settle, quiet)).settled;
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 for.
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.evaluate(settle, quiet).catch(() => ({ settled: true })),
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.evaluate(settle, quiet);
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 page.evaluate(extract);
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: /Timeout/i.test(String(error)) ? "timeout" : "threw",
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 page
765
- // Session storage lives with the tab, not the origin's data, so it
766
- // is cleared on the story's own page before leaving it.
767
- .evaluate(() => sessionStorage.clear())
768
- .then(() => page.goto("about:blank"))
769
- .then(() =>
770
- page.evaluate(() => {
771
- window.name = "";
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 await context.close().catch(() => {});
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
- out.push({
859
- component: relative(configDir, path).split(sep).join("/"),
860
- reason: "no story",
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 = { timing: capture.timing, failure: capture.failure };
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
- let viewports = plannedViewports(story.title, null, planning);
1023
- const results = new Map([[planning.viewport.width, first]]);
1024
- if (first.result) {
1025
- const declared = viewportFor(first.result.declared);
1026
- viewports = plannedViewports(story.title, declared, planning);
1027
- if (declared) {
1028
- results.delete(planning.viewport.width);
1029
- firstAttempt.discarded = true;
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
@@ -237,6 +237,7 @@ export function resolveConfig(raw, path) {
237
237
  if (raw.pages) {
238
238
  const p = raw.pages;
239
239
  config.pages = {
240
+ source: p.source ?? null,
240
241
  baseUrl: p.baseUrl,
241
242
  startCommand: p.startCommand,
242
243
  waitUrl: p.waitUrl,
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; both run inside the capture of one page. */
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(rowSource(row));
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([rowSource(row)]);
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
- const css = ["stories", "routes"]
850
- .filter((source) => used.has(source))
851
- .map((source) => {
852
- const name = cssFile(kit, source);
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
- /** Replaces this half's rows in an existing gaps.json, keeping the stories'. */
232
- export function mergeGaps(existing, mine) {
233
- const kept = Array.isArray(existing)
234
- ? existing.filter(
235
- (row) => row && typeof row === "object" && !isRouteGap(row),
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(byKey((row) => row.id ?? row.route ?? "")),
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 sources = {
256
- ...asObject(manifest.sources),
257
- routes: {
258
- captured: run.captured,
259
- failed: run.failed,
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: run.captured, failed },
350
+ counts: { ...asObject(manifest.counts), routes: routes.captured, failed },
275
351
  ...(run.theme ? { theme: run.theme } : {}),
276
352
  };
277
353
  }