@grimoire-rs/indexer 0.3.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/README.md +137 -2
  2. package/dist/ci.d.ts +6 -1
  3. package/dist/ci.d.ts.map +1 -1
  4. package/dist/ci.js +32 -1
  5. package/dist/ci.js.map +1 -1
  6. package/dist/cli/build.d.ts.map +1 -1
  7. package/dist/cli/build.js +3 -1
  8. package/dist/cli/build.js.map +1 -1
  9. package/dist/cli/ci.d.ts.map +1 -1
  10. package/dist/cli/ci.js +5 -1
  11. package/dist/cli/ci.js.map +1 -1
  12. package/dist/cli/init.d.ts.map +1 -1
  13. package/dist/cli/init.js +17 -3
  14. package/dist/cli/init.js.map +1 -1
  15. package/dist/cli/main.d.ts.map +1 -1
  16. package/dist/cli/main.js +8 -0
  17. package/dist/cli/main.js.map +1 -1
  18. package/dist/cli/ratings.d.ts +3 -0
  19. package/dist/cli/ratings.d.ts.map +1 -0
  20. package/dist/cli/ratings.js +160 -0
  21. package/dist/cli/ratings.js.map +1 -0
  22. package/dist/config.d.ts +19 -0
  23. package/dist/config.d.ts.map +1 -1
  24. package/dist/config.js +47 -1
  25. package/dist/config.js.map +1 -1
  26. package/dist/enrich/index.d.ts.map +1 -1
  27. package/dist/enrich/index.js +74 -1
  28. package/dist/enrich/index.js.map +1 -1
  29. package/dist/ratings/budget.d.ts +16 -0
  30. package/dist/ratings/budget.d.ts.map +1 -0
  31. package/dist/ratings/budget.js +7 -0
  32. package/dist/ratings/budget.js.map +1 -0
  33. package/dist/ratings/config.d.ts +56 -0
  34. package/dist/ratings/config.d.ts.map +1 -0
  35. package/dist/ratings/config.js +86 -0
  36. package/dist/ratings/config.js.map +1 -0
  37. package/dist/ratings/marker.d.ts +105 -0
  38. package/dist/ratings/marker.d.ts.map +1 -0
  39. package/dist/ratings/marker.js +141 -0
  40. package/dist/ratings/marker.js.map +1 -0
  41. package/dist/ratings/provider.d.ts +108 -0
  42. package/dist/ratings/provider.d.ts.map +1 -0
  43. package/dist/ratings/provider.js +202 -0
  44. package/dist/ratings/provider.js.map +1 -0
  45. package/dist/ratings/provider_github.d.ts +3 -0
  46. package/dist/ratings/provider_github.d.ts.map +1 -0
  47. package/dist/ratings/provider_github.js +134 -0
  48. package/dist/ratings/provider_github.js.map +1 -0
  49. package/dist/ratings/provider_gitlab.d.ts +3 -0
  50. package/dist/ratings/provider_gitlab.d.ts.map +1 -0
  51. package/dist/ratings/provider_gitlab.js +152 -0
  52. package/dist/ratings/provider_gitlab.js.map +1 -0
  53. package/dist/ratings/provider_memory.d.ts +28 -0
  54. package/dist/ratings/provider_memory.d.ts.map +1 -0
  55. package/dist/ratings/provider_memory.js +49 -0
  56. package/dist/ratings/provider_memory.js.map +1 -0
  57. package/dist/ratings/reconcile.d.ts +47 -0
  58. package/dist/ratings/reconcile.d.ts.map +1 -0
  59. package/dist/ratings/reconcile.js +133 -0
  60. package/dist/ratings/reconcile.js.map +1 -0
  61. package/dist/ratings/seed.d.ts +60 -0
  62. package/dist/ratings/seed.d.ts.map +1 -0
  63. package/dist/ratings/seed.js +132 -0
  64. package/dist/ratings/seed.js.map +1 -0
  65. package/dist/renderer/astro/components/Catalog.d.ts +2 -0
  66. package/dist/renderer/astro/components/Catalog.js +96 -28
  67. package/dist/renderer/astro/components/Catalog.tsx +136 -32
  68. package/dist/renderer/astro/layouts/Base.astro +15 -5
  69. package/dist/renderer/astro/lib/catalog.d.ts +25 -0
  70. package/dist/renderer/astro/lib/catalog.js +46 -0
  71. package/dist/renderer/astro/lib/catalog.ts +49 -0
  72. package/dist/renderer/astro/pages/p/[...slug].astro +146 -3
  73. package/dist/renderer/index.d.ts.map +1 -1
  74. package/dist/renderer/index.js +101 -2
  75. package/dist/renderer/index.js.map +1 -1
  76. package/dist/renderer/types.d.ts +41 -0
  77. package/dist/renderer/types.d.ts.map +1 -1
  78. package/dist/validate/adapters/http.d.ts +6 -1
  79. package/dist/validate/adapters/http.d.ts.map +1 -1
  80. package/dist/validate/adapters/http.js +5 -1
  81. package/dist/validate/adapters/http.js.map +1 -1
  82. package/package.json +2 -1
  83. package/templates/ci/github-pages.yml +11 -5
  84. package/templates/ci/github-ratings-seed.yml +88 -0
  85. package/templates/ci/github-ratings.yml +51 -0
  86. package/templates/ci/gitlab-ci.yml +3 -3
  87. package/templates/ci/gitlab-ratings-seed.sh +83 -0
  88. package/templates/ci/gitlab-ratings.yml +37 -0
  89. package/templates/gitignore +8 -0
