@grimoire-rs/indexer 0.3.3 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +220 -9
- package/dist/ci.d.ts +6 -1
- package/dist/ci.d.ts.map +1 -1
- package/dist/ci.js +32 -1
- package/dist/ci.js.map +1 -1
- package/dist/cli/build.d.ts.map +1 -1
- package/dist/cli/build.js +3 -1
- package/dist/cli/build.js.map +1 -1
- package/dist/cli/ci.d.ts.map +1 -1
- package/dist/cli/ci.js +5 -1
- package/dist/cli/ci.js.map +1 -1
- package/dist/cli/enrich.d.ts +1 -0
- package/dist/cli/enrich.d.ts.map +1 -1
- package/dist/cli/enrich.js +38 -0
- package/dist/cli/enrich.js.map +1 -1
- package/dist/cli/init.d.ts.map +1 -1
- package/dist/cli/init.js +17 -3
- package/dist/cli/init.js.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/main.js +9 -0
- package/dist/cli/main.js.map +1 -1
- package/dist/cli/ratings.d.ts +3 -0
- package/dist/cli/ratings.d.ts.map +1 -0
- package/dist/cli/ratings.js +160 -0
- package/dist/cli/ratings.js.map +1 -0
- package/dist/config.d.ts +19 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +47 -1
- package/dist/config.js.map +1 -1
- package/dist/data/index.d.ts.map +1 -1
- package/dist/data/index.js +13 -0
- package/dist/data/index.js.map +1 -1
- package/dist/enrich/checkpoint.d.ts +53 -0
- package/dist/enrich/checkpoint.d.ts.map +1 -0
- package/dist/enrich/checkpoint.js +296 -0
- package/dist/enrich/checkpoint.js.map +1 -0
- package/dist/enrich/index.d.ts +14 -0
- package/dist/enrich/index.d.ts.map +1 -1
- package/dist/enrich/index.js +77 -4
- package/dist/enrich/index.js.map +1 -1
- package/dist/ratings/budget.d.ts +16 -0
- package/dist/ratings/budget.d.ts.map +1 -0
- package/dist/ratings/budget.js +7 -0
- package/dist/ratings/budget.js.map +1 -0
- package/dist/ratings/config.d.ts +56 -0
- package/dist/ratings/config.d.ts.map +1 -0
- package/dist/ratings/config.js +86 -0
- package/dist/ratings/config.js.map +1 -0
- package/dist/ratings/marker.d.ts +105 -0
- package/dist/ratings/marker.d.ts.map +1 -0
- package/dist/ratings/marker.js +141 -0
- package/dist/ratings/marker.js.map +1 -0
- package/dist/ratings/paging.d.ts +16 -0
- package/dist/ratings/paging.d.ts.map +1 -0
- package/dist/ratings/paging.js +36 -0
- package/dist/ratings/paging.js.map +1 -0
- package/dist/ratings/provider.d.ts +100 -0
- package/dist/ratings/provider.d.ts.map +1 -0
- package/dist/ratings/provider.js +203 -0
- package/dist/ratings/provider.js.map +1 -0
- package/dist/ratings/provider_github.d.ts +3 -0
- package/dist/ratings/provider_github.d.ts.map +1 -0
- package/dist/ratings/provider_github.js +135 -0
- package/dist/ratings/provider_github.js.map +1 -0
- package/dist/ratings/provider_gitlab.d.ts +3 -0
- package/dist/ratings/provider_gitlab.d.ts.map +1 -0
- package/dist/ratings/provider_gitlab.js +153 -0
- package/dist/ratings/provider_gitlab.js.map +1 -0
- package/dist/ratings/provider_memory.d.ts +28 -0
- package/dist/ratings/provider_memory.d.ts.map +1 -0
- package/dist/ratings/provider_memory.js +49 -0
- package/dist/ratings/provider_memory.js.map +1 -0
- package/dist/ratings/reconcile.d.ts +47 -0
- package/dist/ratings/reconcile.d.ts.map +1 -0
- package/dist/ratings/reconcile.js +133 -0
- package/dist/ratings/reconcile.js.map +1 -0
- package/dist/ratings/seed.d.ts +60 -0
- package/dist/ratings/seed.d.ts.map +1 -0
- package/dist/ratings/seed.js +132 -0
- package/dist/ratings/seed.js.map +1 -0
- package/dist/renderer/astro/components/Catalog.d.ts +2 -0
- package/dist/renderer/astro/components/Catalog.js +65 -16
- package/dist/renderer/astro/components/Catalog.tsx +119 -26
- package/dist/renderer/astro/components/CommandField.astro +1 -0
- package/dist/renderer/astro/components/VersionMenu.astro +31 -23
- package/dist/renderer/astro/layouts/Base.astro +218 -263
- package/dist/renderer/astro/lib/catalog.d.ts +25 -0
- package/dist/renderer/astro/lib/catalog.js +46 -0
- package/dist/renderer/astro/lib/catalog.ts +49 -0
- package/dist/renderer/astro/pages/p/[...slug].astro +238 -85
- package/dist/renderer/astro/styles/tokens.css +157 -0
- package/dist/renderer/index.d.ts.map +1 -1
- package/dist/renderer/index.js +101 -2
- package/dist/renderer/index.js.map +1 -1
- package/dist/renderer/types.d.ts +41 -0
- package/dist/renderer/types.d.ts.map +1 -1
- package/dist/validate/adapters/http.d.ts +29 -1
- package/dist/validate/adapters/http.d.ts.map +1 -1
- package/dist/validate/adapters/http.js +17 -2
- package/dist/validate/adapters/http.js.map +1 -1
- package/package.json +1 -1
- package/templates/ci/github-enrich.yml +9 -1
- package/templates/ci/github-pages.yml +11 -5
- package/templates/ci/github-ratings-seed.yml +88 -0
- package/templates/ci/github-ratings.yml +51 -0
- package/templates/ci/gitlab-ci.yml +3 -3
- package/templates/ci/gitlab-enrich.sh +10 -1
- package/templates/ci/gitlab-ratings-seed.sh +83 -0
- package/templates/ci/gitlab-ratings.yml +37 -0
- package/templates/gitignore +8 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The reconcile + tally loop (design §6.2). Stateless and level-triggered:
|
|
3
|
+
* every run re-derives everything, so a partial run leaves *fewer threads
|
|
4
|
+
* created*, never corrupt state, and a rerun is idempotent.
|
|
5
|
+
*
|
|
6
|
+
* Pure given a `RatingProvider` and a desired-ref set — no filesystem, no
|
|
7
|
+
* config, no network of its own.
|
|
8
|
+
*/
|
|
9
|
+
import { conflictWarning, type RefConflict } from "./marker.js";
|
|
10
|
+
import { type RatingProvider } from "./provider.js";
|
|
11
|
+
import type { StatsSeed } from "./seed.js";
|
|
12
|
+
/** One ref's `rating` value, exactly as it lands in `stats.json`. */
|
|
13
|
+
export interface RatingStat {
|
|
14
|
+
up: number;
|
|
15
|
+
target: string;
|
|
16
|
+
url: string;
|
|
17
|
+
}
|
|
18
|
+
export interface ReconcileInput {
|
|
19
|
+
provider: RatingProvider;
|
|
20
|
+
/** Every ref in the index. */
|
|
21
|
+
desired: readonly string[];
|
|
22
|
+
/** Threads this run may create. */
|
|
23
|
+
budget: number;
|
|
24
|
+
/**
|
|
25
|
+
* The published sidecar. Read **only** to carry ratings forward when the
|
|
26
|
+
* listing pass was truncated — see the carry-forward note in [`reconcile`].
|
|
27
|
+
*/
|
|
28
|
+
seed: StatsSeed;
|
|
29
|
+
}
|
|
30
|
+
export interface ReconcileResult {
|
|
31
|
+
/** `mergeStats`'s `fresh`: ref → this producer's stat value, and nothing else. */
|
|
32
|
+
fresh: Record<string, RatingStat>;
|
|
33
|
+
refs: number;
|
|
34
|
+
created: number;
|
|
35
|
+
/** Refs with no bound thread when the run started. */
|
|
36
|
+
missing: number;
|
|
37
|
+
conflicts: RefConflict[];
|
|
38
|
+
/** A secondary limit was hit somewhere in the run. */
|
|
39
|
+
limited: boolean;
|
|
40
|
+
/** Nothing observed and nothing created, because of a rate limit. */
|
|
41
|
+
starved: boolean;
|
|
42
|
+
}
|
|
43
|
+
export { conflictWarning };
|
|
44
|
+
export declare function reconcile(input: ReconcileInput): Promise<ReconcileResult>;
|
|
45
|
+
/** The one structured line per run (design §8, Observability). */
|
|
46
|
+
export declare function logLine(result: ReconcileResult): string;
|
|
47
|
+
//# sourceMappingURL=reconcile.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"reconcile.d.ts","sourceRoot":"","sources":["../../src/ratings/reconcile.ts"],"names":[],"mappings":"AAGA;;;;;;;GAOG;AAEH,OAAO,EAAE,eAAe,EAAoB,KAAK,WAAW,EAAE,MAAM,aAAa,CAAC;AAElF,OAAO,EAAe,KAAK,cAAc,EAAqB,MAAM,eAAe,CAAC;AACpF,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,WAAW,CAAC;AAE3C,qEAAqE;AACrE,MAAM,WAAW,UAAU;IACzB,EAAE,EAAE,MAAM,CAAC;IACX,MAAM,EAAE,MAAM,CAAC;IACf,GAAG,EAAE,MAAM,CAAC;CACb;AAED,MAAM,WAAW,cAAc;IAC7B,QAAQ,EAAE,cAAc,CAAC;IACzB,8BAA8B;IAC9B,OAAO,EAAE,SAAS,MAAM,EAAE,CAAC;IAC3B,mCAAmC;IACnC,MAAM,EAAE,MAAM,CAAC;IACf;;;OAGG;IACH,IAAI,EAAE,SAAS,CAAC;CACjB;AAED,MAAM,WAAW,eAAe;IAC9B,kFAAkF;IAClF,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,UAAU,CAAC,CAAC;IAClC,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,sDAAsD;IACtD,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,WAAW,EAAE,CAAC;IACzB,sDAAsD;IACtD,OAAO,EAAE,OAAO,CAAC;IACjB,qEAAqE;IACrE,OAAO,EAAE,OAAO,CAAC;CAClB;AAED,OAAO,EAAE,eAAe,EAAE,CAAC;AAW3B,wBAAsB,SAAS,CAAC,KAAK,EAAE,cAAc,GAAG,OAAO,CAAC,eAAe,CAAC,CAoG/E;AAED,kEAAkE;AAClE,wBAAgB,OAAO,CAAC,MAAM,EAAE,eAAe,GAAG,MAAM,CAMvD"}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright 2026 The Grimoire Authors
|
|
3
|
+
/**
|
|
4
|
+
* The reconcile + tally loop (design §6.2). Stateless and level-triggered:
|
|
5
|
+
* every run re-derives everything, so a partial run leaves *fewer threads
|
|
6
|
+
* created*, never corrupt state, and a rerun is idempotent.
|
|
7
|
+
*
|
|
8
|
+
* Pure given a `RatingProvider` and a desired-ref set — no filesystem, no
|
|
9
|
+
* config, no network of its own.
|
|
10
|
+
*/
|
|
11
|
+
import { conflictWarning, resolveConflicts } from "./marker.js";
|
|
12
|
+
import { planCreations } from "./budget.js";
|
|
13
|
+
import { RateLimited } from "./provider.js";
|
|
14
|
+
export { conflictWarning };
|
|
15
|
+
/** A published `rating` value, when the seed carries one for this ref. */
|
|
16
|
+
function seededRating(seed, ref) {
|
|
17
|
+
const rating = seed.entries[ref]?.rating;
|
|
18
|
+
if (rating === null || typeof rating !== "object")
|
|
19
|
+
return null;
|
|
20
|
+
const { up, target, url } = rating;
|
|
21
|
+
if (typeof up !== "number" || typeof target !== "string" || typeof url !== "string")
|
|
22
|
+
return null;
|
|
23
|
+
return { up, target, url };
|
|
24
|
+
}
|
|
25
|
+
export async function reconcile(input) {
|
|
26
|
+
const { provider, desired, budget, seed } = input;
|
|
27
|
+
// 1. OBSERVE — paginated to exhaustion inside the provider, R-1 applied there.
|
|
28
|
+
let threads;
|
|
29
|
+
let listTruncated = false;
|
|
30
|
+
try {
|
|
31
|
+
threads = await provider.listAuthored();
|
|
32
|
+
}
|
|
33
|
+
catch (err) {
|
|
34
|
+
// Every other failure propagates: a forge that did not answer says nothing
|
|
35
|
+
// about how many votes exist, and publishing on that basis is the silent
|
|
36
|
+
// emptying R-2 exists to prevent.
|
|
37
|
+
if (!(err instanceof RateLimited))
|
|
38
|
+
throw err;
|
|
39
|
+
threads = err.observed;
|
|
40
|
+
listTruncated = true;
|
|
41
|
+
}
|
|
42
|
+
let limited = listTruncated;
|
|
43
|
+
// 2. CONFLICT — a ref bound by more than one authorized thread contributes
|
|
44
|
+
// zero, so `bound` already excludes them and step 4 needs no second check.
|
|
45
|
+
const { bound, conflicts } = resolveConflicts(threads);
|
|
46
|
+
// 3. DIFF + BUDGETED CREATE — sorted, so successive runs make monotonic
|
|
47
|
+
// progress with no stored cursor. Skipped outright when the listing pass
|
|
48
|
+
// was truncated: the forge just said stop, and the diff is unreliable
|
|
49
|
+
// anyway when the observation is partial.
|
|
50
|
+
//
|
|
51
|
+
// Conflicted refs count as present, not missing. §6.2's pseudocode
|
|
52
|
+
// subtracts only `observed`, which would have this run create a *third*
|
|
53
|
+
// thread for a ref already bound by two — deepening, every run, the exact
|
|
54
|
+
// ambiguity the warning tells the operator to resolve by deleting.
|
|
55
|
+
const present = new Set([...bound.keys(), ...conflicts.map((conflict) => conflict.ref)]);
|
|
56
|
+
const plan = planCreations(desired, present, budget);
|
|
57
|
+
let created = 0;
|
|
58
|
+
if (!listTruncated) {
|
|
59
|
+
for (const ref of plan.batch) {
|
|
60
|
+
try {
|
|
61
|
+
bound.set(ref, await provider.create(ref));
|
|
62
|
+
created += 1;
|
|
63
|
+
}
|
|
64
|
+
catch (err) {
|
|
65
|
+
if (!(err instanceof RateLimited))
|
|
66
|
+
throw err;
|
|
67
|
+
limited = true;
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
// 4. TALLY — the forge's own scalar counter. A zero-vote thread says nothing,
|
|
73
|
+
// so it is omitted rather than published as `up: 0`.
|
|
74
|
+
const fresh = {};
|
|
75
|
+
for (const [ref, thread] of bound) {
|
|
76
|
+
if (thread.up > 0)
|
|
77
|
+
fresh[ref] = { up: thread.up, target: thread.target, url: thread.url };
|
|
78
|
+
}
|
|
79
|
+
// 4b. CARRY FORWARD what a truncated pass never reached.
|
|
80
|
+
//
|
|
81
|
+
// `mergeStats` drops the `rating` key of every ref absent from `fresh`,
|
|
82
|
+
// and that is correct *because this producer completed* — design §6.2
|
|
83
|
+
// says so in as many words, and only a FAILED producer carries forward.
|
|
84
|
+
// A truncated listing pass did not complete: refs on the pages it never
|
|
85
|
+
// read look identical to refs whose votes went to zero. Publishing on
|
|
86
|
+
// that basis is exactly the silent emptying R-2 forbids, so their
|
|
87
|
+
// published value is re-emitted instead. It is stale, not wiped, and the
|
|
88
|
+
// next complete run re-derives it.
|
|
89
|
+
//
|
|
90
|
+
// A truncated *create* pass needs none of this: listing completed, so the
|
|
91
|
+
// tally is authoritative for every thread that exists.
|
|
92
|
+
if (listTruncated) {
|
|
93
|
+
for (const ref of Object.keys(seed.entries)) {
|
|
94
|
+
if (ref in fresh)
|
|
95
|
+
continue;
|
|
96
|
+
const carried = seededRating(seed, ref);
|
|
97
|
+
if (carried)
|
|
98
|
+
fresh[ref] = carried;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return {
|
|
102
|
+
fresh,
|
|
103
|
+
refs: desired.length,
|
|
104
|
+
created,
|
|
105
|
+
missing: plan.missing.length,
|
|
106
|
+
conflicts,
|
|
107
|
+
limited,
|
|
108
|
+
// Nothing was observed and nothing was created, against an index that
|
|
109
|
+
// wanted threads — the run did no real work, and publishing on that basis
|
|
110
|
+
// empties every previously published rating at exit 0.
|
|
111
|
+
//
|
|
112
|
+
// This deliberately does NOT require `limited`. Spec review (2026-08-18,
|
|
113
|
+
// F-1) drove the real verb with a numeric `trustedBots[].id`: R-1
|
|
114
|
+
// authorized nothing, `listAuthored` returned `[]`, the run *completed*,
|
|
115
|
+
// and the merge dropped every rating key — a full wipe that R-2 never sees,
|
|
116
|
+
// because the seed fetch succeeded and the emptying arrived through the
|
|
117
|
+
// R-1 door instead. Any R-1 misconfiguration (a wrong container id, a
|
|
118
|
+
// wrong category, a bot that is not the author) has the same shape.
|
|
119
|
+
//
|
|
120
|
+
// A genuinely empty tally is distinguishable: threads whose votes all went
|
|
121
|
+
// to zero are still *observed*, so `threads.length > 0`. A first run on a
|
|
122
|
+
// fresh index creates threads, so `created > 0`. An index with no refs at
|
|
123
|
+
// all has nothing to do and is not starved.
|
|
124
|
+
starved: desired.length > 0 && threads.length === 0 && created === 0,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
/** The one structured line per run (design §8, Observability). */
|
|
128
|
+
export function logLine(result) {
|
|
129
|
+
return (`ratings: refs=${result.refs} created=${result.created}/${result.missing} ` +
|
|
130
|
+
`tallied=${Object.keys(result.fresh).length} conflicts=${result.conflicts.length} ` +
|
|
131
|
+
`secondary_limit_hit=${result.limited}`);
|
|
132
|
+
}
|
|
133
|
+
//# sourceMappingURL=reconcile.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"reconcile.js","sourceRoot":"","sources":["../../src/ratings/reconcile.ts"],"names":[],"mappings":"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"}
|
|
@@ -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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
@@ -95,7 +139,7 @@ function CardLogo({ pkg }) {
|
|
|
95
139
|
};
|
|
96
140
|
}, [pkg.logo]);
|
|
97
141
|
if (!pkg.logo) {
|
|
98
|
-
return (_jsx("span", { class: "card-logo card-logo-fallback", "aria-hidden": "true", style: { background: `var(--kind-${pkg.kind}, var(--muted))` }, children: pkg.name[0]?.toUpperCase() }));
|
|
142
|
+
return (_jsx("span", { class: "card-logo card-logo-fallback", "aria-hidden": "true", style: { background: `var(--grim-color-kind-${pkg.kind}, var(--grim-color-muted))` }, children: pkg.name[0]?.toUpperCase() }));
|
|
99
143
|
}
|
|
100
144
|
return (_jsxs("span", { class: "card-logo logo-slot", "data-state": state, role: state === "broken" ? "img" : undefined, "aria-label": state === "broken" ? "Logo image unavailable" : undefined, title: state === "broken" ? "Logo image unavailable" : undefined, children: [state === "broken" ? (_jsx(ImageOff, { class: "logo-mark", "aria-hidden": "true" })) : (_jsx(Image, { class: "logo-mark", "aria-hidden": "true" })), _jsx("img", { ref: imgRef, src: withBase(pkg.logo), alt: "", loading: "lazy", onLoad: () => setState("ready"), onError: () => setState("broken") })] }));
|
|
101
145
|
}
|
|
@@ -376,9 +420,14 @@ export default function Catalog({ packages, vscodeExtension, }) {
|
|
|
376
420
|
});
|
|
377
421
|
const shown = (showDeprecated ? matching : matching.filter((p) => !p.deprecated)).sort((a, b) => compare(a, b, sort));
|
|
378
422
|
// A catalog with nothing deprecated gets no toggle — a control that can
|
|
379
|
-
// 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.
|
|
380
425
|
const hasDeprecated = packages.some((p) => p.deprecated);
|
|
426
|
+
const hasRatings = packages.some((p) => p.rating);
|
|
381
427
|
// Chips leave the Tab order only while there is a grid to arrow up from.
|
|
382
428
|
const chipTabIndex = shown.length === 0 ? 0 : -1;
|
|
383
|
-
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.
|
|
429
|
+
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 ${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", "data-slot": "filter-chip", tabIndex: chipTabIndex, onKeyDown: onChipKeyDown, onClick: () => setSort("name"), children: "name" }), _jsx("button", { type: "button", class: sort === "updated" ? "chip active" : "chip", "data-slot": "filter-chip", tabIndex: chipTabIndex, onKeyDown: onChipKeyDown, onClick: () => setSort("updated"), children: "updated" }), hasRatings && (_jsx("button", { type: "button", class: sort === "rating" ? "chip active" : "chip", "data-slot": "filter-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", "data-slot": "filter-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}`, "data-slot": "filter-chip", 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", "data-slot": "package-card", tabIndex: 0, onKeyDown: onCardKeyDown, children: [_jsxs("div", { class: "card-head", children: [_jsx(CardLogo, { pkg: p }), _jsx("h2", { "data-slot": "package-name", children: _jsx("a", { href: withBase(`/p/${p.namespace}/${p.name}/`), tabIndex: -1, children: p.name }) }), p.deprecated ? (_jsx("span", { class: "badge deprecated", "data-slot": "package-kind", children: "deprecated" })) : (_jsx("span", { class: `badge kind-${p.kind}`, "data-slot": "package-kind", children: p.kind }))] }), _jsx("p", { class: "namespace", children: p.namespace }), (p.version || p.license || lastUpdated(p) || p.rating) && (_jsxs("div", { class: "meta-row", "data-slot": "package-meta", 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", "data-slot": "package-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}`))) }))] }));
|
|
384
433
|
}
|