@grimoire-rs/indexer 0.5.1 → 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.
@@ -1,6 +1,6 @@
1
- import { type CatalogPackage } from "../lib/catalog.js";
1
+ import { type CardPackage } from "../lib/catalog.js";
2
2
  export interface PackageCardProps {
3
- pkg: CatalogPackage;
3
+ pkg: CardPackage;
4
4
  /**
5
5
  * `publisher.extension` id behind the deep links, or `null`. A prop rather
6
6
  * than a `lib/data` read: this island hydrates in the browser, and importing
@@ -38,7 +38,7 @@ export function PackageCard({ pkg: p, vscodeExtension, activeKeywords, onToggleK
38
38
  const thread = externalUrl(p.rating.url);
39
39
  const vote = vscodeVoteUrl(vscodeExtension, p.ref);
40
40
  return (_jsxs("span", { class: "rating-group", children: [thread ? (_jsxs("a", { class: "rating-count", href: thread, target: "_blank", rel: "noopener noreferrer", tabIndex: -1, title: `${votes} — open the thread to vote`, "aria-label": `${p.name}: ${votes}. Open the voting thread`, children: [_jsx(ArrowBigUp, { size: 13, "aria-hidden": "true" }), p.rating.up] })) : (_jsxs("span", { class: "rating-count", title: votes, children: [_jsx(ArrowBigUp, { size: 13, "aria-hidden": "true" }), p.rating.up] })), vote && (_jsx("a", { class: "rating-vote", href: vote, tabIndex: -1, title: "Upvote in VS Code", "aria-label": `Upvote ${p.name} in VS Code`, children: _jsx(BrandMark, { path: mdiMicrosoftVisualStudioCode, size: 12 }) }))] }));
41
- })(), _jsxs("p", { class: "namespace", children: [_jsx("span", { class: "kind", "data-slot": "package-kind", children: p.kind }), _jsx("span", { "aria-hidden": "true", children: " \u00B7 " }), p.namespace] })] }), p.keywords && p.keywords.length > 0 && (_jsx("div", { class: "keywords", "data-slot": "package-keywords", children: p.keywords.slice(0, 5).map((kw) => (_jsx("button", { type: "button", class: activeKeywords.includes(kw)
41
+ })(), _jsxs("p", { class: "namespace", children: [_jsx("span", { class: "kind", "data-slot": "package-kind", children: p.kind }), _jsx("span", { "aria-hidden": "true", children: " \u00B7 " }), _jsx("span", { class: "address", title: p.namespace, children: p.namespace })] })] }), p.keywords && p.keywords.length > 0 && (_jsx("div", { class: "keywords", "data-slot": "package-keywords", children: p.keywords.slice(0, 5).map((kw) => (_jsx("button", { type: "button", class: activeKeywords.includes(kw)
42
42
  ? "chip keyword active"
43
43
  : "chip keyword", "aria-pressed": activeKeywords.includes(kw), tabIndex: -1, onClick: () => onToggleKeyword(kw), children: kw }, kw))) })), p.description && _jsx("p", { class: "description", children: p.description }), _jsxs("div", { class: "card-foot", children: [_jsxs("div", { class: "copy-group", children: [_jsx(CopyButton, { command: `grim add --global ${p.ref}`, variant: "global", name: `global add for ${p.name}` }), _jsx(CopyButton, { command: `grim add ${p.ref}`, name: `project add for ${p.name}` }), vscodeUrl(vscodeExtension, p.ref) && (_jsx("a", { class: "copy vscode", href: vscodeUrl(vscodeExtension, p.ref), title: "Open in VS Code", "aria-label": `Open ${p.name} in VS Code`, tabIndex: -1, children: _jsx(BrandMark, { path: mdiMicrosoftVisualStudioCode }) }))] }), (() => {
44
44
  const at = lastUpdated(p);
@@ -22,11 +22,11 @@ import {
22
22
  timeAgo,
23
23
  vscodeUrl,
24
24
  vscodeVoteUrl,
25
- type CatalogPackage,
25
+ type CardPackage,
26
26
  } from "../lib/catalog.js";
27
27
 
28
28
  export interface PackageCardProps {
29
- pkg: CatalogPackage;
29
+ pkg: CardPackage;
30
30
  /**
31
31
  * `publisher.extension` id behind the deep links, or `null`. A prop rather
32
32
  * than a `lib/data` read: this island hydrates in the browser, and importing
@@ -175,7 +175,15 @@ export function PackageCard({
175
175
  {p.kind}
176
176
  </span>
177
177
  <span aria-hidden="true"> · </span>
178
- {p.namespace}
178
+ {/* Its own element so the row can give the address the slack and
179
+ nothing else: the kind is one short word and keeps its width,
180
+ and what does not fit is dropped off the FRONT — a registry
181
+ host is the least distinguishing part of an address and the
182
+ repository is the most. `title` keeps the whole of it
183
+ reachable, since the ellipsis hides the head. */}
184
+ <span class="address" title={p.namespace}>
185
+ {p.namespace}
186
+ </span>
179
187
  </p>
