@grove-dev/astro 0.3.4 → 0.4.1

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.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  import type { IndexRecord } from "@grove-dev/core";
3
- import Icon from "./Icon.astro";
3
+ import StackPlatformChips from "./StackPlatformChips.astro";
4
4
  import { formatRelative, formatStars, getOwnerAndRepoFromRepoUrl, getOwnerAvatarUrl, projectStackIds, statusDisplay } from "@grove-dev/astro";
5
5
 
6
6
  interface Props {
@@ -69,16 +69,16 @@ const curated = Boolean(item.curation?.reviewed || (project && (project.bestFor.
69
69
  <p class="m-0 line-clamp-2 text-[0.8125rem] leading-relaxed text-ink-600 dark:text-ink-300">{item.description}</p>
70
70
 
71
71
  <dl class="grid grid-cols-3 gap-x-3 text-2xs">
72
- <div class="flex items-baseline gap-1.5"><dt class="font-medium uppercase tracking-wider text-ink-500">Stars</dt><dd class="font-mono text-ink-700 dark:text-ink-300">{formatStars(stars) ?? "—"}</dd></div>
73
- <div class="flex items-baseline gap-1.5"><dt class="font-medium uppercase tracking-wider text-ink-500">Updated</dt><dd class="font-mono text-ink-700 dark:text-ink-300">{formatRelative(updatedAt)}</dd></div>
74
- <div class="flex items-baseline gap-1.5"><dt class="font-medium uppercase tracking-wider text-ink-500">License</dt><dd class="font-mono text-ink-700 dark:text-ink-300">{license ?? "—"}</dd></div>
72
+ <div class="flex items-baseline gap-1.5"><dt class="font-medium uppercase tracking-wider text-ink-500 dark:text-ink-400">Stars</dt><dd class="font-mono text-ink-700 dark:text-ink-300">{formatStars(stars) ?? "—"}</dd></div>
73
+ <div class="flex items-baseline gap-1.5"><dt class="font-medium uppercase tracking-wider text-ink-500 dark:text-ink-400">Updated</dt><dd class="font-mono text-ink-700 dark:text-ink-300">{formatRelative(updatedAt)}</dd></div>
74
+ <div class="flex items-baseline gap-1.5"><dt class="font-medium uppercase tracking-wider text-ink-500 dark:text-ink-400">License</dt><dd class="font-mono text-ink-700 dark:text-ink-300">{license ?? "—"}</dd></div>
75
75
  </dl>
76
76
 
77
77
  {showChips && (platforms.length > 0 || stacks.length > 0) && (
78
- <div class="flex flex-col gap-1.5">
79
- <div class="flex flex-wrap gap-1.5">{platforms.slice(0, 4).map((platform) => <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"><Icon name={platform} category="platform" size={11} />{platform}</span>)}</div>
80
- <div class="flex flex-wrap gap-1.5">{stacks.slice(0, 4).map((stack) => <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"><Icon name={stack} category="stack" size={11} />{stack}</span>)}</div>
81
- </div>
78
+ <StackPlatformChips
79
+ stack={stacks[0]}
80
+ platform={platforms}
81
+ />
82
82
  )}
83
83
 
84
84
  <footer class="relative z-10 mt-1 flex items-center gap-2 border-t border-ink-100 pt-2 text-2xs dark:border-ink-900">
@@ -24,7 +24,7 @@
24
24
  * pattern is to pass `routeSlug` (or `href` directly).
25
25
  */
26
26
  import type { IndexRecord, Resource, ProjectRecord, IndexProjectRecord } from "@grove-dev/core";
27
- import Icon from "./Icon.astro";
27
+ import StackPlatformChips from "./StackPlatformChips.astro";
28
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";
@@ -148,13 +148,7 @@ const category = (item as { category?: string }).category;
148
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}`}
149
149
  >
150
150
  {/* Top row: logo + name + (optional) status + curated mark. */}
151
- <header class="relative z-10 flex items-start gap-3">
152
- <a
153
- href={detailHref}
154
- aria-label={`Open ${name} details`}
155
- class="absolute inset-0 z-0 rounded-lg"
156
- ></a>
157
-
151
+ <header class="flex items-start gap-3">
158
152
  {logoUrl ? (
159
153
  <img
160
154
  src={logoUrl}
@@ -178,11 +172,10 @@ const category = (item as { category?: string }).category;
178
172
  <h3
179
173
  class="m-0 truncate text-[0.9375rem] font-semibold tracking-tight text-ink-900 group-hover:underline dark:text-ink-100"
180
174
  >
181
- {/* Name → detail page (not GitHub repo). Stacks above the
182
- stretched link via z-index. */}
175
+ {/* Name → detail page (not GitHub repo). */}
183
176
  <a
184
177
  href={detailHref}
185
- class="relative z-10 text-inherit no-underline"
178
+ class="inline-flex min-h-6 min-w-6 items-center text-inherit no-underline"
186
179
  >
187
180
  {name}
188
181
  </a>
@@ -198,7 +191,7 @@ const category = (item as { category?: string }).category;
198
191
  data-event="external_repo_click"
199
192
  data-event-source="card_owner"
200
193
  data-event-item={slug}
201
- 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"
202
195
  >
203
196
  {/* `data-event` and `data-event-*` are forwarding hooks
204
197
  for downstream analytics. Sites wire their own
@@ -210,7 +203,7 @@ const category = (item as { category?: string }).category;
210
203
  "detail_owner" | "detail_view_github" | "detail_homepage"
211
204
  | "detail_distribution"), and `data-event-item` is
212
205
  the record slug. */}
213
- <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> : ""}
214
207
  </a>
215
208
  <span class="mx-1 text-ink-300 dark:text-ink-700">·</span>
216
209
  {category}
@@ -252,7 +245,7 @@ const category = (item as { category?: string }).category;
252
245
  {/* Description */}
253
246
  {item.description && (
254
247
  <p
255
- 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"
256
249
  >
257
250
  {item.description}
258
251
  </p>
@@ -261,7 +254,7 @@ const category = (item as { category?: string }).category;
261
254
  {/* Metadata row: stars · updated · license — the three signals
262
255
  that answer "is this alive and usable?" at a glance. */}
263
256
  {showMeta && (
264
- <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">
265
258
  {starsLabel !== null && (
266
259
  <span class="inline-flex items-center gap-1" title={`${stars ?? 0} stars on GitHub`}>
267
260
  <svg viewBox="0 0 16 16" width="11" height="11" aria-hidden="true" fill="currentColor" class="text-amber-500">
@@ -286,40 +279,23 @@ const category = (item as { category?: string }).category;
286
279
  </div>
287
280
  )}
288
281
 
289
- {/* Platform + stack chips, pushed to the bottom. */}
282
+ {/* Platform + stack chips, pushed to the bottom. Shared with
283
+ `IndexRow` / `CollectionRow` so the labelled-row pattern is
284
+ consistent across all card-shaped components. */}
290
285
  {showChips && (
291
- <div class="relative z-10 mt-auto flex flex-col gap-1.5">
292
- {visiblePlatforms.length > 0 && (
293
- <div class="flex flex-wrap items-center gap-1.5">
294
- {visiblePlatforms.map((p) => (
295
- <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">
296
- <Icon name={p} category="platform" size={11} />
297
- <span>{p}</span>
298
- </span>
299
- ))}
300
- {platformOverflow > 0 && (
301
- <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>
302
- )}
303
- </div>
304
- )}
305
- {visibleStacks.length > 0 && (
306
- <div class="flex flex-wrap items-center gap-1.5">
307
- {visibleStacks.map((s) => (
308
- <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">
309
- <Icon name={s} category="stack" size={11} />
310
- <span>{s}</span>
311
- </span>
312
- ))}
313
- {stackOverflow > 0 && (
314
- <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>
315
- )}
316
- </div>
286
+ <div class="mt-auto">
287
+ <StackPlatformChips
288
+ stack={visibleStacks[0]}
289
+ platform={visiblePlatforms}
290
+ />
291
+ {stackOverflow > 0 && (
292
+ <span class="text-2xs text-ink-400 dark:text-ink-600">+{stackOverflow} more stacks</span>
317
293
  )}
318
294
  </div>
319
295
  )}
320
296
 
321
297
  {/* Footer row: View repo + Details links. */}
322
- <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">
298
+ <footer class="mt-1 flex items-center justify-between gap-2 border-t border-ink-100 pt-2 text-2xs dark:border-ink-900">
323
299
  {externalHref && (
324
300
  <a
325
301
  href={externalHref}
@@ -328,7 +304,7 @@ const category = (item as { category?: string }).category;
328
304
  data-event="external_repo_click"
329
305
  data-event-source="card_view_repo"
330
306
  data-event-item={slug}
331
- 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"
307
+ 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"
332
308
  >
333
309
  {repoHref ? "View repo" : "Visit source"}
334
310
  <svg viewBox="0 0 16 16" width="10" height="10" aria-hidden="true" fill="currentColor">
@@ -339,7 +315,7 @@ const category = (item as { category?: string }).category;
339
315
  )}
340
316
  <a
341
317
  href={detailHref}
342
- 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"
318
+ 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"
343
319
  >
344
320
  Details →
345
321
  </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
  (() => {
@@ -0,0 +1,73 @@
1
+ ---
2
+ /**
3
+ * StackPlatformChips — labelled Stack + Platform chip rows.
4
+ *
5
+ * Renders up to two rows: one for stack (filled pills, primary) and
6
+ * one for platform (outlined pills, secondary). Each row is prefixed
7
+ * with a `w-14` (56px) label column so the pills always start at
8
+ * the same horizontal position, even when the stack/label is wider
9
+ * than the longest platform.
10
+ *
11
+ * Used by every card-shaped component in the framework so the
12
+ * primary/secondary distinction is unambiguous across surfaces
13
+ * (homepage grid, directory index, collection rows).
14
+ */
15
+ import Icon from "./Icon.astro";
16
+
17
+ interface Props {
18
+ /** Primary technology — filled pill, one entry per item. */
19
+ stack?: string;
20
+ /** Secondary target platforms — outlined pills, multiple per item. */
21
+ platform?: string[];
22
+ /** Cap the number of platform pills rendered. Default 4. */
23
+ maxPlatform?: number;
24
+ /** Hide the row(s) entirely. Default true. */
25
+ show?: boolean;
26
+ class?: string;
27
+ }
28
+
29
+ const {
30
+ stack,
31
+ platform = [],
32
+ maxPlatform = 4,
33
+ show = true,
34
+ class: className = "",
35
+ } = Astro.props;
36
+
37
+ if (!show || (!stack && platform.length === 0)) {
38
+ return Astro.redirect ? null : null;
39
+ }
40
+
41
+ const visiblePlatform = platform.slice(0, maxPlatform);
42
+ const platformOverflow = platform.length - visiblePlatform.length;
43
+ ---
44
+
45
+ <div class={`flex flex-col gap-1 ${className}`}>
46
+ {stack && (
47
+ <div class="flex flex-wrap items-center gap-x-1.5 gap-y-1">
48
+ <span class="w-14 shrink-0 text-2xs font-medium uppercase tracking-wider text-ink-500 dark:text-ink-400">
49
+ Stack
50
+ </span>
51
+ <span class="inline-flex min-h-5 items-center gap-1 rounded-full border border-transparent bg-secondary px-2 py-0.5 text-2xs font-medium leading-none text-secondary-foreground">
52
+ <Icon name={stack} category="stack" size={10} />
53
+ <span>{stack}</span>
54
+ </span>
55
+ </div>
56
+ )}
57
+ {platform.length > 0 && (
58
+ <div class="flex flex-wrap items-center gap-x-1.5 gap-y-1">
59
+ <span class="w-14 shrink-0 text-2xs font-medium uppercase tracking-wider text-ink-500 dark:text-ink-400">
60
+ Platform
61
+ </span>
62
+ {visiblePlatform.map((p) => (
63
+ <span class="inline-flex min-h-5 items-center gap-1 rounded-full border border-ink-200 bg-transparent px-2 py-0.5 text-2xs font-medium leading-none text-ink-500 dark:border-ink-800 dark:text-ink-400">
64
+ <Icon name={p} category="platform" size={10} />
65
+ <span>{p}</span>
66
+ </span>
67
+ ))}
68
+ {platformOverflow > 0 && (
69
+ <span class="text-2xs text-ink-400 dark:text-ink-600">+{platformOverflow}</span>
70
+ )}
71
+ </div>
72
+ )}
73
+ </div>
@@ -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,212 @@
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
+ it("populates repoHref from repoUrl and homepageHref from links.website", () => {
83
+ const records = [
84
+ {
85
+ slug: "crewai",
86
+ name: "CrewAI",
87
+ repoUrl: "https://github.com/crewAIInc/crewAI",
88
+ links: { github: "https://github.com/other/repo", website: "https://crewai.com" },
89
+ },
90
+ ];
91
+ const entries = recordsToCollectionEntries(records as never, {});
92
+ expect(entries[0].repoHref).toBe("https://github.com/crewAIInc/crewAI");
93
+ expect(entries[0].homepageHref).toBe("https://crewai.com");
94
+ });
95
+
96
+ it("falls back to links.github when repoUrl is missing", () => {
97
+ const records = [
98
+ { slug: "x", name: "X", links: { github: "https://github.com/foo/bar" } },
99
+ ];
100
+ const entries = recordsToCollectionEntries(records as never, {});
101
+ expect(entries[0].repoHref).toBe("https://github.com/foo/bar");
102
+ });
103
+ });
104
+
105
+ const collection: Collection = {
106
+ slug: "top-ai-agents",
107
+ kind: "curated",
108
+ title: "Top AI Agents",
109
+ description: "Best agent frameworks.",
110
+ query: { stacks: ["python"] },
111
+ ranking: { preset: "quality" },
112
+ seo: { index: true },
113
+ editorial: { selectionNote: "Ranked by activity and curation." },
114
+ };
115
+
116
+ const otherCollection: Collection = {
117
+ slug: "top-tools",
118
+ kind: "curated",
119
+ title: "Top Tools",
120
+ description: "All tools.",
121
+ query: { stacks: ["python"] },
122
+ ranking: { preset: "recency" },
123
+ seo: { index: true },
124
+ };
125
+
126
+ const emptyCollection: Collection = {
127
+ slug: "no-matches",
128
+ kind: "curated",
129
+ title: "No Matches",
130
+ description: "Won't match anything.",
131
+ query: { stacks: ["rust"] },
132
+ ranking: { preset: "recency" },
133
+ seo: { index: true },
134
+ };
135
+
136
+ const entries: CollectionEntry[] = [
137
+ {
138
+ slug: "crewai",
139
+ title: "CrewAI",
140
+ description: "Agent framework",
141
+ url: "/projects/crewai/",
142
+ stack: "python",
143
+ curationScore: 0.9,
144
+ activityScore: 0.8,
145
+ },
146
+ {
147
+ slug: "dify",
148
+ title: "Dify",
149
+ description: "LLM platform",
150
+ url: "/projects/dify/",
151
+ stack: "python",
152
+ curationScore: 0.7,
153
+ activityScore: 0.6,
154
+ },
155
+ {
156
+ slug: "flowise",
157
+ title: "Flowise",
158
+ description: "Node tool",
159
+ url: "/projects/flowise/",
160
+ stack: "node",
161
+ curationScore: 0.5,
162
+ activityScore: 0.4,
163
+ },
164
+ ];
165
+
166
+ describe("getCollectionPageModel", () => {
167
+ it("returns ranked entries filtered by query", () => {
168
+ const model = getCollectionPageModel(collection, entries, [collection, otherCollection]);
169
+ expect(model.total).toBe(2);
170
+ expect(model.isEmpty).toBe(false);
171
+ expect(model.entries.map((e) => e.slug)).toEqual(["crewai", "dify"]);
172
+ });
173
+
174
+ it("exposes editorial selection note", () => {
175
+ const model = getCollectionPageModel(collection, entries, [collection]);
176
+ expect(model.collection.selectionNote).toBe("Ranked by activity and curation.");
177
+ });
178
+
179
+ it("returns related collections (max 4)", () => {
180
+ const model = getCollectionPageModel(collection, entries, [collection, otherCollection]);
181
+ expect(model.related).toHaveLength(1);
182
+ expect(model.related[0].slug).toBe("top-tools");
183
+ });
184
+
185
+ it("reports empty when no entries match", () => {
186
+ const model = getCollectionPageModel(emptyCollection, entries, [collection, otherCollection]);
187
+ expect(model.isEmpty).toBe(true);
188
+ });
189
+ });
190
+
191
+ describe("getCollectionIndexModel", () => {
192
+ it("counts entries per collection", () => {
193
+ const model = getCollectionIndexModel([collection, otherCollection], entries);
194
+ expect(model.total).toBe(2);
195
+ expect(model.collections).toHaveLength(2);
196
+ const agentCol = model.collections.find((c) => c.slug === "top-ai-agents");
197
+ expect(agentCol?.count).toBe(2);
198
+ expect(agentCol?.url).toBe("/collections/top-ai-agents/");
199
+ });
200
+ });
201
+
202
+ describe("getCollectionTeaserModel", () => {
203
+ it("respects the limit", () => {
204
+ const model = getCollectionTeaserModel([collection, otherCollection], entries, 1);
205
+ expect(model.collections).toHaveLength(1);
206
+ });
207
+
208
+ it("uses the project's route slug for entry URLs", () => {
209
+ const model = getCollectionTeaserModel([collection], entries, 5);
210
+ expect(model.collections[0].count).toBe(2);
211
+ });
212
+ });