@grove-dev/astro 0.2.20 → 0.3.2

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 (171) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +9 -75
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +11 -1
  5. package/dist/index.js.map +1 -1
  6. package/dist/lib/display.d.ts +1 -26
  7. package/dist/lib/display.d.ts.map +1 -1
  8. package/dist/lib/display.js +1 -103
  9. package/dist/lib/display.js.map +1 -1
  10. package/dist/lib/format.d.ts +1 -28
  11. package/dist/lib/format.d.ts.map +1 -1
  12. package/dist/lib/format.js +1 -71
  13. package/dist/lib/format.js.map +1 -1
  14. package/dist/lib/lenses.d.ts +1 -57
  15. package/dist/lib/lenses.d.ts.map +1 -1
  16. package/dist/lib/lenses.js +1 -198
  17. package/dist/lib/lenses.js.map +1 -1
  18. package/dist/lib/repo.d.ts +1 -19
  19. package/dist/lib/repo.d.ts.map +1 -1
  20. package/dist/lib/repo.js +1 -30
  21. package/dist/lib/repo.js.map +1 -1
  22. package/dist/lib/scores.d.ts +1 -36
  23. package/dist/lib/scores.d.ts.map +1 -1
  24. package/dist/lib/scores.js +1 -75
  25. package/dist/lib/scores.js.map +1 -1
  26. package/dist/lib/search.d.ts +1 -113
  27. package/dist/lib/search.d.ts.map +1 -1
  28. package/dist/lib/search.js +1 -379
  29. package/dist/lib/search.js.map +1 -1
  30. package/dist/lib/taxonomy-counts.d.ts +1 -10
  31. package/dist/lib/taxonomy-counts.d.ts.map +1 -1
  32. package/dist/lib/taxonomy-counts.js +1 -21
  33. package/dist/lib/taxonomy-counts.js.map +1 -1
  34. package/package.json +22 -10
  35. package/src/components/ContributorsGrid.astro +3 -3
  36. package/src/components/CurationGrid.astro +2 -2
  37. package/src/components/DirectoryIndexClient.astro +240 -0
  38. package/src/components/FilterGroupMenu.astro +8 -4
  39. package/src/components/FinalCta.astro +41 -0
  40. package/src/components/Hero.astro +15 -18
  41. package/src/components/Icon.astro +99 -27
  42. package/src/components/RefinePanel.astro +7 -7
  43. package/src/components/SmartLensTabs.astro +5 -4
  44. package/src/components/StackGrid.astro +1 -1
  45. package/src/components/SubmissionClient.astro +133 -0
  46. package/src/index.ts +11 -1
  47. package/src/layouts/BaseLayout.astro +19 -1
  48. package/src/layouts/Footer.astro +25 -56
  49. package/src/layouts/Header.astro +33 -15
  50. package/src/layouts/ThemeToggle.astro +7 -3
  51. package/src/lib/display.ts +1 -111
  52. package/src/lib/format.ts +1 -64
  53. package/src/lib/lenses.ts +1 -220
  54. package/src/lib/repo.ts +1 -32
  55. package/src/lib/scores.ts +1 -90
  56. package/src/lib/search.ts +1 -418
  57. package/src/lib/taxonomy-counts.ts +1 -26
  58. package/{templates/default/src/data/records.ts → src/server/directory.ts} +90 -8
  59. package/src/server/index.ts +2 -0
  60. package/src/server/models.ts +432 -0
  61. package/dist/theme.test.d.ts +0 -2
  62. package/dist/theme.test.d.ts.map +0 -1
  63. package/dist/theme.test.js +0 -55
  64. package/dist/theme.test.js.map +0 -1
  65. package/src/theme.test.ts +0 -67
  66. package/templates/default/.github/ISSUE_TEMPLATE/bug_report.md +0 -33
  67. package/templates/default/.github/ISSUE_TEMPLATE/feature_request.md +0 -20
  68. package/templates/default/.github/ISSUE_TEMPLATE/record_submission.md +0 -48
  69. package/templates/default/.github/pull_request_template.md +0 -27
  70. package/templates/default/.github/workflows/build.yml +0 -55
  71. package/templates/default/.github/workflows/cleanup-stale-records.yml +0 -48
  72. package/templates/default/.github/workflows/daily-refresh.yml +0 -53
  73. package/templates/default/.github/workflows/sync-contributors.yml +0 -53
  74. package/templates/default/.github/workflows/sync-github-metadata.yml +0 -58
  75. package/templates/default/.github/workflows/validate-data.yml +0 -35
  76. package/templates/default/astro.config.mjs +0 -25
  77. package/templates/default/content/pages/about.md +0 -66
  78. package/templates/default/content/pages/methodology.md +0 -103
  79. package/templates/default/data/records/.gitkeep +0 -0
  80. package/templates/default/data/records/coolify.yml +0 -46
  81. package/templates/default/data/records/gitea.yml +0 -46
  82. package/templates/default/data/records/hoppscotch.yml +0 -47
  83. package/templates/default/data/records/inventree.yml +0 -47
  84. package/templates/default/data/records/label-studio.yml +0 -46
  85. package/templates/default/data/records/logseq.yml +0 -48
  86. package/templates/default/data/records/private-gpt.yml +0 -47
  87. package/templates/default/data/records/wakapi.yml +0 -44
  88. package/templates/default/data/taxonomy/categories.yml +0 -47
  89. package/templates/default/data/taxonomy/distribution-channels.yml +0 -43
  90. package/templates/default/data/taxonomy/platforms.yml +0 -27
  91. package/templates/default/data/taxonomy/stacks.yml +0 -41
  92. package/templates/default/grove.config.ts +0 -36
  93. package/templates/default/package.json +0 -38
  94. package/templates/default/public/icons/platforms/android.svg +0 -1
  95. package/templates/default/public/icons/platforms/apple-dark.svg +0 -1
  96. package/templates/default/public/icons/platforms/apple-light.svg +0 -1
  97. package/templates/default/public/icons/platforms/chrome.svg +0 -1
  98. package/templates/default/public/icons/platforms/chromeos.svg +0 -5
  99. package/templates/default/public/icons/platforms/desktop.svg +0 -4
  100. package/templates/default/public/icons/platforms/embedded.svg +0 -5
  101. package/templates/default/public/icons/platforms/ios.svg +0 -1
  102. package/templates/default/public/icons/platforms/linux.svg +0 -1
  103. package/templates/default/public/icons/platforms/macos.svg +0 -1
  104. package/templates/default/public/icons/platforms/ubuntu.svg +0 -1
  105. package/templates/default/public/icons/platforms/web.svg +0 -5
  106. package/templates/default/public/icons/platforms/windows.svg +0 -6
  107. package/templates/default/public/icons/stacks/android.svg +0 -1
  108. package/templates/default/public/icons/stacks/apple-dark.svg +0 -1
  109. package/templates/default/public/icons/stacks/apple-light.svg +0 -1
  110. package/templates/default/public/icons/stacks/bun.svg +0 -1
  111. package/templates/default/public/icons/stacks/capacitor.svg +0 -14
  112. package/templates/default/public/icons/stacks/clojure.svg +0 -1
  113. package/templates/default/public/icons/stacks/clojurescript.svg +0 -1
  114. package/templates/default/public/icons/stacks/dart.svg +0 -1
  115. package/templates/default/public/icons/stacks/deno.svg +0 -1
  116. package/templates/default/public/icons/stacks/django.svg +0 -1
  117. package/templates/default/public/icons/stacks/docker.svg +0 -1
  118. package/templates/default/public/icons/stacks/firebase.svg +0 -1
  119. package/templates/default/public/icons/stacks/flutter.svg +0 -1
  120. package/templates/default/public/icons/stacks/go.svg +0 -1
  121. package/templates/default/public/icons/stacks/graphql.svg +0 -1
  122. package/templates/default/public/icons/stacks/ionic.svg +0 -4
  123. package/templates/default/public/icons/stacks/java.svg +0 -1
  124. package/templates/default/public/icons/stacks/javascript.svg +0 -1
  125. package/templates/default/public/icons/stacks/kotlin.svg +0 -1
  126. package/templates/default/public/icons/stacks/llm.svg +0 -1
  127. package/templates/default/public/icons/stacks/mongodb.svg +0 -1
  128. package/templates/default/public/icons/stacks/node.js.svg +0 -1
  129. package/templates/default/public/icons/stacks/nodejs.svg +0 -1
  130. package/templates/default/public/icons/stacks/python.svg +0 -1
  131. package/templates/default/public/icons/stacks/rag.svg +0 -7
  132. package/templates/default/public/icons/stacks/react-native.svg +0 -1
  133. package/templates/default/public/icons/stacks/react.svg +0 -1
  134. package/templates/default/public/icons/stacks/rust.svg +0 -1
  135. package/templates/default/public/icons/stacks/solidity.svg +0 -1
  136. package/templates/default/public/icons/stacks/sveltekit.svg +0 -1
  137. package/templates/default/public/icons/stacks/swift.svg +0 -1
  138. package/templates/default/public/icons/stacks/tensorflow.svg +0 -1
  139. package/templates/default/public/icons/stacks/typescript.svg +0 -1
  140. package/templates/default/public/icons/stacks/vue.svg +0 -1
  141. package/templates/default/public/llms-full.txt +0 -206
  142. package/templates/default/public/llms.txt +0 -112
  143. package/templates/default/public/og-image.svg +0 -48
  144. package/templates/default/public/robots.txt +0 -53
  145. package/templates/default/public/sitemap.xml +0 -63
  146. package/templates/default/scripts/build-llms.mjs +0 -123
  147. package/templates/default/scripts/build-records-json.mjs +0 -27
  148. package/templates/default/scripts/build-sitemap.mjs +0 -20
  149. package/templates/default/scripts/cleanup-stale-records.mjs +0 -20
  150. package/templates/default/scripts/enrich-github-metadata.mjs +0 -99
  151. package/templates/default/scripts/fetch-icons.mjs +0 -137
  152. package/templates/default/scripts/migrate-legacy-to-schema-v1.mjs +0 -86
  153. package/templates/default/scripts/parse-legacy-readme.mjs +0 -59
  154. package/templates/default/scripts/refresh-records-activity.mjs +0 -24
  155. package/templates/default/scripts/repair-license-format.mjs +0 -72
  156. package/templates/default/scripts/report-cleanup-candidates.mjs +0 -21
  157. package/templates/default/scripts/seed-from-github.mjs +0 -62
  158. package/templates/default/scripts/sync-contributors.mjs +0 -145
  159. package/templates/default/scripts/sync-github-metadata.mjs +0 -28
  160. package/templates/default/scripts/validate-records.mjs +0 -27
  161. package/templates/default/src/pages/404.astro +0 -64
  162. package/templates/default/src/pages/[slug]/[recordSlug].astro +0 -689
  163. package/templates/default/src/pages/[slug]/index.astro +0 -278
  164. package/templates/default/src/pages/about.astro +0 -182
  165. package/templates/default/src/pages/apps/[recordSlug].astro +0 -33
  166. package/templates/default/src/pages/contributors.astro +0 -175
  167. package/templates/default/src/pages/index.astro +0 -225
  168. package/templates/default/src/pages/sitemap.xml.ts +0 -89
  169. package/templates/default/src/pages/submit.astro +0 -281
  170. package/templates/default/src/styles/global.css +0 -33
  171. 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,18 +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 "../lib/display.js";
