@grove-dev/astro 0.1.5 → 0.2.5

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 (148) hide show
  1. package/LICENSE +21 -0
  2. package/dist/index.d.ts +4 -25
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +56 -22
  5. package/dist/index.js.map +1 -1
  6. package/dist/layouts/index.d.ts +25 -0
  7. package/dist/layouts/index.d.ts.map +1 -0
  8. package/dist/layouts/index.js +25 -0
  9. package/dist/layouts/index.js.map +1 -0
  10. package/dist/lib/display.d.ts +33 -0
  11. package/dist/lib/display.d.ts.map +1 -0
  12. package/dist/lib/display.js +104 -0
  13. package/dist/lib/display.js.map +1 -0
  14. package/dist/lib/format.d.ts +30 -0
  15. package/dist/lib/format.d.ts.map +1 -0
  16. package/dist/lib/format.js +73 -0
  17. package/dist/lib/format.js.map +1 -0
  18. package/dist/lib/index.d.ts +26 -0
  19. package/dist/lib/index.d.ts.map +1 -0
  20. package/dist/lib/index.js +26 -0
  21. package/dist/lib/index.js.map +1 -0
  22. package/dist/lib/lenses.d.ts +96 -0
  23. package/dist/lib/lenses.d.ts.map +1 -0
  24. package/dist/lib/lenses.js +181 -0
  25. package/dist/lib/lenses.js.map +1 -0
  26. package/dist/lib/repo.d.ts +30 -0
  27. package/dist/lib/repo.d.ts.map +1 -0
  28. package/dist/lib/repo.js +44 -0
  29. package/dist/lib/repo.js.map +1 -0
  30. package/dist/lib/scores.d.ts +38 -0
  31. package/dist/lib/scores.d.ts.map +1 -0
  32. package/dist/lib/scores.js +83 -0
  33. package/dist/lib/scores.js.map +1 -0
  34. package/dist/lib/search.d.ts +199 -0
  35. package/dist/lib/search.d.ts.map +1 -0
  36. package/dist/lib/search.js +357 -0
  37. package/dist/lib/search.js.map +1 -0
  38. package/dist/lib/taxonomy-counts.d.ts +25 -0
  39. package/dist/lib/taxonomy-counts.d.ts.map +1 -0
  40. package/dist/lib/taxonomy-counts.js +41 -0
  41. package/dist/lib/taxonomy-counts.js.map +1 -0
  42. package/package.json +7 -7
  43. package/src/components/AppsIndexRow.astro +204 -0
  44. package/src/components/AppsPagination.astro +126 -0
  45. package/src/components/CategoryGrid.astro +86 -17
  46. package/src/components/ContributorsGrid.astro +149 -0
  47. package/src/components/DecisionRow.astro +89 -0
  48. package/src/components/ExploreByCategory.astro +67 -0
  49. package/src/components/ExploreByStack.astro +72 -0
  50. package/src/components/Hero.astro +246 -0
  51. package/src/components/Icon.astro +223 -0
  52. package/src/components/ItemCard.astro +306 -30
  53. package/src/components/MinimalAbout.astro +82 -0
  54. package/src/components/OriginalCollection.astro +116 -0
  55. package/src/components/RecordSection.astro +88 -0
  56. package/src/components/RefinePanel.astro +197 -0
  57. package/src/components/ScoreBars.astro +92 -22
  58. package/src/components/SmartLensTabs.astro +71 -0
  59. package/src/components/StackGrid.astro +132 -0
  60. package/src/components/WhyThisExists.astro +116 -0
  61. package/src/index.ts +61 -14
  62. package/src/layouts/BaseLayout.astro +227 -15
  63. package/src/layouts/Container.astro +57 -0
  64. package/src/layouts/Footer.astro +235 -0
  65. package/src/layouts/Header.astro +159 -0
  66. package/src/layouts/SectionHeader.astro +58 -0
  67. package/{templates/default/src/components/layout → src/layouts}/Seo.astro +62 -38
  68. package/src/layouts/ThemeToggle.astro +110 -0
  69. package/src/layouts/astro-shim.d.ts +29 -0
  70. package/src/layouts/index.ts +24 -0
  71. package/src/lib/display.ts +118 -0
  72. package/src/lib/format.ts +65 -0
  73. package/src/lib/index.ts +26 -0
  74. package/src/lib/lenses.ts +241 -0
  75. package/src/lib/repo.ts +44 -0
  76. package/src/lib/scores.ts +94 -0
  77. package/src/lib/search.ts +481 -0
  78. package/src/lib/taxonomy-counts.ts +60 -0
  79. package/src/styles.css +366 -638
  80. package/templates/default/astro.config.mjs +8 -6
  81. package/templates/default/content/pages/about.md +66 -0
  82. package/templates/default/content/pages/methodology.md +103 -0
  83. package/templates/default/data/generated/records.full.json +8 -0
  84. package/templates/default/data/generated/records.index.json +7 -0
  85. package/templates/default/data/generated/records.json +8 -0
  86. package/templates/default/data/generated/site-config.json +31 -0
  87. package/templates/default/data/records/.gitkeep +0 -0
  88. package/templates/default/grove.config.ts +36 -0
  89. package/templates/default/package.json +6 -5
  90. package/templates/default/public/llms-full.txt +14 -0
  91. package/templates/default/public/llms.txt +11 -0
  92. package/templates/default/src/components/layout/BaseLayout.astro +10 -57
  93. package/templates/default/src/components/layout/Footer.astro +16 -134
  94. package/templates/default/src/components/layout/Header.astro +25 -126
  95. package/templates/default/src/data/records.ts +193 -0
  96. package/templates/default/src/lib/markdown.ts +171 -0
  97. package/templates/default/src/pages/about.astro +95 -429
  98. package/templates/default/src/pages/index.astro +263 -61
  99. package/templates/default/src/pages/projects/[slug].astro +480 -0
  100. package/templates/default/src/pages/projects/index.astro +193 -0
  101. package/templates/default/src/pages/sitemap.xml.ts +73 -61
  102. package/templates/default/src/pages/submit.astro +122 -411
  103. package/templates/default/src/styles/global.css +42 -68
  104. package/src/components/ActiveFilterChips.astro +0 -20
  105. package/src/components/DirectoryFilters.astro +0 -100
  106. package/src/components/DirectoryHero.astro +0 -32
  107. package/src/components/HealthBadge.astro +0 -12
  108. package/src/components/LensTabs.astro +0 -26
  109. package/src/components/MethodologyPanel.astro +0 -20
  110. package/src/components/Pagination.astro +0 -24
  111. package/src/components/ProjectDetail.astro +0 -166
  112. package/src/components/SubmitDraft.astro +0 -137
  113. package/templates/default/src/components/AppCard.astro +0 -248
  114. package/templates/default/src/components/AppSection.astro +0 -58
  115. package/templates/default/src/components/AppsIndexRow.astro +0 -230
  116. package/templates/default/src/components/AppsPagination.astro +0 -147
  117. package/templates/default/src/components/CategoryGrid.astro +0 -80
  118. package/templates/default/src/components/ContributorsGrid.astro +0 -105
  119. package/templates/default/src/components/DecisionRow.astro +0 -219
  120. package/templates/default/src/components/ExploreByCategory.astro +0 -74
  121. package/templates/default/src/components/ExploreByStack.astro +0 -102
  122. package/templates/default/src/components/Hero.astro +0 -176
  123. package/templates/default/src/components/MinimalAbout.astro +0 -57
  124. package/templates/default/src/components/OriginalCollection.astro +0 -144
  125. package/templates/default/src/components/RefinePanel.astro +0 -272
  126. package/templates/default/src/components/ScoreBars.astro +0 -72
  127. package/templates/default/src/components/SmartLensTabs.astro +0 -65
  128. package/templates/default/src/components/StackGrid.astro +0 -95
  129. package/templates/default/src/components/WhyThisExists.astro +0 -81
  130. package/templates/default/src/components/icons/Icon.astro +0 -148
  131. package/templates/default/src/components/layout/SectionHeader.astro +0 -42
  132. package/templates/default/src/components/layout/ThemeToggle.astro +0 -97
  133. package/templates/default/src/data/apps.ts +0 -149
  134. package/templates/default/src/data/categories.ts +0 -27
  135. package/templates/default/src/data/config.ts +0 -28
  136. package/templates/default/src/data/contributors.ts +0 -96
  137. package/templates/default/src/data/stacks.ts +0 -47
  138. package/templates/default/src/data/stats.ts +0 -65
  139. package/templates/default/src/data/types.ts +0 -248
  140. package/templates/default/src/lib/apps-search.ts +0 -228
  141. package/templates/default/src/lib/format.ts +0 -36
  142. package/templates/default/src/lib/lenses.ts +0 -98
  143. package/templates/default/src/lib/repo.ts +0 -12
  144. package/templates/default/src/lib/scores.ts +0 -51
  145. package/templates/default/src/lib/taxonomy-counts.ts +0 -31
  146. package/templates/default/src/pages/apps/[slug].astro +0 -709
  147. package/templates/default/src/pages/apps/index.astro +0 -669
  148. package/templates/default/src/pages/contributors.astro +0 -142
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Shared formatting helpers.
3
+ *
4
+ * Dependency-free — importable by both server-rendered Astro
5
+ * components and any future client-side script. No `Intl`-heavy
6
+ * calls that would explode the client bundle.
7
+ */
8
+
9
+ /**
10
+ * Format a number compactly: 1234 → "1.2k", 12345 → "12k", 79 → "79".
11
+ * Used for hero/header star counts and other large-num displays.
12
+ */
13
+ export function compact(n: number): string {
14
+ if (n >= 1000) {
15
+ const k = n / 1000;
16
+ return (k >= 10 ? Math.round(k).toString() : k.toFixed(1)) + "k";
17
+ }
18
+ return n.toString();
19
+ }
20
+
21
+ /**
22
+ * Format a star count compactly. Returns `null` when input is not a
23
+ * finite number, so callers can render "—" without an extra guard.
24
+ */
25
+ export function formatStars(n: number | null | undefined): string | null {
26
+ if (typeof n !== "number" || !Number.isFinite(n)) return null;
27
+ return compact(n);
28
+ }
29
+
30
+ /**
31
+ * Format an ISO date as a human "Xd ago" / "Xmo ago" / "Xy ago" string.
32
+ * Returns "—" for missing or invalid input, and "today" for future
33
+ * dates (which can happen with bad upstream timestamps).
34
+ */
35
+ export function formatRelative(date: string | null | undefined): string {
36
+ if (!date) return "—";
37
+ const d = new Date(date);
38
+ if (Number.isNaN(d.valueOf())) return "—";
39
+ const days = Math.floor((Date.now() - d.valueOf()) / (1000 * 60 * 60 * 24));
40
+ if (days <= 0) return "today";
41
+ if (days === 1) return "1d ago";
42
+ if (days < 30) return `${days}d ago`;
43
+ const months = Math.floor(days / 30);
44
+ if (months === 1) return "1mo ago";
45
+ if (months < 12) return `${months}mo ago`;
46
+ const years = Math.floor(months / 12);
47
+ if (years === 1) return "1y ago";
48
+ return `${years}y ago`;
49
+ }
50
+
51
+ /**
52
+ * Format an ISO date as a localized short date (e.g. "Jan 5, 2026").
53
+ * Returns the original string for unparseable input, or "—" when
54
+ * no value is provided.
55
+ */
56
+ export function formatDate(date: string | null | undefined): string {
57
+ if (!date) return "—";
58
+ const d = new Date(date);
59
+ if (Number.isNaN(d.valueOf())) return date;
60
+ return d.toLocaleDateString("en-US", {
61
+ year: "numeric",
62
+ month: "short",
63
+ day: "numeric",
64
+ });
65
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * @grove-dev/astro — framework-agnostic lib helpers.
3
+ *
4
+ * Re-exports every helper that the showcase component library
5
+ * (and any future consumer) can use to:
6
+ *
7
+ * - parse GitHub repo URLs (`lib/repo`)
8
+ * - format counts, dates, stars (`lib/format`)
9
+ * - apply curated lenses / facets / search filters (`lib/lenses`,
10
+ * `lib/search`)
11
+ * - render score bars (`lib/scores`)
12
+ * - compute taxonomy counts at build time (`lib/taxonomy-counts`)
13
+ * - pretty-print slugs and label/lens/sort/status ids (`lib/display`)
14
+ *
15
+ * All helpers are pure, dependency-free, and typed against
16
+ * `@grove-dev/core` (specifically `ProjectRecord` for the index
17
+ * payload and the `Score` / `HealthStatus` unions).
18
+ */
19
+
20
+ export * from "./repo.js";
21
+ export * from "./format.js";
22
+ export * from "./lenses.js";
23
+ export * from "./search.js";
24
+ export * from "./scores.js";
25
+ export * from "./taxonomy-counts.js";
26
+ export * from "./display.js";
@@ -0,0 +1,241 @@
1
+ /**
2
+ * Curated lenses for the list / discovery page.
3
+ *
4
+ * A lens is a single-select view that translates into a (possibly
5
+ * empty) URL search-param filter. The 6 lenses below cover the
6
+ * "what should I look at next?" mental shortcut:
7
+ *
8
+ * - "all" — no filter
9
+ * - "hot" — recent attention: stars ≥ 1000 OR pushed within 30d
10
+ * - "new" — fresh entries: pushed within 7d OR curated "new" label
11
+ * - "mature" — long-running projects: curated tier AND pushed > 365d ago
12
+ * - "featured" — curated "featured" label
13
+ * - "needs-review" — cleanup candidate (stale / archived / etc.)
14
+ *
15
+ * The lens id is what shows up in the URL as `?lens=...`. Filter
16
+ * resolution happens in `lib/search.ts` (`filterApps`).
17
+ *
18
+ * Keep this list in sync with `lib/search.ts` and any UI tabs.
19
+ */
20
+
21
+ import type {
22
+ HealthStatus,
23
+ ProjectRecord,
24
+ } from "@grove-dev/core";
25
+
26
+ export type LensId =
27
+ | "all"
28
+ | "hot"
29
+ | "new"
30
+ | "mature"
31
+ | "featured"
32
+ | "needs-review";
33
+
34
+ export interface LensDef {
35
+ id: LensId;
36
+ label: string;
37
+ description: string;
38
+ /**
39
+ * Returns true when the record matches this lens. Designed to
40
+ * operate on the index payload (`toIndexRecord`) shape — the
41
+ * same record the list page already holds — so consumers don't
42
+ * need to do a second fetch.
43
+ */
44
+ match: (record: AppLike) => boolean;
45
+ }
46
+
47
+ const DAY_MS = 86_400_000;
48
+
49
+ /**
50
+ * The shape that lens matching actually reads. Decoupled from
51
+ * `ProjectRecord` so the search lib can also pass the index payload
52
+ * (which omits a few heavy fields) and tests can pass minimal
53
+ * fixtures.
54
+ */
55
+ export interface AppLike {
56
+ curation?: { labels?: string[] };
57
+ health?: {
58
+ status?: HealthStatus;
59
+ tier?: "curated" | "listed" | "experimental" | "hidden";
60
+ cleanupCandidate?: boolean;
61
+ };
62
+ github?: {
63
+ stars?: number;
64
+ pushedAt?: string | null;
65
+ };
66
+ }
67
+
68
+ function daysSince(iso: string | null | undefined): number {
69
+ if (!iso) return Infinity;
70
+ const d = new Date(iso);
71
+ if (Number.isNaN(d.valueOf())) return Infinity;
72
+ return (Date.now() - d.valueOf()) / DAY_MS;
73
+ }
74
+
75
+ function hasLabel(record: AppLike, label: string): boolean {
76
+ return record.curation?.labels?.includes(label) ?? false;
77
+ }
78
+
79
+ const CLEANUP_STATUSES: HealthStatus[] = [
80
+ "stale",
81
+ "inactive",
82
+ "archived",
83
+ "unavailable",
84
+ "needs_review",
85
+ ];
86
+
87
+ function isCleanupCandidate(record: AppLike): boolean {
88
+ if (record.health?.cleanupCandidate) return true;
89
+ const status = record.health?.status;
90
+ if (!status) return false;
91
+ return CLEANUP_STATUSES.includes(status);
92
+ }
93
+
94
+ export const LENSES: LensDef[] = [
95
+ {
96
+ id: "all",
97
+ label: "All",
98
+ description: "Every record in the directory.",
99
+ match: () => true,
100
+ },
101
+ {
102
+ id: "hot",
103
+ label: "Trending",
104
+ description:
105
+ "Records with recent attention — stars ≥ 1000 or pushed within 30 days.",
106
+ match: (r) => {
107
+ const stars = r.github?.stars ?? 0;
108
+ const pushed = daysSince(r.github?.pushedAt);
109
+ return stars >= 1000 || pushed <= 30;
110
+ },
111
+ },
112
+ {
113
+ id: "new",
114
+ label: "Recently added",
115
+ description:
116
+ "Fresh entries — pushed within 7 days or curated with the “new” label.",
117
+ match: (r) => {
118
+ const pushed = daysSince(r.github?.pushedAt);
119
+ return pushed <= 7 || hasLabel(r, "new");
120
+ },
121
+ },
122
+ {
123
+ id: "mature",
124
+ label: "Established",
125
+ description:
126
+ "Long-running projects — curated tier and last push > 365 days ago.",
127
+ match: (r) => {
128
+ const tier = r.health?.tier;
129
+ const pushed = daysSince(r.github?.pushedAt);
130
+ return tier === "curated" && pushed > 365;
131
+ },
132
+ },
133
+ {
134
+ id: "featured",
135
+ label: "Featured",
136
+ description: "Records curated with the “featured” label.",
137
+ match: (r) => hasLabel(r, "featured"),
138
+ },
139
+ {
140
+ id: "needs-review",
141
+ label: "Needs review",
142
+ description: "Cleanup candidates — stale, archived, or unmaintained.",
143
+ match: (r) => isCleanupCandidate(r),
144
+ },
145
+ ];
146
+
147
+ /**
148
+ * The lenses rendered as top-row tabs on the list page. Order
149
+ * matters — left to right. Keep this in sync with what the design
150
+ * calls for; URL deep-linking accepts any `LensId` even if it's not
151
+ * in the primary row.
152
+ */
153
+ export const PRIMARY_LENSES: LensId[] = [
154
+ "all",
155
+ "hot",
156
+ "new",
157
+ "mature",
158
+ "featured",
159
+ "needs-review",
160
+ ];
161
+
162
+ export function lensById(id: string | null | undefined): LensDef | undefined {
163
+ if (!id) return undefined;
164
+ return LENSES.find((l) => l.id === id);
165
+ }
166
+
167
+ export function isPrimaryLens(id: string | null | undefined): id is LensId {
168
+ if (!id) return false;
169
+ return PRIMARY_LENSES.includes(id as LensId);
170
+ }
171
+
172
+ /**
173
+ * Resolve the active lens from URL search params. Returns the empty
174
+ * string when no `lens=...` is set, so callers can treat both
175
+ * "absent" and "unknown" identically.
176
+ */
177
+ export function lensFromSearchParams(
178
+ params: URLSearchParams,
179
+ ): LensId | "" {
180
+ const v = params.get("lens");
181
+ if (!v) return "";
182
+ if (LENSES.some((l) => l.id === v)) return v as LensId;
183
+ return "";
184
+ }
185
+
186
+ /**
187
+ * `true` when the URL search params currently reflect this lens's
188
+ * filter shape. The "all" lens is the implicit "no filter" state —
189
+ * it's active only when no other lens-shaped params are present.
190
+ */
191
+ export function isLensActive(lensId: LensId, params: URLSearchParams): boolean {
192
+ if (lensId === "all") {
193
+ return lensFromSearchParams(params) === "" && !params.get("label");
194
+ }
195
+ return lensFromSearchParams(params) === lensId;
196
+ }
197
+
198
+ /**
199
+ * Build a `href` for a lens tab. Selecting a lens resets the other
200
+ * "view" params (label, page) and applies only the chosen lens's
201
+ * filter. Other filters (q, stack, platform, category, sort) are
202
+ * preserved.
203
+ *
204
+ * `pathPrefix` is the URL root for the list page — typically
205
+ * `/projects` for the project-directory blueprint, `/resources` for
206
+ * resource-hub, `/entities` for ecosystem-map. Defaults to `/`.
207
+ */
208
+ export function hrefForLens(
209
+ lensId: LensId,
210
+ params: URLSearchParams,
211
+ pathPrefix: string = "/",
212
+ ): string {
213
+ const sp = new URLSearchParams(params);
214
+ sp.delete("lens");
215
+ sp.delete("label");
216
+ sp.delete("page");
217
+ if (lensId && lensId !== "all") {
218
+ sp.set("lens", lensId);
219
+ }
220
+ const qs = sp.toString();
221
+ const base = pathPrefix.replace(/\/$/, "");
222
+ return qs ? `${base}?${qs}` : base || "/";
223
+ }
224
+
225
+ /**
226
+ * Apply the lens filter to a record array. When `lensId` is
227
+ * `""` (no lens) or `"all"`, returns the input unchanged.
228
+ *
229
+ * Type-parameterized so the same helper works on `ProjectRecord[]`
230
+ * and on the lighter index payload that list pages ship.
231
+ */
232
+ export function applyLens<T extends AppLike>(records: T[], lensId: LensId | ""): T[] {
233
+ if (!lensId || lensId === "all") return records;
234
+ const def = lensById(lensId);
235
+ if (!def) return records;
236
+ return records.filter((r) => def.match(r));
237
+ }
238
+
239
+ // Re-export the type so consumers can write `import type { AppLike }`
240
+ // without depending on `@grove-dev/core` directly.
241
+ export type { ProjectRecord };
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Parse a GitHub repo URL into its owner and repo components.
3
+ *
4
+ * Returns `null` (not `{ owner: null, repo: null }`) if the URL
5
+ * doesn't match the github.com path shape — this lets callers
6
+ * distinguish "no URL" from "URL without owner" with a single
7
+ * null check.
8
+ *
9
+ * Used in:
10
+ * - ItemCard / AppCard avatar fallback (owner avatar via GitHub
11
+ * avatars API).
12
+ * - Detail page (showing owner + repo name in breadcrumb / header).
13
+ * - Anywhere we need to display "@owner / repo" or link to the owner.
14
+ */
15
+ export function getOwnerAndRepoFromRepoUrl(
16
+ repoUrl: string | null | undefined,
17
+ ): { owner: string; repo: string } | null {
18
+ if (!repoUrl || !repoUrl.includes("github.com/")) return null;
19
+ const m = repoUrl.match(/github\.com\/([^/]+)\/([^/?#]+)/);
20
+ if (!m) return null;
21
+ return {
22
+ owner: m[1],
23
+ repo: m[2].replace(/\.git$/, ""),
24
+ };
25
+ }
26
+
27
+ /**
28
+ * Build a GitHub avatar URL for a given owner. Defaults to size 80
29
+ * which is the smallest "high quality" size in GitHub's avatar CDN.
30
+ *
31
+ * Returns a string (not nullable) so callers can plug the result
32
+ * straight into an `src` attribute. The avatar endpoint always
33
+ * returns a placeholder image for unknown owners, so the only
34
+ * reason to check `null` upstream is when you specifically want
35
+ * to render a different fallback (e.g. initials).
36
+ */
37
+ export function getOwnerAvatarUrl(owner: string, size = 80): string {
38
+ if (!owner) {
39
+ // Defensive: callers may pass a possibly-empty string. Return the
40
+ // generic GitHub identicon so an `<img>` doesn't 404.
41
+ return `https://avatars.githubusercontent.com/identicon?v=4&s=${size}`;
42
+ }
43
+ return `https://avatars.githubusercontent.com/${owner}?v=4&s=${size}`;
44
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Score-dimension utilities.
3
+ *
4
+ * Operates on the `scores` field of a `ProjectRecord` (or any
5
+ * object that has the same shape — defined as `Score` in
6
+ * `@grove-dev/core`). All helpers are dependency-free.
7
+ */
8
+
9
+ import type { Score } from "@grove-dev/core";
10
+
11
+ /**
12
+ * Format a single score (0-100) into a 0-5 "tier" used by the UI:
13
+ *
14
+ * 0-19 → 0 (very low)
15
+ * 20-39 → 1 (low)
16
+ * 40-59 → 2 (medium)
17
+ * 60-79 → 3 (high)
18
+ * 80-100 → 4 (very high)
19
+ *
20
+ * The list page renders each score as a 4-cell bar; this gives
21
+ * the visual a stable shape regardless of the underlying number.
22
+ */
23
+ export function scoreTier(n: number): 0 | 1 | 2 | 3 | 4 {
24
+ if (n < 20) return 0;
25
+ if (n < 40) return 1;
26
+ if (n < 60) return 2;
27
+ if (n < 80) return 3;
28
+ return 4;
29
+ }
30
+
31
+ /** Compact textual label for a score tier (used in tooltips). */
32
+ export function scoreTierLabel(n: number): string {
33
+ switch (scoreTier(n)) {
34
+ case 0:
35
+ return "Very low";
36
+ case 1:
37
+ return "Low";
38
+ case 2:
39
+ return "Medium";
40
+ case 3:
41
+ return "High";
42
+ case 4:
43
+ return "Very high";
44
+ }
45
+ }
46
+
47
+ /** Short numeric label used inline next to a score bar, e.g. "82". */
48
+ export function scoreLabel(n: number | undefined): string {
49
+ if (typeof n !== "number") return "—";
50
+ return Math.round(n).toString();
51
+ }
52
+
53
+ /** Order of score dimensions, used by both list and detail. */
54
+ export const SCORE_DIMENSIONS: (keyof Score)[] = [
55
+ "activity",
56
+ "maturity",
57
+ "learning",
58
+ "contribution",
59
+ "docs",
60
+ "overall",
61
+ ];
62
+
63
+ /** Human-readable label for a score dimension. */
64
+ export const SCORE_LABELS: Record<keyof Score, string> = {
65
+ activity: "Activity",
66
+ maturity: "Maturity",
67
+ learning: "Learning",
68
+ contribution: "Contribution",
69
+ docs: "Docs",
70
+ overall: "Overall",
71
+ };
72
+
73
+ /**
74
+ * Reasoning copy per dimension, surfaced in the detail page so the
75
+ * numbers don't feel magical. These are intentionally short — the
76
+ * curation notes on each record carry the longer story.
77
+ */
78
+ export const SCORE_REASONING: Record<keyof Score, string> = {
79
+ activity:
80
+ "Recent commits, open and merged PRs, issue response time, release cadence.",
81
+ maturity:
82
+ "Repo age, stable structure, real-world production usage, contributor stability.",
83
+ learning:
84
+ "Codebase readability, useful architecture patterns, comment quality, examples.",
85
+ contribution:
86
+ "Open issues, recent merged PRs from new contributors, maintainer responsiveness, contribution guide.",
87
+ docs:
88
+ "README quality, architecture docs, contributing guide, examples, code comments.",
89
+ overall:
90
+ "Composite judgment — how likely this record is to satisfy a curious developer.",
91
+ };
92
+
93
+ /** Re-export the `Score` type for callers that only need the lib API. */
94
+ export type { Score };