@grimoire-rs/indexer 0.5.2 → 0.5.4

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.
Files changed (31) hide show
  1. package/CHANGELOG.md +250 -270
  2. package/README.md +26 -0
  3. package/dist/cli/ratings.d.ts.map +1 -1
  4. package/dist/cli/ratings.js +29 -1
  5. package/dist/cli/ratings.js.map +1 -1
  6. package/dist/renderer/astro/components/CardLogo.d.ts +2 -2
  7. package/dist/renderer/astro/components/CardLogo.tsx +2 -2
  8. package/dist/renderer/astro/components/Catalog.d.ts +12 -4
  9. package/dist/renderer/astro/components/Catalog.js +290 -38
  10. package/dist/renderer/astro/components/Catalog.tsx +369 -57
  11. package/dist/renderer/astro/components/PackageCard.d.ts +2 -2
  12. package/dist/renderer/astro/components/PackageCard.tsx +2 -2
  13. package/dist/renderer/astro/components/PackageRow.d.ts +2 -2
  14. package/dist/renderer/astro/components/PackageRow.tsx +2 -2
  15. package/dist/renderer/astro/components/PickerMenu.astro +1 -0
  16. package/dist/renderer/astro/layouts/Base.astro +98 -22
  17. package/dist/renderer/astro/lib/catalog.d.ts +39 -2
  18. package/dist/renderer/astro/lib/catalog.js +42 -1
  19. package/dist/renderer/astro/lib/catalog.ts +82 -1
  20. package/dist/renderer/astro/lib/commands.d.ts +7 -0
  21. package/dist/renderer/astro/lib/commands.js +9 -2
  22. package/dist/renderer/astro/lib/commands.ts +17 -2
  23. package/dist/renderer/astro/lib/search.d.ts +17 -0
  24. package/dist/renderer/astro/lib/search.js +138 -0
  25. package/dist/renderer/astro/lib/search.ts +157 -0
  26. package/dist/renderer/astro/pages/index.astro +51 -3
  27. package/dist/renderer/astro/styles/tokens.css +6 -1
  28. package/dist/renderer/index.d.ts.map +1 -1
  29. package/dist/renderer/index.js +52 -0
  30. package/dist/renderer/index.js.map +1 -1
  31. package/package.json +6 -1
@@ -50,6 +50,24 @@ function forgeEnv(provider) {
50
50
  }
51
51
  return { api: api ?? "", project, token };
52
52
  }
53
+ /**
54
+ * The forge host to publish as `providers.rating_host`, derived from the
55
+ * GraphQL endpoint this run actually talked to.
56
+ *
57
+ * Derived rather than configured, so it can never disagree with where the
58
+ * threads were created — and derived per run, so an operator who moves the
59
+ * instance publishes the new host without touching `index.config.json`.
60
+ * `undefined` for an endpoint that does not parse, which drops the key
61
+ * rather than publishing a value no consumer could dial.
62
+ */
63
+ function ratingHost(api) {
64
+ try {
65
+ return new URL(api).host || undefined;
66
+ }
67
+ catch {
68
+ return undefined;
69
+ }
70
+ }
53
71
  /** Every ref in `index/`. The tally runs before `build`, so `all.json` does not exist yet. */