41
42
  import type {
42
43
  ProjectRecord,
43
44
  ResourceRecord,
@@ -79,12 +80,39 @@ interface SiteConfigPayload {
79
80
  labelSingular?: string;
80
81
  labelPlural?: string;
81
82
  };
83
+ taxonomy?: Partial<
84
+ Record<
85
+ "categories" | "stacks" | "platforms" | "distributionChannels",
86
+ Array<{ id: string; name: string }>
87
+ >
88
+ >;
82
89
  name?: string;
83
90
  }
84
91
 
85
- const fullRecordsRaw: Resource[] = (fullPayload as FullPayload).records ?? [];
86
- const indexRecordsRaw: IndexRecord[] = (indexPayload as IndexPayload).records ?? [];
92
+ const fullRecordsRaw: Resource[] =
93
+ (fullPayload as unknown as FullPayload).records ?? [];
94
+ const indexRecordsRaw: IndexRecord[] =
95
+ (indexPayload as unknown as IndexPayload).records ?? [];
87
96
  const siteConfigRaw: SiteConfigPayload = siteConfigPayload as SiteConfigPayload;
97
+ const taxonomyMaps = Object.fromEntries(
98
+ Object.entries(siteConfigRaw.taxonomy ?? {}).map(([kind, entries]) => [
99
+ kind,
100
+ new Map((entries ?? []).map((entry) => [entry.id, entry.name])),
101
+ ]),
102
+ ) as Partial<
103
+ Record<
104
+ "categories" | "stacks" | "platforms" | "distributionChannels",
105
+ Map<string, string>
106
+ >
107
+ >;
108
+ export const taxonomy = siteConfigRaw.taxonomy ?? {};
109
+
110
+ export function taxonomyLabel(
111
+ kind: "categories" | "stacks" | "platforms" | "distributionChannels",
112
+ id: string,
113
+ ): string {
114
+ return taxonomyMaps[kind]?.get(id) ?? prettySlug(id);
115
+ }
88
116
 
