@grove-dev/astro 0.3.1 → 0.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. package/README.md +9 -75
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +11 -1
  4. package/dist/index.js.map +1 -1
  5. package/dist/lib/display.d.ts +1 -26
  6. package/dist/lib/display.d.ts.map +1 -1
  7. package/dist/lib/display.js +1 -103
  8. package/dist/lib/display.js.map +1 -1
  9. package/dist/lib/format.d.ts +1 -28
  10. package/dist/lib/format.d.ts.map +1 -1
  11. package/dist/lib/format.js +1 -71
  12. package/dist/lib/format.js.map +1 -1
  13. package/dist/lib/lenses.d.ts +1 -57
  14. package/dist/lib/lenses.d.ts.map +1 -1
  15. package/dist/lib/lenses.js +1 -198
  16. package/dist/lib/lenses.js.map +1 -1
  17. package/dist/lib/repo.d.ts +1 -19
  18. package/dist/lib/repo.d.ts.map +1 -1
  19. package/dist/lib/repo.js +1 -30
  20. package/dist/lib/repo.js.map +1 -1
  21. package/dist/lib/scores.d.ts +1 -36
  22. package/dist/lib/scores.d.ts.map +1 -1
  23. package/dist/lib/scores.js +1 -75
  24. package/dist/lib/scores.js.map +1 -1
  25. package/dist/lib/search.d.ts +1 -113
  26. package/dist/lib/search.d.ts.map +1 -1
  27. package/dist/lib/search.js +1 -379
  28. package/dist/lib/search.js.map +1 -1
  29. package/dist/lib/taxonomy-counts.d.ts +1 -10
  30. package/dist/lib/taxonomy-counts.d.ts.map +1 -1
  31. package/dist/lib/taxonomy-counts.js +1 -21
  32. package/dist/lib/taxonomy-counts.js.map +1 -1
  33. package/package.json +23 -11
  34. package/src/components/ContributorsGrid.astro +3 -3
  35. package/src/components/CurationGrid.astro +2 -2
  36. package/src/components/DirectoryIndexClient.astro +240 -0
  37. package/src/components/FilterGroupMenu.astro +8 -4
  38. package/src/components/FinalCta.astro +41 -0
  39. package/src/components/Hero.astro +15 -18
  40. package/src/components/Icon.astro +35 -7
  41. package/src/components/IndexRow.astro +2 -2
  42. package/src/components/ItemCard.astro +2 -7
  43. package/src/components/RefinePanel.astro +7 -7
  44. package/src/components/SmartLensTabs.astro +9 -5
  45. package/src/components/StackGrid.astro +1 -1
  46. package/src/components/SubmissionClient.astro +133 -0
  47. package/src/index.ts +11 -1
  48. package/src/layouts/BaseLayout.astro +30 -8
  49. package/src/layouts/Footer.astro +25 -56
  50. package/src/layouts/Header.astro +33 -15
  51. package/src/layouts/ThemeToggle.astro +7 -3
  52. package/src/lib/display.ts +1 -111
  53. package/src/lib/format.ts +1 -64
  54. package/src/lib/lenses.ts +1 -220
  55. package/src/lib/repo.ts +1 -32
  56. package/src/lib/scores.ts +1 -90
  57. package/src/lib/search.ts +1 -418
  58. package/src/lib/taxonomy-counts.ts +1 -26
  59. package/{templates/default/src/data/records.ts → src/server/directory.ts} +12 -8
  60. package/src/server/index.ts +2 -0
  61. package/src/server/models.ts +436 -0
  62. package/dist/template-routes.test.d.ts +0 -2
  63. package/dist/template-routes.test.d.ts.map +0 -1
  64. package/dist/template-routes.test.js +0 -63
  65. package/dist/template-routes.test.js.map +0 -1
  66. package/dist/theme.test.d.ts +0 -2
  67. package/dist/theme.test.d.ts.map +0 -1
  68. package/dist/theme.test.js +0 -60
  69. package/dist/theme.test.js.map +0 -1
  70. package/src/template-routes.test.ts +0 -103
  71. package/src/theme.test.ts +0 -76
  72. package/templates/default/.github/ISSUE_TEMPLATE/bug_report.md +0 -33
  73. package/templates/default/.github/ISSUE_TEMPLATE/feature_request.md +0 -20
  74. package/templates/default/.github/ISSUE_TEMPLATE/record_submission.md +0 -48
  75. package/templates/default/.github/pull_request_template.md +0 -27
  76. package/templates/default/.github/workflows/build.yml +0 -53
  77. package/templates/default/.github/workflows/cleanup-stale-records.yml +0 -46
  78. package/templates/default/.github/workflows/daily-refresh.yml +0 -51
  79. package/templates/default/.github/workflows/sync-contributors.yml +0 -51
  80. package/templates/default/.github/workflows/sync-github-metadata.yml +0 -56
  81. package/templates/default/.github/workflows/validate-data.yml +0 -33
  82. package/templates/default/astro.config.mjs +0 -25
  83. package/templates/default/content/pages/about.md +0 -66
  84. package/templates/default/content/pages/methodology.md +0 -103
  85. package/templates/default/data/records/.gitkeep +0 -0
  86. package/templates/default/data/records/coolify.yml +0 -46
  87. package/templates/default/data/records/gitea.yml +0 -46
  88. package/templates/default/data/records/hoppscotch.yml +0 -47
  89. package/templates/default/data/records/inventree.yml +0 -47
  90. package/templates/default/data/records/label-studio.yml +0 -46
  91. package/templates/default/data/records/logseq.yml +0 -48
  92. package/templates/default/data/records/private-gpt.yml +0 -47
  93. package/templates/default/data/records/wakapi.yml +0 -44
  94. package/templates/default/data/taxonomy/categories.yml +0 -47
  95. package/templates/default/data/taxonomy/distribution-channels.yml +0 -43
  96. package/templates/default/data/taxonomy/platforms.yml +0 -27
  97. package/templates/default/data/taxonomy/stacks.yml +0 -41
  98. package/templates/default/grove.config.ts +0 -36
  99. package/templates/default/package.json +0 -43
  100. package/templates/default/public/icons/platforms/android.svg +0 -1
  101. package/templates/default/public/icons/platforms/apple-dark.svg +0 -1
  102. package/templates/default/public/icons/platforms/apple-light.svg +0 -1
  103. package/templates/default/public/icons/platforms/chrome.svg +0 -1
  104. package/templates/default/public/icons/platforms/chromeos.svg +0 -5
  105. package/templates/default/public/icons/platforms/desktop.svg +0 -4
  106. package/templates/default/public/icons/platforms/embedded.svg +0 -5
  107. package/templates/default/public/icons/platforms/ios.svg +0 -1
  108. package/templates/default/public/icons/platforms/linux.svg +0 -1
  109. package/templates/default/public/icons/platforms/macos.svg +0 -1
  110. package/templates/default/public/icons/platforms/ubuntu.svg +0 -1
  111. package/templates/default/public/icons/platforms/web.svg +0 -5
  112. package/templates/default/public/icons/platforms/windows.svg +0 -6
  113. package/templates/default/public/icons/stacks/android.svg +0 -1
  114. package/templates/default/public/icons/stacks/apple-dark.svg +0 -1
  115. package/templates/default/public/icons/stacks/apple-light.svg +0 -1
  116. package/templates/default/public/icons/stacks/bun.svg +0 -1
  117. package/templates/default/public/icons/stacks/capacitor.svg +0 -14
  118. package/templates/default/public/icons/stacks/clojure.svg +0 -1
  119. package/templates/default/public/icons/stacks/clojurescript.svg +0 -1
  120. package/templates/default/public/icons/stacks/dart.svg +0 -1
  121. package/templates/default/public/icons/stacks/deno.svg +0 -1
  122. package/templates/default/public/icons/stacks/django.svg +0 -1
  123. package/templates/default/public/icons/stacks/docker.svg +0 -1
  124. package/templates/default/public/icons/stacks/firebase.svg +0 -1
  125. package/templates/default/public/icons/stacks/flutter.svg +0 -1
  126. package/templates/default/public/icons/stacks/go.svg +0 -1
  127. package/templates/default/public/icons/stacks/graphql.svg +0 -1
  128. package/templates/default/public/icons/stacks/ionic.svg +0 -4
  129. package/templates/default/public/icons/stacks/java.svg +0 -1
  130. package/templates/default/public/icons/stacks/javascript.svg +0 -1
  131. package/templates/default/public/icons/stacks/kotlin.svg +0 -1
  132. package/templates/default/public/icons/stacks/llm.svg +0 -1
  133. package/templates/default/public/icons/stacks/mongodb.svg +0 -1
  134. package/templates/default/public/icons/stacks/node.js.svg +0 -1
  135. package/templates/default/public/icons/stacks/nodejs.svg +0 -1
  136. package/templates/default/public/icons/stacks/python.svg +0 -1
  137. package/templates/default/public/icons/stacks/rag.svg +0 -7
  138. package/templates/default/public/icons/stacks/react-native.svg +0 -1
  139. package/templates/default/public/icons/stacks/react.svg +0 -1
  140. package/templates/default/public/icons/stacks/rust.svg +0 -1
  141. package/templates/default/public/icons/stacks/solidity.svg +0 -1
  142. package/templates/default/public/icons/stacks/sveltekit.svg +0 -1
  143. package/templates/default/public/icons/stacks/swift.svg +0 -1
  144. package/templates/default/public/icons/stacks/tensorflow.svg +0 -1
  145. package/templates/default/public/icons/stacks/typescript.svg +0 -1
  146. package/templates/default/public/icons/stacks/vue.svg +0 -1
  147. package/templates/default/public/llms-full.txt +0 -83
  148. package/templates/default/public/llms.txt +0 -11
  149. package/templates/default/public/og-image.svg +0 -48
  150. package/templates/default/public/robots.txt +0 -53
  151. package/templates/default/public/sitemap.xml +0 -63
  152. package/templates/default/src/pages/404.astro +0 -64
  153. package/templates/default/src/pages/[slug]/[recordSlug].astro +0 -691
  154. package/templates/default/src/pages/[slug]/index.astro +0 -482
  155. package/templates/default/src/pages/about.astro +0 -189
  156. package/templates/default/src/pages/apps/[recordSlug].astro +0 -34
  157. package/templates/default/src/pages/contributors.astro +0 -175
  158. package/templates/default/src/pages/index.astro +0 -233
  159. package/templates/default/src/pages/sitemap.xml.ts +0 -89
  160. package/templates/default/src/pages/submit.astro +0 -281
  161. package/templates/default/src/styles/global.css +0 -33
  162. package/templates/default/tsconfig.json +0 -9
  163. package/templates/default/wrangler.jsonc +0 -28