54
72
  function desiredRefs(root, findMetadataFiles) {
55
73
  const refs = [];
@@ -129,7 +147,17 @@ export async function ratings(root) {
129
147
  }
130
148
  for (const conflict of result.conflicts)
131
149
  console.error(conflictWarning(conflict));
132
- const doc = statsDocument(mergeStats(seed, "rating", cfg.provider, result.fresh));
150
+ const merged = mergeStats(seed, "rating", cfg.provider, result.fresh);
151
+ // Replaced or dropped every run, never carried: a seed's `rating_host` is a
152
+ // claim about an endpoint this run may no longer be using, and a consumer
153
+ // sends a credential to it.
154
+ const providers = { ...merged.providers };
155
+ const host = ratingHost(forge.api);
156
+ if (host === undefined)
157
+ delete providers.rating_host;
158
+ else
159
+ providers.rating_host = host;
160
+ const doc = statsDocument({ ...merged, providers });
133
161
  fs.writeFileSync(path.join(rootDir, STATS_FILE), JSON.stringify(doc, null, 2) + "\n");
134
162
  console.log(logLine(result));
135
163
  return EXIT.ok;
@@ -1 +1 @@
1
- {"version":3,"file":"ratings.js","sourceRoot":"","sources":["../../src/cli/ratings.ts"],"names":[],"mappings":"AAAA,sCAAsC;AACtC,sCAAsC;AAEtC,uEAAuE;AACvE,6EAA6E;AAC7E,EAAE;AACF,0EAA0E;AAC1E,6EAA6E;AAC7E,gCAAgC;AAChC,OAAO,EAAE,MAAM,SAAS,CAAC;AACzB,OAAO,IAAI,MAAM,WAAW,CAAC;AAE7B,OAAO,EAAE,QAAQ,EAAE,IAAI,EAAiB,MAAM,WAAW,CAAC;AAC1D,OAAO,EAAE,QAAQ,EAAE,MAAM,8BAA8B,CAAC;AAExD,kFAAkF;AAClF,SAAS,GAAG,CAAC,IAAY;IACvB,MAAM,KAAK,GAAG,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;IAChC,OAAO,KAAK,KAAK,SAAS,IAAI,KAAK,KAAK,EAAE,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,KAAK,CAAC;AACjE,CAAC;AAED;;;;;;;;GAQG;AACH,SAAS,QAAQ,CAAC,QAA6B;IAC7C,MAAM,CAAC,GAAG,EAAE,OAAO,EAAE,KAAK,CAAC,GACzB,QAAQ,KAAK,QAAQ;QACnB,CAAC,CAAC;YACE,GAAG,CAAC,oBAAoB,CAAC,IAAI,gCAAgC;YAC7D,GAAG,CAAC,mBAAmB,CAAC;YACxB,GAAG,CAAC,oBAAoB,CAAC,IAAI,GAAG,CAAC,cAAc,CAAC;SACjD;QACH,CAAC,CAAC;YACE,GAAG,CAAC,oBAAoB,CAAC;gBACvB,CAAC,GAAG,CAAC,eAAe,CAAC,KAAK,SAAS;oBACjC,CAAC,CAAC,GAAG,GAAG,CAAC,eAAe,CAAC,cAAc;oBACvC,CAAC,CAAC,gCAAgC,CAAC;YACvC,GAAG,CAAC,iBAAiB,CAAC;YACtB,GAAG,CAAC,oBAAoB,CAAC;SAC1B,CAAC;IAER,IAAI,OAAO,KAAK,SAAS,EAAE,CAAC;QAC1B,MAAM,IAAI,QAAQ,CAChB,8BAA8B,QAAQ,KAAK,QAAQ,CAAC,CAAC,CAAC,YAAY,CAAC,CAAC,CAAC,SAAS,aAAa;YACzF,iBAAiB,QAAQ,KAAK,QAAQ,CAAC,CAAC,CAAC,mBAAmB,CAAC,CAAC,CAAC,iBAAiB,EAAE,EACpF,IAAI,CAAC,IAAI,CACV,CAAC;IACJ,CAAC;IACD,IAAI,KAAK,KAAK,SAAS,EAAE,CAAC;QACxB,MAAM,IAAI,QAAQ,CAChB,gCACE,QAAQ,KAAK,QAAQ;YACnB,CAAC,CAAC,sEAAsE;YACxE,CAAC,CAAC,qGACN,EAAE,EACF,IAAI,CAAC,IAAI,CACV,CAAC;IACJ,CAAC;IACD,OAAO,EAAE,GAAG,EAAE,GAAG,IAAI,EAAE,EAAE,OAAO,EAAE,KAAK,EAAE,CAAC;AAC5C,CAAC;AAED,8FAA8F;AAC9F,SAAS,WAAW,CAAC,IAAY,EAAE,iBAA4C;IAC7E,MAAM,IAAI,GAAa,EAAE,CAAC;IAC1B,KAAK,MAAM,IAAI,IAAI,iBAAiB,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,OAAO,CAAC,CAAC,EAAE,CAAC;QAC/D,IAAI,IAAa,CAAC;QAClB,IAAI,CAAC;YACH,IAAI,GAAG,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC,YAAY,CAAC,IAAI,EAAE,MAAM,CAAC,CAAY,CAAC;QAC9D,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,MAAM,IAAI,QAAQ,CAAC,GAAG,IAAI,KAAM,GAAa,CAAC,OAAO,EAAE,EAAE,IAAI,CAAC,IAAI,CAAC,CAAC;QACtE,CAAC;QACD,MAAM,GAAG,GAAI,IAA0B,CAAC,GAAG,CAAC;QAC5C,IAAI,OAAO,GAAG,KAAK,QAAQ,IAAI,GAAG,KAAK,EAAE;YAAE,SAAS;QACpD,wEAAwE;QACxE,sEAAsE;QACtE,yEAAyE;QACzE,wEAAwE;QACxE,sEAAsE;QACtE,0EAA0E;QAC1E,wEAAwE;QACxE,0EAA0E;QAC1E,MAAM,MAAM,GAAG,QAAQ,CAAC,GAAG,CAAC,CAAC;QAC7B,IAAI,CAAC,MAAM,CAAC,EAAE,EAAE,CAAC;YACf,MAAM,IAAI,QAAQ,CAAC,GAAG,IAAI,KAAK,MAAM,CAAC,MAAM,EAAE,EAAE,IAAI,CAAC,IAAI,CAAC,CAAC;QAC7D,CAAC;QACD,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;IACjB,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,OAAO,CAAC,IAAY;IACxC,MAAM,OAAO,GAAG,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC;IAEnC,MAAM,EAAE,iBAAiB,EAAE,GAAG,MAAM,MAAM,CAAC,sBAAsB,CAAC,CAAC;IACnE,MAAM,GAAG,GAAG,MAAM,iBAAiB,CAAC,OAAO,CAAC,CAAC;IAC7C,IAAI,CAAC,GAAG,EAAE,CAAC;QACT,0EAA0E;QAC1E,yEAAyE;QACzE,OAAO,CAAC,GAAG,CAAC,qEAAqE,CAAC,CAAC;QACnF,OAAO,IAAI,CAAC,EAAE,CAAC;IACjB,CAAC;IAED,MAAM,EAAE,UAAU,EAAE,GAAG,MAAM,MAAM,CAAC,cAAc,CAAC,CAAC;IACpD,MAAM,IAAI,GAAG,CAAC,MAAM,UAAU,CAAC,OAAO,CAAC,CAAC,CAAC,IAAI,CAAC;IAC9C,IAAI,IAAI,KAAK,SAAS,IAAI,CAAC,IAAI,CAAC,UAAU,CAAC,UAAU,CAAC,EAAE,CAAC;QACvD,wEAAwE;QACxE,0EAA0E;QAC1E,0EAA0E;QAC1E,0EAA0E;QAC1E,wEAAwE;QACxE,sDAAsD;QACtD,MAAM,IAAI,QAAQ,CAChB,uGAAuG,EACvG,IAAI,CAAC,IAAI,CACV,CAAC;IACJ,CAAC;IAED,MAAM,EAAE,WAAW,EAAE,GAAG,UAAU,CAAC,OAAO,CAAC,CAAC;IAC5C,IAAI,CAAC,WAAW,CAAC,IAAI,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,OAAO,GAAG,KAAK,QAAQ,IAAI,GAAG,CAAC,EAAE,KAAK,SAAS,CAAC,EAAE,CAAC;QAChF,uEAAuE;QACvE,uEAAuE;QACvE,+DAA+D;QAC/D,MAAM,IAAI,QAAQ,CAChB,kGAAkG,EAClG,IAAI,CAAC,IAAI,CACV,CAAC;IACJ,CAAC;IAED,MAAM,KAAK,GAAG,QAAQ,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;IACrC,MAAM,EAAE,iBAAiB,EAAE,GAAG,MAAM,MAAM,CAAC,kBAAkB,CAAC,CAAC;IAC/D,MAAM,OAAO,GAAG,WAAW,CAAC,OAAO,EAAE,iBAAiB,CAAC,CAAC;IAExD,MAAM,EAAE,QAAQ,EAAE,UAAU,EAAE,aAAa,EAAE,UAAU,EAAE,GAAG,MAAM,MAAM,CAAC,oBAAoB,CAAC,CAAC;IAC/F,8EAA8E;IAC9E,sEAAsE;IACtE,MAAM,IAAI,GAAG,MAAM,QAAQ,CAAC,GAAG,IAAI,CAAC,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC,aAAa,CAAC,CAAC;IAEtE,MAAM,EAAE,oBAAoB,EAAE,GAAG,MAAM,MAAM,CAAC,wBAAwB,CAAC,CAAC;IACxE,MAAM,EAAE,eAAe,EAAE,OAAO,EAAE,SAAS,EAAE,GAAG,MAAM,MAAM,CAAC,yBAAyB,CAAC,CAAC;IAExF,MAAM,MAAM,GAAG,MAAM,SAAS,CAAC;QAC7B,QAAQ,EAAE,oBAAoB,CAAC,GAAG,CAAC,QAAQ,EAAE,EAAE,GAAG,KAAK,EAAE,SAAS,EAAE,GAAG,CAAC,SAAS,EAAE,WAAW,EAAE,GAAG,CAAC,WAAW,EAAE,WAAW,EAAE,CAAC;QAC/H,OAAO;QACP,MAAM,EAAE,GAAG,CAAC,YAAY;QACxB,IAAI;KACL,CAAC,CAAC;IAEH,IAAI,MAAM,CAAC,OAAO,EAAE,CAAC;QACnB,MAAM,IAAI,QAAQ,CAChB,gFAAgF;YAC9E,uCAAuC,EACzC,IAAI,CAAC,WAAW,CACjB,CAAC;IACJ,CAAC;IAED,KAAK,MAAM,QAAQ,IAAI,MAAM,CAAC,SAAS;QAAE,OAAO,CAAC,KAAK,CAAC,eAAe,CAAC,QAAQ,CAAC,CAAC,CAAC;IAElF,MAAM,GAAG,GAAG,aAAa,CAAC,UAAU,CAAC,IAAI,EAAE,QAAQ,EAAE,GAAG,CAAC,QAAQ,EAAE,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC;IAClF,EAAE,CAAC,aAAa,CAAC,IAAI,CAAC,IAAI,CAAC,OAAO,EAAE,UAAU,CAAC,EAAE,IAAI,CAAC,SAAS,CAAC,GAAG,EAAE,IAAI,EAAE,CAAC,CAAC,GAAG,IAAI,CAAC,CAAC;IAEtF,OAAO,CAAC,GAAG,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC;IAC7B,OAAO,IAAI,CAAC,EAAE,CAAC;AACjB,CAAC;AAED,uFAAuF;AACvF,SAAS,UAAU,CAAC,IAAY;IAC9B,MAAM,IAAI,GAAG,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,mBAAmB,CAAC,CAAC;IAClD,IAAI,IAAY,CAAC;IACjB,IAAI,CAAC;QACH,IAAI,GAAG,EAAE,CAAC,YAAY,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;IACvC,CAAC;IAAC,MAAM,CAAC;QACP,MAAM,IAAI,QAAQ,CAChB,YAAY,IAAI,uEAAuE,EACvF,IAAI,CAAC,IAAI,CACV,CAAC;IACJ,CAAC;IACD,IAAI,MAAe,CAAC;IACpB,IAAI,CAAC;QACH,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAAY,CAAC;IACvC,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,MAAM,IAAI,QAAQ,CAAC,GAAG,IAAI,KAAM,GAAa,CAAC,OAAO,EAAE,EAAE,IAAI,CAAC,IAAI,CAAC,CAAC;IACtE,CAAC;IACD,MAAM,IAAI,GAAI,MAA2C,EAAE,WAAW,CAAC;IACvE,IAAI,IAAI,KAAK,SAAS,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,CAAC;QAC/C,MAAM,IAAI,QAAQ,CAAC,GAAG,IAAI,gCAAgC,EAAE,IAAI,CAAC,IAAI,CAAC,CAAC;IACzE,CAAC;IACD,OAAO,EAAE,WAAW,EAAE,CAAC,IAAI,IAAI,EAAE,CAAyD,EAAE,CAAC;AAC/F,CAAC"}
1
+ {"version":3,"file":"ratings.js","sourceRoot":"","sources":["../../src/cli/ratings.ts"],"names":[],"mappings":"AAAA,sCAAsC;AACtC,sCAAsC;AAEtC,uEAAuE;AACvE,6EAA6E;AAC7E,EAAE;AACF,0EAA0E;AAC1E,6EAA6E;AAC7E,gCAAgC;AAChC,OAAO,EAAE,MAAM,SAAS,CAAC;AACzB,OAAO,IAAI,MAAM,WAAW,CAAC;AAE7B,OAAO,EAAE,QAAQ,EAAE,IAAI,EAAiB,MAAM,WAAW,CAAC;AAC1D,OAAO,EAAE,QAAQ,EAAE,MAAM,8BAA8B,CAAC;AAExD,kFAAkF;AAClF,SAAS,GAAG,CAAC,IAAY;IACvB,MAAM,KAAK,GAAG,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;IAChC,OAAO,KAAK,KAAK,SAAS,IAAI,KAAK,KAAK,EAAE,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,KAAK,CAAC;AACjE,CAAC;AAED;;;;;;;;GAQG;AACH,SAAS,QAAQ,CAAC,QAA6B;IAC7C,MAAM,CAAC,GAAG,EAAE,OAAO,EAAE,KAAK,CAAC,GACzB,QAAQ,KAAK,QAAQ;QACnB,CAAC,CAAC;YACE,GAAG,CAAC,oBAAoB,CAAC,IAAI,gCAAgC;YAC7D,GAAG,CAAC,mBAAmB,CAAC;YACxB,GAAG,CAAC,oBAAoB,CAAC,IAAI,GAAG,CAAC,cAAc,CAAC;SACjD;QACH,CAAC,CAAC;YACE,GAAG,CAAC,oBAAoB,CAAC;gBACvB,CAAC,GAAG,CAAC,eAAe,CAAC,KAAK,SAAS;oBACjC,CAAC,CAAC,GAAG,GAAG,CAAC,eAAe,CAAC,cAAc;oBACvC,CAAC,CAAC,gCAAgC,CAAC;YACvC,GAAG,CAAC,iBAAiB,CAAC;YACtB,GAAG,CAAC,oBAAoB,CAAC;SAC1B,CAAC;IAER,IAAI,OAAO,KAAK,SAAS,EAAE,CAAC;QAC1B,MAAM,IAAI,QAAQ,CAChB,8BAA8B,QAAQ,KAAK,QAAQ,CAAC,CAAC,CAAC,YAAY,CAAC,CAAC,CAAC,SAAS,aAAa;YACzF,iBAAiB,QAAQ,KAAK,QAAQ,CAAC,CAAC,CAAC,mBAAmB,CAAC,CAAC,CAAC,iBAAiB,EAAE,EACpF,IAAI,CAAC,IAAI,CACV,CAAC;IACJ,CAAC;IACD,IAAI,KAAK,KAAK,SAAS,EAAE,CAAC;QACxB,MAAM,IAAI,QAAQ,CAChB,gCACE,QAAQ,KAAK,QAAQ;YACnB,CAAC,CAAC,sEAAsE;YACxE,CAAC,CAAC,qGACN,EAAE,EACF,IAAI,CAAC,IAAI,CACV,CAAC;IACJ,CAAC;IACD,OAAO,EAAE,GAAG,EAAE,GAAG,IAAI,EAAE,EAAE,OAAO,EAAE,KAAK,EAAE,CAAC;AAC5C,CAAC;AAED;;;;;;;;;GASG;AACH,SAAS,UAAU,CAAC,GAAW;IAC7B,IAAI,CAAC;QACH,OAAO,IAAI,GAAG,CAAC,GAAG,CAAC,CAAC,IAAI,IAAI,SAAS,CAAC;IACxC,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,SAAS,CAAC;IACnB,CAAC;AACH,CAAC;AAED,8FAA8F;AAC9F,SAAS,WAAW,CAAC,IAAY,EAAE,iBAA4C;IAC7E,MAAM,IAAI,GAAa,EAAE,CAAC;IAC1B,KAAK,MAAM,IAAI,IAAI,iBAAiB,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,OAAO,CAAC,CAAC,EAAE,CAAC;QAC/D,IAAI,IAAa,CAAC;QAClB,IAAI,CAAC;YACH,IAAI,GAAG,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC,YAAY,CAAC,IAAI,EAAE,MAAM,CAAC,CAAY,CAAC;QAC9D,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,MAAM,IAAI,QAAQ,CAAC,GAAG,IAAI,KAAM,GAAa,CAAC,OAAO,EAAE,EAAE,IAAI,CAAC,IAAI,CAAC,CAAC;QACtE,CAAC;QACD,MAAM,GAAG,GAAI,IAA0B,CAAC,GAAG,CAAC;QAC5C,IAAI,OAAO,GAAG,KAAK,QAAQ,IAAI,GAAG,KAAK,EAAE;YAAE,SAAS;QACpD,wEAAwE;QACxE,sEAAsE;QACtE,yEAAyE;QACzE,wEAAwE;QACxE,sEAAsE;QACtE,0EAA0E;QAC1E,wEAAwE;QACxE,0EAA0E;QAC1E,MAAM,MAAM,GAAG,QAAQ,CAAC,GAAG,CAAC,CAAC;QAC7B,IAAI,CAAC,MAAM,CAAC,EAAE,EAAE,CAAC;YACf,MAAM,IAAI,QAAQ,CAAC,GAAG,IAAI,KAAK,MAAM,CAAC,MAAM,EAAE,EAAE,IAAI,CAAC,IAAI,CAAC,CAAC;QAC7D,CAAC;QACD,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;IACjB,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,OAAO,CAAC,IAAY;IACxC,MAAM,OAAO,GAAG,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC;IAEnC,MAAM,EAAE,iBAAiB,EAAE,GAAG,MAAM,MAAM,CAAC,sBAAsB,CAAC,CAAC;IACnE,MAAM,GAAG,GAAG,MAAM,iBAAiB,CAAC,OAAO,CAAC,CAAC;IAC7C,IAAI,CAAC,GAAG,EAAE,CAAC;QACT,0EAA0E;QAC1E,yEAAyE;QACzE,OAAO,CAAC,GAAG,CAAC,qEAAqE,CAAC,CAAC;QACnF,OAAO,IAAI,CAAC,EAAE,CAAC;IACjB,CAAC;IAED,MAAM,EAAE,UAAU,EAAE,GAAG,MAAM,MAAM,CAAC,cAAc,CAAC,CAAC;IACpD,MAAM,IAAI,GAAG,CAAC,MAAM,UAAU,CAAC,OAAO,CAAC,CAAC,CAAC,IAAI,CAAC;IAC9C,IAAI,IAAI,KAAK,SAAS,IAAI,CAAC,IAAI,CAAC,UAAU,CAAC,UAAU,CAAC,EAAE,CAAC;QACvD,wEAAwE;QACxE,0EAA0E;QAC1E,0EAA0E;QAC1E,0EAA0E;QAC1E,wEAAwE;QACxE,sDAAsD;QACtD,MAAM,IAAI,QAAQ,CAChB,uGAAuG,EACvG,IAAI,CAAC,IAAI,CACV,CAAC;IACJ,CAAC;IAED,MAAM,EAAE,WAAW,EAAE,GAAG,UAAU,CAAC,OAAO,CAAC,CAAC;IAC5C,IAAI,CAAC,WAAW,CAAC,IAAI,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,OAAO,GAAG,KAAK,QAAQ,IAAI,GAAG,CAAC,EAAE,KAAK,SAAS,CAAC,EAAE,CAAC;QAChF,uEAAuE;QACvE,uEAAuE;QACvE,+DAA+D;QAC/D,MAAM,IAAI,QAAQ,CAChB,kGAAkG,EAClG,IAAI,CAAC,IAAI,CACV,CAAC;IACJ,CAAC;IAED,MAAM,KAAK,GAAG,QAAQ,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;IACrC,MAAM,EAAE,iBAAiB,EAAE,GAAG,MAAM,MAAM,CAAC,kBAAkB,CAAC,CAAC;IAC/D,MAAM,OAAO,GAAG,WAAW,CAAC,OAAO,EAAE,iBAAiB,CAAC,CAAC;IAExD,MAAM,EAAE,QAAQ,EAAE,UAAU,EAAE,aAAa,EAAE,UAAU,EAAE,GAAG,MAAM,MAAM,CAAC,oBAAoB,CAAC,CAAC;IAC/F,8EAA8E;IAC9E,sEAAsE;IACtE,MAAM,IAAI,GAAG,MAAM,QAAQ,CAAC,GAAG,IAAI,CAAC,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC,aAAa,CAAC,CAAC;IAEtE,MAAM,EAAE,oBAAoB,EAAE,GAAG,MAAM,MAAM,CAAC,wBAAwB,CAAC,CAAC;IACxE,MAAM,EAAE,eAAe,EAAE,OAAO,EAAE,SAAS,EAAE,GAAG,MAAM,MAAM,CAAC,yBAAyB,CAAC,CAAC;IAExF,MAAM,MAAM,GAAG,MAAM,SAAS,CAAC;QAC7B,QAAQ,EAAE,oBAAoB,CAAC,GAAG,CAAC,QAAQ,EAAE,EAAE,GAAG,KAAK,EAAE,SAAS,EAAE,GAAG,CAAC,SAAS,EAAE,WAAW,EAAE,GAAG,CAAC,WAAW,EAAE,WAAW,EAAE,CAAC;QAC/H,OAAO;QACP,MAAM,EAAE,GAAG,CAAC,YAAY;QACxB,IAAI;KACL,CAAC,CAAC;IAEH,IAAI,MAAM,CAAC,OAAO,EAAE,CAAC;QACnB,MAAM,IAAI,QAAQ,CAChB,gFAAgF;YAC9E,uCAAuC,EACzC,IAAI,CAAC,WAAW,CACjB,CAAC;IACJ,CAAC;IAED,KAAK,MAAM,QAAQ,IAAI,MAAM,CAAC,SAAS;QAAE,OAAO,CAAC,KAAK,CAAC,eAAe,CAAC,QAAQ,CAAC,CAAC,CAAC;IAElF,MAAM,MAAM,GAAG,UAAU,CAAC,IAAI,EAAE,QAAQ,EAAE,GAAG,CAAC,QAAQ,EAAE,MAAM,CAAC,KAAK,CAAC,CAAC;IACtE,4EAA4E;IAC5E,0EAA0E;IAC1E,4BAA4B;IAC5B,MAAM,SAAS,GAAG,EAAE,GAAG,MAAM,CAAC,SAAS,EAAE,CAAC;IAC1C,MAAM,IAAI,GAAG,UAAU,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;IACnC,IAAI,IAAI,KAAK,SAAS;QAAE,OAAO,SAAS,CAAC,WAAW,CAAC;;QAChD,SAAS,CAAC,WAAW,GAAG,IAAI,CAAC;IAClC,MAAM,GAAG,GAAG,aAAa,CAAC,EAAE,GAAG,MAAM,EAAE,SAAS,EAAE,CAAC,CAAC;IACpD,EAAE,CAAC,aAAa,CAAC,IAAI,CAAC,IAAI,CAAC,OAAO,EAAE,UAAU,CAAC,EAAE,IAAI,CAAC,SAAS,CAAC,GAAG,EAAE,IAAI,EAAE,CAAC,CAAC,GAAG,IAAI,CAAC,CAAC;IAEtF,OAAO,CAAC,GAAG,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC;IAC7B,OAAO,IAAI,CAAC,EAAE,CAAC;AACjB,CAAC;AAED,uFAAuF;AACvF,SAAS,UAAU,CAAC,IAAY;IAC9B,MAAM,IAAI,GAAG,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,mBAAmB,CAAC,CAAC;IAClD,IAAI,IAAY,CAAC;IACjB,IAAI,CAAC;QACH,IAAI,GAAG,EAAE,CAAC,YAAY,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;IACvC,CAAC;IAAC,MAAM,CAAC;QACP,MAAM,IAAI,QAAQ,CAChB,YAAY,IAAI,uEAAuE,EACvF,IAAI,CAAC,IAAI,CACV,CAAC;IACJ,CAAC;IACD,IAAI,MAAe,CAAC;IACpB,IAAI,CAAC;QACH,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAAY,CAAC;IACvC,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,MAAM,IAAI,QAAQ,CAAC,GAAG,IAAI,KAAM,GAAa,CAAC,OAAO,EAAE,EAAE,IAAI,CAAC,IAAI,CAAC,CAAC;IACtE,CAAC;IACD,MAAM,IAAI,GAAI,MAA2C,EAAE,WAAW,CAAC;IACvE,IAAI,IAAI,KAAK,SAAS,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,CAAC;QAC/C,MAAM,IAAI,QAAQ,CAAC,GAAG,IAAI,gCAAgC,EAAE,IAAI,CAAC,IAAI,CAAC,CAAC;IACzE,CAAC;IACD,OAAO,EAAE,WAAW,EAAE,CAAC,IAAI,IAAI,EAAE,CAAyD,EAAE,CAAC;AAC/F,CAAC"}
@@ -1,5 +1,5 @@
1
- import type { CatalogPackage } from "../lib/catalog.js";
1
+ import type { CardPackage } from "../lib/catalog.js";
2
2
  export declare function CardLogo({ pkg }: {
3
- pkg: CatalogPackage;
3
+ pkg: CardPackage;
4
4
  }): import("preact").JSX.Element;
5
5
  //# sourceMappingURL=CardLogo.d.ts.map
@@ -7,7 +7,7 @@
7
7
  import { useEffect, useRef, useState } from "preact/hooks";
8
8
  import { Image, ImageOff } from "lucide-preact";
9
9
  import { withBase } from "../lib/base.js";
10
- import type { CatalogPackage } from "../lib/catalog.js";
10
+ import type { CardPackage } from "../lib/catalog.js";
11
11
 
12
12
  export /**
13
13
  * The card's 28px logo slot, in its three states.
@@ -24,7 +24,7 @@ export /**
24
24
  * so it opts into the global handler in `Base.astro` instead — keep the two
25
25
  * placeholders looking alike.
26
26
  */
27
- function CardLogo({ pkg }: { pkg: CatalogPackage }) {
27
+ function CardLogo({ pkg }: { pkg: CardPackage }) {
28
28
  const [state, setState] = useState<"loading" | "ready" | "broken">("loading");
29
29
  const imgRef = useRef<HTMLImageElement>(null);
30
30
 
@@ -1,5 +1,13 @@
1
- import { type CatalogPackage } from "../lib/catalog.js";
2
- export type Sort = "name" | "updated" | "rating";
1
+ import { type CardPackage } from "../lib/catalog.js";
2
+ /**
3
+ * `relevance` orders by how well each package answered the query on screen,
4
+ * where the other three order by something every package carries. It is
5
+ * offered and stored like any of them even so: with no query it has nothing
6
+ * to rank, and `CHAINS.relevance` answers that with alphabetical — the order
7
+ * the catalog opens on anyway — so a reader can leave the catalog set to it
8
+ * and have every later search come back ranked without touching the control.
9
+ */
10
+ export type Sort = "name" | "updated" | "rating" | "relevance";
3
11
  export type Dir = "asc" | "desc";
4
12
  /** Roomy cards, or the same packages as a scannable list. */
5
13
  export type View = "cards" | "table";
@@ -13,9 +21,9 @@ export type View = "cards" | "table";
13
21
  * order actually is rather than which way a flag is set.
14
22
  */
15
23
  export declare const NATURAL: Record<Sort, Dir>;
16
- export declare function compare(a: CatalogPackage, b: CatalogPackage, sort: Sort, dir?: Dir): number;
24
+ export declare function compare(a: CardPackage, b: CardPackage, sort: Sort, dir?: Dir): number;
17
25
  export default function Catalog({ packages, vscodeExtension, }: {
18
- packages: CatalogPackage[];
26
+ packages: CardPackage[];
19
27
  vscodeExtension: string | null;
20
28
  }): import("preact").JSX.Element;
21
29
  //# sourceMappingURL=Catalog.d.ts.map
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "preact/jsx-runtime";
2
- import { useEffect, useLayoutEffect, useMemo, useRef, useState, } from "preact/hooks";
2
+ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, } from "preact/hooks";
3
3
  // Lucide (ISC) draws the toolbar. The brand marks and kind glyphs moved out
4
4
  // with the card and the row that wear them.
5
5
  import { ArrowDownWideNarrow, ArrowUpNarrowWide, LayoutGrid, List, X, } from "lucide-preact";
@@ -7,6 +7,7 @@ import { PackageCard } from "./PackageCard.js";
7
7
  import { PackageRow } from "./PackageRow.js";
8
8
  import { keywordFrequency, selectRailKeywords } from "../lib/keywordRail.js";
9
9
  import { lastUpdated } from "../lib/catalog.js";
10
+ import { withBase } from "../lib/base.js";
10
11
  // Known kinds get stable chip ordering + badge colors; unknown kinds
11
12
  // (future schema growth) still render with a neutral badge.
12
13
  const KNOWN_KINDS = ["skill", "rule", "agent", "mcp", "bundle"];
@@ -22,6 +23,10 @@ function kindOrder(kind) {
22
23
  * all of it is a wall of chips nobody reads.
23
24
  */
24
25
  const KEYWORD_CHIP_LIMIT = 8;
26
+ /**
27
+ * How many packages the catalog builds at a time. See `limit` in `Catalog`.
28
+ */
29
+ const WINDOW = 48;
25
30
  /**
26
31
  * `popovertarget` needs an id, and the catalog is a singleton on its page —
27
32
  * one island, one toolbar, one overflow menu — so this is a constant rather
@@ -41,6 +46,7 @@ export const NATURAL = {
41
46
  name: "asc",
42
47
  updated: "desc",
43
48
  rating: "desc",
49
+ relevance: "desc",
44
50
  };
45
51
  /**
46
52
  * Bigger first, with `null` as its own bucket underneath every number.
@@ -85,6 +91,16 @@ const CHAINS = {
85
91
  name: [byName],
86
92
  updated: [byUpdated, byName],
87
93
  rating: [byRating, byUpdated, byName],
94
+ // Relevance cannot be a key here: a score belongs to a query, not to a
95
+ // package, so it is not on the record `compare` is handed. `shown` sorts
96
+ // that mode itself.
97
+ //
98
+ // This entry is what the mode falls back to whenever there are no scores —
99
+ // no query typed, or the fuzzy index still downloading — which is also why
100
+ // relevance can be offered and stored like any other mode. Alphabetical is
101
+ // the honest answer to "rank these against nothing", and it is what the
102
+ // catalog already shows on arrival.
103
+ relevance: [byName],
88
104
  };
89
105
  // Deprecated packages get no special ordering here — they are filtered out
90
106
  // of the default browse entirely (see `shown` below) and interleave like
@@ -242,7 +258,13 @@ export default function Catalog({ packages, vscodeExtension, }) {
242
258
  // Local to the overflow menu and deliberately not shareable: it narrows
243
259
  // the list of keywords, not the catalog.
244
260
  const [keywordFilter, setKeywordFilter] = useState("");
261
+ // The fuzzy matcher, once it has been fetched. `null` is the normal state
262
+ // for most of a visit — see the loader below — and every consumer treats
263
+ // it as "fall back to the substring filter", never as an error.
264
+ const [index, setIndex] = useState(null);
245
265
  const searchRef = useRef(null);
266
+ /** Whether the sort combo's last interaction came from a pointer. */
267
+ const pickedByPointer = useRef(false);
246
268
  const gridRef = useRef(null);
247
269
  const controlsRef = useRef(null);
248
270
  // Both views, one selector: a table row is the Tab stop its card is, so
@@ -271,6 +293,27 @@ export default function Catalog({ packages, vscodeExtension, }) {
271
293
  /** The slide each chip is currently running, so a new one can replace it. */
272
294
  const railSlides = useRef(new WeakMap());
273
295
  const railRef = useRef(null);
296
+ /**
297
+ * Which chips are up, in order, as of the current render — written below,
298
+ * once `visibleKeywords` exists, and read by the two layout effects.
299
+ *
300
+ * Both of those effects measure geometry, and a geometry read forces a
301
+ * synchronous style and layout pass over the WHOLE document. Neither had a
302
+ * dependency array, so both ran on every commit — including a commit that
303
+ * changed no chip at all. Switching to the list view is that commit, and at
304
+ * 500 packages it paid two forced layouts over several hundred rows Preact
305
+ * had just mounted: a 600ms task, most of it in `ForcedStyleAndLayout`.
306
+ *
307
+ * A ref rather than a dependency array because the chip list is computed
308
+ * hundreds of lines below these hooks, and a value cannot be a dependency
309
+ * before it exists. Writing a ref during render is safe — it is not state,
310
+ * nothing re-renders from it, and an effect body always runs after the
311
+ * render that wrote it.
312
+ */
313
+ const railSignature = useRef("");
314
+ /** What `railSignature` was when each effect last did its reads. */
315
+ const railMeasured = useRef(null);
316
+ const railFlipped = useRef(null);
274
317
  const kwMenuRef = useRef(null);
275
318
  const kwTriggerRef = useRef(null);
276
319
  // Drives the trigger's own `aria-expanded` and its open styling. The panel's
@@ -341,38 +384,55 @@ export default function Catalog({ packages, vscodeExtension, }) {
341
384
  * disagrees with itself on every other frame.
342
385
  */
343
386
  const [railFit, setRailFit] = useState(KEYWORD_CHIP_LIMIT);
344
- useLayoutEffect(() => {
387
+ const measureRail = useCallback(() => {
345
388
  const rail = railRef.current;
346
389
  if (!rail)
347
390
  return;
348
- const measure = () => {
349
- const edge = rail.getBoundingClientRect().right;
350
- let fits = 0;
351
- for (const chip of rail.children) {
352
- // Half a pixel of slack: a fractional layout can leave a chip's right
353
- // edge a rounding error past a boundary it visually sits inside.
354
- if (chip.getBoundingClientRect().right > edge + 0.5)
355
- break;
356
- fits += 1;
357
- }
358
- // At least one, always. A rail too narrow for its shortest chip should
359
- // show that chip clipped rather than render an empty group beside a
360
- // divider that then divides nothing.
361
- setRailFit(Math.max(1, fits));
362
- };
363
- measure();
364
- // Guarded rather than assumed: this effect also runs under the test
365
- // renderer, whose DOM has no `ResizeObserver` and a missing one costs
366
- // only re-measurement on viewport resize, which is not worth throwing
367
- // during a render over.
368
- if (typeof ResizeObserver === "undefined")
391
+ const edge = rail.getBoundingClientRect().right;
392
+ let fits = 0;
393
+ for (const chip of rail.children) {
394
+ // Half a pixel of slack: a fractional layout can leave a chip's right
395
+ // edge a rounding error past a boundary it visually sits inside.
396
+ if (chip.getBoundingClientRect().right > edge + 0.5)
397
+ break;
398
+ fits += 1;
399
+ }
400
+ // At least one, always. A rail too narrow for its shortest chip should
401
+ // show that chip clipped rather than render an empty group beside a
402
+ // divider that then divides nothing.
403
+ setRailFit(Math.max(1, fits));
404
+ }, []);
405
+ /**
406
+ * The rail's own `ref`, so the resize observer is attached exactly when the
407
+ * element exists. It is conditionally rendered no keywords, no rail — and
408
+ * a `[]`-dependency effect would miss it appearing later.
409
+ *
410
+ * This also stops the observer being torn down and rebuilt on every commit,
411
+ * which was not free: `observe()` delivers an initial callback, so a rebuild
412
+ * per commit meant a measurement per commit no matter what gated the effect.
413
+ *
414
+ * Guarded rather than assumed: this runs under the test renderer too, whose
415
+ * DOM has no `ResizeObserver` — and a missing one costs only re-measurement
416
+ * on viewport resize, which is not worth throwing during a render over.
417
+ */
418
+ const railObserver = useRef(null);
419
+ const attachRail = useCallback((el) => {
420
+ railRef.current = el;
421
+ railObserver.current?.disconnect();
422
+ railObserver.current = null;
423
+ if (!el || typeof ResizeObserver === "undefined")
424
+ return;
425
+ railObserver.current = new ResizeObserver(measureRail);
426
+ railObserver.current.observe(el);
427
+ }, [measureRail]);
428
+ // Re-measured when the chips change, and only then — a rescore can swap a
429
+ // short word for a long one at the same width, which the observer above
430
+ // would never see. See `railSignature` for why the guard is a ref.
431
+ useLayoutEffect(() => {
432
+ if (railSignature.current === railMeasured.current)
369
433
  return;
370
- const observer = new ResizeObserver(measure);
371
- observer.observe(rail);
372
- return () => observer.disconnect();
373
- // Re-measured on every commit that changes which chips are up, since the
374
- // observer only fires when the rail's own box changes and a rescore can
375
- // swap a short word for a long one at the same width.
434
+ railMeasured.current = railSignature.current;
435
+ measureRail();
376
436
  });
377
437
  /**
378
438
  * FLIP for the keyword rail: chips slide to their new places instead of
@@ -407,6 +467,19 @@ export default function Catalog({ packages, vscodeExtension, }) {
407
467
  * property of the mechanism rather than of a cleanup remembering to run.
408
468
  */
409
469
  useLayoutEffect(() => {
470
+ // Same guard as the fit measurement, for the same reason: `offsetLeft`
471
+ // forces layout, and a commit that moved no chip has nothing to animate.
472
+ //
473
+ // The seats it leaves behind are therefore from the last chip change
474
+ // rather than from the last commit, which is what FLIP wants. The one
475
+ // case that costs: a viewport resize between two chip changes moves the
476
+ // chips without changing the signature, so the next slide starts from
477
+ // pre-resize seats. One slightly-off slide after a resize, against two
478
+ // forced layouts on every commit — including every keystroke and every
479
+ // view switch — which is the trade this makes.
480
+ if (railSignature.current === railFlipped.current)
481
+ return;
482
+ railFlipped.current = railSignature.current;
410
483
  const previous = railSeats.current;
411
484
  const current = new Map();
412
485
  const moved = [];
@@ -506,7 +579,7 @@ export default function Catalog({ packages, vscodeExtension, }) {
506
579
  const s = readPref("sort");
507
580
  const d = readPref("dir");
508
581
  const v = readPref("view");
509
- const field = s === "updated" || s === "rating" ? s : "name";
582
+ const field = s === "updated" || s === "rating" || s === "relevance" ? s : "name";
510
583
  const published = new Set(packages.flatMap((p) => p.keywords ?? []));
511
584
  setQuery(params.get("q") ?? "");
512
585
  setKinds(list(params.get("kind")).filter((k) => KNOWN_KINDS.includes(k)));
@@ -781,16 +854,71 @@ export default function Catalog({ packages, vscodeExtension, }) {
781
854
  return [...seen].sort((a, b) => kindOrder(a) - kindOrder(b) || a.localeCompare(b));
782
855
  }, [counted]);
783
856
  const q = query.trim().toLowerCase();
857
+ /**
858
+ * Fetch the fuzzy matcher, once, the first time anyone searches.
859
+ *
860
+ * Not on mount: a reader who never types pays nothing — no `fuzzysort`
861
+ * chunk, no `/all.json`. Not per keystroke either; `tried` latches on the
862
+ * first attempt, so a failed load degrades to the substring filter for the
863
+ * rest of the visit rather than re-fetching on every letter.
864
+ *
865
+ * A failure is not shown to the reader on purpose. Search keeps working —
866
+ * the substring path over the card's own fields is what the catalog did
867
+ * before this existed — so an error banner would report a downgrade nobody
868
+ * asked about, over a page that is doing what they asked. It goes to the
869
+ * console with the error value itself, chain and stack intact.
870
+ */
871
+ const tried = useRef(false);
872
+ useEffect(() => {
873
+ if (!q || tried.current)
874
+ return;
875
+ tried.current = true;
876
+ // Self-catching, so the `void` attaches nothing it needs to: every await
877
+ // on the path is inside the try.
878
+ void (async () => {
879
+ try {
880
+ const { loadSearchIndex } = await import("../lib/search.js");
881
+ setIndex(await loadSearchIndex(withBase("/all.json")));
882
+ }
883
+ catch (err) {
884
+ console.error("catalog: fuzzy search unavailable, using substring match", err);
885
+ }
886
+ })();
887
+ }, [q]);
888
+ /**
889
+ * What the current query scored against every package, or `null` when
890
+ * there is no query or no matcher yet.
891
+ *
892
+ * Memoized on the pair: re-scoring the whole catalog is the one genuinely
893
+ * expensive thing a keystroke triggers, and every consumer below reads it.
894
+ */
895
+ const scores = useMemo(() => (index && q ? index.search(q) : null), [index, q]);
784
896
  // Query and facets first, deprecation last — so the toggle can report how
785
897
  // many entries *it alone* is holding back, rather than a catalog-wide
786
898
  // number that has nothing to do with what is on screen.
787
- const matching = packages.filter((p) => {
899
+ //
900
+ // Memoized, and `shown` with it, for a reason beyond the scan's own cost:
901
+ // an unmemoized `.filter().sort()` yields a NEW array on every render, so
902
+ // anything downstream keyed on `shown` — the keyword rail's set-cover below
903
+ // — could never hit its own cache. Both had to move together or neither
904
+ // helped. Every dependency here is a primitive or a state array, so the
905
+ // identity is stable exactly when the answer is.
906
+ const matching = useMemo(() => packages.filter((p) => {
788
907
  if (kinds.length > 0 && !kinds.includes(p.kind))
789
908
  return false;
790
909
  if (!keywords.every((kw) => p.keywords?.includes(kw)))
791
910
  return false;
792
911
  if (!q)
793
912
  return true;
913
+ // The fuzzy index, once it is here: multi-term, order-independent,
914
+ // typo-tolerant, and over every field `/all.json` carries — the
915
+ // licence, the vendor, the repository, the authors, none of which
916
+ // are on the record this island was handed.
917
+ if (scores)
918
+ return scores.has(p.ref);
919
+ // Until then, and if the fetch never lands: the substring pass over
920
+ // the fields the card itself ships. Narrower on both axes, and the
921
+ // reason the search box is never dead while a chunk downloads.
794
922
  return [
795
923
  p.name,
796
924
  p.description ?? "",
@@ -800,8 +928,82 @@ export default function Catalog({ packages, vscodeExtension, }) {
800
928
  p.summary ?? "",
801
929
  (p.keywords ?? []).join(" "),
802
930
  ].some((field) => field.toLowerCase().includes(q));
803
- });
804
- const shown = (showDeprecated ? matching : matching.filter((p) => !p.deprecated)).sort((a, b) => compare(a, b, sort, dir));
931
+ }), [packages, kinds, keywords, q, scores]);
932
+ const shown = useMemo(() => {
933
+ // `.filter()` already returns a fresh array, so sorting in place here
934
+ // mutates nothing the memo above holds — except in the `showDeprecated`
935
+ // branch, where `matching` IS that array. Copy before sorting.
936
+ const list = showDeprecated
937
+ ? [...matching]
938
+ : matching.filter((p) => !p.deprecated);
939
+ // Relevance is sorted here rather than in `compare`, because the score is
940
+ // a property of the query and not of the package — see `CHAINS`. Name
941
+ // breaks the tie, so equally-scored packages keep a total order and the
942
+ // list cannot reshuffle between renders.
943
+ if (sort === "relevance" && scores) {
944
+ return list.sort((a, b) => {
945
+ const d = descending(scores.get(a.ref) ?? null, scores.get(b.ref) ?? null) ||
946
+ byName(a, b);
947
+ return dir === NATURAL.relevance ? d : -d;
948
+ });
949
+ }
950
+ return list.sort((a, b) => compare(a, b, sort, dir));
951
+ }, [matching, showDeprecated, sort, dir, scores]);
952
+ /**
953
+ * How many of `shown` are actually built.
954
+ *
955
+ * The island's cost is dominated by constructing components, not by drawing
956
+ * them: `content-visibility` on the card and the row already means the
957
+ * browser skips layout and paint for anything off screen, but Preact still
958
+ * built every one. At 500 packages that was ~440 card components on
959
+ * hydration and another ~440 row components the moment the view changed —
960
+ * a 1.9s wait before a stored list view was on screen at all.
961
+ *
962
+ * So only a viewport's worth is built, and the slice GROWS as a sentinel
963
+ * below the list comes into view. It never shrinks, which is the whole
964
+ * reason this is a slice rather than true virtualization: an item that has
965
+ * been built stays built, so scrolling back up can never meet a blank row,
966
+ * and find-in-page keeps working over everything reached so far. The
967
+ * worst case — a reader who scrolls to the bottom — is exactly today's
968
+ * behaviour, and its paint is still bounded by `content-visibility`.
969
+ *
970
+ * 48 covers a tall viewport of either shape with room over: rows are 34px,
971
+ * and the card grid is three or four across at 19rem minimum.
972
+ */
973
+ const [limit, setLimit] = useState(WINDOW);
974
+ // A new result set starts a new window — otherwise narrowing to 3 matches
975
+ // and clearing the filter again would leave the whole catalog built.
976
+ // `shown` is memoized, so this identity changes exactly when the answer does.
977
+ const shownRef = useRef(shown);
978
+ if (shownRef.current !== shown) {
979
+ shownRef.current = shown;
980
+ if (limit !== WINDOW)
981
+ setLimit(WINDOW);
982
+ }
983
+ const visible = limit >= shown.length ? shown : shown.slice(0, limit);
984
+ /**
985
+ * Grow the window when the sentinel below the list is reached.
986
+ *
987
+ * `rootMargin` is what keeps this invisible in use: the next slice is built
988
+ * a screen and a half before the reader gets to it, so the list reads as
989
+ * complete rather than as something that loads while you look at it. The
990
+ * observer re-fires while the sentinel stays in view, so a fast scroll
991
+ * keeps growing the window a slice per frame rather than stalling.
992
+ */
993
+ const sentinelRef = useRef(null);
994
+ useEffect(() => {
995
+ const el = sentinelRef.current;
996
+ // Guarded like the rail's observer: the test renderer's DOM has neither.
997
+ if (!el || typeof IntersectionObserver === "undefined")
998
+ return;
999
+ const io = new IntersectionObserver((entries) => {
1000
+ if (!entries.some((e) => e.isIntersecting))
1001
+ return;
1002
+ setLimit((l) => (l >= shown.length ? l : l + WINDOW));
1003
+ }, { rootMargin: "150% 0px" });
1004
+ io.observe(el);
1005
+ return () => io.disconnect();
1006
+ }, [shown.length]);
805
1007
  /**
806
1008
  * The keyword rail, over what is on screen rather than over the catalog.
807
1009
  *
@@ -825,7 +1027,15 @@ export default function Catalog({ packages, vscodeExtension, }) {
825
1027
  keyword,
826
1028
  count: shown.length,
827
1029
  }));
828
- const rail = selectRailKeywords(shown, KEYWORD_CHIP_LIMIT)
1030
+ // Memoized on `shown` alone, because that is the only thing the scan reads.
1031
+ // `selectRailKeywords` is a greedy set-cover over every keyword of every
1032
+ // shown package — it scales with catalog size, and unmemoized it ran on
1033
+ // EVERY render: each keystroke in the search box, each chip click, each
1034
+ // view toggle, and once more for every re-render none of those caused. At a
1035
+ // corporate-sized catalog that is the most expensive thing in the render
1036
+ // path, repeated for an answer that had not changed.
1037
+ const scored = useMemo(() => selectRailKeywords(shown, KEYWORD_CHIP_LIMIT), [shown]);
1038
+ const rail = scored
829
1039
  // `selectRailKeywords` scores the actives like any other keyword, so
830
1040
  // over-request and drop them rather than spend rail slots twice.
831
1041
  .filter((k) => !keywords.includes(k.keyword))
@@ -837,6 +1047,10 @@ export default function Catalog({ packages, vscodeExtension, }) {
837
1047
  // chip clipped at the rail's edge is one the reader cannot reach anywhere
838
1048
  // else, and a "+N more" that does not count it is lying about where it is.
839
1049
  const clippedKeywords = visibleKeywords.slice(railFit).map((k) => k.keyword);
1050
+ // What the two layout effects above compare against. In render order, so a
1051
+ // reorder counts as a change — the FLIP effect exists to animate exactly
1052
+ // that. NUL-joined because a keyword may contain anything but that.
1053
+ railSignature.current = visibleKeywords.map((k) => k.keyword).join("\u0000");
840
1054
  const menuKeywords = keywordFrequency(shown).filter((k) => clippedKeywords.includes(k.keyword) ||
841
1055
  !visibleKeywords.some((v) => v.keyword === k.keyword));
842
1056
  // Plain substring, not a fuzzy match: this searches a list the reader is
@@ -847,7 +1061,10 @@ export default function Catalog({ packages, vscodeExtension, }) {
847
1061
  // no ratings gets no rating chip for the same reason.
848
1062
  const hasDeprecated = packages.some((p) => p.deprecated);
849
1063
  const hasRatings = packages.some((p) => p.rating);
850
- return (_jsxs("section", { class: "catalog", "data-slot": "catalog", children: [_jsxs("div", { class: "controls", "data-slot": "catalog-toolbar", ref: controlsRef, children: [_jsxs("div", { class: "search-field", "data-slot": "catalog-search", children: [_jsx("input", { ref: searchRef, type: "search", placeholder: "Search packages \u2014 name, keyword, description\u2026", value: query, onInput: (e) => setQuery(e.target.value), onKeyDown: onSearchKeyDown, "aria-label": "Search packages", "aria-keyshortcuts": "/" }), _jsx("kbd", { class: "search-hint", "aria-hidden": "true", children: "/" })] }), _jsxs("div", { class: "filter-row", children: [_jsxs("div", { class: "chips kind-chips", role: "group", "aria-label": "Filter by kind", children: [_jsx("button", { type: "button", class: kinds.length === 0 ? "chip active" : "chip", "data-slot": "filter-chip", "aria-pressed": kinds.length === 0, onKeyDown: onChipKeyDown, onClick: () => setKinds([]), children: "all" }), kindNames.map((k) => (_jsx("button", { type: "button", class: kinds.includes(k) ? `chip active kind-${k}` : `chip kind-${k}`, "data-slot": "filter-chip", "aria-pressed": kinds.includes(k), onKeyDown: onChipKeyDown, onClick: () => toggleKind(k), children: k }, k)))] }), visibleKeywords.length > 0 && (_jsxs(_Fragment, { children: [_jsx("span", { class: "filter-divider", "aria-hidden": "true" }), _jsx("div", { class: "chips kw-rail", role: "group", "aria-label": "Filter by keyword", ref: railRef, children: visibleKeywords.map(({ keyword }, i) => {
1064
+ return (_jsxs("section", { class: "catalog", "data-slot": "catalog", children: [_jsxs("div", { class: "controls", "data-slot": "catalog-toolbar", ref: controlsRef, children: [_jsxs("div", { class: "search-field", "data-slot": "catalog-search", children: [_jsx("input", { ref: searchRef, type: "search", placeholder: "Search packages \u2014 name, keyword, description\u2026", value: query, onInput: (e) => setQuery(e.target.value), onKeyDown: onSearchKeyDown, "aria-label": "Search packages", "aria-keyshortcuts": "/" }), _jsx("kbd", { class: "search-hint", "aria-hidden": "true", children: "/" }), query && (_jsx("button", { type: "button", class: "search-clear", "aria-label": "Clear search", title: "Clear search", onClick: () => {
1065
+ setQuery("");
1066
+ searchRef.current?.focus();
1067
+ }, children: _jsx(X, { size: 12, "aria-hidden": "true" }) }))] }), _jsxs("div", { class: "filter-row", children: [_jsxs("div", { class: "chips kind-chips", role: "group", "aria-label": "Filter by kind", children: [_jsx("button", { type: "button", class: kinds.length === 0 ? "chip active" : "chip", "data-slot": "filter-chip", "aria-pressed": kinds.length === 0, onKeyDown: onChipKeyDown, onClick: () => setKinds([]), children: "all" }), kindNames.map((k) => (_jsx("button", { type: "button", class: kinds.includes(k) ? `chip active kind-${k}` : `chip kind-${k}`, "data-slot": "filter-chip", "aria-pressed": kinds.includes(k), onKeyDown: onChipKeyDown, onClick: () => toggleKind(k), children: k }, k)))] }), visibleKeywords.length > 0 && (_jsxs(_Fragment, { children: [_jsx("span", { class: "filter-divider", "aria-hidden": "true" }), _jsx("div", { class: "chips kw-rail", role: "group", "aria-label": "Filter by keyword", ref: attachRail, children: visibleKeywords.map(({ keyword }, i) => {
851
1068
  // Past the measured fit: still laid out, so the measurement
852
1069
  // that decided this stays true on the next pass, but drawn
853
1070
  // as nothing and out of reach. Removing it from the flow
@@ -900,7 +1117,39 @@ export default function Catalog({ packages, vscodeExtension, }) {
900
1117
  ? "Ascending — click for descending"
901
1118
  : "Descending — click for ascending", "aria-label": dir === "asc"
902
1119
  ? "Sorted ascending; sort descending"
903
- : "Sorted descending; sort ascending", onClick: () => setDir((d) => (d === "asc" ? "desc" : "asc")), children: dir === "asc" ? (_jsx(ArrowUpNarrowWide, { size: 15, "aria-hidden": "true" })) : (_jsx(ArrowDownWideNarrow, { size: 15, "aria-hidden": "true" })) }), _jsxs("select", { class: "sort-field", "data-slot": "filter-chip", "aria-label": "Sort by", value: sort, onChange: (event) => {
1120
+ : "Sorted descending; sort ascending", onClick: () => setDir((d) => (d === "asc" ? "desc" : "asc")), children: dir === "asc" ? (_jsx(ArrowUpNarrowWide, { size: 15, "aria-hidden": "true" })) : (_jsx(ArrowDownWideNarrow, { size: 15, "aria-hidden": "true" })) }), _jsxs("select", { class: "sort-field", "data-slot": "filter-chip", "aria-label": "Sort by", value: sort,
1121
+ // Chromium matches `:focus-visible` on a `<select>` after a
1122
+ // plain MOUSE click — a select accepts keyboard input, so the
1123
+ // engine treats every focus as keyboard focus. The repo's
1124
+ // `:focus-visible` convention therefore cannot keep the accent
1125
+ // ring off this one control, and CSS has nothing else to go on:
1126
+ // no selector distinguishes focus that arrived from a pointer.
1127
+ //
1128
+ // So the pointer marks itself. `data-pointer` suppresses the
1129
+ // ring for the whole pointer interaction — an open dropdown is
1130
+ // its own affordance and needs no second one around the closed
1131
+ // box behind it — and the pick then hands focus back, so
1132
+ // nothing is left lit beside the thin neutral chips.
1133
+ //
1134
+ // The keyboard path must do NEITHER. Arrow keys on a closed
1135
+ // select fire `change` per option, so blurring there would take
1136
+ // the control away mid-selection, and a keyboard reader is
1137
+ // exactly who the ring exists for. `onKeyDown` clears both, so
1138
+ // a reader who clicks once and later tabs back is a keyboard
1139
+ // reader again.
1140
+ //
1141
+ // Written to the node rather than to state: this fires while
1142
+ // the native dropdown is open, and a re-render of the element
1143
+ // holding it open is not worth the risk for a styling hint.
1144
+ onPointerDown: (event) => {
1145
+ pickedByPointer.current = true;
1146
+ event.currentTarget.dataset.pointer = "";
1147
+ }, onKeyDown: (event) => {
1148
+ pickedByPointer.current = false;
1149
+ delete event.currentTarget.dataset.pointer;
1150
+ }, onBlur: (event) => {
1151
+ delete event.currentTarget.dataset.pointer;
1152
+ }, onChange: (event) => {
904
1153
  const next = event.currentTarget
905
1154
  .value;
906
1155
  setSort(next);
@@ -908,7 +1157,10 @@ export default function Catalog({ packages, vscodeExtension, }) {
908
1157
  // the previous one over lands the reader on "oldest first"
909
1158
  // because they had asked for Z→A a moment ago.
910
1159
  setDir(NATURAL[next]);
911
- }, children: [_jsx("option", { value: "name", children: "name" }), _jsx("option", { value: "updated", children: "updated" }), hasRatings && _jsx("option", { value: "rating", children: "rating" })] })] }), _jsxs("div", { class: "view-toggle", role: "group", "aria-label": "Catalog view", children: [_jsx("button", { type: "button", class: view === "cards" ? "view-pick active" : "view-pick", "data-slot": "filter-chip", "aria-pressed": view === "cards", title: "Cards", "aria-label": "Show packages as cards", onClick: () => setView("cards"), children: _jsx(LayoutGrid, { size: 15, "aria-hidden": "true" }) }), _jsx("button", { type: "button", class: view === "table" ? "view-pick active" : "view-pick", "data-slot": "filter-chip", "aria-pressed": view === "table", title: "List", "aria-label": "Show packages as a list", onClick: () => setView("table"), children: _jsx(List, { size: 15, "aria-hidden": "true" }) })] })] })] }), shown.length === 0 ? (_jsx("p", { class: "empty", children: "No packages match." })) : view === "table" ? (_jsx(PackageTable, { packages: shown, hasRatings: hasRatings, onKeyDown: onCardKeyDown, rootRef: gridRef })) : (_jsx("ul", { class: "grid", ref: (el) => {
1160
+ // The pointer path only: see the handlers above.
1161
+ if (pickedByPointer.current)
1162
+ event.currentTarget.blur();
1163
+ }, children: [_jsx("option", { value: "name", children: "name" }), _jsx("option", { value: "updated", children: "updated" }), hasRatings && _jsx("option", { value: "rating", children: "rating" }), _jsx("option", { value: "relevance", children: "relevance" })] })] }), _jsxs("div", { class: "view-toggle", role: "group", "aria-label": "Catalog view", children: [_jsx("button", { type: "button", class: view === "cards" ? "view-pick active" : "view-pick", "data-slot": "filter-chip", "aria-pressed": view === "cards", title: "Cards", "aria-label": "Show packages as cards", onClick: () => setView("cards"), children: _jsx(LayoutGrid, { size: 15, "aria-hidden": "true" }) }), _jsx("button", { type: "button", class: view === "table" ? "view-pick active" : "view-pick", "data-slot": "filter-chip", "aria-pressed": view === "table", title: "List", "aria-label": "Show packages as a list", onClick: () => setView("table"), children: _jsx(List, { size: 15, "aria-hidden": "true" }) })] })] })] }), shown.length === 0 ? (_jsx("p", { class: "empty", children: "No packages match." })) : view === "table" ? (_jsx(PackageTable, { packages: visible, hasRatings: hasRatings, onKeyDown: onCardKeyDown, rootRef: gridRef })) : (_jsx("ul", { class: "grid", ref: (el) => {
912
1164
  gridRef.current = el;
913
- }, children: shown.map((p) => (_jsx(PackageCard, { pkg: p, vscodeExtension: vscodeExtension, activeKeywords: keywords, onToggleKeyword: toggleKeyword, onKeyDown: onCardKeyDown }, `${p.namespace}/${p.name}`))) }))] }));
1165
+ }, children: visible.map((p) => (_jsx(PackageCard, { pkg: p, vscodeExtension: vscodeExtension, activeKeywords: keywords, onToggleKeyword: toggleKeyword, onKeyDown: onCardKeyDown }, `${p.namespace}/${p.name}`))) })), visible.length < shown.length && (_jsx("div", { ref: sentinelRef, "aria-hidden": "true" }))] }));
914
1166
  }