@codepress/design-kit 0.4.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -275,6 +275,15 @@ export returns (`async ({ browser, baseUrl, config }) => state`).
275
275
  `slots` names the regions a mockup replaces: the first selector that matches
276
276
  a visible, non-empty element gets `data-kit-slot="<name>"`, and `main` is
277
277
  required. A page without a `main` slot is a gap, not a frame.
278
+ - `source`: names this config's pages when more than one config captures
279
+ pages into one kit, such as a web app and a desktop app's windows. A capture
280
+ with a `source` rewrites only its own pages' frames and gap rows and leaves
281
+ every other source's in place; its stylesheet is `css/routes--<source>.css`.
282
+ Without one (the default), a capture replaces every page in the kit. Page
283
+ names must be unique across sources: a name another source already captured
284
+ into the kit is a config error that names both sources (exit 2), checked
285
+ before the capture starts and again before it writes. To move a page from
286
+ one source to another, rebuild the kit without the old source's rows.
278
287
  - `widths`: the sizes every page is captured at (default 1280×800 and 390×844).
279
288
  - `shellReady`: a selector the app's frame shows before any page content.
280
289
  - `layout`: `{selector, attribute, default?}` reads the layout the app chose,
@@ -288,8 +297,10 @@ value>]` then replaces `slots`.
288
297
  kit's `fonts/` folder (`copyFonts: false` keeps them out); `fontsPath` says
289
298
  which URL path holds them when not every font file is one.
290
299
  - `hooks`: a module exporting `context(context, info)` (may return a cleanup)
291
- and `page(page, info)` (may return `{readyFailed, beforeSerialize}`), for
292
- routing mock data or adding checks.
300
+ and `page(page, info)` (runs before the page opens; may return
301
+ `{beforeReady, readyFailed, beforeSerialize}`), for routing mock data,
302
+ clicking through to a view (`beforeReady` runs once `shellReady` shows,
303
+ before `ready` is awaited) or adding checks.
293
304
  - `timeoutMs`, `startTimeoutMs`, `fixedTime`.
294
305
 
295
306
  ## Commands
@@ -384,17 +395,21 @@ design-kit/
384
395
  html/story--<id>@<w>.html, html/route--<name>@<w>.html
385
396
  css/stories.css every rule the Storybook preview loaded, deduplicated
386
397
  css/routes.css every rule the captured pages loaded, deduplicated
398
+ css/routes--<source>.css the same, for the pages of one page source
387
399
  fonts/ the fonts both use
388
- gaps.json [{id|component|route, reason, detail?}]
400
+ gaps.json [{id|component|route, reason, detail?, source?}]
389
401
  kit.mjs the tool, copied into every kit so tool and data match
390
402
  ```
391
403
 
392
404
  A row is `{id, kind: "story"|"route", title, component?, file, width, height,
393
- bytes, slots?, source_file?, body_class?, html_attrs?, body_attrs?}`. Ids are
405
+ bytes, slots?, source_file?, source?, css?, body_class?, html_attrs?,
406
+ body_attrs?}`; a page row from a page source carries `source` and its
407
+ stylesheet in `css`. Ids are
394
408
  `story:<storybook-id>@<width>` and `route:<name>@<width>`; file names use `--`
395
409
  instead of `:`, so the kit uploads as a CI artifact as it is. Always find a
396
410
  row's markup through its `file`. `manifest.css` may name another stylesheet
397
- per source (`{stories, routes}`).
411
+ per source (`{stories, routes}`). With page sources, `manifest.sources.routes`
412
+ keeps the kit's totals and adds `page_sources.<source>` with each one's own.
398
413
 
399
414
  ## `kit.mjs`
400
415
 
@@ -440,7 +455,11 @@ await capturePages(config, {
440
455
 
441
456
  `capturePages` also takes `forbidStrings` and `stripOrigins` (more origins to
442
457
  remove from markup, such as a mock API's) and a `session` object whose
443
- `interrupted` flag stops it between pages.
458
+ `interrupted` flag stops it between pages. A harness that starts its own
459
+ servers or builds before calling `capturePages` can call
460
+ `assertPageSources(config, out)` first: it throws the same `ConfigError`
461
+ (`exitCode` 2) for a page another source already captured into `out`, before
462
+ anything starts.
444
463
 
445
464
  ## Contributing and license
446
465
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@codepress/design-kit",
3
- "version": "0.4.2",
3
+ "version": "0.5.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"));
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
  }