@@ -0,0 +1,60 @@
1
+ /** The `stats.json` version this producer writes. */
2
+ export declare const SCHEMA_VERSION = 1;
3
+ /**
4
+ * Where a run leaves the sidecar in the repository root. The renderer reads it
5
+ * at build time and the deploy publishes it as `stats.json`; it is never
6
+ * committed, so a rollback is *both* dropping the `ratings` block and deleting
7
+ * the published file (S-016) — the last tally is otherwise served forever.
8
+ */
9
+ export declare const STATS_FILE = ".stats.json";
10
+ /** One ref's bag of stats — `{ rating: {...}, downloads: {...} }`. Values are opaque here. */
11
+ export type StatEntries = Record<string, Record<string, unknown>>;
12
+ /**
13
+ * The carry-forward surface of a published `stats.json`: the two maps that are
14
+ * keyed by stat name and must therefore survive a run that did not produce
15
+ * that stat. Everything else in the document (`schema_version`,
16
+ * `generated_at`) is re-derived, never carried.
17
+ */
18
+ export interface StatsSeed {
19
+ /** Which backend produced each signal, keyed by stat name. */
20
+ providers: Record<string, string>;
21
+ entries: StatEntries;
22
+ }
23
+ /** The complete published document. */
24
+ export interface StatsDocument extends StatsSeed {
25
+ schema_version: number;
26
+ generated_at: string;
27
+ }
28
+ /** The first run's seed, and the only shape a genuine 404 may produce. */
29
+ export declare const EMPTY_SEED: StatsSeed;
30
+ /**
31
+ * Fetch the currently published `stats.json` to merge over.
32
+ *
33
+ * | Outcome | Behaviour |
34
+ * |---|---|
35
+ * | 404 | empty seed — the first run, and the normal case |
36
+ * | 2xx that parses | merge base |
37
+ * | 2xx that does not parse | **throws** (65) |
38
+ * | transport / TLS / 5xx / timeout / over-cap | **throws** (69) |
39
+ *
40
+ * `request()` maps its transport catch *and* its response-size cap to
41
+ * `{status: 0}`, so that value is a hard error here and can never be read as
42
+ * "the seed was fine and empty".
43
+ */
44
+ export declare function loadSeed(url: string): Promise<StatsSeed>;
45
+ /**
46
+ * Merge one completed producer's output over the seed, per stat key.
47
+ *
48
+ * `fresh` is keyed by ref and holds only this producer's stat value, so a ref
49
+ * absent from it had its key genuinely observed as empty — the key is dropped,
50
+ * which is correct *because this producer completed*. A producer that failed
51
+ * never reaches here at all, and the seed it would have merged over is what
52
+ * gets published instead. That asymmetry is R-2.
53
+ *
54
+ * Refs are emitted in sorted order so a re-run over unchanged data produces an
55
+ * unchanged file.
56
+ */
57
+ export declare function mergeStats(seed: StatsSeed, key: string, provider: string, fresh: Record<string, unknown>): StatsSeed;
58
+ /** Wrap merged stats in the publishable document. */
59
+ export declare function statsDocument(merged: StatsSeed, now?: Date): StatsDocument;
60
+ //# sourceMappingURL=seed.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"seed.d.ts","sourceRoot":"","sources":["../../src/ratings/seed.ts"],"names":[],"mappings":"AAoBA,qDAAqD;AACrD,eAAO,MAAM,cAAc,IAAI,CAAC;AAEhC;;;;;GAKG;AACH,eAAO,MAAM,UAAU,gBAAgB,CAAC;AAExC,8FAA8F;AAC9F,MAAM,MAAM,WAAW,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;AAElE;;;;;GAKG;AACH,MAAM,WAAW,SAAS;IACxB,8DAA8D;IAC9D,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAClC,OAAO,EAAE,WAAW,CAAC;CACtB;AAED,uCAAuC;AACvC,MAAM,WAAW,aAAc,SAAQ,SAAS;IAC9C,cAAc,EAAE,MAAM,CAAC;IACvB,YAAY,EAAE,MAAM,CAAC;CACtB;AAED,0EAA0E;AAC1E,eAAO,MAAM,UAAU,EAAE,SAA0C,CAAC;AAEpE;;;;;;;;;;;;;GAaG;AACH,wBAAsB,QAAQ,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,CAAC,CA2C9D;AAoBD;;;;;;;;;;;GAWG;AACH,wBAAgB,UAAU,CACxB,IAAI,EAAE,SAAS,EACf,GAAG,EAAE,MAAM,EACX,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAC7B,SAAS,CAaX;AAED,qDAAqD;AACrD,wBAAgB,aAAa,CAAC,MAAM,EAAE,SAAS,EAAE,GAAG,GAAE,IAAiB,GAAG,aAAa,CAQtF"}
@@ -0,0 +1,132 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright 2026 The Grimoire Authors
3
+ // Invariant R-2, "no silent emptying", in the two functions that carry it: the
4
+ // seed fetch and the per-stat-key merge over what it returned.
5
+ //
6
+ // A published `stats.json` is not committed anywhere (ADR D8), so the live
7
+ // site IS the checkpoint. Every run re-derives its own stat key and carries
8
+ // every other one forward from that checkpoint untouched — never a whole-file
9
+ // replacement, because a `rating` run must not drop a `downloads` key it never
10
+ // computed.
11
+ //
12
+ // The status branching in [`loadSeed`] is the whole of it, and `|| true` is
13
+ // forbidden: a corrupted or unreachable seed is indistinguishable from an
14
+ // empty one, and treating it as empty is exactly how a published rating set
15
+ // gets silently replaced with nothing. So a fetch that did not clearly say
16
+ // "there is nothing here" fails the run instead.
17
+ import { CliError, EXIT } from "../cli/exit.js";
18
+ import { request } from "../validate/adapters/http.js";
19
+ /** The `stats.json` version this producer writes. */
20
+ export const SCHEMA_VERSION = 1;
21
+ /**
22
+ * Where a run leaves the sidecar in the repository root. The renderer reads it
23
+ * at build time and the deploy publishes it as `stats.json`; it is never
24
+ * committed, so a rollback is *both* dropping the `ratings` block and deleting
25
+ * the published file (S-016) — the last tally is otherwise served forever.
26
+ */
27
+ export const STATS_FILE = ".stats.json";
28
+ /** The first run's seed, and the only shape a genuine 404 may produce. */
29
+ export const EMPTY_SEED = { providers: {}, entries: {} };
30
+ /**
31
+ * Fetch the currently published `stats.json` to merge over.
32
+ *
33
+ * | Outcome | Behaviour |
34
+ * |---|---|
35
+ * | 404 | empty seed — the first run, and the normal case |
36
+ * | 2xx that parses | merge base |
37
+ * | 2xx that does not parse | **throws** (65) |
38
+ * | transport / TLS / 5xx / timeout / over-cap | **throws** (69) |
39
+ *
40
+ * `request()` maps its transport catch *and* its response-size cap to
41
+ * `{status: 0}`, so that value is a hard error here and can never be read as
42
+ * "the seed was fine and empty".
43
+ */
44
+ export async function loadSeed(url) {
45
+ const response = await request(url);
46
+ if (response.status === 404)
47
+ return { providers: {}, entries: {} };
48
+ if (response.status < 200 || response.status >= 300) {
49
+ // `status: 0` lands here too, and deliberately: it is the transport catch
50
+ // and the over-cap read, neither of which says anything about what is
51
+ // published.
52
+ throw new CliError(`could not read the published stats.json at ${url} (HTTP ${response.status}) — ` +
53
+ `refusing to publish a sidecar that would empty every rating`, EXIT.unavailable);
54
+ }
55
+ let parsed;
56
+ try {
57
+ parsed = JSON.parse(response.body);
58
+ }
59
+ catch (err) {
60
+ throw new CliError(`the published stats.json at ${url} did not parse (${err.message}) — ` +
61
+ `refusing to treat an unreadable seed as an empty one`, EXIT.data);
62
+ }
63
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
64
+ throw new CliError(`the published stats.json at ${url} is not a JSON object`, EXIT.data);
65
+ }
66
+ // Absence *within* a document that parsed is first-class — a site that
67
+ // published a header and no entries yet has genuinely nothing to carry.
68
+ // A key that is PRESENT but the wrong shape is not absence, though: it is
69
+ // an unreadable seed wearing the clothes of an empty one, and coercing it
70
+ // to `{}` here would empty every published rating on the next deploy —
71
+ // the same silent emptying the parse guard above refuses, one layer in.
72
+ const doc = parsed;
73
+ return {
74
+ providers: object(doc.providers, "providers", url),
75
+ entries: object(doc.entries, "entries", url),
76
+ };
77
+ }
78
+ /**
79
+ * A nested map: `{}` when the key is absent, a hard error when it is present
80
+ * and not a plain object.
81
+ *
82
+ * @throws {CliError} `EXIT.data` when `value` is present but not an object.
83
+ */
84
+ function object(value, key, url) {
85
+ if (value === undefined || value === null)
86
+ return {};
87
+ if (typeof value !== "object" || Array.isArray(value)) {
88
+ throw new CliError(`the published stats.json at ${url} has a '${key}' that is not an object ` +
89
+ `(${Array.isArray(value) ? "array" : typeof value}) — refusing to treat a malformed seed as an empty one`, EXIT.data);
90
+ }
91
+ return value;
92
+ }
93
+ /**
94
+ * Merge one completed producer's output over the seed, per stat key.
95
+ *
96
+ * `fresh` is keyed by ref and holds only this producer's stat value, so a ref
97
+ * absent from it had its key genuinely observed as empty — the key is dropped,
98
+ * which is correct *because this producer completed*. A producer that failed
99
+ * never reaches here at all, and the seed it would have merged over is what
100
+ * gets published instead. That asymmetry is R-2.
101
+ *
102
+ * Refs are emitted in sorted order so a re-run over unchanged data produces an
103
+ * unchanged file.
104
+ */
105
+ export function mergeStats(seed, key, provider, fresh) {
106
+ const entries = {};
107
+ for (const ref of [...new Set([...Object.keys(seed.entries), ...Object.keys(fresh)])].sort()) {
108
+ const carried = {};
109
+ for (const [stat, value] of Object.entries(seed.entries[ref] ?? {})) {
110
+ if (stat !== key)
111
+ carried[stat] = value;
112
+ }
113
+ // A ref absent from `fresh` had this key genuinely observed as empty, so
114
+ // the key is dropped — correct only because this producer completed.
115
+ if (ref in fresh)
116
+ carried[key] = fresh[ref];
117
+ if (Object.keys(carried).length > 0)
118
+ entries[ref] = carried;
119
+ }
120
+ return { providers: { ...seed.providers, [key]: provider }, entries };
121
+ }
122
+ /** Wrap merged stats in the publishable document. */
123
+ export function statsDocument(merged, now = new Date()) {
124
+ return {
125
+ schema_version: SCHEMA_VERSION,
126
+ // RFC 3339 UTC, seconds resolution — the shape every fixture pins.
127
+ generated_at: now.toISOString().replace(/\.\d+Z$/, "Z"),
128
+ providers: merged.providers,
129
+ entries: merged.entries,
130
+ };
131
+ }
132
+ //# sourceMappingURL=seed.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"seed.js","sourceRoot":"","sources":["../../src/ratings/seed.ts"],"names":[],"mappings":"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"}
@@ -1,4 +1,6 @@
1
1
  import { type CatalogPackage } from "../lib/catalog.js";