180
188
  </div>
181
189
  {/* Under the head, above the description: what the package
@@ -1,6 +1,6 @@
1
- import { type CatalogPackage } from "../lib/catalog.js";
1
+ import { type CardPackage } from "../lib/catalog.js";
2
2
  export interface PackageRowProps {
3
- pkg: CatalogPackage;
3
+ pkg: CardPackage;
4
4
  /** Whether this index publishes ratings at all — decided once, not per row. */
5
5
  hasRatings: boolean;
6
6
  /** The catalog's own arrow-key navigation. */
@@ -13,10 +13,10 @@ import { ArrowBigUp } from "lucide-preact";
13
13
  import { CardLogo } from "./CardLogo.js";
14
14
  import { DEPRECATED_MARK, KIND_MARKS, KindMark } from "./KindMark.js";
15
15
  import { withBase } from "../lib/base.js";
16
- import { lastUpdated, timeAgo, type CatalogPackage } from "../lib/catalog.js";
16
+ import { lastUpdated, timeAgo, type CardPackage } from "../lib/catalog.js";
17
17
 
18
18
  export interface PackageRowProps {
19
- pkg: CatalogPackage;
19
+ pkg: CardPackage;
20
20
  /** Whether this index publishes ratings at all — decided once, not per row. */
21
21
  hasRatings: boolean;
22
22
  /** The catalog's own arrow-key navigation. */
@@ -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
  }
@@ -849,20 +851,16 @@ const { title, description, image = config.logo ?? config.favicon } = Astro.prop
849
851
  font-size: var(--grim-text-md);
850
852
  min-width: 0;
851
853
  }
852
- .controls input[type="search"]:focus-visible {
853
- outline: 2px solid var(--grim-color-accent);
854
- outline-offset: 1px;
855
- }
856
854
  /* The `/` shortcut, shown as the key it is. It is a hint for a field
857
855
  that is not yet in use, so it withdraws the moment the field is
858
856
  focused or holds a query — at which point it would only be
859
857
  sitting on top of what is being typed. */
