@codepress/design-kit 0.6.0 → 0.8.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
@@ -408,6 +408,7 @@ design-kit/
408
408
  manifest.json {kit_version, sha, built_at, counts, sources, theme?, css?}
409
409
  index.json story rows, sorted by id
410
410
  routes.json page rows
411
+ text.json {id: the row's visible text, first 600 characters}
411
412
  html/story--<id>@<w>.html, html/route--<name>@<w>.html
412
413
  css/stories.css every rule the Storybook preview loaded, deduplicated
413
414
  css/routes.css every rule the captured pages loaded, deduplicated
@@ -434,15 +435,39 @@ The tool needs only Node: no install and no network.
434
435
  ```bash
435
436
  node kit.mjs info # the manifest
436
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
437
439
  node kit.mjs show <id> [--full] # path, size and an outline (≤ 2 KB) or the markup
438
440
  node kit.mjs compose --frame route:dashboard@1280 --slot main=new.html \
439
441
  [--theme light|dark] [--show-new] --out page.html
440
442
  ```
441
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
+
442
463
  `compose` writes one self-contained page: the frame's markup with slots
443
464
  filled and every `<kit-snippet id="…">` expanded, its CSS inlined, every
444
465
  relative `url()` that names a file in the kit (fonts included) as a data URI,
445
- and `data-kit-sha`/`data-kit-id` on `<html>`. Scripts, event handlers,
466
+ and `data-kit-sha`/`data-kit-id` on `<html>`. Each expanded snippet's
467
+ top-level elements carry `data-kit-snippet="<id>"`, and a
468
+ `<meta name="design-kit-usage">` in the head holds a JSON record of the kit sha,
469
+ the frame, the snippet ids and how many `data-kit-new` elements the page has.
470
+ The record stays in the page: `kit.mjs` makes no network call. Scripts, event handlers,
446
471
  `<link>`s and SVG animation are removed, and URLs pass an allowlist: only
447
472
  relative references, fragments and `data:` survive, read the way the browser
448
473
  reads them. `--theme dark` applies the manifest's theme. Elements marked
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@codepress/design-kit",
3
- "version": "0.6.0",
3
+ "version": "0.8.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": {
@@ -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))
@@ -30,6 +30,7 @@ import { extname, join, relative, resolve, sep } from "node:path";
30
30
  import { fileURLToPath } from "node:url";
31
31
 
32
32
  import { globToRegExp, matchFile } from "./glob.mjs";
33
+ import { writeKitText } from "./kit.mjs";
33
34
  import { loadPlaywright, playwrightVersion } from "./playwright.mjs";
34
35
 
35
36
  export { playwrightVersion };
@@ -1386,6 +1387,9 @@ export async function captureStories(config, options = {}) {
1386
1387
  join(out, "index.json"),
1387
1388
  `${JSON.stringify(rows, null, 1)}\n`,
1388
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);
1389
1393
 
1390
1394
  const storyGaps = [
1391
1395
  ...failures,
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
- /** Rows whose id, title, component or source file holds every word. */
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
- /** Story rows from index.json plus route rows from routes.json, by id. */
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
- return { dir, manifest, rows: sorted, byId: new Map(rows) };
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
- const tokens = [];
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
- tokens.push({ type: "text", start: i, end: html.length });
205
+ yield { type: "text", start: i, end: html.length };
188
206
  break;
189
207
  }
