@grove-dev/astro 0.3.2 → 0.4.0

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.
@@ -25,7 +25,7 @@
25
25
  */
26
26
  import type { IndexRecord, Resource, ProjectRecord, IndexProjectRecord } from "@grove-dev/core";
27
27
  import Icon from "./Icon.astro";
28
- import { getOwnerAndRepoFromRepoUrl, getOwnerAvatarUrl } from "@grove-dev/astro";
28
+ import { getOwnerAndRepoFromRepoUrl, getOwnerAvatarUrl, projectStackIds } from "@grove-dev/astro";
29
29
  import { formatStars, formatRelative } from "@grove-dev/astro";
30
30
  import { statusDisplay } from "@grove-dev/astro";
31
31
 
@@ -126,12 +126,7 @@ const platforms = isProject ? (item as { platforms?: string[] }).platforms ?? []
126
126
  const visiblePlatforms = platforms.slice(0, 4);
127
127
  const platformOverflow = platforms.length - visiblePlatforms.length;
128
128
 
129
- const allStacks = isProject
130
- ? [
131
- (item as { stack?: string }).stack,
132
- ...((item as { stacks?: string[] }).stacks ?? []),
133
- ].filter((s): s is string => Boolean(s))
134
- : [];
129
+ const allStacks = isProject ? projectStackIds(item as ProjectRecord) : [];
135
130
  const visibleStacks = allStacks.slice(0, 3);
136
131
  const stackOverflow = allStacks.length - visibleStacks.length;
137
132
 
@@ -153,13 +148,7 @@ const category = (item as { category?: string }).category;
153
148
  class={`rounded-[calc(var(--radius)+0.25rem)] border-0 bg-muted shadow-none transition-colors hover:bg-secondary group relative flex h-full flex-col gap-3 p-4 ${className}`}
154
149
  >
155
150
  {/* Top row: logo + name + (optional) status + curated mark. */}
