@codepress/design-kit 0.5.0 → 0.7.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 +41 -4
- package/package.json +1 -1
- package/src/capture-pages.mjs +18 -1
- package/src/capture-stories.mjs +261 -46
- package/src/kit.d.ts +36 -1
- package/src/kit.mjs +293 -34
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
|
|
|
@@ -362,12 +372,15 @@ both with the same `--out`.
|
|
|
362
372
|
Exit codes: 0 done, 1 a capture could not run (no Storybook build, the app
|
|
363
373
|
did not start, a forbidden string) or `init` found the repository already set
|
|
364
374
|
up, 2 a config or usage error. A story or page that fails is a
|
|
365
|
-
`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.
|
|
366
378
|
|
|
367
379
|
Operational knobs: `DESIGN_KIT_CONCURRENCY` (browser pages at once, default
|
|
368
380
|
4), `DESIGN_KIT_STORY_TIMEOUT_MS` (render and, separately, settle budget per
|
|
369
381
|
story, default 15000), `DESIGN_KIT_QUIET_MS` (how long the DOM must stay
|
|
370
|
-
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),
|
|
371
384
|
`DESIGN_KIT_STOP_TIMEOUT_MS` (how long stopping the app may take).
|
|
372
385
|
|
|
373
386
|
## Capture rules
|
|
@@ -378,7 +391,10 @@ unchanged, default 600), `DESIGN_KIT_TIMINGS_FILE` (per-phase capture times),
|
|
|
378
391
|
- A story is captured after Storybook's `storyRendered` event (its `play`
|
|
379
392
|
function has finished), fonts, frame and image loads, no request in flight,
|
|
380
393
|
and six frames plus `DESIGN_KIT_QUIET_MS` with no DOM change. A story that
|
|
381
|
-
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.
|
|
382
398
|
- Attributes are serialized in sorted order, and React `useId` values are
|
|
383
399
|
renumbered in pages, so the markup depends on the final state, not on timing.
|
|
384
400
|
- Story captures only reach the local Storybook server. Same-origin images
|
|
@@ -392,6 +408,7 @@ design-kit/
|
|
|
392
408
|
manifest.json {kit_version, sha, built_at, counts, sources, theme?, css?}
|
|
393
409
|
index.json story rows, sorted by id
|
|
394
410
|
routes.json page rows
|
|
411
|
+
text.json {id: the row's visible text, first 600 characters}
|
|
395
412
|
html/story--<id>@<w>.html, html/route--<name>@<w>.html
|
|
396
413
|
css/stories.css every rule the Storybook preview loaded, deduplicated
|
|
397
414
|
css/routes.css every rule the captured pages loaded, deduplicated
|
|
@@ -418,11 +435,31 @@ The tool needs only Node: no install and no network.
|
|
|
418
435
|
```bash
|
|
419
436
|
node kit.mjs info # the manifest
|
|
420
437
|
node kit.mjs find button # ≤ 30 lines: id, kind, width, bytes, title
|
|
438
|
+
node kit.mjs find "transfer app" # the words on screen work too
|
|
421
439
|
node kit.mjs show <id> [--full] # path, size and an outline (≤ 2 KB) or the markup
|
|
422
440
|
node kit.mjs compose --frame route:dashboard@1280 --slot main=new.html \
|
|
423
441
|
[--theme light|dark] [--show-new] --out page.html
|
|
424
442
|
```
|
|
425
443
|
|
|
444
|
+
`find` lists the rows holding every word of the query in their id, title,
|
|
445
|
+
component, source file or visible text. Rows with the words in the id come
|
|
446
|
+
first, then the title, component or source file, then the text only; among
|
|
447
|
+
rows that tie, one whose text holds the whole query comes first. A row found
|
|
448
|
+
only through its text ends with a quoted excerpt of about 60 characters
|
|
449
|
+
around the words, so you see why it matched. When more rows match than the
|
|
450
|
+
30 lines hold, rows found by name fill the list but its last 5 lines are kept
|
|
451
|
+
for rows found by text, and the closing line counts what each left out
|
|
452
|
+
(`+37 more by name, +3 more by on-screen text; narrow the query`). The text
|
|
453
|
+
is `text.json`, built
|
|
454
|
+
whenever a capture writes the kit: each row's markup without its scripts,
|
|
455
|
+
styles, SVG, head and templates, tags and attribute values dropped, entities
|
|
456
|
+
decoded and whitespace collapsed, with the row's `data-kit-slot` regions
|
|
457
|
+
first (so a page's own words come ahead of the navigation every page
|
|
458
|
+
repeats), cut to 600 characters. Rows an incremental build copies from its
|
|
459
|
+
base get text too, and a page capture refuses a `text.json` holding a
|
|
460
|
+
`forbidStrings` value as it does the markup. A kit without `text.json` finds
|
|
461
|
+
by id, title, component and source file only.
|
|
462
|
+
|
|
426
463
|
`compose` writes one self-contained page: the frame's markup with slots
|
|
427
464
|
filled and every `<kit-snippet id="…">` expanded, its CSS inlined, every
|
|
428
465
|
relative `url()` that names a file in the kit (fonts included) as a data URI,
|
package/package.json
CHANGED
package/src/capture-pages.mjs
CHANGED
|
@@ -42,6 +42,7 @@ import {
|
|
|
42
42
|
sourceCollisions,
|
|
43
43
|
} from "./pages.mjs";
|
|
44
44
|
import { ConfigError } from "./config.mjs";
|
|
45
|
+
import { kitText, kitTextJson, visibleText } from "./kit.mjs";
|
|
45
46
|
import { loadPlaywright } from "./playwright.mjs";
|
|
46
47
|
|
|
47
48
|
const STOP_TIMEOUT_MS =
|
|
@@ -601,6 +602,8 @@ export async function capturePages(config, options = {}) {
|
|
|
601
602
|
const found = findForbidden(
|
|
602
603
|
[
|
|
603
604
|
html,
|
|
605
|
+
// The words text.json will hold, entities decoded.
|
|
606
|
+
visibleText(html),
|
|
604
607
|
JSON.stringify(capture.htmlAttrs),
|
|
605
608
|
JSON.stringify(capture.bodyAttrs),
|
|
606
609
|
].join("\n"),
|
|
@@ -774,6 +777,20 @@ export function writePagesKit(
|
|
|
774
777
|
)
|
|
775
778
|
.join("\n"),
|
|
776
779
|
);
|
|
780
|
+
const sortedRows = mergeRoutes(existingRows, rows, owns);
|
|
781
|
+
// text.json covers the whole kit, stories included, and is checked for
|
|
782
|
+
// forbidden values before anything is removed or written.
|
|
783
|
+
const text = kitTextJson(
|
|
784
|
+
kitText(
|
|
785
|
+
[...asArray(readJson(path.join(out, "index.json"))), ...sortedRows],
|
|
786
|
+
(file) =>
|
|
787
|
+
files.get(file) ?? fs.readFileSync(path.join(out, file), "utf8"),
|
|
788
|
+
),
|
|
789
|
+
);
|
|
790
|
+
if (findForbidden(text, needles))
|
|
791
|
+
throw new Error(
|
|
792
|
+
"text.json carries a forbidStrings value; refusing to write the kit",
|
|
793
|
+
);
|
|
777
794
|
fs.mkdirSync(path.join(out, "html"), { recursive: true });
|
|
778
795
|
fs.mkdirSync(path.join(out, "css"), { recursive: true });
|
|
779
796
|
// This half's files only: earlier page captures go, story files stay. A
|
|
@@ -807,11 +824,11 @@ export function writePagesKit(
|
|
|
807
824
|
for (const file of stale) fs.rmSync(path.join(out, file), { force: true });
|
|
808
825
|
}
|
|
809
826
|
|
|
810
|
-
const sortedRows = mergeRoutes(existingRows, rows, owns);
|
|
811
827
|
const written = [
|
|
812
828
|
...[...files.entries()].sort(byKey(([file]) => file)),
|
|
813
829
|
["routes.json", kitJson(sortedRows)],
|
|
814
830
|
[routeCssFile(source), css],
|
|
831
|
+
["text.json", text],
|
|
815
832
|
];
|
|
816
833
|
for (const [file, text] of written) {
|
|
817
834
|
if (findForbidden(text, needles))
|
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
|
*/
|
|
@@ -29,6 +30,7 @@ import { extname, join, relative, resolve, sep } from "node:path";
|
|
|
29
30
|
import { fileURLToPath } from "node:url";
|
|
30
31
|
|
|
31
32
|
import { globToRegExp, matchFile } from "./glob.mjs";
|
|
33
|
+
import { writeKitText } from "./kit.mjs";
|
|
32
34
|
import { loadPlaywright, playwrightVersion } from "./playwright.mjs";
|
|
33
35
|
|
|
34
36
|
export { playwrightVersion };
|
|
@@ -45,6 +47,38 @@ const QUIET_FRAMES = 6;
|
|
|
45
47
|
*/
|
|
46
48
|
const quietMs =
|
|
47
49
|
Number.parseInt(process.env.DESIGN_KIT_QUIET_MS ?? "600", 10) || 0;
|
|
50
|
+
/**
|
|
51
|
+
* DESIGN_KIT_PARAMS_READ_MS: how long a failed capture waits to read the
|
|
52
|
+
* story's `designKit.widths` from the preview (default 1000).
|
|
53
|
+
*/
|
|
54
|
+
const paramsReadMs =
|
|
55
|
+
Number.parseInt(process.env.DESIGN_KIT_PARAMS_READ_MS ?? "1000", 10) || 1000;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The console prefix a story's page announces its own widths with at
|
|
59
|
+
* storyPrepared. A console message reaches node even when the story holds
|
|
60
|
+
* the main thread right after, which a read of the page never would.
|
|
61
|
+
*/
|
|
62
|
+
const WIDTHS_ANNOUNCEMENT = "design-kit:widths ";
|
|
63
|
+
|
|
64
|
+
/** A page that stopped answering: its main thread is held by the story. */
|
|
65
|
+
class Unresponsive extends Error {}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Settles with `promise`, or rejects with Unresponsive after `ms`.
|
|
69
|
+
* Playwright's evaluate has no timeout, and one set in the page cannot fire
|
|
70
|
+
* while a story holds the page's main thread, so every wait on a story's
|
|
71
|
+
* page goes through this node-side timer. The timer is cleared either way.
|
|
72
|
+
*/
|
|
73
|
+
function bounded(promise, ms, detail) {
|
|
74
|
+
let timer;
|
|
75
|
+
return Promise.race([
|
|
76
|
+
promise,
|
|
77
|
+
new Promise((_, fail) => {
|
|
78
|
+
timer = setTimeout(() => fail(new Unresponsive(detail)), ms);
|
|
79
|
+
}),
|
|
80
|
+
]).finally(() => clearTimeout(timer));
|
|
81
|
+
}
|
|
48
82
|
|
|
49
83
|
/** Storybook's built-in viewport names (minimal set plus the iPhones in use). */
|
|
50
84
|
const NAMED_VIEWPORTS = {
|
|
@@ -151,15 +185,50 @@ export function mergeManifest(existing, { sha, builtAt, stories, theme }) {
|
|
|
151
185
|
};
|
|
152
186
|
}
|
|
153
187
|
|
|
188
|
+
/**
|
|
189
|
+
* A story's own widths, from `parameters.designKit.widths`: numbers or
|
|
190
|
+
* `{width, height}` entries. Anything else, or an empty list, is no list,
|
|
191
|
+
* so a malformed parameter never drops a story.
|
|
192
|
+
*/
|
|
193
|
+
export function ownWidths(raw) {
|
|
194
|
+
if (!Array.isArray(raw)) return null;
|
|
195
|
+
const list = [];
|
|
196
|
+
for (const entry of raw) {
|
|
197
|
+
const width = typeof entry === "number" ? entry : entry?.width;
|
|
198
|
+
const height = typeof entry === "number" ? undefined : entry?.height;
|
|
199
|
+
if (!Number.isInteger(width) || width <= 0) continue;
|
|
200
|
+
if (list.some((planned) => planned.width === width)) continue;
|
|
201
|
+
list.push(
|
|
202
|
+
Number.isInteger(height) && height > 0 ? { width, height } : { width },
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
return list.length ? list : null;
|
|
206
|
+
}
|
|
207
|
+
|
|
154
208
|
/**
|
|
155
209
|
* Which captures a story gets: its declared viewport or the default, plus
|
|
156
210
|
* each configured width whose `titles` pattern matches the story's title.
|
|
211
|
+
* A story that lists its own widths (`parameters.designKit.widths`, see
|
|
212
|
+
* ownWidths) gets exactly those, each with its listed height, else the
|
|
213
|
+
* height the declared viewport, default or a configured width has for it,
|
|
214
|
+
* else the default height.
|
|
157
215
|
*/
|
|
158
216
|
export function plannedViewports(
|
|
159
217
|
title,
|
|
160
218
|
declared,
|
|
161
219
|
{ viewport = DEFAULT_VIEWPORT, widths = [] } = {},
|
|
220
|
+
own = null,
|
|
162
221
|
) {
|
|
222
|
+
if (own?.length) {
|
|
223
|
+
const known = [declared, viewport, ...widths].filter(Boolean);
|
|
224
|
+
return own.map(({ width, height }) => ({
|
|
225
|
+
width,
|
|
226
|
+
height:
|
|
227
|
+
height ??
|
|
228
|
+
known.find((size) => size.width === width && size.height)?.height ??
|
|
229
|
+
viewport.height,
|
|
230
|
+
}));
|
|
231
|
+
}
|
|
163
232
|
const first = declared ?? viewport;
|
|
164
233
|
const list = [first];
|
|
165
234
|
for (const extra of widths) {
|
|
@@ -276,7 +345,7 @@ export function fontCss(base, fonts, files = fontFiles(fonts)) {
|
|
|
276
345
|
}
|
|
277
346
|
|
|
278
347
|
/** Runs before any page script: a seeded Math.random, the font CSS, and a channel tap. */
|
|
279
|
-
function pageInit({ css, families }) {
|
|
348
|
+
function pageInit({ css, families, width, announcement }) {
|
|
280
349
|
let seed = 0x2f6b9a1d;
|
|
281
350
|
Math.random = () => {
|
|
282
351
|
seed = (seed + 0x6d2b79f5) | 0;
|
|
@@ -297,6 +366,9 @@ function pageInit({ css, families }) {
|
|
|
297
366
|
}
|
|
298
367
|
const kit = { outcome: null, detail: "", pending: 0 };
|
|
299
368
|
window.__kit = kit;
|
|
369
|
+
// Public: a story with live data or a running animation can render a
|
|
370
|
+
// still state of it when this is set (README "Capture rules").
|
|
371
|
+
window.__DESIGN_KIT_CAPTURE__ = true;
|
|
300
372
|
// In-flight requests, so settling can wait for them rather than a timer.
|
|
301
373
|
const track = (promise) => {
|
|
302
374
|
kit.pending += 1;
|
|
@@ -345,6 +417,25 @@ function pageInit({ css, families }) {
|
|
|
345
417
|
finish("threw", `play: ${describe(e)}`),
|
|
346
418
|
);
|
|
347
419
|
value.on("storyMissing", () => finish("missing"));
|
|
420
|
+
// A story that lists its own widths and leaves this one out is not
|
|
421
|
+
// captured here: no render, no play, no gap (ownWidths).
|
|
422
|
+
value.on("storyPrepared", (event) => {
|
|
423
|
+
const own = event?.parameters?.designKit?.widths;
|
|
424
|
+
if (!Array.isArray(own)) return;
|
|
425
|
+
kit.widths = own;
|
|
426
|
+
try {
|
|
427
|
+
console.debug(`${announcement}${JSON.stringify(own)}`);
|
|
428
|
+
} catch {
|
|
429
|
+
// A list that cannot be serialized is read from __kit instead.
|
|
430
|
+
}
|
|
431
|
+
// The same rule as ownWidths: positive integer widths only, and no
|
|
432
|
+
// usable width means no list, so a malformed one never skips.
|
|
433
|
+
const listed = own
|
|
434
|
+
.map((entry) => (typeof entry === "number" ? entry : entry?.width))
|
|
435
|
+
.filter((value) => Number.isInteger(value) && value > 0);
|
|
436
|
+
if (listed.length && !listed.includes(width))
|
|
437
|
+
finish("skipped", `not listed in designKit.widths`);
|
|
438
|
+
});
|
|
348
439
|
},
|
|
349
440
|
});
|
|
350
441
|
window.__kitLoaded = new WeakSet();
|
|
@@ -442,6 +533,7 @@ async function extract() {
|
|
|
442
533
|
".sb-preparing-story,.sb-preparing-docs,.sb-nopreview,.sb-errordisplay,#storybook-docs,script,noscript,template,link,[data-kit-inject]";
|
|
443
534
|
const story = window.__STORYBOOK_PREVIEW__?.currentRender?.story;
|
|
444
535
|
const parameters = story?.parameters ?? {};
|
|
536
|
+
const widths = parameters.designKit?.widths ?? null;
|
|
445
537
|
const viewport = parameters.viewport ?? {};
|
|
446
538
|
let declared = null;
|
|
447
539
|
const name = viewport.defaultViewport;
|
|
@@ -577,6 +669,7 @@ async function extract() {
|
|
|
577
669
|
html,
|
|
578
670
|
sheets,
|
|
579
671
|
declared,
|
|
672
|
+
widths,
|
|
580
673
|
bodyClass,
|
|
581
674
|
empty: !rootHasContent && !portalHasContent,
|
|
582
675
|
};
|
|
@@ -638,7 +731,12 @@ async function freshPage({
|
|
|
638
731
|
const page = await context.newPage();
|
|
639
732
|
page.on("pageerror", () => {});
|
|
640
733
|
await page.clock.setFixedTime(new Date(fixedTime));
|
|
641
|
-
await page.addInitScript(pageInit, {
|
|
734
|
+
await page.addInitScript(pageInit, {
|
|
735
|
+
css,
|
|
736
|
+
families,
|
|
737
|
+
width: viewport.width,
|
|
738
|
+
announcement: WIDTHS_ANNOUNCEMENT,
|
|
739
|
+
});
|
|
642
740
|
return { context, page, cdp: await context.newCDPSession(page) };
|
|
643
741
|
}
|
|
644
742
|
|
|
@@ -659,6 +757,20 @@ async function captureTimed(
|
|
|
659
757
|
}));
|
|
660
758
|
const { context, page, cdp } = slot;
|
|
661
759
|
let reusable = false;
|
|
760
|
+
// The widths this story announced, heard even if its page then stops
|
|
761
|
+
// answering (WIDTHS_ANNOUNCEMENT).
|
|
762
|
+
let announced;
|
|
763
|
+
const hear = (message) => {
|
|
764
|
+
if (message.type() !== "debug") return;
|
|
765
|
+
const text = message.text();
|
|
766
|
+
if (!text.startsWith(WIDTHS_ANNOUNCEMENT)) return;
|
|
767
|
+
try {
|
|
768
|
+
announced = JSON.parse(text.slice(WIDTHS_ANNOUNCEMENT.length));
|
|
769
|
+
} catch {
|
|
770
|
+
// Not ours after all; the bounded read below still runs.
|
|
771
|
+
}
|
|
772
|
+
};
|
|
773
|
+
page.on("console", hear);
|
|
662
774
|
try {
|
|
663
775
|
await cdp.send("Storage.clearDataForOrigin", {
|
|
664
776
|
origin,
|
|
@@ -673,18 +785,50 @@ async function captureTimed(
|
|
|
673
785
|
`${origin}/iframe.html?id=${encodeURIComponent(story.id)}&viewMode=story&globals=a11y.manual:!true`,
|
|
674
786
|
{ waitUntil: "domcontentloaded", timeout },
|
|
675
787
|
);
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
}
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
788
|
+
// The node-side timer bounds the wait too: Playwright's own timeout never
|
|
789
|
+
// fires while a story holds the page's main thread.
|
|
790
|
+
const notRendered = {
|
|
791
|
+
outcome: "timeout",
|
|
792
|
+
detail: `no storyRendered within ${timeout}ms`,
|
|
793
|
+
};
|
|
794
|
+
const outcome = await bounded(
|
|
795
|
+
page
|
|
796
|
+
.waitForFunction(() => window.__kit?.outcome, null, {
|
|
797
|
+
timeout: Math.max(1000, deadline - Date.now()),
|
|
798
|
+
polling: 50,
|
|
799
|
+
})
|
|
800
|
+
.then(() => page.evaluate(() => ({ ...window.__kit }))),
|
|
801
|
+
Math.max(1000, deadline - Date.now()) + 1000,
|
|
802
|
+
).catch(() => notRendered);
|
|
686
803
|
if (outcome.outcome !== "rendered") {
|
|
804
|
+
// The story's own widths: from storyPrepared, or read from the preview
|
|
805
|
+
// when this Storybook sends no parameters with it.
|
|
806
|
+
// Bounded: a story that holds the main thread (the usual reason for a
|
|
807
|
+
// timeout) would never answer, and the lane would never finish.
|
|
808
|
+
const own = ownWidths(
|
|
809
|
+
outcome.widths ??
|
|
810
|
+
announced ??
|
|
811
|
+
(await bounded(
|
|
812
|
+
page.evaluate(
|
|
813
|
+
() =>
|
|
814
|
+
window.__STORYBOOK_PREVIEW__?.currentRender?.story?.parameters
|
|
815
|
+
?.designKit?.widths ?? null,
|
|
816
|
+
),
|
|
817
|
+
paramsReadMs,
|
|
818
|
+
).catch(() => null)),
|
|
819
|
+
);
|
|
820
|
+
// Left out of the story's own widths: not a capture and not a gap.
|
|
821
|
+
// Any other failure stays one.
|
|
822
|
+
if (
|
|
823
|
+
outcome.outcome === "skipped" ||
|
|
824
|
+
(own && !own.some((entry) => entry.width === viewport.width))
|
|
825
|
+
) {
|
|
826
|
+
// A page that timed out may still be held: never park it.
|
|
827
|
+
reusable = outcome.outcome !== "timeout";
|
|
828
|
+
return { skipped: true, widths: own };
|
|
829
|
+
}
|
|
687
830
|
return {
|
|
831
|
+
widths: own,
|
|
688
832
|
failure: {
|
|
689
833
|
reason: outcome.outcome === "missing" ? "missing" : outcome.outcome,
|
|
690
834
|
detail: outcome.detail,
|
|
@@ -701,7 +845,12 @@ async function captureTimed(
|
|
|
701
845
|
// guests one after another. Settle the guests, then the host, until a
|
|
702
846
|
// round brings no new frame and no host change.
|
|
703
847
|
const settleBy = Date.now() + timeout;
|
|
704
|
-
|
|
848
|
+
// `settle` gives up in the page after `timeout`; a second more covers the
|
|
849
|
+
// round trip, so only a page that stopped answering reaches the bound.
|
|
850
|
+
const unanswered = `page stopped answering after storyRendered (${timeout}ms)`;
|
|
851
|
+
const settleOn = (target) =>
|
|
852
|
+
bounded(target.evaluate(settle, quiet), timeout + 1000, unanswered);
|
|
853
|
+
let settled = (await settleOn(page)).settled;
|
|
705
854
|
let known = new Set();
|
|
706
855
|
while (settled) {
|
|
707
856
|
const guests = page
|
|
@@ -711,12 +860,16 @@ async function captureTimed(
|
|
|
711
860
|
if (!fresh && known.size === 0) break;
|
|
712
861
|
known = new Set(guests);
|
|
713
862
|
const results = await Promise.all(
|
|
714
|
-
// A frame that navigated away or detached has nothing left to wait
|
|
863
|
+
// A frame that navigated away or detached has nothing left to wait
|
|
864
|
+
// for; one that stopped answering is a timeout like its host.
|
|
715
865
|
guests.map((frame) =>
|
|
716
|
-
frame
|
|
866
|
+
settleOn(frame).catch((error) => {
|
|
867
|
+
if (error instanceof Unresponsive) throw error;
|
|
868
|
+
return { settled: true };
|
|
869
|
+
}),
|
|
717
870
|
),
|
|
718
871
|
);
|
|
719
|
-
const host = await page
|
|
872
|
+
const host = await settleOn(page);
|
|
720
873
|
settled =
|
|
721
874
|
results.every((result) => result.settled) &&
|
|
722
875
|
host.settled &&
|
|
@@ -732,7 +885,11 @@ async function captureTimed(
|
|
|
732
885
|
};
|
|
733
886
|
}
|
|
734
887
|
step("serialize");
|
|
735
|
-
const result = await
|
|
888
|
+
const result = await bounded(
|
|
889
|
+
page.evaluate(extract),
|
|
890
|
+
timeout,
|
|
891
|
+
`page stopped answering while serializing (${timeout}ms)`,
|
|
892
|
+
);
|
|
736
893
|
// An inline sheet is keyed by its text, which resolved its url()s against
|
|
737
894
|
// this run's server; the port must not reach the kit.
|
|
738
895
|
for (const sheet of result.sheets)
|
|
@@ -746,38 +903,48 @@ async function captureTimed(
|
|
|
746
903
|
};
|
|
747
904
|
}
|
|
748
905
|
reusable = true;
|
|
749
|
-
return { result };
|
|
906
|
+
return { result, widths: ownWidths(result.widths ?? outcome.widths) };
|
|
750
907
|
} catch (error) {
|
|
751
908
|
return {
|
|
752
909
|
failure: {
|
|
753
|
-
reason:
|
|
910
|
+
reason:
|
|
911
|
+
error instanceof Unresponsive || /Timeout/i.test(String(error))
|
|
912
|
+
? "timeout"
|
|
913
|
+
: "threw",
|
|
754
914
|
detail: String(error?.message ?? error)
|
|
755
915
|
.split("\n")[0]
|
|
756
916
|
.slice(0, 500),
|
|
757
917
|
},
|
|
758
918
|
};
|
|
759
919
|
} finally {
|
|
920
|
+
page.off("console", hear);
|
|
760
921
|
step("close");
|
|
761
922
|
const parked =
|
|
762
923
|
reusable &&
|
|
763
924
|
parkedCount() < parkLimit &&
|
|
764
|
-
(await
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
925
|
+
(await bounded(
|
|
926
|
+
page
|
|
927
|
+
// Session storage lives with the tab, not the origin's data, so it
|
|
928
|
+
// is cleared on the story's own page before leaving it.
|
|
929
|
+
.evaluate(() => sessionStorage.clear())
|
|
930
|
+
.then(() => page.goto("about:blank", { timeout }))
|
|
931
|
+
.then(() =>
|
|
932
|
+
page.evaluate(() => {
|
|
933
|
+
window.name = "";
|
|
934
|
+
}),
|
|
935
|
+
),
|
|
936
|
+
timeout,
|
|
937
|
+
)
|
|
774
938
|
.then(() => true)
|
|
775
939
|
.catch(() => false));
|
|
776
940
|
if (parked) {
|
|
777
941
|
const list = idlePages.get(key) ?? [];
|
|
778
942
|
list.push(slot);
|
|
779
943
|
idlePages.set(key, list);
|
|
780
|
-
} else
|
|
944
|
+
} else {
|
|
945
|
+
// A context whose page is held is left to browser.close() at the end.
|
|
946
|
+
await bounded(context.close(), timeout).catch(() => {});
|
|
947
|
+
}
|
|
781
948
|
}
|
|
782
949
|
}
|
|
783
950
|
|
|
@@ -829,11 +996,32 @@ function storybookStories(buildDir) {
|
|
|
829
996
|
.sort((a, b) => (a.id < b.id ? -1 : 1));
|
|
830
997
|
}
|
|
831
998
|
|
|
999
|
+
/**
|
|
1000
|
+
* The reason a `design-kit: no-story <reason>` comment gives (a `//`, block or
|
|
1001
|
+
* JSX comment anywhere in the file), or null when there is none or its reason
|
|
1002
|
+
* is empty: a bare marker exempts nothing.
|
|
1003
|
+
*/
|
|
1004
|
+
export function noStoryReason(source) {
|
|
1005
|
+
const marker =
|
|
1006
|
+
/(?:\/\/|\/\*|^[ \t]*\*)[ \t]*design-kit:[ \t]*no-story\b([^\n]*)/gm;
|
|
1007
|
+
for (const match of String(source).matchAll(marker)) {
|
|
1008
|
+
const reason = match[1]
|
|
1009
|
+
.replace(/\*\/.*$/, "")
|
|
1010
|
+
.replace(/[}\s]+$/, "")
|
|
1011
|
+
.trim();
|
|
1012
|
+
if (reason) return reason;
|
|
1013
|
+
}
|
|
1014
|
+
return null;
|
|
1015
|
+
}
|
|
1016
|
+
|
|
832
1017
|
/**
|
|
833
1018
|
* Component files nothing documents: files under `gaps.sourceRoot` whose path
|
|
834
1019
|
* from the Storybook project matches `gaps.componentGlob`, minus stories,
|
|
835
1020
|
* 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.
|
|
1021
|
+
* the rows are relative to the config file's folder. A file carrying a
|
|
1022
|
+
* `design-kit: no-story <reason>` comment (a context, a controller, a hook
|
|
1023
|
+
* wrapper) is an `exempt` row with that reason as its detail, listed apart
|
|
1024
|
+
* so a person can check the reason; with an empty reason it stays `no story`.
|
|
837
1025
|
*/
|
|
838
1026
|
export function componentsWithoutStories(stories, { gaps, root, configDir }) {
|
|
839
1027
|
const documented = new Set(
|
|
@@ -855,10 +1043,13 @@ export function componentsWithoutStories(stories, { gaps, root, configDir }) {
|
|
|
855
1043
|
} else if (!/\.(stories|test|spec)\.[cm]?[jt]sx?$/.test(name.name)) {
|
|
856
1044
|
const rel = relative(root, path).split(sep).join("/");
|
|
857
1045
|
if (matchFile(pattern, rel) && !documented.has(rel)) {
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
1046
|
+
const component = relative(configDir, path).split(sep).join("/");
|
|
1047
|
+
const exempt = noStoryReason(readFileSync(path, "utf8"));
|
|
1048
|
+
out.push(
|
|
1049
|
+
exempt
|
|
1050
|
+
? { component, reason: "exempt", detail: exempt }
|
|
1051
|
+
: { component, reason: "no story" },
|
|
1052
|
+
);
|
|
862
1053
|
}
|
|
863
1054
|
}
|
|
864
1055
|
}
|
|
@@ -1002,7 +1193,11 @@ export async function captureStories(config, options = {}) {
|
|
|
1002
1193
|
const keepSheetsOnce = sheetStore();
|
|
1003
1194
|
// What the timings report needs from a capture, without its result.
|
|
1004
1195
|
const attempt = (capture) => {
|
|
1005
|
-
const record = {
|
|
1196
|
+
const record = {
|
|
1197
|
+
timing: capture.timing,
|
|
1198
|
+
failure: capture.failure,
|
|
1199
|
+
skipped: capture.skipped,
|
|
1200
|
+
};
|
|
1006
1201
|
attempts.push(record);
|
|
1007
1202
|
return record;
|
|
1008
1203
|
};
|
|
@@ -1019,16 +1214,28 @@ export async function captureStories(config, options = {}) {
|
|
|
1019
1214
|
fixedTime,
|
|
1020
1215
|
});
|
|
1021
1216
|
const firstAttempt = attempt(first);
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1217
|
+
// The story's declared viewport and own widths are known once it has
|
|
1218
|
+
// loaded at the default one; that first capture is kept only when the
|
|
1219
|
+
// plan still has the default viewport in it.
|
|
1220
|
+
const declared = first.result ? viewportFor(first.result.declared) : null;
|
|
1221
|
+
const viewports = plannedViewports(
|
|
1222
|
+
story.title,
|
|
1223
|
+
declared,
|
|
1224
|
+
planning,
|
|
1225
|
+
first.widths,
|
|
1226
|
+
);
|
|
1227
|
+
const results = new Map();
|
|
1228
|
+
if (
|
|
1229
|
+
!first.skipped &&
|
|
1230
|
+
!declared &&
|
|
1231
|
+
viewports.some(
|
|
1232
|
+
(viewport) =>
|
|
1233
|
+
viewport.width === planning.viewport.width &&
|
|
1234
|
+
viewport.height === planning.viewport.height,
|
|
1235
|
+
)
|
|
1236
|
+
) {
|
|
1237
|
+
results.set(planning.viewport.width, first);
|
|
1238
|
+
} else if (!first.skipped) firstAttempt.discarded = true;
|
|
1032
1239
|
for (const viewport of viewports) {
|
|
1033
1240
|
if (!results.has(viewport.width)) {
|
|
1034
1241
|
const capture = await captureOne({
|
|
@@ -1046,6 +1253,7 @@ export async function captureStories(config, options = {}) {
|
|
|
1046
1253
|
}
|
|
1047
1254
|
const capture = results.get(viewport.width);
|
|
1048
1255
|
const id = kitId(story.id, viewport.width);
|
|
1256
|
+
if (capture.skipped) continue;
|
|
1049
1257
|
if (capture.failure) {
|
|
1050
1258
|
failures.push({ id, ...capture.failure });
|
|
1051
1259
|
} else {
|
|
@@ -1179,6 +1387,9 @@ export async function captureStories(config, options = {}) {
|
|
|
1179
1387
|
join(out, "index.json"),
|
|
1180
1388
|
`${JSON.stringify(rows, null, 1)}\n`,
|
|
1181
1389
|
);
|
|
1390
|
+
// Every row's on-screen words for `kit find`, rows kept from the base
|
|
1391
|
+
// and the pages' rows included, so an incremental build is complete.
|
|
1392
|
+
writeKitText(out);
|
|
1182
1393
|
|
|
1183
1394
|
const storyGaps = [
|
|
1184
1395
|
...failures,
|
|
@@ -1236,6 +1447,7 @@ export async function captureStories(config, options = {}) {
|
|
|
1236
1447
|
const timings = {
|
|
1237
1448
|
...timingBreakdown(attempts),
|
|
1238
1449
|
discarded: attempts.filter((a) => a.discarded).length,
|
|
1450
|
+
skipped: attempts.filter((a) => a.skipped).length,
|
|
1239
1451
|
lanes: concurrency,
|
|
1240
1452
|
quiet_ms: quietMs,
|
|
1241
1453
|
timeout_ms: timeout,
|
|
@@ -1255,6 +1467,9 @@ export async function captureStories(config, options = {}) {
|
|
|
1255
1467
|
return { out, rows: rows.length, failed: storyGaps.length };
|
|
1256
1468
|
} finally {
|
|
1257
1469
|
await browser?.close().catch(() => {});
|
|
1470
|
+
// Parked pages belong to this browser; a later capture in the same
|
|
1471
|
+
// process must not pick one up.
|
|
1472
|
+
idlePages.clear();
|
|
1258
1473
|
server?.close();
|
|
1259
1474
|
if (ownedBuild) rmSync(ownedBuild, { recursive: true, force: true });
|
|
1260
1475
|
}
|
package/src/kit.d.ts
CHANGED
|
@@ -30,13 +30,48 @@ export interface Kit {
|
|
|
30
30
|
manifest: KitManifest | null;
|
|
31
31
|
rows: KitRow[];
|
|
32
32
|
byId: Map<string, KitRow>;
|
|
33
|
+
/** Each row's visible text by id, from text.json; absent in older kits. */
|
|
34
|
+
text?: Map<string, string>;
|
|
33
35
|
}
|
|
34
36
|
|
|
35
37
|
export function loadKit(dir: string): Kit;
|
|
36
38
|
|
|
37
|
-
/**
|
|
39
|
+
/** One `find` result; `excerpt` is set when a word matched only the row's text. */
|
|
40
|
+
export interface KitMatch {
|
|
41
|
+
row: KitRow;
|
|
42
|
+
excerpt?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Rows whose id, title, component, source file or visible text holds every
|
|
47
|
+
* word: id matches first, then title, component or source file, then text.
|
|
48
|
+
*/
|
|
49
|
+
export function findMatches(kit: Kit, query: string): KitMatch[];
|
|
50
|
+
|
|
51
|
+
/** The rows of `findMatches`, in the same order. */
|
|
38
52
|
export function find(kit: Kit, query: string): KitRow[];
|
|
39
53
|
|
|
54
|
+
/** Characters of visible text text.json keeps per row: 600. */
|
|
55
|
+
export const TEXT_LIMIT: number;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* A row's on-screen words: text outside scripts, styles, SVG, the head and
|
|
59
|
+
* templates, entities decoded, whitespace collapsed, at most `limit` characters.
|
|
60
|
+
*/
|
|
61
|
+
export function visibleText(html: string, limit?: number): string;
|
|
62
|
+
|
|
63
|
+
/** text.json's content for `rows`, reading each row's markup by its `file`. */
|
|
64
|
+
export function kitText(
|
|
65
|
+
rows: KitRow[],
|
|
66
|
+
read: (file: string) => string,
|
|
67
|
+
): Record<string, string>;
|
|
68
|
+
|
|
69
|
+
/** text.json as written. */
|
|
70
|
+
export function kitTextJson(text: Record<string, string>): string;
|
|
71
|
+
|
|
72
|
+
/** Rebuilds `dir`/text.json from the rows and markup already in the kit. */
|
|
73
|
+
export function writeKitText(dir: string): string;
|
|
74
|
+
|
|
40
75
|
/** Up to five candidates, closest first. */
|
|
41
76
|
export function nearMatches(wanted: string, candidates: string[]): string[];
|
|
42
77
|
|
package/src/kit.mjs
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* always match. Pure Node: no dependencies, no network.
|
|
5
5
|
*
|
|
6
6
|
* node kit.mjs info
|
|
7
|
-
* node kit.mjs find <query>
|
|
7
|
+
* node kit.mjs find <query> (ids, titles and the words on screen)
|
|
8
8
|
* node kit.mjs show <id> [--full]
|
|
9
9
|
* node kit.mjs compose --frame <id> [--slot <name>=<file>]… [--theme light|dark]
|
|
10
10
|
* [--show-new] --out <file>
|
|
@@ -17,6 +17,8 @@ import { basename, dirname, extname, join, resolve, sep } from "node:path";
|
|
|
17
17
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
18
18
|
|
|
19
19
|
const FIND_LIMIT = 30;
|
|
20
|
+
/** Of FIND_LIMIT, the lines kept for rows found by on-screen text. */
|
|
21
|
+
const FIND_TEXT_LINES = 5;
|
|
20
22
|
const SHOW_LIMIT = 2048;
|
|
21
23
|
const SNIPPET_DEPTH = 8;
|
|
22
24
|
/**
|
|
@@ -35,7 +37,10 @@ function readJson(file, fallback) {
|
|
|
35
37
|
return JSON.parse(readFileSync(file, "utf8"));
|
|
36
38
|
}
|
|
37
39
|
|
|
38
|
-
/**
|
|
40
|
+
/**
|
|
41
|
+
* Story rows from index.json plus route rows from routes.json, by id, and
|
|
42
|
+
* each row's visible text from text.json when the kit has one.
|
|
43
|
+
*/
|
|
39
44
|
export function loadKit(dir) {
|
|
40
45
|
const manifest = readJson(join(dir, "manifest.json"), null);
|
|
41
46
|
const rows = new Map();
|
|
@@ -50,7 +55,15 @@ export function loadKit(dir) {
|
|
|
50
55
|
const sorted = [...rows.values()].sort((a, b) =>
|
|
51
56
|
a.id < b.id ? -1 : a.id > b.id ? 1 : 0,
|
|
52
57
|
);
|
|
53
|
-
|
|
58
|
+
const text = readText(dir);
|
|
59
|
+
return {
|
|
60
|
+
dir,
|
|
61
|
+
manifest,
|
|
62
|
+
rows: sorted,
|
|
63
|
+
byId: new Map(rows),
|
|
64
|
+
// Only a kit with text.json has it, so an older kit loads as before.
|
|
65
|
+
...(text ? { text } : {}),
|
|
66
|
+
};
|
|
54
67
|
}
|
|
55
68
|
|
|
56
69
|
function rowSource(row) {
|
|
@@ -179,19 +192,24 @@ function parseAttributes(source) {
|
|
|
179
192
|
|
|
180
193
|
/** Tokens with offsets: open / close / text / comment. */
|
|
181
194
|
export function tokenize(html) {
|
|
182
|
-
|
|
195
|
+
return [...scan(html)];
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** `tokenize`, one token at a time, so a reader can stop early. */
|
|
199
|
+
function* scan(html) {
|
|
200
|
+
let lower = null;
|
|
183
201
|
let i = 0;
|
|
184
202
|
while (i < html.length) {
|
|
185
203
|
const lt = html.indexOf("<", i);
|
|
186
204
|
if (lt < 0) {
|
|
187
|
-
|
|
205
|
+
yield { type: "text", start: i, end: html.length };
|
|
188
206
|
break;
|
|
189
207
|
}
|
|
190
|
-
if (lt > i)
|
|
208
|
+
if (lt > i) yield { type: "text", start: i, end: lt };
|
|
191
209
|
if (html.startsWith("<!--", lt)) {
|
|
192
210
|
const close = html.indexOf("-->", lt + 4);
|
|
193
211
|
const end = close < 0 ? html.length : close + 3;
|
|
194
|
-
|
|
212
|
+
yield { type: "comment", start: lt, end };
|
|
195
213
|
i = end;
|
|
196
214
|
continue;
|
|
197
215
|
}
|
|
@@ -200,7 +218,7 @@ export function tokenize(html) {
|
|
|
200
218
|
html.slice(lt + (closing ? 2 : 1), lt + 200),
|
|
201
219
|
);
|
|
202
220
|
if (!nameMatch) {
|
|
203
|
-
|
|
221
|
+
yield { type: "text", start: lt, end: lt + 1 };
|
|
204
222
|
i = lt + 1;
|
|
205
223
|
continue;
|
|
206
224
|
}
|
|
@@ -217,29 +235,29 @@ export function tokenize(html) {
|
|
|
217
235
|
const end = Math.min(j + 1, html.length);
|
|
218
236
|
const name = nameMatch[0].toLowerCase();
|
|
219
237
|
if (closing) {
|
|
220
|
-
|
|
238
|
+
yield { type: "close", name, start: lt, end };
|
|
221
239
|
i = end;
|
|
222
240
|
continue;
|
|
223
241
|
}
|
|
224
242
|
const inner = html.slice(lt + 1 + nameMatch[0].length, end - 1);
|
|
225
243
|
const selfClosing = /\/\s*$/.test(inner);
|
|
226
|
-
|
|
244
|
+
yield {
|
|
227
245
|
type: "open",
|
|
228
246
|
name,
|
|
229
247
|
attrs: parseAttributes(inner.replace(/\/\s*$/, "")),
|
|
230
248
|
start: lt,
|
|
231
249
|
end,
|
|
232
250
|
selfClosing: selfClosing || VOID.has(name),
|
|
233
|
-
}
|
|
251
|
+
};
|
|
234
252
|
i = end;
|
|
235
253
|
if (RAW.has(name) && !selfClosing) {
|
|
236
|
-
|
|
254
|
+
lower ??= html.toLowerCase();
|
|
255
|
+
const closeAt = lower.indexOf(`</${name}`, end);
|
|
237
256
|
const stop = closeAt < 0 ? html.length : closeAt;
|
|
238
|
-
if (stop > end)
|
|
257
|
+
if (stop > end) yield { type: "text", start: end, end: stop };
|
|
239
258
|
i = stop;
|
|
240
259
|
}
|
|
241
260
|
}
|
|
242
|
-
return tokens;
|
|
243
261
|
}
|
|
244
262
|
|
|
245
263
|
/** Each element with its inner range: [{name, attrs, start, end, innerStart, innerEnd, depth}]. */
|
|
@@ -912,6 +930,174 @@ ${sanitizeMarkup(body)}
|
|
|
912
930
|
return page;
|
|
913
931
|
}
|
|
914
932
|
|
|
933
|
+
// ── visible text (text.json) ────────────────────────────────────────────────
|
|
934
|
+
|
|
935
|
+
/** Characters of visible text text.json keeps per row. */
|
|
936
|
+
export const TEXT_LIMIT = 600;
|
|
937
|
+
/** Characters of a `find` excerpt. */
|
|
938
|
+
const EXCERPT_CHARS = 60;
|
|
939
|
+
/** Elements whose content is never read on screen as text. */
|
|
940
|
+
const HIDDEN_CONTENT = new Set([
|
|
941
|
+
"script",
|
|
942
|
+
"style",
|
|
943
|
+
"svg",
|
|
944
|
+
"head",
|
|
945
|
+
"template",
|
|
946
|
+
"noscript",
|
|
947
|
+
"title",
|
|
948
|
+
]);
|
|
949
|
+
/** Phrasing elements that sit inside a word; every other tag separates words. */
|
|
950
|
+
const INLINE = new Set(
|
|
951
|
+
"a abbr b bdi bdo cite code data dfn em i kbd mark q s samp small strong sub sup time u var wbr".split(
|
|
952
|
+
" ",
|
|
953
|
+
),
|
|
954
|
+
);
|
|
955
|
+
|
|
956
|
+
/** Each `data-kit-slot` element's inner range, for slot-first text. */
|
|
957
|
+
function slotRanges(html) {
|
|
958
|
+
if (!html.includes("data-kit-slot")) return [];
|
|
959
|
+
return elements(html)
|
|
960
|
+
.filter((element) => element.attrs["data-kit-slot"] !== undefined)
|
|
961
|
+
.map((element) => [element.innerStart, element.innerEnd]);
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
/**
|
|
965
|
+
* The words a row shows: text content outside scripts, styles, SVG, the head
|
|
966
|
+
* and templates, entities decoded and whitespace collapsed, at most `limit`
|
|
967
|
+
* characters. Attribute values are never text. The text of the row's slots
|
|
968
|
+
* (a page's `main`) comes first and the rest of the markup after it, so a
|
|
969
|
+
* page's own words are kept ahead of the navigation and top bar every page
|
|
970
|
+
* repeats.
|
|
971
|
+
*/
|
|
972
|
+
export function visibleText(html, limit = TEXT_LIMIT) {
|
|
973
|
+
const ranges = slotRanges(html);
|
|
974
|
+
const lastSlotEnd = Math.max(0, ...ranges.map(([, end]) => end));
|
|
975
|
+
const inSlot = (at) => ranges.some(([from, to]) => at >= from && at < to);
|
|
976
|
+
// [slot text, the rest]
|
|
977
|
+
const parts = ["", ""];
|
|
978
|
+
const add = (at, words) => {
|
|
979
|
+
const part = inSlot(at) ? 0 : 1;
|
|
980
|
+
if (words === " " && (parts[part] === "" || parts[part].endsWith(" ")))
|
|
981
|
+
return;
|
|
982
|
+
parts[part] += words;
|
|
983
|
+
};
|
|
984
|
+
let hidden = null;
|
|
985
|
+
let depth = 0;
|
|
986
|
+
for (const token of scan(html)) {
|
|
987
|
+
if (hidden) {
|
|
988
|
+
if (token.name !== hidden) continue;
|
|
989
|
+
if (token.type === "open" && !token.selfClosing) depth += 1;
|
|
990
|
+
else if (token.type === "close" && (depth -= 1) === 0) hidden = null;
|
|
991
|
+
continue;
|
|
992
|
+
}
|
|
993
|
+
if (token.type === "open" || token.type === "close") {
|
|
994
|
+
if (
|
|
995
|
+
token.type === "open" &&
|
|
996
|
+
HIDDEN_CONTENT.has(token.name) &&
|
|
997
|
+
!token.selfClosing
|
|
998
|
+
) {
|
|
999
|
+
hidden = token.name;
|
|
1000
|
+
depth = 1;
|
|
1001
|
+
} else if (!INLINE.has(token.name)) {
|
|
1002
|
+
add(token.start, " ");
|
|
1003
|
+
}
|
|
1004
|
+
continue;
|
|
1005
|
+
}
|
|
1006
|
+
if (token.type !== "text") continue;
|
|
1007
|
+
add(
|
|
1008
|
+
token.start,
|
|
1009
|
+
decodeEntities(html.slice(token.start, token.end)).replace(/\s+/g, " "),
|
|
1010
|
+
);
|
|
1011
|
+
// Enough collected: a row's text is a search aid, not its content.
|
|
1012
|
+
const [slot, rest] = parts;
|
|
1013
|
+
if (slot.length > limit * 2 && slot.trim().length > limit) break;
|
|
1014
|
+
if (
|
|
1015
|
+
token.end >= lastSlotEnd &&
|
|
1016
|
+
slot.length + rest.length > limit * 2 &&
|
|
1017
|
+
`${slot} ${rest}`.trim().length > limit
|
|
1018
|
+
)
|
|
1019
|
+
break;
|
|
1020
|
+
}
|
|
1021
|
+
return [...`${parts[0]} ${parts[1]}`.replace(/\s+/g, " ").trim()]
|
|
1022
|
+
.slice(0, limit)
|
|
1023
|
+
.join("");
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
/**
|
|
1027
|
+
* text.json's content: each row's visible text by id, from its markup.
|
|
1028
|
+
* `read(file)` returns a row's markup by its kit-relative `file`; a row
|
|
1029
|
+
* whose file is missing or leaves the kit has no entry. The first row of an
|
|
1030
|
+
* id wins, as in `loadKit`.
|
|
1031
|
+
*/
|
|
1032
|
+
export function kitText(rows, read) {
|
|
1033
|
+
const text = {};
|
|
1034
|
+
const seen = new Set();
|
|
1035
|
+
for (const row of rows) {
|
|
1036
|
+
if (!row || typeof row.id !== "string" || seen.has(row.id)) continue;
|
|
1037
|
+
seen.add(row.id);
|
|
1038
|
+
const file = row.file;
|
|
1039
|
+
if (typeof file !== "string" || !file || file.split(/[\\/]/).includes(".."))
|
|
1040
|
+
continue;
|
|
1041
|
+
let html;
|
|
1042
|
+
try {
|
|
1043
|
+
html = read(file);
|
|
1044
|
+
} catch {
|
|
1045
|
+
continue;
|
|
1046
|
+
}
|
|
1047
|
+
if (typeof html !== "string") continue;
|
|
1048
|
+
const words = visibleText(html);
|
|
1049
|
+
if (words) text[row.id] = words;
|
|
1050
|
+
}
|
|
1051
|
+
return Object.fromEntries(
|
|
1052
|
+
Object.entries(text).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)),
|
|
1053
|
+
);
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
/** text.json as written: stable keys, one row per line. */
|
|
1057
|
+
export function kitTextJson(text) {
|
|
1058
|
+
return `${JSON.stringify(text, null, 1)}\n`;
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
/** Rebuilds `dir`/text.json from the rows and markup already in the kit. */
|
|
1062
|
+
export function writeKitText(dir) {
|
|
1063
|
+
const kit = loadKit(dir);
|
|
1064
|
+
const json = kitTextJson(
|
|
1065
|
+
kitText(kit.rows, (file) => readFileSync(join(dir, file), "utf8")),
|
|
1066
|
+
);
|
|
1067
|
+
writeFileSync(join(dir, "text.json"), json);
|
|
1068
|
+
return json;
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
/** text.json, if the kit has a readable one: id → visible text. */
|
|
1072
|
+
function readText(dir) {
|
|
1073
|
+
const file = join(dir, "text.json");
|
|
1074
|
+
if (!existsSync(file)) return undefined;
|
|
1075
|
+
let parsed;
|
|
1076
|
+
try {
|
|
1077
|
+
parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
1078
|
+
} catch {
|
|
1079
|
+
// An optional search aid: a broken one leaves find as it is without it.
|
|
1080
|
+
return undefined;
|
|
1081
|
+
}
|
|
1082
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
1083
|
+
return undefined;
|
|
1084
|
+
const text = new Map();
|
|
1085
|
+
for (const [id, words] of Object.entries(parsed))
|
|
1086
|
+
if (typeof words === "string") text.set(id, words);
|
|
1087
|
+
return text;
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
/** About EXCERPT_CHARS of `words` around `at`, cut words marked with "…". */
|
|
1091
|
+
function excerpt(words, at, length) {
|
|
1092
|
+
const chars = [...words];
|
|
1093
|
+
// `at` counts UTF-16 units; recount it in characters.
|
|
1094
|
+
const from = [...words.slice(0, at)].length;
|
|
1095
|
+
let start = Math.max(0, from - Math.floor((EXCERPT_CHARS - length) / 2));
|
|
1096
|
+
const end = Math.min(chars.length, start + EXCERPT_CHARS);
|
|
1097
|
+
start = Math.max(0, end - EXCERPT_CHARS);
|
|
1098
|
+
return `${start > 0 ? "…" : ""}${chars.slice(start, end).join("").trim()}${end < chars.length ? "…" : ""}`;
|
|
1099
|
+
}
|
|
1100
|
+
|
|
915
1101
|
// ── show / find / info ──────────────────────────────────────────────────────
|
|
916
1102
|
|
|
917
1103
|
export function outline(html, limit = SHOW_LIMIT) {
|
|
@@ -948,8 +1134,17 @@ function formatBytes(bytes) {
|
|
|
948
1134
|
return bytes >= 1024 ? `${(bytes / 1024).toFixed(1)} KB` : `${bytes} B`;
|
|
949
1135
|
}
|
|
950
1136
|
|
|
951
|
-
|
|
1137
|
+
/**
|
|
1138
|
+
* Rows holding every word of `query` in their id, title, component, source
|
|
1139
|
+
* file or visible text (text.json), best first: a word in the id ranks
|
|
1140
|
+
* above one in the title, component or source file, which ranks above one
|
|
1141
|
+
* only in the text, and among rows that tie, one whose text holds the whole
|
|
1142
|
+
* query as written comes first. A row a word matched only through its text
|
|
1143
|
+
* carries an `excerpt` around that phrase, or else the first such word.
|
|
1144
|
+
*/
|
|
1145
|
+
export function findMatches(kit, query) {
|
|
952
1146
|
const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
|
|
1147
|
+
const phrase = terms.join(" ");
|
|
953
1148
|
const scored = [];
|
|
954
1149
|
for (const row of kit.rows) {
|
|
955
1150
|
const id = row.id.toLowerCase();
|
|
@@ -957,20 +1152,59 @@ export function find(kit, query) {
|
|
|
957
1152
|
.filter(Boolean)
|
|
958
1153
|
.join(" ")
|
|
959
1154
|
.toLowerCase();
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
1155
|
+
const words = kit.text?.get(row.id) ?? "";
|
|
1156
|
+
let lowered = null;
|
|
1157
|
+
let score = 0;
|
|
1158
|
+
let textHit = null;
|
|
1159
|
+
let matched = true;
|
|
1160
|
+
for (const term of terms) {
|
|
1161
|
+
if (id.includes(term)) continue;
|
|
1162
|
+
if (hay.includes(term)) {
|
|
1163
|
+
score += 1;
|
|
1164
|
+
continue;
|
|
1165
|
+
}
|
|
1166
|
+
lowered ??= words.toLowerCase();
|
|
1167
|
+
const at = lowered.indexOf(term);
|
|
1168
|
+
if (at < 0) {
|
|
1169
|
+
matched = false;
|
|
1170
|
+
break;
|
|
1171
|
+
}
|
|
1172
|
+
score += 2;
|
|
1173
|
+
textHit ??= { at, length: term.length };
|
|
1174
|
+
}
|
|
1175
|
+
if (!matched) continue;
|
|
1176
|
+
// The whole query as written, in the text: the excerpt shows it, and it
|
|
1177
|
+
// ranks first among rows that tie.
|
|
1178
|
+
const at = textHit && terms.length > 1 ? lowered.indexOf(phrase) : -1;
|
|
1179
|
+
if (at >= 0) textHit = { at, length: phrase.length };
|
|
1180
|
+
scored.push({
|
|
1181
|
+
row,
|
|
1182
|
+
score,
|
|
1183
|
+
phrase: at >= 0 ? 0 : 1,
|
|
1184
|
+
...(textHit
|
|
1185
|
+
? { excerpt: excerpt(words, textHit.at, textHit.length) }
|
|
1186
|
+
: {}),
|
|
1187
|
+
});
|
|
966
1188
|
}
|
|
967
|
-
scored.sort(
|
|
968
|
-
|
|
1189
|
+
scored.sort(
|
|
1190
|
+
(a, b) =>
|
|
1191
|
+
a.score - b.score ||
|
|
1192
|
+
a.phrase - b.phrase ||
|
|
1193
|
+
(a.row.id < b.row.id ? -1 : 1),
|
|
1194
|
+
);
|
|
1195
|
+
return scored.map(({ row, excerpt: cut }) =>
|
|
1196
|
+
cut === undefined ? { row } : { row, excerpt: cut },
|
|
1197
|
+
);
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
/** Rows holding every word of `query`, best first (see findMatches). */
|
|
1201
|
+
export function find(kit, query) {
|
|
1202
|
+
return findMatches(kit, query).map(({ row }) => row);
|
|
969
1203
|
}
|
|
970
1204
|
|
|
971
1205
|
function findLines(kit, query) {
|
|
972
|
-
const
|
|
973
|
-
if (!
|
|
1206
|
+
const matches = findMatches(kit, query);
|
|
1207
|
+
if (!matches.length) {
|
|
974
1208
|
const near = nearMatches(
|
|
975
1209
|
query,
|
|
976
1210
|
kit.rows.map((row) => row.id),
|
|
@@ -979,15 +1213,40 @@ function findLines(kit, query) {
|
|
|
979
1213
|
`nothing matches "${query}".${near.length ? ` Near: ${near.join(", ")}` : ""}`,
|
|
980
1214
|
);
|
|
981
1215
|
}
|
|
982
|
-
const
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
1216
|
+
const line = ({ row, excerpt: cut }) =>
|
|
1217
|
+
`${row.id} ${row.kind ?? rowSource(row)} ${row.width ?? "?"}w ${formatBytes(row.bytes ?? 0)} ${row.title ?? ""}${cut === undefined ? "" : ` "${cut}"`}`;
|
|
1218
|
+
if (matches.length <= FIND_LIMIT) return matches.map(line);
|
|
1219
|
+
const room = FIND_LIMIT - 1;
|
|
1220
|
+
// A row with an excerpt was found by on-screen text; any other, by name.
|
|
1221
|
+
const byText = matches.filter((match) => match.excerpt !== undefined);
|
|
1222
|
+
if (!byText.length) {
|
|
1223
|
+
return [
|
|
1224
|
+
...matches.slice(0, room).map(line),
|
|
1225
|
+
`… ${matches.length - room} more; add a word to narrow it`,
|
|
1226
|
+
];
|
|
989
1227
|
}
|
|
990
|
-
|
|
1228
|
+
// Names fill the list first, but the last lines are kept for text hits,
|
|
1229
|
+
// so an agent sees them even when names alone would fill it.
|
|
1230
|
+
const byName = matches.filter((match) => match.excerpt === undefined);
|
|
1231
|
+
const textRoom = Math.min(
|
|
1232
|
+
byText.length,
|
|
1233
|
+
Math.max(FIND_TEXT_LINES, room - byName.length),
|
|
1234
|
+
);
|
|
1235
|
+
const names = byName.slice(0, room - textRoom);
|
|
1236
|
+
const texts = byText.slice(0, room - names.length);
|
|
1237
|
+
const left = [
|
|
1238
|
+
...(byName.length > names.length
|
|
1239
|
+
? [`+${byName.length - names.length} more by name`]
|
|
1240
|
+
: []),
|
|
1241
|
+
...(byText.length > texts.length
|
|
1242
|
+
? [`+${byText.length - texts.length} more by on-screen text`]
|
|
1243
|
+
: []),
|
|
1244
|
+
];
|
|
1245
|
+
return [
|
|
1246
|
+
...names.map(line),
|
|
1247
|
+
...texts.map(line),
|
|
1248
|
+
`${left.join(", ")}; narrow the query`,
|
|
1249
|
+
];
|
|
991
1250
|
}
|
|
992
1251
|
|
|
993
1252
|
function showText(kit, id, full) {
|
|
@@ -1022,7 +1281,7 @@ function infoText(kit) {
|
|
|
1022
1281
|
|
|
1023
1282
|
const USAGE = `usage: kit.mjs [--kit <dir>] <command>
|
|
1024
1283
|
info print the manifest
|
|
1025
|
-
find <query> list up to ${FIND_LIMIT}
|
|
1284
|
+
find <query> list up to ${FIND_LIMIT} rows whose id, title or on-screen text holds every word
|
|
1026
1285
|
show <id> [--full] path, size and an outline (--full: the markup)
|
|
1027
1286
|
compose --frame <id> [--slot <name>=<file>]… [--theme light|dark] [--show-new] --out <file>`;
|
|
1028
1287
|
|