@codepress/design-kit 0.6.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 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,11 +435,31 @@ 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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@codepress/design-kit",
3
- "version": "0.6.0",
3
+ "version": "0.7.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}]. */
@@ -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
- export function find(kit, query) {
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
- 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 });
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((a, b) => a.score - b.score || (a.row.id < b.row.id ? -1 : 1));
968
- return scored.map(({ row }) => row);
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 rows = find(kit, query);
973
- if (!rows.length) {
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 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`);
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
- return lines;
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} matching ids
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