2
+ export type Sort = "name" | "updated" | "rating";
3
+ export declare function compare(a: CatalogPackage, b: CatalogPackage, sort: Sort): number;
2
4
  export default function Catalog({ packages, vscodeExtension, }: {
3
5
  packages: CatalogPackage[];
4
6
  vscodeExtension: string | null;
@@ -2,11 +2,11 @@ import { jsx as _jsx, jsxs as _jsxs } from "preact/jsx-runtime";
2
2
  import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks";
3
3
  // Lucide (ISC) draws the UI; brand marks come from `@mdi/js`, which Lucide
4
4
  // deliberately does not carry. No SVG on this site is hand-written.
5
- import { Check, FolderRoot, Globe, Image, ImageOff } from "lucide-preact";
5
+ import { ArrowBigUp, Check, FolderRoot, Globe, Image, ImageOff } from "lucide-preact";
6
6
  import { mdiMicrosoftVisualStudioCode } from "@mdi/js";
7
7
  import { BrandMark } from "./BrandMark.js";
8
8
  import { withBase } from "../lib/base.js";
9
- import { timeAgo, vscodeUrl } from "../lib/catalog.js";
9
+ import { lastUpdated, timeAgo, vscodeUrl } from "../lib/catalog.js";
10
10
  // Known kinds get stable chip ordering + badge colors; unknown kinds
11
11
  // (future schema growth) still render with a neutral badge.
12
12
  const KNOWN_KINDS = ["skill", "rule", "agent", "mcp", "bundle"];
@@ -14,17 +14,61 @@ function kindOrder(kind) {
14
14
  const i = KNOWN_KINDS.indexOf(kind);
15
15
  return i === -1 ? KNOWN_KINDS.length : i;
16
16
  }
17
- // Deprecated packages sink to the bottom regardless of sort mode; the
18
- // chosen sort only orders within the two groups.
19
- function compare(a, b, sort) {
20
- const dep = Number(!!a.deprecated) - Number(!!b.deprecated);
21
- if (dep !== 0)
22
- return dep;
23
- if (sort === "name")
24
- return a.name.localeCompare(b.name);
25
- if (!a.created || !b.created)
26
- return (a.created ? 0 : 1) - (b.created ? 0 : 1);
27
- return new Date(b.created).getTime() - new Date(a.created).getTime();
17
+ /**
18
+ * Bigger first, with `null` as its own bucket underneath every number.
19
+ *
20
+ * The shared shape of the two ranked keys, and the reason both are written
21
+ * this way: "missing" is not a low value, it is the absence of one. Folding
22
+ * it into a number — 0 upvotes, epoch 0 — orders those rows against real
23
+ * data by accident, and ties them all with each other.
24
+ */
25
+ function descending(a, b) {
26
+ if (a === null || b === null)
27
+ return Number(a === null) - Number(b === null);
28
+ return b - a;
29
+ }
30
+ /** `updated` as epoch ms; null when absent, empty or not a date at all. */
31
+ function updatedAt(p) {
32
+ const at = lastUpdated(p);
33
+ const ms = at ? new Date(at).getTime() : NaN;
34
+ return Number.isFinite(ms) ? ms : null;
35
+ }
36
+ /**
37
+ * Ascending, case-insensitive. The full ref breaks the last tie in every
38
+ * mode, and it is unique, so no two rows ever compare equal — a browse order
39
+ * that is not total is a browse order that reshuffles on rebuild.
40
+ */
41
+ const byName = (a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "accent" }) || a.ref.localeCompare(b.ref);
42
+ /**
43
+ * Newest first. No usable date is *unknown*, not epoch 0: dating an undated
44
+ * package to 1970 sorts it below real packages by accident rather than by
45
+ * rule, so it goes into a bucket of its own at the bottom.
46
+ */
47
+ const byUpdated = (a, b) => descending(updatedAt(a), updatedAt(b));
48
+ /**
49
+ * Most upvotes first. Unrated is its own bucket at the bottom, never a zero:
50
+ * a fresh index is all-unrated, and zeroes would leave every one of those
51
+ * rows comparing equal with nothing left to break the tie.
52
+ */
53
+ const byRating = (a, b) => descending(a.rating?.up ?? null, b.rating?.up ?? null);
54
+ /** Each mode as a chain of keys, most significant first. */
55
+ const CHAINS = {
56
+ name: [byName],
57
+ updated: [byUpdated, byName],
58
+ rating: [byRating, byUpdated, byName],
59
+ };
60
+ // Deprecated packages get no special ordering here — they are filtered out
61
+ // of the default browse entirely (see `shown` below) and interleave like
62
+ // any other row when the toggle brings them back. grim's own browse order
63
+ // (`browse_sort.rs`) has no deprecated key either; keeping this comparator
64
+ // silent on deprecation is what keeps the two in sync.
65
+ export function compare(a, b, sort) {
66
+ for (const key of CHAINS[sort]) {
67
+ const d = key(a, b);
68
+ if (d !== 0)
69
+ return d;
70
+ }
71
+ return 0;
28
72
  }
29
73
  // The two install scopes previously wore the VS Code extension's own
30
74
  // codicons so "project" and "global" read identically in both. That parity
@@ -120,12 +164,21 @@ function CopyButton({ command, variant = "default", name, }) {
120
164
  // hydrates in the browser, so importing the build-time payload here would
121
165
  // ship the whole catalog twice.
122
166
  export default function Catalog({ packages, vscodeExtension, }) {
123
- // Seeded from `?q=…` on the very first render, not from an effect: a
124
- // keyword chip on a package page links here, and filtering one render late
125
- // means painting the full catalog and then collapsing it. The server has
126
- // no `location`, so it renders the unfiltered list — which is what a
127
- // crawler and a `?q=`-less visitor should both get.
128
- const [query, setQuery] = useState(() => typeof location === "undefined" ? "" : (new URLSearchParams(location.search).get("q") ?? ""));
167
+ // Empty on the first render, ALWAYS `?q=…` is applied a beat later, in
168
+ // the layout effect below. This is not a style preference, it is the one
169
+ // rule this island has to obey.
170
+ //
171
+ // Preact does not diff props while hydrating; its own source says so, and
172
+ // only re-applies props whose value is a function. Text children *are*
173
+ // diffed. So when the first client render disagrees with the server's, you
174
+ // get cards whose text is right and whose every attribute belongs to
175
+ // whichever package the server put at that position: one package's logo
176
+ // over another's name, and a link that opens the wrong page. Nothing
177
+ // throws. This used to seed from `location` here, and did exactly that.
178
+ const [query, setQuery] = useState("");
179
+ // Whether the URL's query has been applied. Gates the reveal below, so the
180
+ // catalog is never unhidden while it still shows the unfiltered list.
181
+ const [seeded, setSeeded] = useState(false);
129
182
  const [kind, setKind] = useState(null);
130
183
  const [sort, setSort] = useState("name");
131
184
  // Deprecated packages are hidden until asked for: a retired package is
@@ -168,19 +221,29 @@ export default function Catalog({ packages, vscodeExtension, }) {
168
221
  behavior: matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth",
169
222
  });
170
223
  };
224
+ // Apply `?q=…`, now that hydration has matched the server's markup and
225
+ // Preact owns the tree. A layout effect rather than a plain one: the
226
+ // resulting render must land before the browser paints, or a `?q=` visitor
227
+ // sees the whole catalog flash past on the way to their results.
228
+ useLayoutEffect(() => {
229
+ setQuery(new URLSearchParams(location.search).get("q") ?? "");
230
+ setSeeded(true);
231
+ }, []);
171
232
  // Base.astro hides the catalog before first paint when the URL carries a
172
- // query. Reveal it once this render the filtered one has hit the DOM.
173
- // A layout effect, so the reveal lands in the same frame as the content.
233
+ // query. Reveal it only once the filtered render is in the DOMkeyed on
234
+ // `seeded`, so the unfiltered first render is never the one revealed.
174
235
  useLayoutEffect(() => {
175
- // Hydration attaches handlers but does not diff props against the server
176
- // markup, so the box the server rendered empty stays empty even though
177
- // this render filtered on `query`. Written straight to the DOM, which is
178
- // what the vnode already claims.
236
+ if (!seeded)
237
+ return;
238
+ // The input's `value` prop was skipped during hydration for the same
239
+ // reason every other prop was, and the render that applied the query is
240
+ // a normal diff — but write it anyway: this effect is also what runs on
241
+ // a `?q=`-less load, where no second render is queued at all.
179
242
  const input = searchRef.current;
180
243
  if (input && input.value !== query)
181
244
  input.value = query;
182
245
  delete document.documentElement.dataset.query;
183
- }, []);
246
+ }, [seeded]);
184
247
  // `/` jumps to the search box, the convention every package registry