package/src/lib/search.ts CHANGED
@@ -1,418 +1 @@
1
- import type { IndexRecord } from "@grove-dev/core";
2
- import { labelDisplay, lensDisplay, statusDisplay } from "./display.js";
3
-
4
- /**
5
- * URL-driven filter state for the /items discovery page.
6
- *
7
- * Multi-value fields use repeated keys in the URL: `?stack=Flutter&stack=React+Native`.
8
- * Empty / undefined fields mean "no filter on this dimension".
9
- */
10
- export type IndexFilters = {
11
- q?: string;
12
- stacks?: string[];
13
- platforms?: string[];
14
- categories?: string[];
15
- labels?: string[];
16
- licenses?: string[];
17
- statuses?: string[];
18
- /** A curated lens (e.g. "good-to-learn", "production-like"). Single value. */
19
- lens?: string;
20
- /** Sort order. Single value, defaults to "recently-updated". */
21
- sort?: IndexSort | undefined;
22
- /** 1-based page number. */
23
- page?: number | undefined;
24
- };
25
-
26
- /** Available sort orders, in UI order. */
27
- export const SORT_OPTIONS = [
28
- { value: "recently-updated", label: "Recently updated" },
29
- { value: "most-starred", label: "Most starred" },
30
- { value: "recently-added", label: "Recently added" },
31
- { value: "best-overall", label: "Best overall" },
32
- { value: "alphabetical", label: "Alphabetical" },
33
- ] as const;
34
-
35
- export type IndexSort = (typeof SORT_OPTIONS)[number]["value"];
36
-
37
- const DEFAULT_SORT: IndexSort = "recently-updated";
38
- const DEFAULT_PAGE = 1;
39
- const DEFAULT_PAGE_SIZE = 20;
40
-
41
- const KEYS = {
42
- q: "q",
43
- stacks: "stack",
44
- platforms: "platform",
45
- categories: "category",
46
- labels: "label",
47
- licenses: "license",
48
- statuses: "status",
49
- lens: "lens",
50
- sort: "sort",
51
- page: "page",
52
- } as const;
53
-
54
- /**
55
- * Read filters from a URLSearchParams (or anything URLSearchParams-shaped,
56
- * e.g. the result of `new URL(req.url).searchParams`).
57
- */
58
- export function filtersFromSearchParams(sp: URLSearchParams): IndexFilters {
59
- const rawSort = sp.get(KEYS.sort);
60
- // Backwards-compat: old "most-mature" sort now maps to "best-overall".
61
- const normalizedSort = rawSort === "most-mature" ? "best-overall" : rawSort;
62
- const sort: IndexSort | undefined =
63
- normalizedSort && (SORT_OPTIONS as readonly { value: string }[]).some((o) => o.value === normalizedSort)
64
- ? (normalizedSort as IndexSort)
65
- : undefined;
66
- const rawPage = Number(sp.get(KEYS.page));
67
- const page = Number.isFinite(rawPage) && rawPage >= 1 ? Math.floor(rawPage) : undefined;
68
- const q = sp.get(KEYS.q);
69
- const lens = sp.get(KEYS.lens);
70
- return {
71
- ...(q !== null ? { q } : {}),
72
- stacks: sp.getAll(KEYS.stacks).filter(Boolean),
73
- platforms: sp.getAll(KEYS.platforms).filter(Boolean),
74
- categories: sp.getAll(KEYS.categories).filter(Boolean),
75
- labels: sp.getAll(KEYS.labels).filter(Boolean),
76
- licenses: sp.getAll(KEYS.licenses).filter(Boolean),
77
- statuses: sp.getAll(KEYS.statuses).flatMap((s) => s.split(",")).filter(Boolean),
78
- ...(lens !== null ? { lens } : {}),
79
- ...(sort !== undefined ? { sort } : {}),
80
- ...(page !== undefined ? { page } : {}),
81
- };
82
- }
83
-
84
- /**
85
- * Serialize a filters object back to URLSearchParams.
86
- * Undefined / empty values are dropped. Defaults (sort, page 1, comfortable
87
- * density) are also dropped to keep URLs short and canonical.
88
- */
89
- export function searchParamsFromFilters(f: IndexFilters): URLSearchParams {
90
- const sp = new URLSearchParams();
91
- if (f.q) sp.set(KEYS.q, f.q);
92
- for (const v of f.stacks ?? []) sp.append(KEYS.stacks, v);
93
- for (const v of f.platforms ?? []) sp.append(KEYS.platforms, v);
94
- for (const v of f.categories ?? []) sp.append(KEYS.categories, v);
95
- for (const v of f.labels ?? []) sp.append(KEYS.labels, v);
96
- for (const v of f.licenses ?? []) sp.append(KEYS.licenses, v);
97
- if (f.statuses?.length) sp.set(KEYS.statuses, f.statuses.join(","));
98
- if (f.lens) sp.set(KEYS.lens, f.lens);
99
- if (f.sort && f.sort !== DEFAULT_SORT) sp.set(KEYS.sort, f.sort);
100
- if (f.page && f.page !== DEFAULT_PAGE) sp.set(KEYS.page, String(f.page));
101
- return sp;
102
- }
103
-
104
- /** True if any filter is active (i.e. the result list isn't "all items"). */
105
- export function hasAnyFilter(f: IndexFilters): boolean {
106
- if (f.q && f.q.trim()) return true;
107
- if (f.lens) return true;
108
- return Boolean(
109
- (f.stacks?.length ?? 0) +
110
- (f.platforms?.length ?? 0) +
111
- (f.categories?.length ?? 0) +
112
- (f.labels?.length ?? 0) +
113
- (f.licenses?.length ?? 0) +
114
- (f.statuses?.length ?? 0),
115
- );
116
- }
117
-
118
- /** How many items per page. Constant for now; future versions may vary. */
119
- export const PAGE_SIZE = DEFAULT_PAGE_SIZE;
120
-
121
- /**
122
- * Sort items in-place-free: returns a new array. Stable for ties.
123
- * Apps missing the sort key (e.g. no stars) sink to the bottom rather
124
- * than vanishing.
125
- */
126
- function recordName(record: IndexRecord): string {
127
- return record.kind === "resource" ? record.title : record.name;
128
- }
129
-
130
- function projectStars(record: IndexRecord): number {
131
- return record.kind === "project" ? record.github?.stars ?? 0 : 0;
132
- }
133
-
134
- function recordUpdatedAt(record: IndexRecord): string | null {
135
- if (record.kind === "project") return record.github?.pushedAt ?? null;
136
- if (record.kind === "resource") return record.publishedAt ?? null;
137
- return null;
138
- }
139
-
140
- function recordAddedAt(record: IndexRecord): string | null {
141
- return record.curation?.reviewedAt ?? null;
142
- }
143
-
144
- export function applySort(items: IndexRecord[], sort: IndexSort): IndexRecord[] {
145
- const arr = items.slice();
146
- const ts = (s?: string | null): number => (s ? new Date(s).valueOf() : 0);
147
- switch (sort) {
148
- case "most-starred":
149
- arr.sort((a, b) => projectStars(b) - projectStars(a));
150
- break;
151
- case "recently-updated":
152
- arr.sort((a, b) => ts(recordUpdatedAt(b)) - ts(recordUpdatedAt(a)));
153
- break;
154
- case "recently-added":
155
- arr.sort((a, b) => ts(recordAddedAt(b)) - ts(recordAddedAt(a)));
156
- break;
157
- case "best-overall": {
158
- const reviewed = (a: IndexRecord) => (a.curation?.reviewed ? 1 : 0);
159
- const visible = (a: IndexRecord) => (a.visibility === "keep" ? 1 : 0);
160
- arr.sort((a, b) => {
161
- const reviewedDelta = reviewed(b) - reviewed(a);
162
- if (reviewedDelta !== 0) return reviewedDelta;
163
- const visibleDelta = visible(b) - visible(a);
164
- if (visibleDelta !== 0) return visibleDelta;
165
- return projectStars(b) - projectStars(a);
166
- });
167
- break;
168
- }
169
- case "alphabetical":
170
- arr.sort((a, b) => recordName(a).localeCompare(recordName(b)));
171
- break;
172
- }
173
- return arr;
174
- }
175
-
176
- /** Slice an array for a given page (1-based). */
177
- export function paginate<T>(items: T[], page: number, pageSize = PAGE_SIZE): T[] {
178
- const start = (page - 1) * pageSize;
179
- return items.slice(start, start + pageSize);
180
- }
181
-
182
- /** Total number of pages given an item count. Always >= 1. */
183
- export function totalPages(itemCount: number, pageSize = PAGE_SIZE): number {
184
- return Math.max(1, Math.ceil(itemCount / pageSize));
185
- }
186
-
187
- /** Resolve the effective sort from a filters object, applying default. */
188
- export function effectiveSort(f: IndexFilters): IndexSort {
189
- return f.sort ?? DEFAULT_SORT;
190
- }
191
-
192
- /** Resolve the effective page from a filters object, applying default. */
193
- export function effectivePage(f: IndexFilters): number {
194
- return f.page ?? DEFAULT_PAGE;
195
- }
196
-
197
- /**
198
- * Return the subset of items that match all active filters (AND across
199
- * dimensions, OR within a dimension).
200
- *
201
- * `q` is a case-insensitive substring search across name, owner,
202
- * description, and category. Other dimensions are exact match.
203
- */
204
- export function filterRecords(items: IndexRecord[], f: IndexFilters): IndexRecord[] {
205
- const q = f.q?.trim().toLowerCase();
206
- const stacks = f.stacks?.length ? new Set(f.stacks) : null;
207
- const platforms = f.platforms?.length ? new Set(f.platforms) : null;
208
- const categories = f.categories?.length ? new Set(f.categories) : null;
209
- const labels = f.labels?.length ? new Set(f.labels) : null;
210
- const licenses = f.licenses?.length ? new Set(f.licenses) : null;
211
- const statuses = f.statuses?.length ? new Set(f.statuses) : null;
212
-
213
- return items.filter((a) => {
214
- // Text search — match name, owner, description, category, stack,
215
- // stacks, platforms, tags, backend, architecture, stateManagement,
216
- // bestFor, whyListed, license, status.
217
- if (q) {
218
- const repoUrl = a.kind === "project" ? a.repoUrl : a.links.github ?? "";
219
- const ownerMatch =
220
- /github\.com\/([^/]+)\//.exec(repoUrl ?? "")?.[1]?.toLowerCase() ?? "";
221
- const projectFields =
222
- a.kind === "project"
223
- ? [
224
- a.stack,
225
- ...a.stacks,
226
- ...a.platforms,
227
- a.projectType,
228
- a.difficulty ?? "",
229
- a.codebaseSize ?? "",
230
- ...a.bestFor,
231
- ...a.whyListed,
232
- a.github?.license ?? "",
233
- a.health?.status ? statusDisplay(a.health.status) : "",
234
- ]
235
- : [];
236
- const resourceFields =
237
- a.kind === "resource"
238
- ? [a.type, a.topic, a.author ?? ""]
239
- : [];
240
- const entityFields =
241
- a.kind === "entity"
242
- ? [a.type, a.location ?? "", a.parent ?? ""]
243
- : [];
244
- const haystack = [
245
- recordName(a),
246
- ownerMatch,
247
- a.description,
248
- a.category,
249
- ...a.tags,
250
- ...projectFields,
251
- ...resourceFields,
252
- ...entityFields,
253
- ]
254
- .join(" ")
255
- .toLowerCase();
256
- if (!haystack.includes(q)) return false;
257
- }
258
-
259
- if (stacks) {
260
- if (a.kind !== "project") return false;
261
- const allStacks = [a.stack, ...a.stacks].filter(
262
- (stack): stack is string => Boolean(stack),
263
- );
264
- if (!allStacks.some((s) => stacks.has(s))) return false;
265
- }
266
-
267
- if (platforms) {
268
- if (a.kind !== "project") return false;
269
- if (!a.platforms.some((p) => platforms.has(p))) return false;
270
- }
271
-
272
- if (categories) {
273
- if (!categories.has(a.category)) return false;
274
- }
275
-
276
- if (labels) {
277
- if (!a.curation?.labels?.some((l) => labels.has(l))) return false;
278
- }
279
-
280
- if (licenses) {
281
- if (a.kind !== "project") return false;
282
- const license = a.github?.license;
283
- if (!license || !licenses.has(license)) return false;
284
- }
285
-
286
- if (statuses) {
287
- if (a.kind !== "project") return false;
288
- const status = a.health?.status;
289
- if (!status || !statuses.has(status)) return false;
290
- }
291
-
292
- if (f.lens) {
293
- if (!a.curation?.lenses?.includes(f.lens)) return false;
294
- }
295
-
296
- return true;
297
- });
298
- }
299
-
300
- /** Build a flat list of "active filter" chips for display + removal. */
301
- export function activeFilterChips(
302
- f: IndexFilters,
303
- ): { key: keyof IndexFilters; value: string; label: string }[] {
304
- const out: { key: keyof IndexFilters; value: string; label: string }[] = [];
305
-
306
- if (f.q && f.q.trim()) {
307
- out.push({ key: "q", value: "", label: `“${f.q}”` });
308
- }
309
- for (const v of f.stacks ?? []) {
310
- out.push({ key: "stacks", value: v, label: `Stack: ${v}` });
311
- }
312
- for (const v of f.platforms ?? []) {
313
- out.push({ key: "platforms", value: v, label: `Platform: ${v}` });
314
- }
315
- for (const v of f.categories ?? []) {
316
- out.push({ key: "categories", value: v, label: `Category: ${v}` });
317
- }
318
- for (const v of f.labels ?? []) {
319
- out.push({ key: "labels", value: v, label: labelDisplay(v) ?? v });
320
- }
321
- for (const v of f.licenses ?? []) {
322
- out.push({ key: "licenses", value: v, label: `License: ${v}` });
323
- }
324
- if (f.lens) {
325
- out.push({ key: "lens", value: f.lens, label: lensDisplay(f.lens) });
326
- }
327
- // Status: collapse the "stale,quiet" composite into a single chip.
328
- if (f.statuses && f.statuses.length) {
329
- const joined = f.statuses.join(",");
330
- if (joined === "stale,quiet") {
331
- out.push({ key: "statuses", value: "stale,quiet", label: statusDisplay("needs-maintainer") });
332
- } else {
333
- for (const v of f.statuses) {
334
- out.push({ key: "statuses", value: v, label: statusDisplay(v) });
335
- }
336
- }
337
- }
338
-
339
- return out;
340
- }
341
-
342
- /**
343
- * Return a new filters object with one (key, value) removed.
344
- * - key="q" → clears the search string
345
- * - multi-value keys → removes the single matching value
346
- * Used by the "x" button on each active filter chip.
347
- * Also resets page to 1 so the user doesn't get stranded on a high page
348
- * that no longer has results.
349
- */
350
- export function removeFilter(
351
- f: IndexFilters,
352
- key: keyof IndexFilters,
353
- value: string,
354
- ): IndexFilters {
355
- let next: IndexFilters;
356
- if (key === "q") {
357
- // `q` is `string | undefined` under exactOptionalPropertyTypes; the
358
- // cleanest way to drop it is to omit the key entirely (delete-style).
359
- const { q: _drop, ...rest } = f;
360
- next = rest;
361
- } else {
362
- const current = f[key] as string[] | undefined;
363
- if (!current) return f;
364
- const filtered = current.filter((v) => v !== value);
365
- if (filtered.length === 0) {
366
- const { [key]: _drop, ...rest } = f;
367
- next = rest;
368
- } else {
369
- next = { ...f, [key]: filtered };
370
- }
371
- }
372
- next.page = 1;
373
- return next;
374
- }
375
-
376
- /**
377
- * Build the full list of facet values from the items array, preserving
378
- * a sensible order (frequency desc, then alphabetical for ties).
379
- */
380
- export function buildFacets(items: IndexRecord[]) {
381
- const counts = {
382
- stack: new Map<string, number>(),
383
- platform: new Map<string, number>(),
384
- category: new Map<string, number>(),
385
- label: new Map<string, number>(),
386
- license: new Map<string, number>(),
387
- };
388
- for (const a of items) {
389
- if (a.kind === "project") {
390
- const allStacks = new Set(
391
- [a.stack, ...a.stacks].filter((stack): stack is string => Boolean(stack)),
392
- );
393
- for (const s of allStacks) counts.stack.set(s, (counts.stack.get(s) ?? 0) + 1);
394
- for (const p of a.platforms) counts.platform.set(p, (counts.platform.get(p) ?? 0) + 1);
395
- const license = a.github?.license;
396
- if (license) counts.license.set(license, (counts.license.get(license) ?? 0) + 1);
397
- }
398
- counts.category.set(a.category, (counts.category.get(a.category) ?? 0) + 1);
399
- for (const l of a.curation?.labels ?? []) counts.label.set(l, (counts.label.get(l) ?? 0) + 1);
400
- }
401
- const sortByCountThenName = (m: Map<string, number>) =>
402
- [...m.entries()]
403
- .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
404
- .map(([value, count]) => ({ value, count }));
405
- return {
406
- stacks: sortByCountThenName(counts.stack),
407
- platforms: sortByCountThenName(counts.platform),
408
- categories: sortByCountThenName(counts.category),
409
- labels: sortByCountThenName(counts.label),
410
- licenses: sortByCountThenName(counts.license),
411
- };
412
- }
413
-
414
- // ── V1-published API ─────────────────────────────────────────────────
415
- // `IndexFilters` / `IndexSort` are the canonical V1 names for the
416
- // filter-state type. The internal functions (filterRecords, applySort,
417
- // paginate, buildFacets, etc.) operate on the generic `IndexRecord`
418
- // shape and are intentionally framework-agnostic.
1
+ export * from "@grove-dev/core";
@@ -1,26 +1 @@
1
- type TaxonomyRecord = {
2
- category?: string;
3
- stack?: string;
4
- stacks?: string[];
5
- };
6
-
7
- /** Number of items in the directory, per category name. */
8
- export function countByCategory(items: TaxonomyRecord[] = []): Map<string, number> {
9
- const m = new Map<string, number>();
10
- for (const a of items) {
11
- const k = a.category || "Other";
12
- m.set(k, (m.get(k) ?? 0) + 1);
13
- }
14
- return m;
15
- }
16
-
17
- /** Number of items in the directory, per primary stack. */
18
- export function countByStack(items: TaxonomyRecord[] = []): Map<string, number> {
19
- const m = new Map<string, number>();
20
- for (const a of items) {
21
- const stacks = new Set([a.stack, ...(a.stacks ?? [])].filter(Boolean) as string[]);
22
- if (stacks.size === 0) stacks.add("Other");
23
- for (const stack of stacks) m.set(stack, (m.get(stack) ?? 0) + 1);
24
- }
25
- return m;
26
- }
1
+ export * from "@grove-dev/core";
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Source of truth: `data/generated/records.{full,index,json}` and
3
3
  * `data/generated/site-config.json`, produced at build time by