190
- if (lt > i) tokens.push({ type: "text", start: i, end: lt });
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
- tokens.push({ type: "comment", start: lt, end });
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
- tokens.push({ type: "text", start: lt, end: lt + 1 });
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
- tokens.push({ type: "close", name, start: lt, end });
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
- tokens.push({
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
- const closeAt = html.toLowerCase().indexOf(`</${name}`, end);
254
+ lower ??= html.toLowerCase();
255
+ const closeAt = lower.indexOf(`</${name}`, end);
237
256
  const stop = closeAt < 0 ? html.length : closeAt;
238
- if (stop > end) tokens.push({ type: "text", start: end, end: stop });
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}]. */
@@ -693,7 +711,30 @@ export function inlineAssets(kit, css, cssDir = join(kit.dir, "css")) {
693
711
 
694
712
  // ── compose ─────────────────────────────────────────────────────────────────
695
713
 
696
- function expandSnippets(kit, html, used, budget, depth = 0) {
714
+ /**
715
+ * `data-kit-snippet="<id>"` on each top-level element of a snippet's markup,
716
+ * so the composed page records which real piece every part came from. A
717
+ * nested `<kit-snippet>` at the top is left alone: its own expansion stamps
718
+ * the elements that replace it.
719
+ */
720
+ function stampSnippetRoots(html, id) {
721
+ const roots = elements(html).filter(
722
+ (e) =>
723
+ e.depth === 0 &&
724
+ e.name !== "kit-snippet" &&
725
+ !("data-kit-snippet" in e.attrs),
726
+ );
727
+ let out = "";
728
+ let i = 0;
729
+ for (const root of roots) {
730
+ const at = root.start + 1 + root.name.length;
731
+ out += `${html.slice(i, at)} data-kit-snippet="${escapeAttr(id)}"`;
732
+ i = at;
733
+ }
734
+ return out + html.slice(i);
735
+ }
736
+
737
+ function expandSnippets(kit, html, used, budget, depth = 0, ids = []) {
697
738
  const found = elements(html).filter((e) => e.name === "kit-snippet");
698
739
  if (!found.length) return html;
699
740
  if (depth >= SNIPPET_DEPTH) {
@@ -717,9 +758,17 @@ function expandSnippets(kit, html, used, budget, depth = 0) {
717
758
  );
718
759
  }
719
760
  if (rowSource(row) === "stories") markup = unwrapStoryRoot(markup);
761
+ if (!ids.includes(row.id)) ids.push(row.id);
720
762
  out +=
721
763
  html.slice(i, element.start) +
722
- expandSnippets(kit, markup, used, budget, depth + 1);
764
+ expandSnippets(
765
+ kit,
766
+ stampSnippetRoots(markup, row.id),
767
+ used,
768
+ budget,
769
+ depth + 1,
770
+ ids,
771
+ );
723
772
  i = element.end;
724
773
  }
725
774
  return out + html.slice(i);
@@ -856,10 +905,17 @@ export function compose(
856
905
  }
857
906
  const used = new Set([rowCssFile(kit, row)]);
858
907
  const filled = fillSlots(kit, row, readSnippet(kit, row), slots);
859
- const body = expandSnippets(kit, filled, used, {
860
- left: maxBytes - filled.length,
861
- max: maxBytes,
862
- });
908
+ const snippetIds = [];
909
+ const body = sanitizeMarkup(
910
+ expandSnippets(
911
+ kit,
912
+ filled,
913
+ used,
914
+ { left: maxBytes - filled.length, max: maxBytes },
915
+ 0,
916
+ snippetIds,
917
+ ),
918
+ );
863
919
 
864
920
  // Stories' sheet first, then the route sheets, as before page sources.
865
921
  const storiesCss = cssFile(kit, "stories");
@@ -894,24 +950,201 @@ export function compose(
894
950
  "body",
895
951
  {},
896
952
  );
953
+ // What this page used, for whoever wants to count it later. Recorded in
954
+ // the page only; kit.mjs never sends it anywhere.
955
+ const usage = {
956
+ kit_sha: sha,
957
+ frame,
958
+ snippets: snippetIds,
959
+ new_parts: elements(body).filter((e) => "data-kit-new" in e.attrs).length,
960
+ };
897
961
  const page = `<!doctype html>
898
962
  <html${htmlAttrs}>
899
963
  <head>
900
964
  <meta charset="utf-8">
901
965
  <meta name="viewport" content="width=device-width, initial-scale=1">
966
+ <meta name="design-kit-usage" content="${escapeAttr(JSON.stringify(usage))}">
902
967
  <title>${escapeAttr(row.title ?? frame)}</title>
903
968
  <style>
904
969
  ${sanitizeCss(css)}${showNewCss}
905
970
  </style>
906
971
  </head>
907
972
  <body${bodyAttrs}>
908
- ${sanitizeMarkup(body)}
973
+ ${body}
909
974
  </body>
910
975
  </html>
911
976
  `;
912
977
  return page;
913
978
  }
914
979
 
980
+ // ── visible text (text.json) ────────────────────────────────────────────────
981
+
982
+ /** Characters of visible text text.json keeps per row. */
983
+ export const TEXT_LIMIT = 600;
984
+ /** Characters of a `find` excerpt. */
985
+ const EXCERPT_CHARS = 60;
986
+ /** Elements whose content is never read on screen as text. */
987
+ const HIDDEN_CONTENT = new Set([
988
+ "script",
989
+ "style",
990
+ "svg",
991
+ "head",
992
+ "template",
993
+ "noscript",
994
+ "title",
995
+ ]);
996
+ /** Phrasing elements that sit inside a word; every other tag separates words. */
997
+ const INLINE = new Set(
998
+ "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(
999
+ " ",
1000
+ ),
1001
+ );
1002
+
1003
+ /** Each `data-kit-slot` element's inner range, for slot-first text. */
1004
+ function slotRanges(html) {
1005
+ if (!html.includes("data-kit-slot")) return [];
1006
+ return elements(html)
1007
+ .filter((element) => element.attrs["data-kit-slot"] !== undefined)
1008
+ .map((element) => [element.innerStart, element.innerEnd]);
1009
+ }
1010
+
1011
+ /**
1012
+ * The words a row shows: text content outside scripts, styles, SVG, the head
1013
+ * and templates, entities decoded and whitespace collapsed, at most `limit`
1014
+ * characters. Attribute values are never text. The text of the row's slots
1015
+ * (a page's `main`) comes first and the rest of the markup after it, so a
1016
+ * page's own words are kept ahead of the navigation and top bar every page
1017
+ * repeats.
1018
+ */
1019
+ export function visibleText(html, limit = TEXT_LIMIT) {
1020
+ const ranges = slotRanges(html);
1021
+ const lastSlotEnd = Math.max(0, ...ranges.map(([, end]) => end));
1022
+ const inSlot = (at) => ranges.some(([from, to]) => at >= from && at < to);
1023
+ // [slot text, the rest]
1024
+ const parts = ["", ""];
1025
+ const add = (at, words) => {
1026
+ const part = inSlot(at) ? 0 : 1;
1027
+ if (words === " " && (parts[part] === "" || parts[part].endsWith(" ")))
1028
+ return;
1029
+ parts[part] += words;
1030
+ };
1031
+ let hidden = null;
1032
+ let depth = 0;
1033
+ for (const token of scan(html)) {
1034
+ if (hidden) {
1035
+ if (token.name !== hidden) continue;
1036
+ if (token.type === "open" && !token.selfClosing) depth += 1;
1037
+ else if (token.type === "close" && (depth -= 1) === 0) hidden = null;
1038
+ continue;
1039
+ }
1040
+ if (token.type === "open" || token.type === "close") {
1041
+ if (
1042
+ token.type === "open" &&
1043
+ HIDDEN_CONTENT.has(token.name) &&
1044
+ !token.selfClosing
1045
+ ) {
1046
+ hidden = token.name;
1047
+ depth = 1;
1048
+ } else if (!INLINE.has(token.name)) {
1049
+ add(token.start, " ");
1050
+ }
1051
+ continue;
1052
+ }
1053
+ if (token.type !== "text") continue;
1054
+ add(
1055
+ token.start,
1056
+ decodeEntities(html.slice(token.start, token.end)).replace(/\s+/g, " "),
1057
+ );
1058
+ // Enough collected: a row's text is a search aid, not its content.
1059
+ const [slot, rest] = parts;
1060
+ if (slot.length > limit * 2 && slot.trim().length > limit) break;
1061
+ if (
1062
+ token.end >= lastSlotEnd &&
1063
+ slot.length + rest.length > limit * 2 &&
1064
+ `${slot} ${rest}`.trim().length > limit
1065
+ )
1066
+ break;
1067
+ }
1068
+ return [...`${parts[0]} ${parts[1]}`.replace(/\s+/g, " ").trim()]
1069
+ .slice(0, limit)
1070
+ .join("");
1071
+ }
1072
+
1073
+ /**
1074
+ * text.json's content: each row's visible text by id, from its markup.
1075
+ * `read(file)` returns a row's markup by its kit-relative `file`; a row
1076
+ * whose file is missing or leaves the kit has no entry. The first row of an
1077
+ * id wins, as in `loadKit`.
1078
+ */
1079
+ export function kitText(rows, read) {
1080
+ const text = {};
1081
+ const seen = new Set();
1082
+ for (const row of rows) {
1083
+ if (!row || typeof row.id !== "string" || seen.has(row.id)) continue;
1084
+ seen.add(row.id);
1085
+ const file = row.file;
1086
+ if (typeof file !== "string" || !file || file.split(/[\\/]/).includes(".."))
1087
+ continue;
1088
+ let html;
1089
+ try {
1090
+ html = read(file);
1091
+ } catch {
1092
+ continue;
1093
+ }
1094
+ if (typeof html !== "string") continue;
1095
+ const words = visibleText(html);
1096
+ if (words) text[row.id] = words;
1097
+ }
1098
+ return Object.fromEntries(
1099
+ Object.entries(text).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)),
1100
+ );
1101
+ }
1102
+
1103
+ /** text.json as written: stable keys, one row per line. */
1104
+ export function kitTextJson(text) {
1105
+ return `${JSON.stringify(text, null, 1)}\n`;
1106
+ }
1107
+
1108
+ /** Rebuilds `dir`/text.json from the rows and markup already in the kit. */
1109
+ export function writeKitText(dir) {
1110
+ const kit = loadKit(dir);
1111
+ const json = kitTextJson(
1112
+ kitText(kit.rows, (file) => readFileSync(join(dir, file), "utf8")),
1113
+ );
1114
+ writeFileSync(join(dir, "text.json"), json);
1115
+ return json;
1116
+ }
1117
+
1118
+ /** text.json, if the kit has a readable one: id → visible text. */
1119
+ function readText(dir) {
1120
+ const file = join(dir, "text.json");
1121
+ if (!existsSync(file)) return undefined;
1122
+ let parsed;
1123
+ try {
1124
+ parsed = JSON.parse(readFileSync(file, "utf8"));
1125
+ } catch {
1126
+ // An optional search aid: a broken one leaves find as it is without it.
1127
+ return undefined;
1128
+ }
1129
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
1130
+ return undefined;
1131
+ const text = new Map();
1132
+ for (const [id, words] of Object.entries(parsed))
1133
+ if (typeof words === "string") text.set(id, words);
1134
+ return text;
1135
+ }
1136
+
1137
+ /** About EXCERPT_CHARS of `words` around `at`, cut words marked with "…". */
1138
+ function excerpt(words, at, length) {
1139
+ const chars = [...words];
1140
+ // `at` counts UTF-16 units; recount it in characters.
1141
+ const from = [...words.slice(0, at)].length;
1142
+ let start = Math.max(0, from - Math.floor((EXCERPT_CHARS - length) / 2));
1143
+ const end = Math.min(chars.length, start + EXCERPT_CHARS);
1144
+ start = Math.max(0, end - EXCERPT_CHARS);
1145
+ return `${start > 0 ? "…" : ""}${chars.slice(start, end).join("").trim()}${end < chars.length ? "…" : ""}`;
1146
+ }
1147
+
915
1148
  // ── show / find / info ──────────────────────────────────────────────────────