89
117
  /** Full records (every record, all visibility). Use this for the
90
118
  * detail page (where you need `content`, `bestFor`, `whyListed`,
@@ -148,7 +176,7 @@ export function entityBySlug(slug: string): EntityRecord | undefined {
148
176
  // route slug, the kind filter, and the human label. Rather than
149
177
  // hardcode "project" / "projects" / "kind: project" at every
150
178
  // call-site, we derive them once from `site-config.json`'s
151
- // `blueprintConfig` block (populated by `grove generate`).
179
+ // `blueprintConfig` block (populated by Grove preparation).
152
180
  //
153
181
  // `indexSlug()` and `itemLabel()` retain their old signatures
154
182
  // (zero-arg) so existing pages keep working — they now read from
@@ -224,6 +252,10 @@ export const fullItems: Resource[] = (() => {
224
252
  return fullRecords.filter((r) => r.kind === blueprintKind);
225
253
  })();
226
254
 
255
+ export const fullProjects: ProjectRecord[] = fullRecords.filter(
256
+ (record): record is ProjectRecord => record.kind === "project",
257
+ );
258
+
227
259
  // ──────────────────────────────────────────────────────────────────────
228
260
  // Markdown content rendering (sanitized)
229
261
  // ──────────────────────────────────────────────────────────────────────
@@ -316,3 +348,53 @@ for (const r of fullRecords) {
316
348
  export function getContentHtml(slug: string): string | null {
317
349
  return contentHtmlBySlug.get(slug) ?? null;
318
350
  }
351
+
352
+ /**
353
+ * Sanitized Markdown for a consumer-authored page under
354
+ * `content/pages/<page>.md`. Default template pages use this to
355
+ * accept custom copy while keeping Grove's layout and components.
356
+ */
357
+ export function getPageContentHtml(page: string): string | null {
358
+ const candidates = [
359
+ resolve(here, "..", "..", "content", "pages", `${page}.md`),
360
+ resolve(here, "..", "..", "..", "content", "pages", `${page}.md`),
361
+ resolve(process.cwd(), "content", "pages", `${page}.md`),
362
+ ];
363
+ const path = candidates.find((candidate) => existsSync(candidate));
364
+ if (!path) return null;
365
+
366
+ try {
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;
372
+ return sanitizeHtml(rawHtml, {
373
+ allowedTags: [
374
+ "h1", "h2", "h3", "h4",
375
+ "p", "br", "hr",
376
+ "ul", "ol", "li",
377
+ "strong", "em", "b", "i", "u", "s", "del",
378
+ "a", "code", "pre", "blockquote", "img",
379
+ ],
380
+ allowedAttributes: {
381
+ a: ["href", "title", "rel", "target"],
382
+ img: ["src", "alt", "title", "width", "height", "loading"],
383
+ },
384
+ allowedSchemes: ["http", "https", "mailto", "tel"],
385
+ allowedSchemesByTag: {
386
+ a: ["http", "https", "mailto", "tel"],
387
+ img: ["http", "https"],
388
+ },
389
+ transformTags: {
390
+ a: sanitizeHtml.simpleTransform("a", {
391
+ rel: "noopener noreferrer",
392
+ target: "_blank",
393
+ }, true),
394
+ },
395
+ disallowedTagsMode: "discard",
396
+ });
397
+ } catch {
398
+ return null;
399
+ }
400
+ }
@@ -0,0 +1,2 @@
1
+ export * from "./directory.js";
2
+ export * from "./models.js";