185
248
  // shares. Bound on the document so it works wherever the reader is.
186
249
  useEffect(() => {
@@ -357,9 +420,14 @@ export default function Catalog({ packages, vscodeExtension, }) {
357
420
  });
358
421
  const shown = (showDeprecated ? matching : matching.filter((p) => !p.deprecated)).sort((a, b) => compare(a, b, sort));
359
422
  // A catalog with nothing deprecated gets no toggle — a control that can
360
- // only ever be a no-op is worse than its absence.
423
+ // only ever be a no-op is worse than its absence. An index that publishes
424
+ // no ratings gets no rating chip for the same reason.
361
425
  const hasDeprecated = packages.some((p) => p.deprecated);
426
+ const hasRatings = packages.some((p) => p.rating);
362
427
  // Chips leave the Tab order only while there is a grid to arrow up from.
363
428
  const chipTabIndex = shown.length === 0 ? 0 : -1;
364
- return (_jsxs("section", { class: "catalog", children: [_jsxs("div", { class: "controls", ref: controlsRef, children: [_jsxs("div", { class: "search-field", children: [_jsx("input", { ref: searchRef, type: "search", placeholder: `Search ${counted.length} packages…`, 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: "chips", role: "group", "aria-label": "Sort by", children: [_jsx("button", { type: "button", class: sort === "name" ? "chip active" : "chip", tabIndex: chipTabIndex, onKeyDown: onChipKeyDown, onClick: () => setSort("name"), children: "name" }), _jsx("button", { type: "button", class: sort === "updated" ? "chip active" : "chip", tabIndex: chipTabIndex, onKeyDown: onChipKeyDown, onClick: () => setSort("updated"), children: "updated" })] }), _jsx("span", { class: "chip-sep", "aria-hidden": "true" }), _jsxs("div", { class: "chips", role: "group", "aria-label": "Filter by kind", children: [_jsxs("button", { type: "button", class: kind === null ? "chip active" : "chip", tabIndex: chipTabIndex, onKeyDown: onChipKeyDown, onClick: () => setKind(null), children: ["all ", _jsx("small", { children: counted.length })] }), kinds.map(([k, count]) => (_jsxs("button", { type: "button", class: kind === k ? `chip active kind-${k}` : `chip kind-${k}`, tabIndex: chipTabIndex, onKeyDown: onChipKeyDown, onClick: () => setKind(kind === k ? null : k), children: [k, " ", _jsx("small", { children: count })] }, k)))] }), hasDeprecated && (_jsx("button", { type: "button", class: showDeprecated ? "chip deprecated-toggle active" : "chip deprecated-toggle", "aria-pressed": showDeprecated, title: showDeprecated ? "Hide deprecated packages" : "Show deprecated packages", tabIndex: chipTabIndex, onKeyDown: onChipKeyDown, onClick: () => setShowDeprecated((on) => !on), children: "deprecated" }))] }), shown.length === 0 ? (_jsx("p", { class: "empty", children: "No packages match." })) : (_jsx("ul", { class: "grid", ref: gridRef, children: shown.map((p) => (_jsxs("li", { class: "card", tabIndex: 0, onKeyDown: onCardKeyDown, children: [_jsxs("div", { class: "card-head", children: [_jsx(CardLogo, { pkg: p }), _jsx("h2", { children: _jsx("a", { href: withBase(`/p/${p.namespace}/${p.name}/`), tabIndex: -1, children: p.name }) }), p.deprecated ? (_jsx("span", { class: "badge deprecated", children: "deprecated" })) : (_jsx("span", { class: `badge kind-${p.kind}`, children: p.kind }))] }), _jsx("p", { class: "namespace", children: p.namespace }), (p.version || p.license || p.created) && (_jsxs("div", { class: "meta-row", children: [p.version && _jsxs("span", { class: "pill version", children: ["v", p.version] }), p.license && _jsx("span", { class: "pill license", children: p.license }), p.created && timeAgo(p.created) && (_jsxs("time", { class: "updated", datetime: p.created, title: p.created, children: ["updated ", timeAgo(p.created)] }))] })), p.deprecated && (_jsxs("p", { class: "deprecated-strip", children: ["deprecated", p.replacedBy ? ` — replaced by ${p.replacedBy}` : ""] })), p.description && _jsx("p", { class: "description", children: p.description }), p.keywords && p.keywords.length > 0 && (_jsxs("div", { class: "keywords", children: [p.keywords.slice(0, 5).map((kw) => (_jsx("button", { type: "button", class: "chip keyword", tabIndex: -1, onClick: () => setQuery(kw), children: kw }, kw))), p.keywords.length > 5 && (_jsxs("span", { class: "chip keyword overflow", children: ["+", p.keywords.length - 5] }))] })), _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 }) }))] }), p.repository && (_jsx("a", { class: "source", href: p.repository, target: "_blank", rel: "noopener noreferrer", tabIndex: -1, children: "source" }))] })] }, `${p.namespace}/${p.name}`))) }))] }));
429
+ return (_jsxs("section", { class: "catalog", children: [_jsxs("div", { class: "controls", ref: controlsRef, children: [_jsxs("div", { class: "search-field", children: [_jsx("input", { ref: searchRef, type: "search", placeholder: `Search ${counted.length} packages…`, 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: "chips", role: "group", "aria-label": "Sort by", children: [_jsx("button", { type: "button", class: sort === "name" ? "chip active" : "chip", tabIndex: chipTabIndex, onKeyDown: onChipKeyDown, onClick: () => setSort("name"), children: "name" }), _jsx("button", { type: "button", class: sort === "updated" ? "chip active" : "chip", tabIndex: chipTabIndex, onKeyDown: onChipKeyDown, onClick: () => setSort("updated"), children: "updated" }), hasRatings && (_jsx("button", { type: "button", class: sort === "rating" ? "chip active" : "chip", tabIndex: chipTabIndex, onKeyDown: onChipKeyDown, onClick: () => setSort("rating"), children: "rating" }))] }), _jsx("span", { class: "chip-sep", "aria-hidden": "true" }), _jsxs("div", { class: "chips", role: "group", "aria-label": "Filter by kind", children: [_jsxs("button", { type: "button", class: kind === null ? "chip active" : "chip", tabIndex: chipTabIndex, onKeyDown: onChipKeyDown, onClick: () => setKind(null), children: ["all ", _jsx("small", { children: counted.length })] }), kinds.map(([k, count]) => (_jsxs("button", { type: "button", class: kind === k ? `chip active kind-${k}` : `chip kind-${k}`, tabIndex: chipTabIndex, onKeyDown: onChipKeyDown, onClick: () => setKind(kind === k ? null : k), children: [k, " ", _jsx("small", { children: count })] }, k)))] }), hasDeprecated && (_jsx("button", { type: "button", class: showDeprecated ? "chip deprecated-toggle active" : "chip deprecated-toggle", "aria-pressed": showDeprecated, title: showDeprecated ? "Hide deprecated packages" : "Show deprecated packages", tabIndex: chipTabIndex, onKeyDown: onChipKeyDown, onClick: () => setShowDeprecated((on) => !on), children: "deprecated" }))] }), shown.length === 0 ? (_jsx("p", { class: "empty", children: "No packages match." })) : (_jsx("ul", { class: "grid", ref: gridRef, children: shown.map((p) => (_jsxs("li", { class: "card", tabIndex: 0, onKeyDown: onCardKeyDown, children: [_jsxs("div", { class: "card-head", children: [_jsx(CardLogo, { pkg: p }), _jsx("h2", { children: _jsx("a", { href: withBase(`/p/${p.namespace}/${p.name}/`), tabIndex: -1, children: p.name }) }), p.deprecated ? (_jsx("span", { class: "badge deprecated", children: "deprecated" })) : (_jsx("span", { class: `badge kind-${p.kind}`, children: p.kind }))] }), _jsx("p", { class: "namespace", children: p.namespace }), (p.version || p.license || lastUpdated(p) || p.rating) && (_jsxs("div", { class: "meta-row", children: [p.version && _jsxs("span", { class: "pill version", children: ["v", p.version] }), p.license && _jsx("span", { class: "pill license", children: p.license }), p.rating && (_jsxs("span", { class: "pill rating", title: `${p.rating.up} upvote${p.rating.up === 1 ? "" : "s"}`, children: [_jsx(ArrowBigUp, { size: 13, "aria-hidden": "true" }), p.rating.up] })), (() => {
430
+ const at = lastUpdated(p);
431
+ return at && timeAgo(at) ? (_jsxs("time", { class: "updated", datetime: at, title: at, children: ["updated ", timeAgo(at)] })) : null;
432
+ })()] })), p.deprecated && (_jsxs("p", { class: "deprecated-strip", children: ["deprecated", p.replacedBy ? ` — replaced by ${p.replacedBy}` : ""] })), p.description && _jsx("p", { class: "description", children: p.description }), p.keywords && p.keywords.length > 0 && (_jsxs("div", { class: "keywords", children: [p.keywords.slice(0, 5).map((kw) => (_jsx("button", { type: "button", class: "chip keyword", tabIndex: -1, onClick: () => setQuery(kw), children: kw }, kw))), p.keywords.length > 5 && (_jsxs("span", { class: "chip keyword overflow", children: ["+", p.keywords.length - 5] }))] })), _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 }) }))] }), p.repository && (_jsx("a", { class: "source", href: p.repository, target: "_blank", rel: "noopener noreferrer", tabIndex: -1, children: "source" }))] })] }, `${p.namespace}/${p.name}`))) }))] }));
365
433
  }