156
- <header class="relative z-10 flex items-start gap-3">
157
- <a
158
- href={detailHref}
159
- aria-label={`Open ${name} details`}
160
- class="absolute inset-0 z-0 rounded-lg"
161
- ></a>
162
-
151
+ <header class="flex items-start gap-3">
163
152
  {logoUrl ? (
164
153
  <img
165
154
  src={logoUrl}
@@ -183,11 +172,10 @@ const category = (item as { category?: string }).category;
183
172
  <h3
184
173
  class="m-0 truncate text-[0.9375rem] font-semibold tracking-tight text-ink-900 group-hover:underline dark:text-ink-100"
185
174
  >
186
- {/* Name → detail page (not GitHub repo). Stacks above the
187
- stretched link via z-index. */}
175
+ {/* Name → detail page (not GitHub repo). */}
188
176
  <a
189
177
  href={detailHref}
190
- class="relative z-10 text-inherit no-underline"
178
+ class="inline-flex min-h-6 min-w-6 items-center text-inherit no-underline"
191
179
  >
192
180
  {name}
193
181
  </a>
@@ -203,7 +191,7 @@ const category = (item as { category?: string }).category;
203
191
  data-event="external_repo_click"
204
192
  data-event-source="card_owner"
205
193
  data-event-item={slug}
206
- class="relative z-10 font-mono text-ink-500 hover:text-ink-900 dark:text-ink-400 dark:hover:text-ink-100"
194
+ class="font-mono text-ink-500 hover:text-ink-900 dark:text-ink-400 dark:hover:text-ink-100"
207
195
  >
208
196
  {/* `data-event` and `data-event-*` are forwarding hooks
209
197
  for downstream analytics. Sites wire their own
@@ -215,7 +203,7 @@ const category = (item as { category?: string }).category;
215
203
  "detail_owner" | "detail_view_github" | "detail_homepage"
216
204
  | "detail_distribution"), and `data-event-item` is
217
205
  the record slug. */}
218
- <span class="text-ink-400 dark:text-ink-500">@</span>{owner}{repo ? <span class="text-ink-400 dark:text-ink-500">/{repo}</span> : ""}
206
+ <span class="text-ink-400">@</span>{owner}{repo ? <span class="text-ink-400">/{repo}</span> : ""}
219
207
  </a>
220
208
  <span class="mx-1 text-ink-300 dark:text-ink-700">·</span>
221
209
  {category}
@@ -257,7 +245,7 @@ const category = (item as { category?: string }).category;
257
245
  {/* Description */}
258
246
  {item.description && (
259
247
  <p
260
- class="relative z-10 m-0 line-clamp-2 text-[0.8125rem] leading-relaxed text-ink-500 dark:text-ink-400"
248
+ class="m-0 line-clamp-2 text-[0.8125rem] leading-relaxed text-ink-500 dark:text-ink-400"
261
249
  >
262
250
  {item.description}
263
251
  </p>
@@ -266,7 +254,7 @@ const category = (item as { category?: string }).category;
266
254
  {/* Metadata row: stars · updated · license — the three signals
267
255
  that answer "is this alive and usable?" at a glance. */}
268
256
  {showMeta && (
269
- <div class="relative z-10 flex flex-wrap items-center gap-x-3 gap-y-1 text-2xs text-ink-500 dark:text-ink-400">
257
+ <div class="flex flex-wrap items-center gap-x-3 gap-y-1 text-2xs text-ink-500 dark:text-ink-400">
270
258
  {starsLabel !== null && (
271
259
  <span class="inline-flex items-center gap-1" title={`${stars ?? 0} stars on GitHub`}>
272
260
  <svg viewBox="0 0 16 16" width="11" height="11" aria-hidden="true" fill="currentColor" class="text-amber-500">
@@ -293,7 +281,7 @@ const category = (item as { category?: string }).category;
293
281
 
294
282
  {/* Platform + stack chips, pushed to the bottom. */}
295
283
  {showChips && (
296
- <div class="relative z-10 mt-auto flex flex-col gap-1.5">
284
+ <div class="mt-auto flex flex-col gap-1.5">
297
285
  {visiblePlatforms.length > 0 && (
298
286
  <div class="flex flex-wrap items-center gap-1.5">
299
287
  {visiblePlatforms.map((p) => (
@@ -303,7 +291,7 @@ const category = (item as { category?: string }).category;
303
291
  </span>
304
292
  ))}
305
293
  {platformOverflow > 0 && (
306
- <span class="inline-flex min-h-5 items-center justify-center gap-1 rounded-full border border-transparent bg-secondary px-2 py-0.5 align-middle text-xs font-medium leading-none text-secondary-foreground text-ink-400 dark:text-ink-500">+{platformOverflow}</span>
294
+ <span class="inline-flex min-h-5 items-center justify-center gap-1 rounded-full border border-transparent bg-secondary px-2 py-0.5 align-middle text-xs font-medium leading-none text-secondary-foreground text-ink-400">+{platformOverflow}</span>
307
295
  )}
308
296
  </div>
309
297
  )}
@@ -316,7 +304,7 @@ const category = (item as { category?: string }).category;
316
304
  </span>
317
305
  ))}
318
306
  {stackOverflow > 0 && (
319
- <span class="inline-flex min-h-5 items-center justify-center gap-1 rounded-full border border-transparent bg-secondary px-2 py-0.5 align-middle text-xs font-medium leading-none text-secondary-foreground !bg-muted !text-muted-foreground text-ink-400 dark:text-ink-500">+{stackOverflow}</span>
307
+ <span class="inline-flex min-h-5 items-center justify-center gap-1 rounded-full border border-transparent bg-secondary px-2 py-0.5 align-middle text-xs font-medium leading-none text-secondary-foreground !bg-muted !text-muted-foreground text-ink-400">+{stackOverflow}</span>
320
308
  )}
321
309
  </div>
322
310
  )}
@@ -324,7 +312,7 @@ const category = (item as { category?: string }).category;
324
312
  )}
325
313
 
326
314
  {/* Footer row: View repo + Details links. */}
327
- <footer class="relative z-10 mt-1 flex items-center justify-between gap-2 border-t border-ink-100 pt-2 text-2xs dark:border-ink-900">
315
+ <footer class="mt-1 flex items-center justify-between gap-2 border-t border-ink-100 pt-2 text-2xs dark:border-ink-900">
328
316
  {externalHref && (
329
317
  <a
330
318
  href={externalHref}
@@ -333,7 +321,7 @@ const category = (item as { category?: string }).category;
333
321
  data-event="external_repo_click"
334
322
  data-event-source="card_view_repo"
335
323
  data-event-item={slug}
336
- class="relative z-10 inline-flex items-center gap-1 font-medium text-ink-700 transition-colors hover:text-ink-900 dark:text-ink-300 dark:hover:text-ink-100"
324
+ class="inline-flex items-center gap-1 font-medium text-ink-700 transition-colors hover:text-ink-900 dark:text-ink-300 dark:hover:text-ink-100"
337
325
  >
338
326
  {repoHref ? "View repo" : "Visit source"}
339
327
  <svg viewBox="0 0 16 16" width="10" height="10" aria-hidden="true" fill="currentColor">
@@ -344,7 +332,7 @@ const category = (item as { category?: string }).category;
344
332
  )}
345
333
  <a
346
334
  href={detailHref}
347
- class="relative z-10 inline-flex items-center gap-1 font-medium text-ink-500 transition-colors hover:text-ink-900 dark:text-ink-400 dark:hover:text-ink-100"
335
+ class="inline-flex items-center gap-1 font-medium text-ink-500 transition-colors hover:text-ink-900 dark:text-ink-400 dark:hover:text-ink-100"
348
336
  >
349
337
  Details →
350
338
  </a>
@@ -99,7 +99,7 @@ const groups: RenderedFilterGroup[] = baseGroups
99
99
  }));
