@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 +21 -0
- package/package.json +1 -1
- package/src/capture-pages.mjs +18 -1
- package/src/capture-stories.mjs +4 -0
- package/src/kit.d.ts +36 -1
- package/src/kit.mjs +293 -34
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
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
|
@@ -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
|
-
/**
|
|
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
|
|