4
- * `grove generate` from `data/records/*.yml` and `grove.config.ts`.
4
+ * automatically from `data/records/*.yml` and `grove.config.ts`.
5
5
  *
6
6
  * Three flavors of records are written by the generator:
7
7
  *
@@ -26,19 +26,19 @@
26
26
  *
27
27
  * JSON imports are static at the top of this module so Vite
28
28
  * resolves them at build time. A fresh scaffold that has not yet
29
- * run `grove generate` will fail the build with a clear Vite
29
+ * complete preparation will fail the build with a clear Vite
30
30
  * resolution error, which is the correct signal — a build that
31
31
  * silently renders an empty directory hides real config mistakes.
32
32
  */
33
- import fullPayload from "../../data/generated/records.full.json";
34
- import indexPayload from "../../data/generated/records.index.json";
35
- import siteConfigPayload from "../../data/generated/site-config.json";
33
+ import fullPayload from "@grove/generated/records.full.json";
34
+ import indexPayload from "@grove/generated/records.index.json";
35
+ import siteConfigPayload from "@grove/generated/site-config.json";
36
36
  import { existsSync, readFileSync } from "node:fs";
37
37
  import { fileURLToPath } from "node:url";
38
38
  import { dirname, resolve } from "node:path";
39
39
  import { marked } from "marked";
