@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.
- package/CHANGELOG.md +91 -1
- package/dist/renderer/astro/components/CardLogo.d.ts +2 -2
- package/dist/renderer/astro/components/CardLogo.tsx +2 -2
- package/dist/renderer/astro/components/Catalog.d.ts +12 -4
- package/dist/renderer/astro/components/Catalog.js +290 -38
- package/dist/renderer/astro/components/Catalog.tsx +369 -57
- package/dist/renderer/astro/components/PackageCard.d.ts +2 -2
- package/dist/renderer/astro/components/PackageCard.tsx +2 -2
- package/dist/renderer/astro/components/PackageRow.d.ts +2 -2
- package/dist/renderer/astro/components/PackageRow.tsx +2 -2
- package/dist/renderer/astro/layouts/Base.astro +92 -22
- package/dist/renderer/astro/lib/catalog.d.ts +31 -1
- package/dist/renderer/astro/lib/catalog.js +32 -0
- package/dist/renderer/astro/lib/catalog.ts +73 -1
- package/dist/renderer/astro/lib/search.d.ts +17 -0
- package/dist/renderer/astro/lib/search.js +138 -0
- package/dist/renderer/astro/lib/search.ts +157 -0
- package/dist/renderer/astro/pages/index.astro +45 -2
- package/dist/renderer/astro/styles/tokens.css +6 -1
- package/dist/renderer/index.d.ts.map +1 -1
- package/dist/renderer/index.js +52 -0
- package/dist/renderer/index.js.map +1 -1
- package/package.json +6 -1
|
@@ -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
|
|
66
|
-
// the wrong one. Flag it so CSS holds the catalog back
|
|
67
|
-
// showing the full list and rearranging it a moment
|
|
68
|
-
// drops the flag once it has applied the view —
|
|
69
|
-
// after hydration, never the hydrating one
|
|
70
|
-
// failsafe for an island that never
|
|
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) =>
|
|
74
|
-
|
|
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
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
1755
|
+
grid-template-columns: min-content min-content minmax(6rem, 1fr) auto;
|
|
1687
1756
|
}
|
|
1688
1757
|
.table.rated {
|
|
1689
|
-
grid-template-columns:
|
|
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
|
+
}
|