@grimoire-rs/indexer 0.5.2 → 0.5.3

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.
@@ -62,16 +62,18 @@ const { title, description, image = config.logo ?? config.favicon } = Astro.prop
62
62
 
63
63
  // Also before first paint: a deep link (a keyword chip on a package
64
64
  // page) or a stored preference means the catalog the server rendered —
65
- // every package, empty search box, name order, deprecated hidden — is
66
- // the wrong one. Flag it so CSS holds the catalog back rather than
67
- // showing the full list and rearranging it a moment later. The island
68
- // drops the flag once it has applied the view — which is the render
69
- // after hydration, never the hydrating one itself; the timeout is the
70
- // failsafe for an island that never hydrates, where a flash beats a
71
- // blank page.
65
+ // every package, empty search box, name order, deprecated hidden, as
66
+ // cards — is the wrong one. Flag it so CSS holds the catalog back
67
+ // rather than showing the full list and rearranging it a moment
68
+ // later. The island drops the flag once it has applied the view —
69
+ // which is the render after hydration, never the hydrating one
70
+ // itself; the timeout is the failsafe for an island that never
71
+ // hydrates, where a flash beats a blank page.
72
72
  const view = new URLSearchParams(location.search);
73
- const arranged = ["sort", "dir", "deprecated"].some((k) => pref("grim.catalog." + k));
74
- if (view.get("q") || view.get("kind") || arranged) {
73
+ const arranged = ["sort", "dir", "deprecated", "view"].some((k) =>
74
+ pref("grim.catalog." + k),
75
+ );
76
+ if (view.get("q") || view.get("kind") || view.get("kw") || arranged) {
75
77
  document.documentElement.dataset.query = "";
76
78
  setTimeout(() => delete document.documentElement.dataset.query, 3000);
77
79
  }
@@ -853,12 +855,12 @@ const { title, description, image = config.logo ?? config.favicon } = Astro.prop
853
855
  that is not yet in use, so it withdraws the moment the field is
854
856
  focused or holds a query — at which point it would only be
855
857
  sitting on top of what is being typed. */
856
- .search-hint {
858
+ .search-hint,
859
+ .search-clear {
857
860
  position: absolute;
858
861
  right: 0.5rem;
859
862
  top: 50%;
860
863
  translate: 0 -50%;
861
- pointer-events: none;
862
864
  border: var(--grim-border-width) solid var(--grim-color-border);
863
865
  border-bottom-width: 2px;
864
866
  border-radius: var(--grim-radius-inset);
@@ -869,10 +871,33 @@ const { title, description, image = config.logo ?? config.favicon } = Astro.prop
869
871
  line-height: 1;
870
872
  padding: var(--grim-space-2) var(--grim-space-3);
871
873
  }
874
+ .search-hint {
875
+ pointer-events: none;
876
+ }
877
+ /* The two never coexist — the hint withdraws exactly when a query
878
+ appears, which is when the clear button is rendered — so they share
879
+ one corner rather than sitting beside each other. */
872
880
  .search-field:focus-within .search-hint,
873
881
  .search-field:has(input:not(:placeholder-shown)) .search-hint {
874
882
  display: none;
875
883
  }
884
+ /* Sized to the glyph rather than to a text line, so the box matches
885
+ the hint's height instead of growing around an SVG on a baseline. */
886
+ .search-clear {
887
+ display: grid;
888
+ place-items: center;
889
+ cursor: pointer;
890
+ }
891
+ .search-clear:hover {
892
+ color: var(--grim-color-fg);
893
+ }
894
+ /* The UA's own clear button, gone: it renders in this same corner in
895
+ Chromium and Safari, in a style that answers to no token here, and
896
+ it cannot be restyled beyond hiding. Ours replaces it. */
897
+ .controls input[type="search"]::-webkit-search-cancel-button {
898
+ appearance: none;
899
+ display: none;
900
+ }
876
901
  /* One line, always. Wrapping was the defect: at a narrow width the
877
902
  row dropped `deprecated` onto a second line, because no chip can
878
903
  shrink below its own longest word and the rail's chip count is a
@@ -1240,6 +1265,18 @@ const { title, description, image = config.logo ?? config.favicon } = Astro.prop
1240
1265
  outline-offset: 0;
1241
1266
  border-color: var(--grim-color-accent);
1242
1267
  }
1268
+ /* The sort combo, while a POINTER is driving it. Chromium hands a
1269
+ `<select>` `:focus-visible` on an ordinary click, so this control
1270
+ alone would wear the keyboard ring for a mouse interaction — around
1271
+ a closed box sitting behind its own open dropdown, which already
1272
+ says everything the ring would. `data-pointer` is set by the
1273
+ element's own `pointerdown` and cleared by a keypress or a blur, so
1274
+ the ring is only ever suppressed for the interaction that did not
1275
+ need it; every keyboard focus keeps it. */
1276
+ .sort-field[data-pointer]:focus-visible {
1277
+ outline: none;
1278
+ border-color: var(--grim-color-accent);
1279
+ }
1243
1280
 
1244
1281
  .grid {
1245
1282
  list-style: none;
@@ -1504,13 +1541,20 @@ const { title, description, image = config.logo ?? config.favicon } = Astro.prop
1504
1541
  the container rather than cutting square across it. */
1505
1542
  .table {
1506
1543
  display: grid;
1507
- /* logo | kind | name | description | updated. The two fixed-width
1508
- marks lead, so the names start on one line down the whole list
1509
- and the eye runs the column it actually reads. The name is
1510
- capped rather than `max-content`: an unbounded name ate the
1511
- description's space, and past the cap both clip instead. The
1512
- description is the only track that grows. */
1513
- grid-template-columns: auto auto minmax(8rem, 14rem) 1fr auto;
1544
+ /* logo | kind | name | description | updated. The two marks lead,
1545
+ so the names start on one line down the whole list and the eye
1546
+ runs the column it actually reads.
1547
+
1548
+ Every track is sized to give the description whatever is left.
1549
+ The marks are `min-content` a 20px tile and a 16px glyph, and
1550
+ nothing about them should reserve more than they draw. The name
1551
+ is `fit-content(14rem)`: as wide as the longest name in the list
1552
+ and no wider, capped, past which it clips. It was
1553
+ `minmax(8rem, 14rem)`, and that 8rem floor was a hole — a list of
1554
+ short names paid a fixed 8rem for them, so the description read
1555
+ as pushed away from a column of empty space. */
1556
+ grid-template-columns:
1557
+ min-content min-content fit-content(14rem) 1fr auto;
1514
1558
  background: var(--grim-color-card);
1515
1559
  border: var(--grim-border-width) solid var(--grim-color-border);
1516
1560
  /* `xl`, the content-surface step — the same one the card wears.
@@ -1524,20 +1568,45 @@ const { title, description, image = config.logo ?? config.favicon } = Astro.prop
1524
1568
  /* One more track when the index publishes ratings. Decided once per
1525
1569
  index, never per filter — see `PackageTable`. */
1526
1570
  .table.rated {
1527
- grid-template-columns: auto auto minmax(8rem, 14rem) 1fr auto auto;
1571
+ grid-template-columns:
1572
+ min-content min-content fit-content(14rem) 1fr auto auto;
1528
1573
  }
1529
1574
  .table .row {
1530
1575
  grid-column: 1 / -1;
1531
1576
  display: grid;
1532
1577
  grid-template-columns: subgrid;
1533
1578
  align-items: center;
1534
- gap: var(--grim-space-5);
1579
+ /* One step tighter than the row's own padding. A 12px gutter either
1580
+ side of a 16px glyph reads as a hole rather than as spacing. */
1581
+ gap: var(--grim-space-4);
1535
1582
  padding: var(--grim-space-3) var(--grim-space-5);
1536
1583
  color: inherit;
1537
1584
  text-decoration: none;
1538
1585
  font-size: var(--grim-text-sm);
1539
1586
  transition: background-color var(--grim-duration-base) ease;
1540
1587
  }
1588
+ /* NO `content-visibility` on a row, and the reason is load-bearing.
1589
+ `content-visibility: auto` applies SIZE CONTAINMENT, and a
1590
+ size-contained element cannot be a subgrid — the `subgrid` above
1591
+ degrades to `none`, silently. Measured in Chromium 152 against a
1592
+ real build: with it, `getComputedStyle(row).gridTemplateColumns`
1593
+ reads `none` and every cell is one sixth of the table (181px each
1594
+ at a 1280px viewport); without it the parent's tracks apply and the
1595
+ description gets the 832px the `1fr` exists to give it.
1596
+
1597
+ It shipped in 2a77c15 as a paint bound, and the check made at the
1598
+ time — first and last rows' cells at identical offsets, before and
1599
+ after scrolling — could not have caught this: when EVERY row is the
1600
+ same independently-computed grid, the offsets match perfectly and
1601
+ the alignment is fake. Any check here must read the row's own
1602
+ `gridTemplateColumns`, or a cell's width against its content, never
1603
+ two rows against each other. `style_contract.test.ts` holds the
1604
+ text form of the invariant.
1605
+
1606
+ What that commit bought is not lost. The logo fetches it was aimed
1607
+ at are held off by `loading="lazy"` on the image itself, and the
1608
+ list's real cost — building every row — is bounded by the window in
1609
+ `Catalog.tsx`, which is that same commit's other half. */
1541
1610
  /* The rule belongs between rows, not under the last one — which the
1542
1611
  container's own border already draws. */
1543
1612
  .table .row + .row {
@@ -1683,10 +1752,11 @@ const { title, description, image = config.logo ?? config.favicon } = Astro.prop
1683
1752
  @media (max-width: 40rem) {
1684
1753
  .table,
1685
1754
  .table.rated {
1686
- grid-template-columns: auto auto minmax(6rem, 1fr) auto;
1755
+ grid-template-columns: min-content min-content minmax(6rem, 1fr) auto;
1687
1756
  }
1688
1757
  .table.rated {
1689
- grid-template-columns: auto auto minmax(6rem, 1fr) auto auto;
1758
+ grid-template-columns:
1759
+ min-content min-content minmax(6rem, 1fr) auto auto;
1690
1760
  }
1691
1761
  .t-desc {
1692
1762
  display: none;
@@ -1,5 +1,35 @@
1
1
  import type { CatalogPackage } from "../../types.js";
2
2
  export type { CatalogPackage } from "../../types.js";
3
+ /**
4
+ * The fields the CATALOG ISLAND may read — and, because the island's props
5
+ * are serialized into the page, the only fields that are worth shipping.
6
+ *
7
+ * `index.astro` projects every package through `cardPackage()` before handing
8
+ * the array to `<Catalog>`. Astro serializes island props into an HTML
9
+ * attribute on `<astro-island>`, so anything left in a record is paid for
10
+ * twice on the landing page: once in the server-rendered card markup, and
11
+ * again as escaped JSON the browser parses at hydration. At a corporate-sized
12
+ * catalog the untrimmed record set was 323 KB of that attribute, ~18% of the
13
+ * page, most of it fields no card or row ever looks at — `license`,
14
+ * `authors`, `vendor`, `documentation`, `compatibility`, `revision`,
15
+ * `support`, `repository`, and every unknown enrichment key riding
16
+ * `IndexRecord`'s index signature.
17
+ *
18
+ * This is a TYPE, not just a filter, and that is the point: a component that
19
+ * reaches for a field not listed here fails to compile, rather than silently
20
+ * rendering `undefined` in the browser while the server render — which reads
21
+ * the full record — looks correct. The detail pages are unaffected; they read
22
+ * `data` directly and hydrate nothing.
23
+ */
24
+ export type CardPackage = Pick<CatalogPackage, "name" | "kind" | "ref" | "namespace" | "description" | "summary" | "version" | "keywords" | "created" | "updated" | "deprecated" | "replacedBy" | "logo" | "rating">;
25
+ /**
26
+ * One package, trimmed to what the island renders.
27
+ *
28
+ * An absent key is omitted rather than set to `undefined`: `JSON.stringify`
29
+ * drops an undefined value anyway, and omitting it keeps the projection's
30
+ * output identical to what a hand-written object literal would produce.
31
+ */
32
+ export declare function cardPackage(p: CatalogPackage): CardPackage;
3
33
  /**
4
34
  * When a package last moved, for sorting and for the "updated" stamp.
5
35
  *
@@ -9,7 +39,7 @@ export type { CatalogPackage } from "../../types.js";
9
39
  * existed: those records still date correctly instead of dropping into the
10
40
  * unknown bucket on the first build after an upgrade.
11
41
  */
12
- export declare function lastUpdated(p: CatalogPackage): string | undefined;
42
+ export declare function lastUpdated(p: Pick<CatalogPackage, "updated" | "created">): string | undefined;
13
43
  /**
14
44
  * The deprecation sentence, or `null` for a package that is not deprecated.
15
45
  *
@@ -1,5 +1,37 @@
1
1
  // SPDX-License-Identifier: Apache-2.0
2
2
  // Copyright 2026 The Grimoire Authors
3
+ /** The keys of `CardPackage`, as one list the projection actually iterates. */
4
+ const CARD_FIELDS = [
5
+ "name",
6
+ "kind",
7
+ "ref",
8
+ "namespace",
9
+ "description",
10
+ "summary",
11
+ "version",
12
+ "keywords",
13
+ "created",
14
+ "updated",
15
+ "deprecated",
16
+ "replacedBy",
17
+ "logo",
18
+ "rating",
19
+ ];
20
+ /**
21
+ * One package, trimmed to what the island renders.
22
+ *
23
+ * An absent key is omitted rather than set to `undefined`: `JSON.stringify`
24
+ * drops an undefined value anyway, and omitting it keeps the projection's
25
+ * output identical to what a hand-written object literal would produce.
26
+ */
27
+ export function cardPackage(p) {
28
+ const out = {};
29
+ for (const key of CARD_FIELDS) {
30
+ if (p[key] !== undefined)
31
+ out[key] = p[key];
32
+ }
33
+ return out;
34
+ }
3
35
  /**
4
36
  * When a package last moved, for sorting and for the "updated" stamp.
5
37
  *
@@ -9,6 +9,78 @@ import type { CatalogPackage } from "../../types.js";
9
9
 
10
10
  export type { CatalogPackage } from "../../types.js";
11
11
 
12
+ /**
13
+ * The fields the CATALOG ISLAND may read — and, because the island's props
14
+ * are serialized into the page, the only fields that are worth shipping.
15
+ *
16
+ * `index.astro` projects every package through `cardPackage()` before handing
17
+ * the array to `<Catalog>`. Astro serializes island props into an HTML
18
+ * attribute on `<astro-island>`, so anything left in a record is paid for
19
+ * twice on the landing page: once in the server-rendered card markup, and
20
+ * again as escaped JSON the browser parses at hydration. At a corporate-sized
21
+ * catalog the untrimmed record set was 323 KB of that attribute, ~18% of the
22
+ * page, most of it fields no card or row ever looks at — `license`,
23
+ * `authors`, `vendor`, `documentation`, `compatibility`, `revision`,
24
+ * `support`, `repository`, and every unknown enrichment key riding
25
+ * `IndexRecord`'s index signature.
26
+ *
27
+ * This is a TYPE, not just a filter, and that is the point: a component that
28
+ * reaches for a field not listed here fails to compile, rather than silently
29
+ * rendering `undefined` in the browser while the server render — which reads
30
+ * the full record — looks correct. The detail pages are unaffected; they read
31
+ * `data` directly and hydrate nothing.
32
+ */
33
+ export type CardPackage = Pick<
34
+ CatalogPackage,
35
+ | "name"
36
+ | "kind"
37
+ | "ref"
38
+ | "namespace"
39
+ | "description"
40
+ | "summary"
41
+ | "version"
42
+ | "keywords"
43
+ | "created"
44
+ | "updated"
45
+ | "deprecated"
46
+ | "replacedBy"
47
+ | "logo"
48
+ | "rating"
49
+ >;
50
+
51
+ /** The keys of `CardPackage`, as one list the projection actually iterates. */
52
+ const CARD_FIELDS = [
53
+ "name",
54
+ "kind",
55
+ "ref",
56
+ "namespace",
57
+ "description",
58
+ "summary",
59
+ "version",
60
+ "keywords",
61
+ "created",
62
+ "updated",
63
+ "deprecated",
64
+ "replacedBy",
65
+ "logo",
66
+ "rating",
67
+ ] as const satisfies readonly (keyof CardPackage)[];
68
+
69
+ /**
70
+ * One package, trimmed to what the island renders.
71
+ *
72
+ * An absent key is omitted rather than set to `undefined`: `JSON.stringify`
73
+ * drops an undefined value anyway, and omitting it keeps the projection's
74
+ * output identical to what a hand-written object literal would produce.
75
+ */
76
+ export function cardPackage(p: CatalogPackage): CardPackage {
77
+ const out: Record<string, unknown> = {};
78
+ for (const key of CARD_FIELDS) {
79
+ if (p[key] !== undefined) out[key] = p[key];
80
+ }
81
+ return out as CardPackage;
82
+ }
83
+
12
84
  /**
13
85
  * When a package last moved, for sorting and for the "updated" stamp.
14
86
  *
@@ -18,7 +90,7 @@ export type { CatalogPackage } from "../../types.js";
18
90
  * existed: those records still date correctly instead of dropping into the
19
91
  * unknown bucket on the first build after an upgrade.
20
92
  */
21
- export function lastUpdated(p: CatalogPackage): string | undefined {
93
+ export function lastUpdated(p: Pick<CatalogPackage, "updated" | "created">): string | undefined {
22
94
  return p.updated ?? p.created;
23
95
  }
24
96
 
@@ -0,0 +1,17 @@
1
+ /** Every package that matched, by `ref`, with its 0–1 score. */
2
+ export type Scores = ReadonlyMap<string, number>;
3
+ export interface SearchIndex {
4
+ /** Score one query against the whole catalog. Empty query, empty map. */
5
+ search(query: string): Scores;
6
+ }
7
+ /**
8
+ * Fetch the full catalog and build the matcher over it.
9
+ *
10
+ * Rejects rather than degrading quietly — the caller decides what a failure
11
+ * means, and for the island it means "keep the substring filter". Records
12
+ * without a string `ref` are dropped instead of failing the whole load: `ref`
13
+ * is how a hit is joined back onto the card the island already holds, so a
14
+ * record without one could never be shown even if it matched.
15
+ */
16
+ export declare function loadSearchIndex(url: string): Promise<SearchIndex>;
17
+ //# sourceMappingURL=search.d.ts.map
@@ -0,0 +1,138 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright 2026 The Grimoire Authors
3
+ // The catalog's fuzzy search, and the only module in the client bundle that
4
+ // imports `fuzzysort`.
5
+ //
6
+ // It is reached exclusively through `await import()` from `Catalog.tsx`, so
7
+ // the library and this file land in a chunk of their own that nothing
8
+ // downloads until a reader actually types. That is the whole reason this is
9
+ // a separate file rather than a function in the island: a static import here
10
+ // would put ~7.6 KB of matcher on the critical path of a page most visitors
11
+ // never search.
12
+ //
13
+ // It is also where the catalog stops being limited to what the island was
14
+ // handed. `Catalog.tsx` receives `CardPackage` — the fourteen fields a card
15
+ // or a row draws — because island props are serialized into the page and
16
+ // every extra field is paid for twice. Search wants the opposite trade: the
17
+ // whole record, including the fields no card shows. `/all.json` already
18
+ // publishes exactly that and is a frozen public URL (see `renderer/index.ts`),
19
+ // so the full text is one lazy fetch away and costs the initial render
20
+ // nothing.
21
+ import fuzzysort from "fuzzysort";
22
+ /**
23
+ * How good a match has to be to count, on fuzzysort's 0–1 scale.
24
+ *
25
+ * Measured against the library's own scoring (4.0.2), not guessed: a
26
+ * one-character typo — `catlog` against `catalog-indexer` — scores .353, and
27
+ * a coincidental subsequence — `gard` finding g…a…r…d scattered across an
28
+ * unrelated description — scores .221. Anything between those two numbers
29
+ * keeps typo tolerance while dropping the coincidences, and .3 sits there.
30
+ *
31
+ * The library's own default is .5, which rejects every typo above, and its
32
+ * default `limit` is 10, which would silently cap a catalog search at the
33
+ * first ten hits. Both are overridden below on purpose.
34
+ */
35
+ const THRESHOLD = 0.3;
36
+ /**
37
+ * How long to wait for `/all.json` before giving up on fuzzy search.
38
+ *
39
+ * Failure here is not an error the reader sees: the island keeps its own
40
+ * substring filter over the fields it was handed, so a slow or unreachable
41
+ * index degrades to what the catalog did before this file existed.
42
+ */
43
+ const TIMEOUT_MS = 15_000;
44
+ function isRecord(value) {
45
+ return typeof value === "object" && value !== null && !Array.isArray(value);
46
+ }
47
+ /**
48
+ * One field as searchable text, or nothing.
49
+ *
50
+ * Arrays (`keywords`, `tags`) join; anything that is not a string is dropped
51
+ * rather than coerced. `String(value)` would put `[object Object]` and
52
+ * `undefined` into the haystack, which then match queries containing them.
53
+ */
54
+ function text(value) {
55
+ if (typeof value === "string")
56
+ return value || undefined;
57
+ if (Array.isArray(value)) {
58
+ const parts = value.filter((item) => typeof item === "string");
59
+ return parts.length > 0 ? parts.join(" ") : undefined;
60
+ }
61
+ return undefined;
62
+ }
63
+ /**
64
+ * What a query is matched against — deliberately everything a human would
65
+ * expect to find a package by, not just what the card draws.
66
+ *
67
+ * Each key is scored separately and the terms of a multi-word query may land
68
+ * on different ones, so listing a field here means "typing this word finds
69
+ * the package", never "the word has to appear beside the others".
70
+ *
71
+ * Left out on purpose: `logo` and `revision` (opaque identifiers nobody
72
+ * searches for), `created`/`updated` (timestamps — the sort answers that
73
+ * question), `rating` (a count), `support` (contact URLs, not descriptions),
74
+ * and `deprecated`, whose reason text would pull retired packages into
75
+ * results for words their replacement never used.
76
+ */
77
+ const KEYS = [
78
+ (p) => text(p.name),
79
+ (p) => text(p.title),
80
+ (p) => text(p.namespace),
81
+ (p) => text(p.kind),
82
+ (p) => text(p.ref),
83
+ (p) => text(p.description),
84
+ (p) => text(p.summary),
85
+ (p) => text(p.keywords),
86
+ (p) => text(p.tags),
87
+ (p) => text(p.vendor),
88
+ (p) => text(p.authors),
89
+ (p) => text(p.license),
90
+ (p) => text(p.repository),
91
+ (p) => text(p.url),
92
+ (p) => text(p.documentation),
93
+ (p) => text(p.compatibility),
94
+ (p) => text(p.replacedBy),
95
+ ];
96
+ /**
97
+ * Fetch the full catalog and build the matcher over it.
98
+ *
99
+ * Rejects rather than degrading quietly — the caller decides what a failure
100
+ * means, and for the island it means "keep the substring filter". Records
101
+ * without a string `ref` are dropped instead of failing the whole load: `ref`
102
+ * is how a hit is joined back onto the card the island already holds, so a
103
+ * record without one could never be shown even if it matched.
104
+ */
105
+ export async function loadSearchIndex(url) {
106
+ const response = await fetch(url, { signal: AbortSignal.timeout(TIMEOUT_MS) });
107
+ if (!response.ok) {
108
+ throw new Error(`GET ${url}: ${response.status} ${response.statusText}`);
109
+ }
110
+ const raw = await response.json();
111
+ if (!Array.isArray(raw))
112
+ throw new Error(`${url}: expected an array of records`);
113
+ const records = raw
114
+ .filter(isRecord)
115
+ .filter((record) => typeof record.ref === "string");
116
+ // Prepared once, reused for every keystroke — the whole point of the
117
+ // snapshot API, and what keeps typing cheap on a large catalog.
118
+ const snapshot = fuzzysort.snapshot(records, { keys: KEYS });
119
+ return {
120
+ search(query) {
121
+ const scores = new Map();
122
+ const needle = query.trim();
123
+ if (!needle)
124
+ return scores;
125
+ // `limit: 0` because this feeds a filter, not a typeahead: a capped
126
+ // result set would hide packages the reader can see are missing.
127
+ for (const hit of fuzzysort.go(needle, snapshot, {
128
+ limit: 0,
129
+ threshold: THRESHOLD,
130
+ })) {
131
+ const ref = hit.obj.ref;
132
+ if (typeof ref === "string")
133
+ scores.set(ref, hit.score);
134
+ }
135
+ return scores;
136
+ },
137
+ };
138
+ }
@@ -0,0 +1,157 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright 2026 The Grimoire Authors
3
+
4
+ // The catalog's fuzzy search, and the only module in the client bundle that
5
+ // imports `fuzzysort`.
6
+ //
7
+ // It is reached exclusively through `await import()` from `Catalog.tsx`, so
8
+ // the library and this file land in a chunk of their own that nothing
9
+ // downloads until a reader actually types. That is the whole reason this is
10
+ // a separate file rather than a function in the island: a static import here
11
+ // would put ~7.6 KB of matcher on the critical path of a page most visitors
12
+ // never search.
13
+ //
14
+ // It is also where the catalog stops being limited to what the island was
15
+ // handed. `Catalog.tsx` receives `CardPackage` — the fourteen fields a card
16
+ // or a row draws — because island props are serialized into the page and
17
+ // every extra field is paid for twice. Search wants the opposite trade: the
18
+ // whole record, including the fields no card shows. `/all.json` already
19
+ // publishes exactly that and is a frozen public URL (see `renderer/index.ts`),
20
+ // so the full text is one lazy fetch away and costs the initial render
21
+ // nothing.
22
+ import fuzzysort from "fuzzysort";
23
+
24
+ /**
25
+ * How good a match has to be to count, on fuzzysort's 0–1 scale.
26
+ *
27
+ * Measured against the library's own scoring (4.0.2), not guessed: a
28
+ * one-character typo — `catlog` against `catalog-indexer` — scores .353, and
29
+ * a coincidental subsequence — `gard` finding g…a…r…d scattered across an
30
+ * unrelated description — scores .221. Anything between those two numbers
31
+ * keeps typo tolerance while dropping the coincidences, and .3 sits there.
32
+ *
33
+ * The library's own default is .5, which rejects every typo above, and its
34
+ * default `limit` is 10, which would silently cap a catalog search at the
35
+ * first ten hits. Both are overridden below on purpose.
36
+ */
37
+ const THRESHOLD = 0.3;
38
+
39
+ /**
40
+ * How long to wait for `/all.json` before giving up on fuzzy search.
41
+ *
42
+ * Failure here is not an error the reader sees: the island keeps its own
43
+ * substring filter over the fields it was handed, so a slow or unreachable
44
+ * index degrades to what the catalog did before this file existed.
45
+ */
46
+ const TIMEOUT_MS = 15_000;
47
+
48
+ /** Every package that matched, by `ref`, with its 0–1 score. */
49
+ export type Scores = ReadonlyMap<string, number>;
50
+
51
+ export interface SearchIndex {
52
+ /** Score one query against the whole catalog. Empty query, empty map. */
53
+ search(query: string): Scores;
54
+ }
55
+
56
+ /**
57
+ * A record straight off the wire — every value `unknown`, because nothing
58
+ * validated it and `all.json` carries publisher-authored enrichment under an
59
+ * open index signature.
60
+ */
61
+ type WireRecord = Readonly<Record<string, unknown>>;
62
+
63
+ function isRecord(value: unknown): value is WireRecord {
64
+ return typeof value === "object" && value !== null && !Array.isArray(value);
65
+ }
66
+
67
+ /**
68
+ * One field as searchable text, or nothing.
69
+ *
70
+ * Arrays (`keywords`, `tags`) join; anything that is not a string is dropped
71
+ * rather than coerced. `String(value)` would put `[object Object]` and
72
+ * `undefined` into the haystack, which then match queries containing them.
73
+ */
74
+ function text(value: unknown): string | undefined {
75
+ if (typeof value === "string") return value || undefined;
76
+ if (Array.isArray(value)) {
77
+ const parts = value.filter((item) => typeof item === "string");
78
+ return parts.length > 0 ? parts.join(" ") : undefined;
79
+ }
80
+ return undefined;
81
+ }
82
+
83
+ /**
84
+ * What a query is matched against — deliberately everything a human would
85
+ * expect to find a package by, not just what the card draws.
86
+ *
87
+ * Each key is scored separately and the terms of a multi-word query may land
88
+ * on different ones, so listing a field here means "typing this word finds
89
+ * the package", never "the word has to appear beside the others".
90
+ *
91
+ * Left out on purpose: `logo` and `revision` (opaque identifiers nobody
92
+ * searches for), `created`/`updated` (timestamps — the sort answers that
93
+ * question), `rating` (a count), `support` (contact URLs, not descriptions),
94
+ * and `deprecated`, whose reason text would pull retired packages into
95
+ * results for words their replacement never used.
96
+ */
97
+ const KEYS: readonly ((p: WireRecord) => string | undefined)[] = [
98
+ (p) => text(p.name),
99
+ (p) => text(p.title),
100
+ (p) => text(p.namespace),
101
+ (p) => text(p.kind),
102
+ (p) => text(p.ref),
103
+ (p) => text(p.description),
104
+ (p) => text(p.summary),
105
+ (p) => text(p.keywords),
106
+ (p) => text(p.tags),
107
+ (p) => text(p.vendor),
108
+ (p) => text(p.authors),
109
+ (p) => text(p.license),
110
+ (p) => text(p.repository),
111
+ (p) => text(p.url),
112
+ (p) => text(p.documentation),
113
+ (p) => text(p.compatibility),
114
+ (p) => text(p.replacedBy),
115
+ ];
116
+
117
+ /**
118
+ * Fetch the full catalog and build the matcher over it.
119
+ *
120
+ * Rejects rather than degrading quietly — the caller decides what a failure
121
+ * means, and for the island it means "keep the substring filter". Records
122
+ * without a string `ref` are dropped instead of failing the whole load: `ref`
123
+ * is how a hit is joined back onto the card the island already holds, so a
124
+ * record without one could never be shown even if it matched.
125
+ */
126
+ export async function loadSearchIndex(url: string): Promise<SearchIndex> {
127
+ const response = await fetch(url, { signal: AbortSignal.timeout(TIMEOUT_MS) });
128
+ if (!response.ok) {
129
+ throw new Error(`GET ${url}: ${response.status} ${response.statusText}`);
130
+ }
131
+ const raw: unknown = await response.json();
132
+ if (!Array.isArray(raw)) throw new Error(`${url}: expected an array of records`);
133
+ const records = raw
134
+ .filter(isRecord)
135
+ .filter((record) => typeof record.ref === "string");
136
+ // Prepared once, reused for every keystroke — the whole point of the
137
+ // snapshot API, and what keeps typing cheap on a large catalog.
138
+ const snapshot = fuzzysort.snapshot(records, { keys: KEYS });
139
+
140
+ return {
141
+ search(query) {
142
+ const scores = new Map<string, number>();
143
+ const needle = query.trim();
144
+ if (!needle) return scores;
145
+ // `limit: 0` because this feeds a filter, not a typeahead: a capped
146
+ // result set would hide packages the reader can see are missing.
147
+ for (const hit of fuzzysort.go(needle, snapshot, {
148
+ limit: 0,
149
+ threshold: THRESHOLD,
150
+ })) {
151
+ const ref = hit.obj.ref;
152
+ if (typeof ref === "string") scores.set(ref, hit.score);
153
+ }
154
+ return scores;
155
+ },
156
+ };
157
+ }