40
40
  import sanitizeHtml from "sanitize-html";
41
- import { prettySlug } from "@grove-dev/astro";
41
+ import { prettySlug } from "../lib/display.js";
42
42
  import type {
43
43
  ProjectRecord,
44
44
  ResourceRecord,
@@ -176,7 +176,7 @@ export function entityBySlug(slug: string): EntityRecord | undefined {
176
176
  // route slug, the kind filter, and the human label. Rather than
177
177
  // hardcode "project" / "projects" / "kind: project" at every
178
178
  // call-site, we derive them once from `site-config.json`'s
179
- // `blueprintConfig` block (populated by `grove generate`).
179
+ // `blueprintConfig` block (populated by Grove preparation).
180
180
  //
181
181
  // `indexSlug()` and `itemLabel()` retain their old signatures
182
182
  // (zero-arg) so existing pages keep working — they now read from
@@ -364,7 +364,11 @@ export function getPageContentHtml(page: string): string | null {
364
364
  if (!path) return null;
365
365
 
366
366
  try {
367
- const rawHtml = marked.parse(readFileSync(path, "utf8"), { async: false }) as string;
367
+ const markdown = readFileSync(path, "utf8").replace(
368
+ /^---\r?\n[\s\S]*?\r?\n---\r?\n?/,
369
+ "",
370
+ );
371
+ const rawHtml = marked.parse(markdown, { async: false }) as string;
368
372
  return sanitizeHtml(rawHtml, {
369
373
  allowedTags: [
370
374
  "h1", "h2", "h3", "h4",
@@ -0,0 +1,2 @@
1
+ export * from "./directory.js";
2
+ export * from "./models.js";