@@ -1,11 +1,11 @@
1
1
  import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks";
2
2
  // Lucide (ISC) draws the UI; brand marks come from `@mdi/js`, which Lucide
3
3
  // deliberately does not carry. No SVG on this site is hand-written.
4
- import { Check, FolderRoot, Globe, Image, ImageOff } from "lucide-preact";
4
+ import { ArrowBigUp, Check, FolderRoot, Globe, Image, ImageOff } from "lucide-preact";
5
5
  import { mdiMicrosoftVisualStudioCode } from "@mdi/js";
6
6
  import { BrandMark } from "./BrandMark.js";
7
7
  import { withBase } from "../lib/base.js";
8
- import { timeAgo, vscodeUrl, type CatalogPackage } from "../lib/catalog.js";
8
+ import { lastUpdated, timeAgo, vscodeUrl, type CatalogPackage } from "../lib/catalog.js";
9
9
 
10
10
  // Known kinds get stable chip ordering + badge colors; unknown kinds
11
11
  // (future schema growth) still render with a neutral badge.
@@ -16,16 +16,70 @@ function kindOrder(kind: string): number {
16
16
  return i === -1 ? KNOWN_KINDS.length : i;
17
17
  }
18
18
 
19
- type Sort = "name" | "updated";
19
+ export type Sort = "name" | "updated" | "rating";
20
20
 