100
100
  ---
101
101
 
102
- <section class:list={["flex flex-wrap items-center gap-2", className]} role="toolbar" aria-label="Filter items">
102
+ <div class:list={["flex flex-wrap items-center gap-2", className]} role="toolbar" aria-label="Filter items">
103
103
  {groups.map((group) => <FilterGroupMenu {...group} />)}
104
104
 
105
105
  {!hide.includes("sort") && (
@@ -113,7 +113,7 @@ const groups: RenderedFilterGroup[] = baseGroups
113
113
  </svg>
114
114
  </label>
115
115
  )}
116
- </section>
116
+ </div>
117
117
 
118
118
  <script is:inline define:vars={{ pathPrefix }}>
119
119
  (() => {
@@ -24,6 +24,8 @@ interface Props {
24
24
  class?: string;
25
25
  /** Override the visible lens list (defaults to LENSES). */
26
26
  lensIds?: string[];
27
+ /** Consumer noun for the unfiltered lens, e.g. "All apps". */
28
+ allLabel?: string;
27
29
  }
28
30
 
29
31
  const {
@@ -31,6 +33,7 @@ const {
31
33
  pathPrefix = "/",
32
34
  class: className = "",
33
35
  lensIds,
36
+ allLabel,
34
37
  } = Astro.props;
35
38
 
36
39
  // If `lensIds` is provided, filter LENSES to those ids in that order.
@@ -65,7 +68,7 @@ const lenses =
65
68
  data-event="lens_tab_click"
66
69
  data-event-source="smart_lens_tabs"
67
70
  >
68
- {lensDisplay(lens.id)}
71
+ {lens.id === "all" && allLabel ? allLabel : lensDisplay(lens.id)}
69
72
  </a>
70
73
  );
71
74
  })}
@@ -11,8 +11,8 @@
11
11
  * own config (typically `data/generated/site-config.json` for the
12
12
  * showcase).
13
13
  *
14
- * Optional `gaId` adds a GA4 measurement snippet. Omit for
15
- * privacy-first deployments.
14
+ * GA4 can be configured once through `site.analytics.googleAnalyticsId`
15
+ * or overridden per page with `gaId`. Omit both for privacy-first deployments.
16
16
  */