860
- .search-hint {
858
+ .search-hint,
859
+ .search-clear {
861
860
  position: absolute;
862
861
  right: 0.5rem;
863
862
  top: 50%;
864
863
  translate: 0 -50%;
865
- pointer-events: none;
866
864
  border: var(--grim-border-width) solid var(--grim-color-border);
867
865
  border-bottom-width: 2px;
868
866
  border-radius: var(--grim-radius-inset);
@@ -873,10 +871,33 @@ const { title, description, image = config.logo ?? config.favicon } = Astro.prop
873
871
  line-height: 1;
874
872
  padding: var(--grim-space-2) var(--grim-space-3);
875
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. */
876
880
  .search-field:focus-within .search-hint,
877
881
  .search-field:has(input:not(:placeholder-shown)) .search-hint {
878
882
  display: none;
879
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
+ }
880
901
  /* One line, always. Wrapping was the defect: at a narrow width the
881
902
  row dropped `deprecated` onto a second line, because no chip can
882
903
  shrink below its own longest word and the rail's chip count is a
@@ -1026,11 +1047,6 @@ const { title, description, image = config.logo ?? config.favicon } = Astro.prop
1026
1047
  .sort-field:focus-visible {
1027
1048
  z-index: 1;
1028
1049
  }
1029
- .sort-dir:focus-visible,
1030
- .sort-field:focus-visible {
1031
- outline: 2px solid var(--grim-color-accent);
1032
- outline-offset: 1px;
1033
- }
1034
1050
  /* One box for every chip in the filter row — the kinds, the keywords,
1035
1051
  the deprecated toggle and the overflow menu's trigger alike. The
1036
1052
  line-height is stated rather than inherited because a `<summary>`
@@ -1062,21 +1078,13 @@ const { title, description, image = config.logo ?? config.favicon } = Astro.prop
1062
1078
  background: var(--grim-color-accent-soft);
1063
1079
  }
1064
1080
  /* The keyword rail reorders on every click — it is rescored against
1065
- what is on screen — and the chips slide rather than jump. The FLIP
1066
- effect in `Catalog.tsx` sets an inline `translate` back to where
1067
- each chip was and drops it on the next frame; this transition is
1068
- what plays that back. Without a declared duration the invert would
1069
- simply stick. */
1070
- .chip.kw {
1071
- transition:
1072
- border-color var(--grim-duration-base) ease,
1073
- translate var(--grim-duration-slow) ease-out;
1074
- }
1075
- @media (prefers-reduced-motion: reduce) {
1076
- .chip.kw {
1077
- transition: border-color var(--grim-duration-base) ease;
1078
- }
1079
- }
1081
+ what is on screen — and the chips slide rather than jump. There is
1082
+ deliberately no `translate` transition here: the FLIP effect in
1083
+ `Catalog.tsx` runs each slide as a Web Animation instead, reading
1084
+ `--grim-duration-slow` for its length and honouring
1085
+ `prefers-reduced-motion` itself. A transition needs an inline style
1086
+ to drive it, and an inline style is what a superseded pass used to
1087
+ leave behind on a chip stopped off its seat. */
1080
1088
  /* The overflow menu. A popover, and it has to be one: `.filter-row`
1081
1089
  above is a scroll container, and an absolutely-positioned panel
1082
1090
  inside a scroll container is cropped to the row AND stretches the
@@ -1143,10 +1151,6 @@ const { title, description, image = config.logo ?? config.favicon } = Astro.prop
1143
1151
  font: inherit;
1144
1152
  font-size: var(--grim-text-sm);
1145
1153
  }
1146
- .kw-menu-search:focus-visible {
1147
- outline: 2px solid var(--grim-color-accent);
1148
- outline-offset: 1px;
1149
- }
1150
1154
  .kw-menu-list {
1151
1155
  display: flex;
1152
1156
  flex-direction: column;
@@ -1240,9 +1244,38 @@ const { title, description, image = config.logo ?? config.favicon } = Astro.prop
1240
1244
  .view-pick.active {
1241
1245
  z-index: 1;
1242
1246
  }
1247
+
1248
+ /* One focus ring for every bordered control in the toolbar, and it is
1249
+ the card's: the border takes the accent and the outline sits
1250
+ directly on it, so the two merge into one thicker edge.
1251
+
1252
+ An offset ring is what put two borders on the sort combo. Hover
1253
+ already tints the border, so a ring standing 1px clear of it drew a
1254
+ second line in the same colour — one state reading as two. Every
1255
+ control here tints its border on hover or while active, so the
1256
+ offset had to go from all of them together rather than from the one
1257
+ that was reported. */
1258
+ .chip:focus-visible,
1259
+ .controls input[type="search"]:focus-visible,
1260
+ .kw-menu-search:focus-visible,
1261
+ .sort-dir:focus-visible,
1262
+ .sort-field:focus-visible,
1243
1263
  .view-pick:focus-visible {
1244
1264
  outline: 2px solid var(--grim-color-accent);
1245
- outline-offset: 1px;
1265
+ outline-offset: 0;
1266
+ border-color: var(--grim-color-accent);
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);
1246
1279
  }
1247
1280
 
1248
1281
  .grid {
@@ -1508,13 +1541,20 @@ const { title, description, image = config.logo ?? config.favicon } = Astro.prop
1508
1541
  the container rather than cutting square across it. */
1509
1542
  .table {
1510
1543
  display: grid;
1511
- /* logo | kind | name | description | updated. The two fixed-width
1512
- marks lead, so the names start on one line down the whole list
1513
- and the eye runs the column it actually reads. The name is
1514
- capped rather than `max-content`: an unbounded name ate the
1515
- description's space, and past the cap both clip instead. The
1516
- description is the only track that grows. */
1517
- 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;
1518
1558
  background: var(--grim-color-card);
1519
1559
  border: var(--grim-border-width) solid var(--grim-color-border);
1520
1560
  /* `xl`, the content-surface step — the same one the card wears.
@@ -1528,20 +1568,45 @@ const { title, description, image = config.logo ?? config.favicon } = Astro.prop
1528
1568
  /* One more track when the index publishes ratings. Decided once per
1529
1569
  index, never per filter — see `PackageTable`. */
1530
1570
  .table.rated {
1531
- 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;
1532
1573
  }
1533
1574
  .table .row {
1534
1575
  grid-column: 1 / -1;
1535
1576
  display: grid;
1536
1577
  grid-template-columns: subgrid;
1537
1578
  align-items: center;
1538
- 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);
1539
1582
  padding: var(--grim-space-3) var(--grim-space-5);
1540
1583
  color: inherit;
1541
1584
  text-decoration: none;
1542
1585
  font-size: var(--grim-text-sm);
1543
1586
  transition: background-color var(--grim-duration-base) ease;
1544
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. */
1545
1610
  /* The rule belongs between rows, not under the last one — which the
1546
1611
  container's own border already draws. */
1547
1612
  .table .row + .row {
@@ -1687,10 +1752,11 @@ const { title, description, image = config.logo ?? config.favicon } = Astro.prop
1687
1752
  @media (max-width: 40rem) {
1688
1753
  .table,
1689
1754
  .table.rated {
1690
- grid-template-columns: auto auto minmax(6rem, 1fr) auto;
1755
+ grid-template-columns: min-content min-content minmax(6rem, 1fr) auto;
1691
1756
  }
1692
1757
  .table.rated {
1693
- grid-template-columns: auto auto minmax(6rem, 1fr) auto auto;
1758
+ grid-template-columns:
1759
+ min-content min-content minmax(6rem, 1fr) auto auto;
1694
1760
  }
1695
1761
  .t-desc {
1696
1762
  display: none;
@@ -1745,6 +1811,34 @@ const { title, description, image = config.logo ?? config.favicon } = Astro.prop
1745
1811
  font-size: var(--grim-text-xs);
1746
1812
  line-height: 1.3;
1747
1813
  font-family: ui-monospace, "SFMono-Regular", monospace;
1814
+ /* One line, whatever the address is. A namespace breaks at its own
1815
+ slashes and dots, so left to itself a long one wrapped to a second
1816
+ line and made the card's head taller than its neighbours'. */
1817
+ display: flex;
1818
+ min-width: 0;
1819
+ white-space: nowrap;
1820
+ }
1821
+ /* Trimmed from the FRONT: `github.com/` is on every address in an
1822
+ index and the repository at the tail is what tells two apart, so the
1823
+ head is what to spend.
1824
+
1825
+ `direction: rtl` is the mechanism — `text-overflow` ellipsises at
1826
+ the line's inline END, which in an RTL box is its left edge, and
1827
+ `text-align: left` keeps a short address sitting under the name
1828
+ rather than flung to the right. The address itself still reads
1829
+ left-to-right: it is one run of Latin characters, so the bidi
1830
+ algorithm lays it out LTR inside the RTL box. That holds because a
1831
+ namespace neither starts nor ends with punctuation — one that did
1832
+ would have that character reordered to the far end.
1833
+
1834
+ The two-value `text-overflow: ellipsis ""` would say this directly
1835
+ and is Firefox-only. */
1836
+ .namespace .address {
1837
+ min-width: 0;
1838
+ overflow: hidden;
1839
+ text-overflow: ellipsis;
1840
+ direction: rtl;
1841
+ text-align: left;
1748
1842
  }
1749
1843
  /* The rating is one control with two halves — the count, which opens
1750
1844
  the forge thread, and the vote, which casts one — joined the way
@@ -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