@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.
- package/dist/lib/index.d.ts +1 -0
- package/dist/lib/index.d.ts.map +1 -1
- package/dist/lib/index.js +1 -0
- package/dist/lib/index.js.map +1 -1
- package/dist/lib/load-collections.d.ts +12 -0
- package/dist/lib/load-collections.d.ts.map +1 -0
- package/dist/lib/load-collections.js +34 -0
- package/dist/lib/load-collections.js.map +1 -0
- package/package.json +5 -4
- package/src/components/CollectionIndex.astro +72 -0
- package/src/components/CollectionPage.astro +89 -0
- package/src/components/CollectionRow.astro +109 -0
- package/src/components/CollectionTeaser.astro +81 -0
- package/src/components/CurationGrid.astro +2 -2
- package/src/components/GroveDocumentHead.astro +28 -0
- package/src/components/Hero.astro +0 -1
- package/src/components/Icon.astro +4 -18
- package/src/components/IndexRow.astro +5 -5
- package/src/components/ItemCard.astro +15 -27
- package/src/components/RefinePanel.astro +2 -2
- package/src/components/SmartLensTabs.astro +4 -1
- package/src/layouts/BaseLayout.astro +11 -7
- package/src/layouts/Footer.astro +2 -2
- package/src/lib/index.ts +1 -0
- package/src/lib/load-collections.ts +33 -0
- package/src/server/collections.test.ts +190 -0
- package/src/server/collections.ts +197 -0
- package/src/server/index.ts +1 -0
- package/src/server/models.ts +5 -1
|
@@ -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="
|
|
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).
|
|
187
|
-
stretched link via z-index. */}
|
|
175
|
+
{/* Name → detail page (not GitHub repo). */}
|
|
188
176
|
<a
|
|
189
177
|
href={detailHref}
|
|
190
|
-
class="
|
|
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="
|
|
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
|
|
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="
|
|
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="
|
|
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="
|
|
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
|
|
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
|
|
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="
|
|
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="
|
|
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="
|
|
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
|
-
<
|
|
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
|
-
</
|
|
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
|
-
*
|
|
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
|
-
{
|
|
137
|
+
{effectiveGaId && (
|
|
134
138
|
<>
|
|
135
139
|
<script
|
|
136
140
|
is:inline
|
|
137
141
|
async
|
|
138
|
-
src={`https://www.googletagmanager.com/gtag/js?id=${
|
|
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
|
-
{
|
|
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
|
package/src/layouts/Footer.astro
CHANGED
|
@@ -170,9 +170,9 @@ const resolvedLicenseLine = licenseLine ?? site.footer?.license;
|
|
|
170
170
|
|
|
171
171
|
{columns.map((column) => (
|
|
172
172
|
<div>
|
|
173
|
-
<
|
|
173
|
+
<h2 class="mb-3 text-2xs font-semibold uppercase tracking-wider text-ink-900 dark:text-ink-100">
|
|
174
174
|
{column.heading}
|
|
175
|
-
</
|
|
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
|
@@ -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
|
+
}
|
package/src/server/index.ts
CHANGED