916
1149
 
917
1150
  export function outline(html, limit = SHOW_LIMIT) {
@@ -948,8 +1181,17 @@ function formatBytes(bytes) {
948
1181
  return bytes >= 1024 ? `${(bytes / 1024).toFixed(1)} KB` : `${bytes} B`;
949
1182
  }
950
1183
 
951
- export function find(kit, query) {
1184
+ /**
1185
+ * Rows holding every word of `query` in their id, title, component, source
1186
+ * file or visible text (text.json), best first: a word in the id ranks
1187
+ * above one in the title, component or source file, which ranks above one
1188
+ * only in the text, and among rows that tie, one whose text holds the whole
1189
+ * query as written comes first. A row a word matched only through its text
1190
+ * carries an `excerpt` around that phrase, or else the first such word.
1191
+ */
1192
+ export function findMatches(kit, query) {
952
1193
  const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
1194
+ const phrase = terms.join(" ");
953
1195
  const scored = [];
954
1196
  for (const row of kit.rows) {
955
1197
  const id = row.id.toLowerCase();
@@ -957,20 +1199,59 @@ export function find(kit, query) {
957
1199
  .filter(Boolean)
958
1200
  .join(" ")
959
1201
  .toLowerCase();
960
- if (!terms.every((term) => hay.includes(term))) continue;
961
- const score = terms.reduce(
962
- (sum, term) => sum + (id.includes(term) ? 0 : 1),
963
- 0,
964
- );
965
- scored.push({ row, score });
1202
+ const words = kit.text?.get(row.id) ?? "";
1203
+ let lowered = null;
1204
+ let score = 0;
1205
+ let textHit = null;
1206
+ let matched = true;
1207
+ for (const term of terms) {
1208
+ if (id.includes(term)) continue;
1209
+ if (hay.includes(term)) {
1210
+ score += 1;
1211
+ continue;
1212
+ }
1213
+ lowered ??= words.toLowerCase();
1214
+ const at = lowered.indexOf(term);
1215
+ if (at < 0) {
1216
+ matched = false;
1217
+ break;
1218
+ }
1219
+ score += 2;
1220
+ textHit ??= { at, length: term.length };
1221
+ }
1222
+ if (!matched) continue;
1223
+ // The whole query as written, in the text: the excerpt shows it, and it
1224
+ // ranks first among rows that tie.
1225
+ const at = textHit && terms.length > 1 ? lowered.indexOf(phrase) : -1;
1226
+ if (at >= 0) textHit = { at, length: phrase.length };
1227
+ scored.push({
1228
+ row,
1229
+ score,
1230
+ phrase: at >= 0 ? 0 : 1,
1231
+ ...(textHit
1232
+ ? { excerpt: excerpt(words, textHit.at, textHit.length) }
1233
+ : {}),
1234
+ });
966
1235
  }
967
- scored.sort((a, b) => a.score - b.score || (a.row.id < b.row.id ? -1 : 1));
968
- return scored.map(({ row }) => row);
1236
+ scored.sort(
1237
+ (a, b) =>
1238
+ a.score - b.score ||
1239
+ a.phrase - b.phrase ||
1240
+ (a.row.id < b.row.id ? -1 : 1),
1241
+ );
1242
+ return scored.map(({ row, excerpt: cut }) =>
1243
+ cut === undefined ? { row } : { row, excerpt: cut },
1244
+ );
1245
+ }
1246
+
1247
+ /** Rows holding every word of `query`, best first (see findMatches). */
1248
+ export function find(kit, query) {
1249
+ return findMatches(kit, query).map(({ row }) => row);
969
1250
  }
970
1251
 
971
1252
  function findLines(kit, query) {
972
- const rows = find(kit, query);
973
- if (!rows.length) {
1253
+ const matches = findMatches(kit, query);
1254
+ if (!matches.length) {
974
1255
  const near = nearMatches(
975
1256
  query,
976
1257
  kit.rows.map((row) => row.id),
@@ -979,15 +1260,40 @@ function findLines(kit, query) {
979
1260
  `nothing matches "${query}".${near.length ? ` Near: ${near.join(", ")}` : ""}`,
980
1261
  );
981
1262
  }
982
- const shown = rows.length > FIND_LIMIT ? rows.slice(0, FIND_LIMIT - 1) : rows;
983
- const lines = shown.map(
984
- (row) =>
985
- `${row.id} ${row.kind ?? rowSource(row)} ${row.width ?? "?"}w ${formatBytes(row.bytes ?? 0)} ${row.title ?? ""}`,
986
- );
987
- if (rows.length > shown.length) {
988
- lines.push(`… ${rows.length - shown.length} more; add a word to narrow it`);
1263
+ const line = ({ row, excerpt: cut }) =>
1264
+ `${row.id} ${row.kind ?? rowSource(row)} ${row.width ?? "?"}w ${formatBytes(row.bytes ?? 0)} ${row.title ?? ""}${cut === undefined ? "" : ` "${cut}"`}`;
1265
+ if (matches.length <= FIND_LIMIT) return matches.map(line);
1266
+ const room = FIND_LIMIT - 1;
1267
+ // A row with an excerpt was found by on-screen text; any other, by name.
1268
+ const byText = matches.filter((match) => match.excerpt !== undefined);
1269
+ if (!byText.length) {
1270
+ return [
1271
+ ...matches.slice(0, room).map(line),
1272
+ `… ${matches.length - room} more; add a word to narrow it`,
1273
+ ];
989
1274
  }
990
- return lines;
1275
+ // Names fill the list first, but the last lines are kept for text hits,
1276
+ // so an agent sees them even when names alone would fill it.
1277
+ const byName = matches.filter((match) => match.excerpt === undefined);
1278
+ const textRoom = Math.min(
1279
+ byText.length,
1280
+ Math.max(FIND_TEXT_LINES, room - byName.length),
1281
+ );
1282
+ const names = byName.slice(0, room - textRoom);
1283
+ const texts = byText.slice(0, room - names.length);
1284
+ const left = [
1285
+ ...(byName.length > names.length
1286
+ ? [`+${byName.length - names.length} more by name`]
1287
+ : []),
1288
+ ...(byText.length > texts.length
1289
+ ? [`+${byText.length - texts.length} more by on-screen text`]
1290
+ : []),
1291
+ ];
1292
+ return [
1293
+ ...names.map(line),
1294
+ ...texts.map(line),
1295
+ `${left.join(", ")}; narrow the query`,
1296
+ ];
991
1297
  }
992
1298
 
993
1299
  function showText(kit, id, full) {
@@ -1022,7 +1328,7 @@ function infoText(kit) {
1022
1328
 
1023
1329
  const USAGE = `usage: kit.mjs [--kit <dir>] <command>
1024
1330
  info print the manifest
1025
- find <query> list up to ${FIND_LIMIT} matching ids
1331
+ find <query> list up to ${FIND_LIMIT} rows whose id, title or on-screen text holds every word
1026
1332
  show <id> [--full] path, size and an outline (--full: the markup)
1027
1333
  compose --frame <id> [--slot <name>=<file>]… [--theme light|dark] [--show-new] --out <file>`;
1028
1334