21
- // Deprecated packages sink to the bottom regardless of sort mode; the
22
- // chosen sort only orders within the two groups.
23
- function compare(a: CatalogPackage, b: CatalogPackage, sort: Sort): number {
24
- const dep = Number(!!a.deprecated) - Number(!!b.deprecated);
25
- if (dep !== 0) return dep;
26
- if (sort === "name") return a.name.localeCompare(b.name);
27
- if (!a.created || !b.created) return (a.created ? 0 : 1) - (b.created ? 0 : 1);
28
- return new Date(b.created).getTime() - new Date(a.created).getTime();
21
+ type Key = (a: CatalogPackage, b: CatalogPackage) => number;
22
+
23
+ /**
24
+ * Bigger first, with `null` as its own bucket underneath every number.
25
+ *
26
+ * The shared shape of the two ranked keys, and the reason both are written
27
+ * this way: "missing" is not a low value, it is the absence of one. Folding
28
+ * it into a number — 0 upvotes, epoch 0 — orders those rows against real
29
+ * data by accident, and ties them all with each other.
30
+ */
31
+ function descending(a: number | null, b: number | null): number {
32
+ if (a === null || b === null) return Number(a === null) - Number(b === null);
33
+ return b - a;
34
+ }
35
+
36
+ /** `updated` as epoch ms; null when absent, empty or not a date at all. */
37
+ function updatedAt(p: CatalogPackage): number | null {
38
+ const at = lastUpdated(p);
39
+ const ms = at ? new Date(at).getTime() : NaN;
40
+ return Number.isFinite(ms) ? ms : null;
41
+ }
42
+
43
+ /**
44
+ * Ascending, case-insensitive. The full ref breaks the last tie in every
45
+ * mode, and it is unique, so no two rows ever compare equal — a browse order
46
+ * that is not total is a browse order that reshuffles on rebuild.
47
+ */
48
+ const byName: Key = (a, b) =>
49
+ a.name.localeCompare(b.name, undefined, { sensitivity: "accent" }) || a.ref.localeCompare(b.ref);
50
+
51
+ /**
52
+ * Newest first. No usable date is *unknown*, not epoch 0: dating an undated
53
+ * package to 1970 sorts it below real packages by accident rather than by
54
+ * rule, so it goes into a bucket of its own at the bottom.
55
+ */
56
+ const byUpdated: Key = (a, b) => descending(updatedAt(a), updatedAt(b));
57
+
58
+ /**
59
+ * Most upvotes first. Unrated is its own bucket at the bottom, never a zero:
60
+ * a fresh index is all-unrated, and zeroes would leave every one of those
61
+ * rows comparing equal with nothing left to break the tie.
62
+ */
63
+ const byRating: Key = (a, b) => descending(a.rating?.up ?? null, b.rating?.up ?? null);
64
+
65
+ /** Each mode as a chain of keys, most significant first. */
66
+ const CHAINS: Record<Sort, Key[]> = {
67
+ name: [byName],
68
+ updated: [byUpdated, byName],
69
+ rating: [byRating, byUpdated, byName],
70
+ };
71
+
72
+ // Deprecated packages get no special ordering here — they are filtered out
73
+ // of the default browse entirely (see `shown` below) and interleave like
74
+ // any other row when the toggle brings them back. grim's own browse order
75
+ // (`browse_sort.rs`) has no deprecated key either; keeping this comparator
76
+ // silent on deprecation is what keeps the two in sync.
77
+ export function compare(a: CatalogPackage, b: CatalogPackage, sort: Sort): number {
78
+ for (const key of CHAINS[sort]) {
79
+ const d = key(a, b);
80
+ if (d !== 0) return d;
81
+ }
82
+ return 0;
29
83
  }
30
84
 
31
85
  // The two install scopes previously wore the VS Code extension's own
@@ -187,14 +241,21 @@ export default function Catalog({
187
241
  packages: CatalogPackage[];
188
242
  vscodeExtension: string | null;
189
243
  }) {
190
- // Seeded from `?q=…` on the very first render, not from an effect: a
191
- // keyword chip on a package page links here, and filtering one render late
192
- // means painting the full catalog and then collapsing it. The server has
193
- // no `location`, so it renders the unfiltered list — which is what a
194
- // crawler and a `?q=`-less visitor should both get.
195
- const [query, setQuery] = useState(() =>
196
- typeof location === "undefined" ? "" : (new URLSearchParams(location.search).get("q") ?? ""),
197
- );
244
+ // Empty on the first render, ALWAYS `?q=…` is applied a beat later, in
245
+ // the layout effect below. This is not a style preference, it is the one
246
+ // rule this island has to obey.
247
+ //
248
+ // Preact does not diff props while hydrating; its own source says so, and
249
+ // only re-applies props whose value is a function. Text children *are*
250
+ // diffed. So when the first client render disagrees with the server's, you
251
+ // get cards whose text is right and whose every attribute belongs to
252
+ // whichever package the server put at that position: one package's logo
253
+ // over another's name, and a link that opens the wrong page. Nothing
254
+ // throws. This used to seed from `location` here, and did exactly that.
255
+ const [query, setQuery] = useState("");
256
+ // Whether the URL's query has been applied. Gates the reveal below, so the
257
+ // catalog is never unhidden while it still shows the unfiltered list.
258
+ const [seeded, setSeeded] = useState(false);
198
259
  const [kind, setKind] = useState<string | null>(null);
199
260
  const [sort, setSort] = useState<Sort>("name");
200
261
  // Deprecated packages are hidden until asked for: a retired package is
@@ -240,18 +301,28 @@ export default function Catalog({
240
301
  });
241
302
  };
242
303
 
304
+ // Apply `?q=…`, now that hydration has matched the server's markup and
305
+ // Preact owns the tree. A layout effect rather than a plain one: the
306
+ // resulting render must land before the browser paints, or a `?q=` visitor
307
+ // sees the whole catalog flash past on the way to their results.
308
+ useLayoutEffect(() => {
309
+ setQuery(new URLSearchParams(location.search).get("q") ?? "");
310
+ setSeeded(true);
311
+ }, []);
312
+
243
313
  // Base.astro hides the catalog before first paint when the URL carries a
244
- // query. Reveal it once this render the filtered one has hit the DOM.
245
- // A layout effect, so the reveal lands in the same frame as the content.
314
+ // query. Reveal it only once the filtered render is in the DOMkeyed on
315
+ // `seeded`, so the unfiltered first render is never the one revealed.
246
316
  useLayoutEffect(() => {
247
- // Hydration attaches handlers but does not diff props against the server
248
- // markup, so the box the server rendered empty stays empty even though
249
- // this render filtered on `query`. Written straight to the DOM, which is
250
- // what the vnode already claims.
317
+ if (!seeded) return;
318
+ // The input's `value` prop was skipped during hydration for the same
319
+ // reason every other prop was, and the render that applied the query is
320
+ // a normal diff but write it anyway: this effect is also what runs on
321
+ // a `?q=`-less load, where no second render is queued at all.
251
322
  const input = searchRef.current;
252
323
  if (input && input.value !== query) input.value = query;
253
324
  delete document.documentElement.dataset.query;
254
- }, []);
325
+ }, [seeded]);
255
326
 