17
17
  import "../styles.css";
18
18
  import Header from "./Header.astro";
@@ -61,6 +61,9 @@ interface Site {
61
61
  };
62
62
  repoUrl?: string;
63
63
  url?: string;
64
+ analytics?: {
65
+ googleAnalyticsId?: string;
66
+ };
64
67
  /** Optional directory-level stats — populated from site-config.json
65
68
  * at generate time. Components use it to render hero counts,
66
69
  * origin card stats, and footer summary lines. */
@@ -103,6 +106,7 @@ const {
103
106
  } = Astro.props;
104
107
  const directoryPath =
105
108
  searchPath ?? `/${site.blueprintConfig?.routeSlug ?? "items"}`;
109
+ const effectiveGaId = gaId ?? site.analytics?.googleAnalyticsId;
106
110
 
107
111
  const favicon =
108
112
  "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Crect width='16' height='16' rx='3' fill='%2318181b'/%3E%3C/svg%3E";
@@ -130,14 +134,14 @@ const favicon =
130
134
  title="LLM usage guidance"
131
135
  />
132
136
 
133
- {gaId && (
137
+ {effectiveGaId && (
134
138
  <>
135
139
  <script
136
140
  is:inline
137
141
  async
138
- src={`https://www.googletagmanager.com/gtag/js?id=${gaId}`}
142
+ src={`https://www.googletagmanager.com/gtag/js?id=${effectiveGaId}`}
139
143
  />
140
- <script is:inline define:vars={{ gaId }}>
144
+ <script is:inline define:vars={{ gaId: effectiveGaId }}>
141
145
  window.dataLayer = window.dataLayer || [];
142
146
  function gtag() {
143
147
  dataLayer.push(arguments);
@@ -290,8 +294,8 @@ const favicon =
290
294
 
291
295
  <slot name="body-end" />
292
296
 
293
- {gaId && (
294
- <script is:inline define:vars={{ gaId }}>
297
+ {effectiveGaId && (
298
+ <script is:inline define:vars={{ gaId: effectiveGaId }}>
295
299
  (function () {
296
300
  // GA4's `gtag('config', id)` call in the <head> already auto-pings
297
301
  // `page_view` on full navigations, so this block only needs to
@@ -170,9 +170,9 @@ const resolvedLicenseLine = licenseLine ?? site.footer?.license;
170
170
 
171
171
  {columns.map((column) => (
172
172
  <div>
173
- <h4 class="mb-3 text-2xs font-semibold uppercase tracking-wider text-ink-900 dark:text-ink-100">
173
+ <h2 class="mb-3 text-2xs font-semibold uppercase tracking-wider text-ink-900 dark:text-ink-100">
174
174
  {column.heading}
175
- </h4>
175
+ </h2>
176
176
  <ul class="m-0 flex list-none flex-col gap-2 p-0">
177
177
  {column.items.map((item) => (
178
178
  <li>
package/src/lib/index.ts CHANGED
@@ -24,3 +24,4 @@ export * from "./search.js";
24
24
  export * from "./scores.js";
25
25
  export * from "./taxonomy-counts.js";
26
26
  export * from "./display.js";
27
+ export * from "./load-collections.js";
@@ -0,0 +1,33 @@
1
+ import { readFile, readdir } from "node:fs/promises";
2
+ import { join, resolve } from "node:path";
3
+ import { parse as parseYaml } from "yaml";
4
+ import type { Collection } from "@grove-dev/core";
5
+
6
+ /**
7
+ * Load all `Collection` YAML files from `<cwd>/data/collections/*.yml`.
8
+ *
9
+ * Returns an empty array if the directory does not exist, so callers can
10
+ * safely render an empty/loading state without having to guard each
11
+ * invocation site. Parse errors and other unexpected failures are NOT
12
+ * swallowed — they surface so real problems (malformed YAML, permission
13
+ * errors, etc.) are visible instead of silently producing empty output.
14
+ */
15
+ export async function loadCollections(cwd: string): Promise<Collection[]> {
16
+ const dir = resolve(cwd, "data/collections");
17
+ let files: string[];
18
+ try {
19
+ files = await readdir(dir);
20
+ } catch (err) {
21
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return [];
22
+ throw err;
23
+ }
24
+ const out: Collection[] = [];
25
+ for (const f of files.filter((f) => f.endsWith(".yml"))) {
26
+ const raw = parseYaml(await readFile(join(dir, f), "utf8"));
27
+ if (!raw || typeof raw !== "object") {
28
+ throw new Error(`Invalid collection YAML: ${f}`);
29
+ }
30
+ out.push(raw as Collection);
31
+ }
32
+ return out;
33
+ }
@@ -0,0 +1,190 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ recordsToCollectionEntries,
4
+ getCollectionPageModel,
5
+ getCollectionIndexModel,
6
+ getCollectionTeaserModel,
7
+ } from "./collections.js";
8
+ import type { Collection, CollectionEntry } from "@grove-dev/core";
9
+
10
+ describe("recordsToCollectionEntries", () => {
11
+ it("maps a full record to a CollectionEntry", () => {
12
+ const records = [
13
+ {
14
+ slug: "crewai",
15
+ name: "CrewAI",
16
+ description: "Agent framework",
17
+ stack: "python",
18
+ stacks: ["python"],
19
+ platforms: ["linux", "macos"],
20
+ license: "MIT",
21
+ visibility: "keep",
22
+ stars: 100,
23
+ pushedAt: "2026-01-15T00:00:00Z",
24
+ lastCommitAt: "2026-02-01T00:00:00Z",
25
+ category: "agents",
26
+ tags: ["multi-agent", "automation"],
27
+ scores: { curation: 0.8, activity: 0.7 },
28
+ },
29
+ ];
30
+ const entries = recordsToCollectionEntries(records as never, { routeSlug: "projects" });
31
+ expect(entries).toHaveLength(1);
32
+ expect(entries[0]).toMatchObject({
33
+ slug: "crewai",
34
+ title: "CrewAI",
35
+ description: "Agent framework",
36
+ url: "/projects/crewai/",
37
+ stack: "python",
38
+ platform: ["linux", "macos"],
39
+ license: "MIT",
40
+ status: "keep",
41
+ stars: 100,
42
+ pushedAt: "2026-01-15T00:00:00Z",
43
+ curationScore: 0.8,
44
+ activityScore: 0.7,
45
+ categories: expect.arrayContaining(["agents", "multi-agent", "automation"]),
46
+ });
47
+ });
48
+
49
+ it("filters out hidden records", () => {
50
+ const records = [
51
+ { slug: "a", name: "A", visibility: "keep" },
52
+ { slug: "b", name: "B", visibility: "hide" },
53
+ ];
54
+ const entries = recordsToCollectionEntries(records as never, { routeSlug: "projects" });
55
+ expect(entries.map((e) => e.slug)).toEqual(["a"]);
56
+ });
57
+
58
+ it("falls back to stacks[0] when stack is missing", () => {
59
+ const records = [{ slug: "x", name: "X", stacks: ["node"] }];
60
+ const entries = recordsToCollectionEntries(records as never, { routeSlug: "projects" });
61
+ expect(entries[0].stack).toBe("node");
62
+ });
63
+
64
+ it("prefers lastCommitAt when pushedAt is missing", () => {
65
+ const records = [{ slug: "x", name: "X", lastCommitAt: "2026-03-01T00:00:00Z" }];
66
+ const entries = recordsToCollectionEntries(records as never, { routeSlug: "projects" });
67
+ expect(entries[0].pushedAt).toBe("2026-03-01T00:00:00Z");
68
+ });
69
+
70
+ it("deduplicates categories when category is also in tags", () => {
71
+ const records = [{ slug: "x", name: "X", category: "agents", tags: ["agents", "x"] }];
72
+ const entries = recordsToCollectionEntries(records as never, { routeSlug: "projects" });
73
+ expect(entries[0].categories).toEqual(["agents", "x"]);
74
+ });
75
+
76
+ it("defaults routeSlug to 'projects' when not provided", () => {
77
+ const records = [{ slug: "x", name: "X" }];
78
+ const entries = recordsToCollectionEntries(records as never, {});
79
+ expect(entries[0].url).toBe("/projects/x/");
80
+ });
81
+ });
82
+
83
+ const collection: Collection = {
84
+ slug: "top-ai-agents",
85
+ kind: "curated",
86
+ title: "Top AI Agents",
87
+ description: "Best agent frameworks.",
88
+ query: { stacks: ["python"] },
89
+ ranking: { preset: "quality" },
90
+ seo: { index: true },
91
+ editorial: { selectionNote: "Ranked by activity and curation." },
92
+ };
93
+
94
+ const otherCollection: Collection = {
95
+ slug: "top-tools",
96
+ kind: "curated",
97
+ title: "Top Tools",
98
+ description: "All tools.",
99
+ query: { stacks: ["python"] },
100
+ ranking: { preset: "recency" },
101
+ seo: { index: true },
102
+ };
103
+
104
+ const emptyCollection: Collection = {
105
+ slug: "no-matches",
106
+ kind: "curated",
107
+ title: "No Matches",
108
+ description: "Won't match anything.",
109
+ query: { stacks: ["rust"] },
110
+ ranking: { preset: "recency" },
111
+ seo: { index: true },
112
+ };
113
+
114
+ const entries: CollectionEntry[] = [
115
+ {
116
+ slug: "crewai",
117
+ title: "CrewAI",
118
+ description: "Agent framework",
119
+ url: "/projects/crewai/",
120
+ stack: "python",
121
+ curationScore: 0.9,
122
+ activityScore: 0.8,
123
+ },
124
+ {
125
+ slug: "dify",
126
+ title: "Dify",
127
+ description: "LLM platform",
128
+ url: "/projects/dify/",
129
+ stack: "python",
130
+ curationScore: 0.7,
131
+ activityScore: 0.6,
132
+ },
133
+ {
134
+ slug: "flowise",
135
+ title: "Flowise",
136
+ description: "Node tool",
137
+ url: "/projects/flowise/",
138
+ stack: "node",
139
+ curationScore: 0.5,
140
+ activityScore: 0.4,
141
+ },
142
+ ];
143
+
144
+ describe("getCollectionPageModel", () => {
145
+ it("returns ranked entries filtered by query", () => {
146
+ const model = getCollectionPageModel(collection, entries, [collection, otherCollection]);
147
+ expect(model.total).toBe(2);
148
+ expect(model.isEmpty).toBe(false);
149
+ expect(model.entries.map((e) => e.slug)).toEqual(["crewai", "dify"]);
150
+ });
151
+
152
+ it("exposes editorial selection note", () => {
153
+ const model = getCollectionPageModel(collection, entries, [collection]);
154
+ expect(model.collection.selectionNote).toBe("Ranked by activity and curation.");
155
+ });
156
+
157
+ it("returns related collections (max 4)", () => {
158
+ const model = getCollectionPageModel(collection, entries, [collection, otherCollection]);
159
+ expect(model.related).toHaveLength(1);
160
+ expect(model.related[0].slug).toBe("top-tools");
161
+ });
162
+
163
+ it("reports empty when no entries match", () => {
164
+ const model = getCollectionPageModel(emptyCollection, entries, [collection, otherCollection]);
165
+ expect(model.isEmpty).toBe(true);
166
+ });
167
+ });
168
+
169
+ describe("getCollectionIndexModel", () => {
170
+ it("counts entries per collection", () => {
171
+ const model = getCollectionIndexModel([collection, otherCollection], entries);
172
+ expect(model.total).toBe(2);
173
+ expect(model.collections).toHaveLength(2);
174
+ const agentCol = model.collections.find((c) => c.slug === "top-ai-agents");
175
+ expect(agentCol?.count).toBe(2);
176
+ expect(agentCol?.url).toBe("/collections/top-ai-agents/");
177
+ });
178
+ });
179
+
180
+ describe("getCollectionTeaserModel", () => {
181
+ it("respects the limit", () => {
182
+ const model = getCollectionTeaserModel([collection, otherCollection], entries, 1);
183
+ expect(model.collections).toHaveLength(1);
184
+ });
185
+
186
+ it("uses the project's route slug for entry URLs", () => {
187
+ const model = getCollectionTeaserModel([collection], entries, 5);
188
+ expect(model.collections[0].count).toBe(2);
189
+ });
190
+ });
@@ -0,0 +1,197 @@
1
+ import type { Collection, CollectionEntry } from "@grove-dev/core";
2
+ import { findRelated, runCollection } from "@grove-dev/core";
3
+ import { readFile, readdir } from "node:fs/promises";
4
+ import { join, resolve } from "node:path";
5
+ import { parse as parseYaml } from "yaml";
6
+
7
+ interface RouteHint {
8
+ routeSlug?: string;
9
+ blueprintConfig?: { routeSlug?: string };
10
+ }
11
+
12
+ interface RawRecord {
13
+ slug?: string;
14
+ name?: string;
15
+ title?: string;
16
+ description?: string;
17
+ stack?: string;
18
+ stacks?: string[];
19
+ platforms?: string[];
20
+ license?: string;
21
+ visibility?: string;
22
+ stars?: number;
23
+ pushedAt?: string;
24
+ lastCommitAt?: string;
25
+ category?: string;
26
+ tags?: string[];
27
+ scores?: { curation?: number; activity?: number };
28
+ }
29
+
30
+ /**
31
+ * Map a list of full records (shape produced by `records.json`) to
32
+ * the lightweight `CollectionEntry` shape consumed by `runCollection`.
33
+ *
34
+ * - Skips records with `visibility === "hide"` — they should never
35
+ * appear in any collection.
36
+ * - Combines `category` and `tags` into a `categories` array (deduplicated).
37
+ * - Falls back to `stacks[0]` when `stack` is missing.
38
+ * - Prefers `pushedAt`; falls back to `lastCommitAt`.
39
+ * - Constructs `url` as `${routeSlug}/${slug}/` so the value points
40
+ * back to the consumer's detail page.
41
+ */
42
+ export function recordsToCollectionEntries(
43
+ records: RawRecord[],
44
+ site: RouteHint,
45
+ ): CollectionEntry[] {
46
+ const routeSlug = site.routeSlug ?? site.blueprintConfig?.routeSlug ?? "projects";
47
+ const out: CollectionEntry[] = [];
48
+ for (const r of records) {
49
+ if (r.visibility === "hide") continue;
50
+ if (!r.slug) continue;
51
+ const categories = [
52
+ ...(r.category ? [r.category] : []),
53
+ ...(r.tags ?? []),
54
+ ].filter((value, index, self) => self.indexOf(value) === index);
55
+ out.push({
56
+ slug: r.slug,
57
+ title: r.name ?? r.title ?? r.slug,
58
+ description: r.description ?? "",
59
+ url: `/${routeSlug}/${r.slug}/`,
60
+ stack: r.stack ?? r.stacks?.[0],
61
+ platform: r.platforms,
62
+ license: r.license,
63
+ status: r.visibility,
64
+ stars: r.stars,
65
+ pushedAt: r.pushedAt ?? r.lastCommitAt,
66
+ curationScore: r.scores?.curation,
67
+ activityScore: r.scores?.activity,
68
+ categories: categories.length > 0 ? categories : undefined,
69
+ });
70
+ }
71
+ return out;
72
+ }
73
+
74
+ // ── Collection view-models ──────────────────────────────────────
75
+ //
76
+ // A Collection is a curated or generated grouping of records
77
+ // (see `@grove-dev/core`'s `runCollection` engine). These view-models
78
+ // produce the input the `CollectionPage`, `CollectionIndex`, and
79
+ // `CollectionTeaser` components consume.
80
+
81
+ export interface CollectionPageModel {
82
+ collection: {
83
+ slug: string;
84
+ title: string;
85
+ description: string;
86
+ kind: "curated" | "generated";
87
+ selectionNote?: string;
88
+ introduction?: string;
89
+ };
90
+ total: number;
91
+ isEmpty: boolean;
92
+ entries: CollectionEntry[];
93
+ related: Array<{ slug: string; title: string; url: string }>;
94
+ }
95
+
96
+ export interface CollectionIndexModel {
97
+ total: number;
98
+ collections: Array<{
99
+ slug: string;
100
+ title: string;
101
+ description: string;
102
+ kind: "curated" | "generated";
103
+ count: number;
104
+ url: string;
105
+ }>;
106
+ }
107
+
108
+ export interface CollectionTeaserModel {
109
+ total: number;
110
+ collections: CollectionIndexModel["collections"];
111
+ }
112
+
113
+ export function getCollectionPageModel(
114
+ collection: Collection,
115
+ entries: CollectionEntry[],
116
+ allCollections: Collection[],
117
+ ): CollectionPageModel {
118
+ const result = runCollection(collection, entries);
119
+ const related = findRelated(collection, allCollections, 4).map((c) => ({
120
+ slug: c.slug,
121
+ title: c.title,
122
+ url: `/collections/${c.slug}/`,
123
+ }));
124
+ return {
125
+ collection: {
126
+ slug: collection.slug,
127
+ title: collection.title,
128
+ description: collection.description,
129
+ kind: collection.kind,
130
+ selectionNote: collection.editorial?.selectionNote,
131
+ introduction: collection.editorial?.introduction,
132
+ },
133
+ total: result.entries.length,
134
+ isEmpty: result.isEmpty,
135
+ entries: result.entries,
136
+ related,
137
+ };
138
+ }
139
+
140
+ export function getCollectionIndexModel(
141
+ collections: Collection[],
142
+ entries: CollectionEntry[],
143
+ ): CollectionIndexModel {
144
+ return {
145
+ total: collections.length,
146
+ collections: collections.map((c) => {
147
+ const result = runCollection(c, entries);
148
+ return {
149
+ slug: c.slug,
150
+ title: c.title,
151
+ description: c.description,
152
+ kind: c.kind,
153
+ count: result.entries.length,
154
+ url: `/collections/${c.slug}/`,
155
+ };
156
+ }),
157
+ };
158
+ }
159
+
160
+ export function getCollectionTeaserModel(
161
+ collections: Collection[],
162
+ entries: CollectionEntry[],
163
+ limit = 3,
164
+ ): CollectionTeaserModel {
165
+ const full = getCollectionIndexModel(collections, entries);
166
+ return {
167
+ total: full.total,
168
+ collections: full.collections.slice(0, limit),
169
+ };
170
+ }
171
+
172
+ /**
173
+ * Load all `Collection` YAML files from `<cwd>/data/collections/*.yml`.
174
+ *
175
+ * Returns an empty array if the directory does not exist. Parse
176
+ * errors are NOT swallowed — they surface so real problems
177
+ * (malformed YAML, permission errors) are not hidden.
178
+ */
179
+ export async function loadCollections(cwd: string): Promise<Collection[]> {
180
+ const dir = resolve(cwd, "data/collections");
181
+ let files: string[];
182
+ try {
183
+ files = await readdir(dir);
184
+ } catch (err) {
185
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return [];
186
+ throw err;
187
+ }
188
+ const out: Collection[] = [];
189
+ for (const f of files.filter((f) => f.endsWith(".yml"))) {
190
+ const raw = parseYaml(await readFile(join(dir, f), "utf8"));
191
+ if (!raw || typeof raw !== "object") {
192
+ throw new Error(`Invalid collection YAML: ${f}`);
193
+ }
194
+ out.push(raw as Collection);
195
+ }
196
+ return out;
197
+ }
@@ -1,2 +1,3 @@
1
1
  export * from "./directory.js";
2
2
  export * from "./models.js";
3
+ export * from "./collections.js";