@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,481 @@
1
+ /**
2
+ * URL-driven search/filter/sort state for the directory list page.
3
+ *
4
+ * Multi-value fields use repeated keys: `?stack=Flutter&stack=RN`.
5
+ * Single-value fields (q, sort, page, lens) are scalar. Empty
6
+ * / undefined fields mean "no filter on this dimension".
7
+ *
8
+ * The functions here are generic over a record-like shape that
9
+ * carries the index payload (see `toIndexRecord` in
10
+ * `@grove-dev/core`). The list page only needs `name`, `slug`,
11
+ * `description`, `category`, `tags`, `stack`, `stacks`, `platforms`,
12
+ * `repoUrl`, `logoUrl`, `health`, `curation`, and `github.{stars,
13
+ * pushedAt}` — all of which are present in the index projection.
14
+ */
15
+
16
+ import type {
17
+ HealthStatus,
18
+ ProjectRecord,
19
+ } from "@grove-dev/core";
20
+ import { labelDisplay, statusDisplay } from "./display.js";
21
+ import { applyLens, lensFromSearchParams, type LensId } from "./lenses.js";
22
+
23
+ // ── Types ─────────────────────────────────────────────────────────
24
+
25
+ /**
26
+ * Subset of `ProjectRecord` the search lib actually reads. Defined
27
+ * inline so the lib works on both full records and the lighter
28
+ * index payload. Use `ProjectRecord` from core when you have it;
29
+ * pass any object that satisfies `Searchable` otherwise.
30
+ *
31
+ * `Searchable` is a superset of `AppLike` (the lens-matching shape
32
+ * in `lib/lenses.ts`) — both interfaces share the same optional
33
+ * field shape, so `Searchable` records can be passed to
34
+ * `applyLens()` and `filterApps()` interchangeably.
35
+ */
36
+ export interface Searchable {
37
+ slug?: string;
38
+ name: string;
39
+ description?: string;
40
+ category?: string;
41
+ tags?: string[];
42
+ stack?: string;
43
+ stacks?: string[];
44
+ platforms?: string[];
45
+ /** Canonical repo URL — used for owner extraction in text search. */
46
+ repoUrl?: string;
47
+ logoUrl?: string;
48
+ /** Curated labels (new, hot, mature, featured). */
49
+ curation?: { labels?: string[] };
50
+ /** Health block — drives status filter + tier fallback. */
51
+ health?: {
52
+ status?: HealthStatus;
53
+ tier?: "curated" | "listed" | "experimental" | "hidden";
54
+ visibility?: "highlight" | "keep" | "needs_review" | "hide" | "remove" | "historical";
55
+ cleanupCandidate?: boolean;
56
+ };
57
+ /** GitHub metadata used for sort + text search. */
58
+ github?: {
59
+ stars?: number;
60
+ forks?: number;
61
+ pushedAt?: string | null;
62
+ license?: string | null;
63
+ };
64
+ /** Score block — used for "best-overall" sort. */
65
+ scores?: {
66
+ activity?: number;
67
+ maturity?: number;
68
+ learning?: number;
69
+ contribution?: number;
70
+ docs?: number;
71
+ overall?: number;
72
+ };
73
+ }
74
+
75
+ export type AppsFilters = {
76
+ q?: string;
77
+ /** Stack slugs, repeated URL key `stack=`. Matches against `stack` AND `stacks[]`. */
78
+ stacks?: string[];
79
+ /** Platform slugs, repeated URL key `platform=`. Matches `platforms[]`. */
80
+ platforms?: string[];
81
+ /** Category names, repeated URL key `category=`. */
82
+ categories?: string[];
83
+ /** Curated label ids (new/hot/mature/featured), repeated URL key `label=`. */
84
+ labels?: string[];
85
+ /** SPDX license ids, repeated URL key `license=`. */
86
+ licenses?: string[];
87
+ /** Health status values, comma-separated repeated URL key `status=`. */
88
+ statuses?: string[];
89
+ /** A curated lens (single-select view). */
90
+ lens?: LensId;
91
+ /** Sort order. Single value, defaults to "recently-updated". */
92
+ sort?: AppsSort;
93
+ /** 1-based page number. */
94
+ page?: number;
95
+ };
96
+
97
+ /** Available sort orders, in UI order. */
98
+ export const SORT_OPTIONS = [
99
+ { value: "recently-updated", label: "Recently updated" },
100
+ { value: "most-starred", label: "Most starred" },
101
+ { value: "recently-added", label: "Recently added" },
102
+ { value: "best-overall", label: "Best overall" },
103
+ { value: "alphabetical", label: "Alphabetical" },
104
+ ] as const;
105
+
106
+ export type AppsSort = (typeof SORT_OPTIONS)[number]["value"];
107
+
108
+ const DEFAULT_SORT: AppsSort = "recently-updated";
109
+ const DEFAULT_PAGE = 1;
110
+
111
+ /**
112
+ * How many records per page. Constant for now; future versions may
113
+ * vary per blueprint.
114
+ */
115
+ export const PAGE_SIZE = 24;
116
+
117
+ const KEYS = {
118
+ q: "q",
119
+ stacks: "stack",
120
+ platforms: "platform",
121
+ categories: "category",
122
+ labels: "label",
123
+ licenses: "license",
124
+ statuses: "status",
125
+ lens: "lens",
126
+ sort: "sort",
127
+ page: "page",
128
+ } as const;
129
+
130
+ // ── Search param round-tripping ──────────────────────────────────
131
+
132
+ /**
133
+ * Read filters from a URLSearchParams (or anything URLSearchParams-
134
+ * shaped, e.g. the result of `new URL(req.url).searchParams`).
135
+ */
136
+ export function filtersFromSearchParams(
137
+ sp: URLSearchParams,
138
+ ): AppsFilters {
139
+ const rawSort = sp.get(KEYS.sort);
140
+ // Backwards-compat: old "most-mature" sort now maps to "best-overall".
141
+ const normalizedSort = rawSort === "most-mature" ? "best-overall" : rawSort;
142
+ const sort: AppsSort | undefined =
143
+ normalizedSort && SORT_OPTIONS.some((o) => o.value === normalizedSort)
144
+ ? (normalizedSort as AppsSort)
145
+ : undefined;
146
+ const rawPage = Number(sp.get(KEYS.page));
147
+ const page =
148
+ Number.isFinite(rawPage) && rawPage >= 1 ? Math.floor(rawPage) : undefined;
149
+ return {
150
+ q: sp.get(KEYS.q) ?? undefined,
151
+ stacks: sp.getAll(KEYS.stacks).filter(Boolean),
152
+ platforms: sp.getAll(KEYS.platforms).filter(Boolean),
153
+ categories: sp.getAll(KEYS.categories).filter(Boolean),
154
+ labels: sp.getAll(KEYS.labels).filter(Boolean),
155
+ licenses: sp.getAll(KEYS.licenses).filter(Boolean),
156
+ statuses: sp
157
+ .getAll(KEYS.statuses)
158
+ .flatMap((s) => s.split(","))
159
+ .filter(Boolean),
160
+ lens: lensFromSearchParams(sp) || undefined,
161
+ sort,
162
+ page,
163
+ };
164
+ }
165
+
166
+ /**
167
+ * Serialize a filters object back to URLSearchParams. Undefined /
168
+ * empty values are dropped. Defaults (sort, page 1) are also dropped
169
+ * to keep URLs short and canonical.
170
+ */
171
+ export function searchParamsFromFilters(f: AppsFilters): URLSearchParams {
172
+ const sp = new URLSearchParams();
173
+ if (f.q) sp.set(KEYS.q, f.q);
174
+ for (const v of f.stacks ?? []) sp.append(KEYS.stacks, v);
175
+ for (const v of f.platforms ?? []) sp.append(KEYS.platforms, v);
176
+ for (const v of f.categories ?? []) sp.append(KEYS.categories, v);
177
+ for (const v of f.labels ?? []) sp.append(KEYS.labels, v);
178
+ for (const v of f.licenses ?? []) sp.append(KEYS.licenses, v);
179
+ if (f.statuses?.length) sp.set(KEYS.statuses, f.statuses.join(","));
180
+ if (f.lens) sp.set(KEYS.lens, f.lens);
181
+ if (f.sort && f.sort !== DEFAULT_SORT) sp.set(KEYS.sort, f.sort);
182
+ if (f.page && f.page !== DEFAULT_PAGE) sp.set(KEYS.page, String(f.page));
183
+ return sp;
184
+ }
185
+
186
+ // ── Filter application ───────────────────────────────────────────
187
+
188
+ /**
189
+ * True if any filter is active (i.e. the result list isn't "all
190
+ * records, default sort").
191
+ */
192
+ export function hasAnyFilter(f: AppsFilters): boolean {
193
+ if (f.q && f.q.trim()) return true;
194
+ if (f.lens) return true;
195
+ return Boolean(
196
+ (f.stacks?.length ?? 0) +
197
+ (f.platforms?.length ?? 0) +
198
+ (f.categories?.length ?? 0) +
199
+ (f.labels?.length ?? 0) +
200
+ (f.licenses?.length ?? 0) +
201
+ (f.statuses?.length ?? 0),
202
+ );
203
+ }
204
+
205
+ /**
206
+ * Return the subset of records that match all active filters (AND
207
+ * across dimensions, OR within a dimension).
208
+ *
209
+ * `q` is a case-insensitive substring search across name, owner,
210
+ * description, category, stack/stacks/platforms, tags, and license.
211
+ * Other dimensions are exact match.
212
+ */
213
+ export function filterApps<T extends Searchable>(
214
+ records: T[],
215
+ f: AppsFilters,
216
+ ): T[] {
217
+ const q = f.q?.trim().toLowerCase();
218
+ const stacks = f.stacks?.length ? new Set(f.stacks) : null;
219
+ const platforms = f.platforms?.length ? new Set(f.platforms) : null;
220
+ const categories = f.categories?.length ? new Set(f.categories) : null;
221
+ const labels = f.labels?.length ? new Set(f.labels) : null;
222
+ const licenses = f.licenses?.length ? new Set(f.licenses) : null;
223
+ const statuses = f.statuses?.length ? new Set(f.statuses) : null;
224
+
225
+ return records.filter((r) => {
226
+ if (q) {
227
+ const ownerMatch =
228
+ /github\.com\/([^/]+)\//.exec(r.repoUrl ?? "")?.[1]?.toLowerCase() ??
229
+ "";
230
+ const haystack = [
231
+ r.name,
232
+ ownerMatch,
233
+ r.description ?? "",
234
+ r.category ?? "",
235
+ r.stack ?? "",
236
+ ...(r.stacks ?? []),
237
+ ...(r.platforms ?? []),
238
+ ...(r.tags ?? []),
239
+ r.github?.license ?? "",
240
+ r.health?.status ? statusDisplay(r.health.status) : "",
241
+ ]
242
+ .join(" ")
243
+ .toLowerCase();
244
+ if (!haystack.includes(q)) return false;
245
+ }
246
+
247
+ if (stacks) {
248
+ const allStacks = [r.stack, ...(r.stacks ?? [])].filter(
249
+ (s): s is string => Boolean(s),
250
+ );
251
+ if (!allStacks.some((s) => stacks.has(s))) return false;
252
+ }
253
+
254
+ if (platforms) {
255
+ if (!r.platforms?.some((p) => platforms.has(p))) return false;
256
+ }
257
+
258
+ if (categories) {
259
+ if (!r.category || !categories.has(r.category)) return false;
260
+ }
261
+
262
+ if (labels) {
263
+ if (!r.curation?.labels?.some((l) => labels.has(l))) return false;
264
+ }
265
+
266
+ if (licenses) {
267
+ const license = r.github?.license;
268
+ if (!license || !licenses.has(license)) return false;
269
+ }
270
+
271
+ if (statuses) {
272
+ const status = r.health?.status;
273
+ if (!status || !statuses.has(status)) return false;
274
+ }
275
+
276
+ return true;
277
+ });
278
+ }
279
+
280
+ // ── Lenses, sort, pagination ─────────────────────────────────────
281
+
282
+ /**
283
+ * Apply lens → filter → sort pipeline. Lens is applied first because
284
+ * it's a single-select "view" that may be broader or narrower than
285
+ * the regular filter dimensions.
286
+ */
287
+ export function pipeline<T extends Searchable>(
288
+ records: T[],
289
+ filters: AppsFilters,
290
+ ): T[] {
291
+ const afterLens = applyLens(records, filters.lens ?? "");
292
+ const afterFilter = filterApps(afterLens, filters);
293
+ return applySort(afterFilter, effectiveSort(filters));
294
+ }
295
+
296
+ /**
297
+ * Sort records in-place-free: returns a new array. Stable for ties.
298
+ * Records missing the sort key (e.g. no stars) sink to the bottom
299
+ * rather than vanishing.
300
+ */
301
+ export function applySort<T extends Searchable>(
302
+ records: T[],
303
+ sort: AppsSort,
304
+ ): T[] {
305
+ const arr = records.slice();
306
+ const ts = (s?: string | null): number => (s ? new Date(s).valueOf() : 0);
307
+ switch (sort) {
308
+ case "most-starred":
309
+ arr.sort((a, b) => (b.github?.stars ?? 0) - (a.github?.stars ?? 0));
310
+ break;
311
+ case "recently-updated":
312
+ arr.sort((a, b) => ts(b.github?.pushedAt) - ts(a.github?.pushedAt));
313
+ break;
314
+ case "recently-added":
315
+ // Index payload doesn't have a dedicated "addedAt" yet — fall
316
+ // back to `pushedAt` so the sort still works. Once we add an
317
+ // explicit addedAt field, swap the key here.
318
+ arr.sort((a, b) => ts(b.github?.pushedAt) - ts(a.github?.pushedAt));
319
+ break;
320
+ case "best-overall": {
321
+ // Composite: scores.overall → scores.maturity → scores.activity →
322
+ // active status → license present → stars. Avoids ranking by
323
+ // stars alone.
324
+ const score = (
325
+ x: T,
326
+ k: "overall" | "maturity" | "activity",
327
+ ): number => (typeof x.scores?.[k] === "number" ? x.scores![k]! : 0);
328
+ const active = (x: T): number => (x.health?.status === "active" ? 1 : 0);
329
+ const licensed = (x: T): number => (x.github?.license ? 1 : 0);
330
+ arr.sort((a, b) => {
331
+ const sOverall = score(b, "overall") - score(a, "overall");
332
+ if (sOverall !== 0) return sOverall;
333
+ const sMaturity = score(b, "maturity") - score(a, "maturity");
334
+ if (sMaturity !== 0) return sMaturity;
335
+ const sActivity = score(b, "activity") - score(a, "activity");
336
+ if (sActivity !== 0) return sActivity;
337
+ const sActive = active(b) - active(a);
338
+ if (sActive !== 0) return sActive;
339
+ const sLicensed = licensed(b) - licensed(a);
340
+ if (sLicensed !== 0) return sLicensed;
341
+ return (b.github?.stars ?? 0) - (a.github?.stars ?? 0);
342
+ });
343
+ break;
344
+ }
345
+ case "alphabetical":
346
+ arr.sort((a, b) => a.name.localeCompare(b.name));
347
+ break;
348
+ }
349
+ return arr;
350
+ }
351
+
352
+ /** Slice an array for a given page (1-based). */
353
+ export function paginate<T>(items: T[], page: number, pageSize = PAGE_SIZE): T[] {
354
+ const start = (page - 1) * pageSize;
355
+ return items.slice(start, start + pageSize);
356
+ }
357
+
358
+ /** Total number of pages given an item count. Always >= 1. */
359
+ export function totalPages(itemCount: number, pageSize = PAGE_SIZE): number {
360
+ return Math.max(1, Math.ceil(itemCount / pageSize));
361
+ }
362
+
363
+ /** Resolve the effective sort from a filters object, applying default. */
364
+ export function effectiveSort(f: AppsFilters): AppsSort {
365
+ return f.sort ?? DEFAULT_SORT;
366
+ }
367
+
368
+ /** Resolve the effective page from a filters object, applying default. */
369
+ export function effectivePage(f: AppsFilters): number {
370
+ return f.page ?? DEFAULT_PAGE;
371
+ }
372
+
373
+ // ── Facets ───────────────────────────────────────────────────────
374
+
375
+ /**
376
+ * Return a flat list of active-filter chips for display + removal.
377
+ * Used by the "x" button on each chip in the UI.
378
+ */
379
+ export function activeFilterChips(
380
+ f: AppsFilters,
381
+ ): { key: keyof AppsFilters; value: string; label: string }[] {
382
+ const out: { key: keyof AppsFilters; value: string; label: string }[] = [];
383
+
384
+ if (f.q && f.q.trim()) {
385
+ out.push({ key: "q", value: "", label: `“${f.q}”` });
386
+ }
387
+ for (const v of f.stacks ?? []) {
388
+ out.push({ key: "stacks", value: v, label: `Stack: ${v}` });
389
+ }
390
+ for (const v of f.platforms ?? []) {
391
+ out.push({ key: "platforms", value: v, label: `Platform: ${v}` });
392
+ }
393
+ for (const v of f.categories ?? []) {
394
+ out.push({ key: "categories", value: v, label: `Category: ${v}` });
395
+ }
396
+ for (const v of f.labels ?? []) {
397
+ out.push({ key: "labels", value: v, label: labelDisplay(v) ?? v });
398
+ }
399
+ for (const v of f.licenses ?? []) {
400
+ out.push({ key: "licenses", value: v, label: `License: ${v}` });
401
+ }
402
+ if (f.lens) {
403
+ // Inline map keeps this function dependency-free for unit tests
404
+ // (no need to import from `lenses.ts` to render a chip label).
405
+ const LENS_CHIP_LABELS: Record<LensId, string> = {
406
+ all: "All",
407
+ hot: "Trending",
408
+ new: "Recently added",
409
+ mature: "Established",
410
+ featured: "Featured",
411
+ "needs-review": "Needs review",
412
+ };
413
+ out.push({ key: "lens", value: f.lens, label: LENS_CHIP_LABELS[f.lens] ?? f.lens });
414
+ }
415
+ if (f.statuses && f.statuses.length) {
416
+ for (const v of f.statuses) {
417
+ out.push({ key: "statuses", value: v, label: statusDisplay(v) });
418
+ }
419
+ }
420
+
421
+ return out;
422
+ }
423
+
424
+ /**
425
+ * Build the full list of facet values from the record array,
426
+ * preserving a sensible order (frequency desc, then alphabetical
427
+ * for ties).
428
+ */
429
+ export function buildFacets<T extends Searchable>(records: T[]) {
430
+ const counts = {
431
+ stack: new Map<string, number>(),
432
+ platform: new Map<string, number>(),
433
+ category: new Map<string, number>(),
434
+ label: new Map<string, number>(),
435
+ license: new Map<string, number>(),
436
+ };
437
+ for (const r of records) {
438
+ const allStacks = new Set(
439
+ [r.stack, ...(r.stacks ?? [])].filter(
440
+ (s): s is string => Boolean(s),
441
+ ),
442
+ );
443
+ for (const s of allStacks) {
444
+ counts.stack.set(s, (counts.stack.get(s) ?? 0) + 1);
445
+ }
446
+ for (const p of r.platforms ?? []) {
447
+ counts.platform.set(p, (counts.platform.get(p) ?? 0) + 1);
448
+ }
449
+ if (r.category) {
450
+ counts.category.set(
451
+ r.category,
452
+ (counts.category.get(r.category) ?? 0) + 1,
453
+ );
454
+ }
455
+ for (const l of r.curation?.labels ?? []) {
456
+ counts.label.set(l, (counts.label.get(l) ?? 0) + 1);
457
+ }
458
+ const license = r.github?.license;
459
+ if (license) {
460
+ counts.license.set(license, (counts.license.get(license) ?? 0) + 1);
461
+ }
462
+ }
463
+ const sortByCountThenName = (m: Map<string, number>) =>
464
+ [...m.entries()]
465
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
466
+ .map(([value, count]) => ({ value, count }));
467
+ return {
468
+ stacks: sortByCountThenName(counts.stack),
469
+ platforms: sortByCountThenName(counts.platform),
470
+ categories: sortByCountThenName(counts.category),
471
+ labels: sortByCountThenName(counts.label),
472
+ licenses: sortByCountThenName(counts.license),
473
+ };
474
+ }
475
+
476
+ /**
477
+ * Re-export the `ProjectRecord` type so consumers of the search lib
478
+ * can write `import type { ProjectRecord } from "@grove-dev/astro/lib"`
479
+ * without a second import.
480
+ */
481
+ export type { ProjectRecord };
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Build-time helpers that compute taxonomy counts from an array of
3
+ * records. Generic over any object that carries the index-payload
4
+ * fields the UI cares about (`category`, `stack` / `stacks`,
5
+ * `curation.labels`). No filesystem / data import — consumers pass
6
+ * the records explicitly so this stays testable.
7
+ *
8
+ * All functions are synchronous and cheap — they're called from
9
+ * Astro page frontmatter at build time, never on the client.
10
+ */
11
+
12
+ export interface Taxonomable {
13
+ category?: string;
14
+ stack?: string;
15
+ stacks?: string[];
16
+ curation?: { labels?: string[] };
17
+ }
18
+
19
+ /** Number of records per category name. */
20
+ export function countByCategory<T extends Taxonomable>(
21
+ records: T[],
22
+ ): Map<string, number> {
23
+ const m = new Map<string, number>();
24
+ for (const r of records) {
25
+ const k = r.category || "Other";
26
+ m.set(k, (m.get(k) ?? 0) + 1);
27
+ }
28
+ return m;
29
+ }
30
+
31
+ /** Number of records per primary stack (combines `stack` and `stacks`). */
32
+ export function countByStack<T extends Taxonomable>(
33
+ records: T[],
34
+ ): Map<string, number> {
35
+ const m = new Map<string, number>();
36
+ for (const r of records) {
37
+ const keys = new Set(
38
+ [r.stack, ...(r.stacks ?? [])].filter(
39
+ (s): s is string => Boolean(s),
40
+ ),
41
+ );
42
+ for (const k of keys) {
43
+ m.set(k, (m.get(k) ?? 0) + 1);
44
+ }
45
+ }
46
+ return m;
47
+ }
48
+
49
+ /** Number of records per curated label (curation.labels). */
50
+ export function countByLabel<T extends Taxonomable>(
51
+ records: T[],
52
+ ): Map<string, number> {
53
+ const m = new Map<string, number>();
54
+ for (const r of records) {
55
+ for (const l of r.curation?.labels ?? []) {
56
+ m.set(l, (m.get(l) ?? 0) + 1);
57
+ }
58
+ }
59
+ return m;
60
+ }