256
327
  // `/` jumps to the search box, the convention every package registry
257
328
  // shares. Bound on the document so it works wherever the reader is.
@@ -432,8 +503,10 @@ export default function Catalog({
432
503
  );
433
504
 
434
505
  // A catalog with nothing deprecated gets no toggle — a control that can
435
- // only ever be a no-op is worse than its absence.
506
+ // only ever be a no-op is worse than its absence. An index that publishes
507
+ // no ratings gets no rating chip for the same reason.
436
508
  const hasDeprecated = packages.some((p) => p.deprecated);
509
+ const hasRatings = packages.some((p) => p.rating);
437
510
 
438
511
  // Chips leave the Tab order only while there is a grid to arrow up from.
439
512
  const chipTabIndex = shown.length === 0 ? 0 : -1;
@@ -476,6 +549,17 @@ export default function Catalog({
476
549
  >
477
550
  updated
478
551
  </button>
552
+ {hasRatings && (
553
+ <button
554
+ type="button"
555
+ class={sort === "rating" ? "chip active" : "chip"}
556
+ tabIndex={chipTabIndex}
557
+ onKeyDown={onChipKeyDown}
558
+ onClick={() => setSort("rating")}
559
+ >
560
+ rating
561
+ </button>
562
+ )}
479
563
  </div>
480
564
  {/* Divides sort from filter — two different questions sharing a row.
481
565
  Decorative only: each group already carries its own aria-label,
@@ -557,15 +641,35 @@ export default function Catalog({
557
641
  )}
558
642
  </div>
559
643
  <p class="namespace">{p.namespace}</p>
560
- {(p.version || p.license || p.created) && (
644
+ {(p.version || p.license || lastUpdated(p) || p.rating) && (
561
645
  <div class="meta-row">
562
646
  {p.version && <span class="pill version">v{p.version}</span>}
563
647
  {p.license && <span class="pill license">{p.license}</span>}
564
- {p.created && timeAgo(p.created) && (
565
- <time class="updated" datetime={p.created} title={p.created}>
566
- updated {timeAgo(p.created)}
567
- </time>
648
+ {/* A count and nothing more. The page is prerendered once
649
+ for everyone, so it cannot know whether *you* voted —
650
+ showing "not voted" to someone who has would be worse
651
+ than showing nothing. */}
652
+ {p.rating && (
653
+ <span
654
+ class="pill rating"
655
+ title={`${p.rating.up} upvote${p.rating.up === 1 ? "" : "s"}`}
656
+ >
657
+ <ArrowBigUp size={13} aria-hidden="true" />
658
+ {p.rating.up}
659
+ </span>
568
660
  )}
661
+ {/* The date the sidecar derived, not the artifact's own
662
+ `created`: a package republished from the same commit
663
+ keeps its date, and one with no commit date at all gets
664
+ the day this index first saw its current digest. */}
665
+ {(() => {
666
+ const at = lastUpdated(p);
667
+ return at && timeAgo(at) ? (
668
+ <time class="updated" datetime={at} title={at}>
669
+ updated {timeAgo(at)}
670
+ </time>
671
+ ) : null;
672
+ })()}
569
673
  </div>
570
674
  )}
571
675
  {p.deprecated && (