@grove-dev/astro 0.3.4 → 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.
@@ -23,4 +23,5 @@ export * from "./search.js";
23
23
  export * from "./scores.js";
24
24
  export * from "./taxonomy-counts.js";
25
25
  export * from "./display.js";
26
+ export * from "./load-collections.js";
26
27
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/lib/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH,cAAc,WAAW,CAAC;AAC1B,cAAc,aAAa,CAAC;AAC5B,cAAc,aAAa,CAAC;AAC5B,cAAc,aAAa,CAAC;AAC5B,cAAc,aAAa,CAAC;AAC5B,cAAc,sBAAsB,CAAC;AACrC,cAAc,cAAc,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/lib/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH,cAAc,WAAW,CAAC;AAC1B,cAAc,aAAa,CAAC;AAC5B,cAAc,aAAa,CAAC;AAC5B,cAAc,aAAa,CAAC;AAC5B,cAAc,aAAa,CAAC;AAC5B,cAAc,sBAAsB,CAAC;AACrC,cAAc,cAAc,CAAC;AAC7B,cAAc,uBAAuB,CAAC"}
package/dist/lib/index.js CHANGED
@@ -23,4 +23,5 @@ export * from "./search.js";
23
23
  export * from "./scores.js";
24
24
  export * from "./taxonomy-counts.js";
25
25
  export * from "./display.js";
26
+ export * from "./load-collections.js";
26
27
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/lib/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH,cAAc,WAAW,CAAC;AAC1B,cAAc,aAAa,CAAC;AAC5B,cAAc,aAAa,CAAC;AAC5B,cAAc,aAAa,CAAC;AAC5B,cAAc,aAAa,CAAC;AAC5B,cAAc,sBAAsB,CAAC;AACrC,cAAc,cAAc,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/lib/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH,cAAc,WAAW,CAAC;AAC1B,cAAc,aAAa,CAAC;AAC5B,cAAc,aAAa,CAAC;AAC5B,cAAc,aAAa,CAAC;AAC5B,cAAc,aAAa,CAAC;AAC5B,cAAc,sBAAsB,CAAC;AACrC,cAAc,cAAc,CAAC;AAC7B,cAAc,uBAAuB,CAAC"}
@@ -0,0 +1,12 @@
1
+ import type { Collection } from "@grove-dev/core";
2
+ /**
3
+ * Load all `Collection` YAML files from `<cwd>/data/collections/*.yml`.
4
+ *
5
+ * Returns an empty array if the directory does not exist, so callers can
6
+ * safely render an empty/loading state without having to guard each
7
+ * invocation site. Parse errors and other unexpected failures are NOT
8
+ * swallowed — they surface so real problems (malformed YAML, permission
9
+ * errors, etc.) are visible instead of silently producing empty output.
10
+ */
11
+ export declare function loadCollections(cwd: string): Promise<Collection[]>;
12
+ //# sourceMappingURL=load-collections.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"load-collections.d.ts","sourceRoot":"","sources":["../../src/lib/load-collections.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAElD;;;;;;;;GAQG;AACH,wBAAsB,eAAe,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,EAAE,CAAC,CAkBxE"}
@@ -0,0 +1,34 @@
1
+ import { readFile, readdir } from "node:fs/promises";
2
+ import { join, resolve } from "node:path";
3
+ import { parse as parseYaml } from "yaml";
4
+ /**
5
+ * Load all `Collection` YAML files from `<cwd>/data/collections/*.yml`.
6
+ *
7
+ * Returns an empty array if the directory does not exist, so callers can
8
+ * safely render an empty/loading state without having to guard each
9
+ * invocation site. Parse errors and other unexpected failures are NOT
10
+ * swallowed — they surface so real problems (malformed YAML, permission
11
+ * errors, etc.) are visible instead of silently producing empty output.
12
+ */
13
+ export async function loadCollections(cwd) {
14
+ const dir = resolve(cwd, "data/collections");
15
+ let files;
16
+ try {
17
+ files = await readdir(dir);
18
+ }
19
+ catch (err) {
20
+ if (err.code === "ENOENT")
21
+ return [];
22
+ throw err;
23
+ }
24
+ const out = [];
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);
31
+ }
32
+ return out;
33
+ }
34
+ //# sourceMappingURL=load-collections.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"load-collections.js","sourceRoot":"","sources":["../../src/lib/load-collections.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,OAAO,EAAE,MAAM,kBAAkB,CAAC;AACrD,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAC1C,OAAO,EAAE,KAAK,IAAI,SAAS,EAAE,MAAM,MAAM,CAAC;AAG1C;;;;;;;;GAQG;AACH,MAAM,CAAC,KAAK,UAAU,eAAe,CAAC,GAAW;IAC/C,MAAM,GAAG,GAAG,OAAO,CAAC,GAAG,EAAE,kBAAkB,CAAC,CAAC;IAC7C,IAAI,KAAe,CAAC;IACpB,IAAI,CAAC;QACH,KAAK,GAAG,MAAM,OAAO,CAAC,GAAG,CAAC,CAAC;IAC7B,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,IAAK,GAA6B,CAAC,IAAI,KAAK,QAAQ;YAAE,OAAO,EAAE,CAAC;QAChE,MAAM,GAAG,CAAC;IACZ,CAAC;IACD,MAAM,GAAG,GAAiB,EAAE,CAAC;IAC7B,KAAK,MAAM,CAAC,IAAI,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,EAAE,CAAC;QACxD,MAAM,GAAG,GAAG,SAAS,CAAC,MAAM,QAAQ,CAAC,IAAI,CAAC,GAAG,EAAE,CAAC,CAAC,EAAE,MAAM,CAAC,CAAC,CAAC;QAC5D,IAAI,CAAC,GAAG,IAAI,OAAO,GAAG,KAAK,QAAQ,EAAE,CAAC;YACpC,MAAM,IAAI,KAAK,CAAC,4BAA4B,CAAC,EAAE,CAAC,CAAC;QACnD,CAAC;QACD,GAAG,CAAC,IAAI,CAAC,GAAiB,CAAC,CAAC;IAC9B,CAAC;IACD,OAAO,GAAG,CAAC;AACb,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@grove-dev/astro",
3
- "version": "0.3.4",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "description": "Composable Astro UI, data adapters, and integration for Grove directories.",
6
6
  "license": "MIT",
@@ -54,7 +54,8 @@
54
54
  "astro": "^7.1.3",
55
55
  "marked": "^18.0.0",
56
56
  "sanitize-html": "^2.17.5",
57
- "@grove-dev/core": "0.3.4"
57
+ "yaml": "^2.9.0",
58
+ "@grove-dev/core": "0.4.0"
58
59
  },
59
60
  "peerDependencies": {
60
61
  "astro": "^6.0.0 || ^7.0.0"
@@ -0,0 +1,72 @@
1
+ ---
2
+ /**
3
+ * CollectionIndex — renders a grid of every collection defined in
4
+ * `data/collections/*.yml`. Each card links to the collection's
5
+ * detail page and shows the entry count.
6
+ *
7
+ * Consumes a `CollectionIndexModel` produced by
8
+ * `getCollectionIndexModel`. Generic: no blueprint-specific copy.
9
+ */
10
+ import Container from "../layouts/Container.astro";
11
+ import type { CollectionIndexModel } from "../server/collections.js";
12
+
13
+ interface Props {
14
+ model: CollectionIndexModel;
15
+ title?: string;
16
+ description?: string;
17
+ class?: string;
18
+ }
19
+
20
+ const {
21
+ model,
22
+ title = "Collections",
23
+ description = "Curated and generated directories of related items.",
24
+ class: className = "",
25
+ } = Astro.props;
26
+ ---
27
+
28
+ <section class={`py-12 sm:py-16 ${className}`}>
29
+ <Container>
30
+ <header class="mb-6">
31
+ <span class="text-2xs font-medium uppercase tracking-wider text-ink-500 dark:text-ink-400">
32
+ Browse
33
+ </span>
34
+ <h1 class="m-0 mt-1 text-[1.875rem] sm:text-[2.125rem] font-semibold tracking-tight text-ink-900 dark:text-ink-100">
35
+ {title}
36
+ </h1>
37
+ <p class="m-0 mt-2 max-w-[64ch] text-[0.9375rem] text-ink-500 dark:text-ink-400">
38
+ {description}
39
+ </p>
40
+ </header>
41
+
42
+ {model.collections.length === 0 ? (
43
+ <p class="rounded-md border border-dashed border-ink-200 bg-white p-6 text-sm text-ink-500 dark:border-ink-800 dark:bg-ink-950 dark:text-ink-400">
44
+ No collections yet.
45
+ </p>
46
+ ) : (
47
+ <ul class="m-0 grid list-none grid-cols-1 gap-3 p-0 sm:grid-cols-2 lg:grid-cols-3">
48
+ {model.collections.map((c) => (
49
+ <li class="m-0 p-0">
50
+ <a
51
+ href={c.url}
52
+ class="flex h-full flex-col gap-2 rounded-md border border-ink-200 bg-white p-5 transition-colors hover:border-ink-400 dark:border-ink-800 dark:bg-ink-950 dark:hover:border-ink-600"
53
+ >
54
+ <span class="text-2xs font-medium uppercase tracking-wider text-ink-500 dark:text-ink-400">
55
+ {c.kind === "curated" ? "Curated" : "Generated"}
56
+ </span>
57
+ <span class="text-[1.0625rem] font-semibold tracking-tight text-ink-900 dark:text-ink-100">
58
+ {c.title}
59
+ </span>
60
+ <span class="text-[0.875rem] text-ink-500 dark:text-ink-400">
61
+ {c.description}
62
+ </span>
63
+ <span class="mt-auto pt-2 text-2xs font-medium text-ink-500 dark:text-ink-400">
64
+ {c.count} {c.count === 1 ? "item" : "items"} →
65
+ </span>
66
+ </a>
67
+ </li>
68
+ ))}
69
+ </ul>
70
+ )}
71
+ </Container>
72
+ </section>
@@ -0,0 +1,89 @@
1
+ ---
2
+ /**
3
+ * CollectionPage — renders a single curated/generated collection.
4
+ *
5
+ * Consumes a `CollectionPageModel` produced by `getCollectionPageModel`.
6
+ * Renders the header (title, description, editorial notes), the
7
+ * matched entries via `CollectionRow`, and a related-collections
8
+ * section.
9
+ *
10
+ * Generic: no blueprint-specific copy. The `eyebrow` prop lets the
11
+ * consumer pick a label (e.g. "Curated" or "Generated").
12
+ */
13
+ import CollectionRow from "./CollectionRow.astro";
14
+ import Container from "../layouts/Container.astro";
15
+ import type { CollectionPageModel } from "../server/collections.js";
16
+
17
+ interface Props {
18
+ model: CollectionPageModel;
19
+ eyebrow?: string;
20
+ class?: string;
21
+ }
22
+
23
+ const {
24
+ model,
25
+ eyebrow = "Collection",
26
+ class: className = "",
27
+ } = Astro.props;
28
+ ---
29
+
30
+ <section class={`py-12 sm:py-16 ${className}`}>
31
+ <Container>
32
+ <header class="mb-6">
33
+ <span class="text-2xs font-medium uppercase tracking-wider text-ink-500 dark:text-ink-400">
34
+ {eyebrow}
35
+ </span>
36
+ <h1 class="m-0 mt-1 text-[1.875rem] sm:text-[2.125rem] font-semibold tracking-tight text-ink-900 dark:text-ink-100">
37
+ {model.collection.title}
38
+ </h1>
39
+ <p class="m-0 mt-2 max-w-[64ch] text-[0.9375rem] text-ink-500 dark:text-ink-400">
40
+ {model.collection.description}
41
+ </p>
42
+ {model.collection.introduction && (
43
+ <p class="m-0 mt-3 max-w-[64ch] text-[0.9375rem] text-ink-700 dark:text-ink-300">
44
+ {model.collection.introduction}
45
+ </p>
46
+ )}
47
+ {model.collection.selectionNote && (
48
+ <p class="m-0 mt-3 max-w-[64ch] text-[0.875rem] font-medium text-ink-700 dark:text-ink-300">
49
+ <span class="text-2xs font-medium uppercase tracking-wider text-ink-500 dark:text-ink-400">Selection criteria · </span>
50
+ {model.collection.selectionNote}
51
+ </p>
52
+ )}
53
+ </header>
54
+
55
+ {model.isEmpty ? (
56
+ <p class="rounded-md border border-dashed border-ink-200 bg-white p-6 text-sm text-ink-500 dark:border-ink-800 dark:bg-ink-950 dark:text-ink-400">
57
+ This collection is currently empty.
58
+ </p>
59
+ ) : (
60
+ <ul class="m-0 flex list-none flex-col gap-3 p-0">
61
+ {model.entries.map((entry, i) => (
62
+ <li class="m-0 p-0">
63
+ <CollectionRow entry={entry} eager={i < 2} />
64
+ </li>
65
+ ))}
66
+ </ul>
67
+ )}
68
+
69
+ {model.related.length > 0 && (
70
+ <section class="mt-12 border-t border-ink-200 pt-8 dark:border-ink-800">
71
+ <h2 class="m-0 text-[1.125rem] font-semibold tracking-tight text-ink-900 dark:text-ink-100">
72
+ Related collections
73
+ </h2>
74
+ <ul class="m-0 mt-3 flex list-none flex-col gap-2 p-0">
75
+ {model.related.map((r) => (
76
+ <li>
77
+ <a
78
+ href={r.url}
79
+ class="text-sm text-ink-700 transition-colors hover:text-ink-900 dark:text-ink-300 dark:hover:text-ink-100"
80
+ >
81
+ {r.title} →
82
+ </a>
83
+ </li>
84
+ ))}
85
+ </ul>
86
+ </section>
87
+ )}
88
+ </Container>
89
+ </section>
@@ -0,0 +1,109 @@
1
+ ---
2
+ /**
3
+ * CollectionRow — a single entry inside a curated/generated collection.
4
+ *
5
+ * Mirrors `IndexRow`'s visual rhythm (avatar, header, description, dl
6
+ * stats, footer links) but consumes the lighter `CollectionEntry`
7
+ * shape directly. The existing `IndexRow` is tightly coupled to
8
+ * `IndexRecord` (which carries `kind`, `repoUrl`, `github`, `bestFor`,
9
+ * etc.) — collections only have a subset of those fields, so a
10
+ * dedicated row keeps the contract clean.
11
+ *
12
+ * Generic: no blueprint-specific copy. Visual style mirrors IndexRow
13
+ * so a collection page sitting next to the directory index reads as
14
+ * the same site.
15
+ */
16
+ import type { CollectionEntry } from "@grove-dev/core";
17
+ import { formatStars, formatRelative } from "@grove-dev/astro";
18
+
19
+ interface Props {
20
+ entry: CollectionEntry;
21
+ eager?: boolean;
22
+ class?: string;
23
+ }
24
+
25
+ const { entry, eager = false, class: className = "" } = Astro.props;
26
+
27
+ const initials = entry.title
28
+ .split(/\s+/)
29
+ .map((word) => word[0])
30
+ .filter(Boolean)
31
+ .slice(0, 2)
32
+ .join("")
33
+ .toUpperCase();
34
+ const stack = entry.stack ?? "—";
35
+ const stars = entry.stars ?? null;
36
+ const pushedAt = entry.pushedAt ?? null;
37
+ const category = entry.categories?.[0];
38
+ ---
39
+
40
+ <article
41
+ class={`rounded-[calc(var(--radius)+0.25rem)] border border-ink-200 bg-white shadow-none transition-colors hover:bg-secondary dark:border-ink-800 dark:bg-ink-950 dark:hover:bg-ink-900 group relative flex flex-col gap-3 p-4 ${className}`}
42
+ >
43
+ <header class="flex items-start gap-3">
44
+ <a
45
+ href={entry.url}
46
+ class="relative z-10 flex shrink-0"
47
+ aria-label={`Open ${entry.title} details`}
48
+ >
49
+ <span class="flex h-11 w-11 items-center justify-center rounded-md border border-ink-200 bg-ink-50 text-sm font-semibold text-ink-700 dark:border-ink-800 dark:bg-ink-900/60 dark:text-ink-300">
50
+ {initials || "·"}
51
+ </span>
52
+ </a>
53
+ <div class="min-w-0 flex-1">
54
+ <div class="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
55
+ <h3 class="m-0 text-[1.0625rem] font-semibold tracking-tight text-ink-900 dark:text-ink-100">
56
+ <a href={entry.url} class="relative z-10 hover:underline">{entry.title}</a>
57
+ </h3>
58
+ {category && (
59
+ <span class="text-2xs font-medium uppercase tracking-wider text-ink-500 dark:text-ink-400">
60
+ {category}
61
+ </span>
62
+ )}
63
+ </div>
64
+ <div class="flex flex-wrap items-center gap-x-1.5 text-2xs text-ink-500 dark:text-ink-400">
65
+ <span class="font-mono">{stack}</span>
66
+ {entry.platform?.length ? (
67
+ <>
68
+ <span class="text-ink-300 dark:text-ink-700">·</span>
69
+ <span>{entry.platform.slice(0, 3).join(", ")}</span>
70
+ </>
71
+ ) : null}
72
+ {entry.license ? (
73
+ <>
74
+ <span class="text-ink-300 dark:text-ink-700">·</span>
75
+ <span>{entry.license}</span>
76
+ </>
77
+ ) : null}
78
+ </div>
79
+ </div>
80
+ </header>
81
+
82
+ <p class="m-0 line-clamp-2 text-[0.8125rem] leading-relaxed text-ink-600 dark:text-ink-300">
83
+ {entry.description}
84
+ </p>
85
+
86
+ <dl class="grid grid-cols-3 gap-x-3 text-2xs">
87
+ <div class="flex items-baseline gap-1.5">
88
+ <dt class="font-medium uppercase tracking-wider text-ink-500 dark:text-ink-400">Stars</dt>
89
+ <dd class="font-mono text-ink-700 dark:text-ink-300">{formatStars(stars) ?? "—"}</dd>
90
+ </div>
91
+ <div class="flex items-baseline gap-1.5">
92
+ <dt class="font-medium uppercase tracking-wider text-ink-500 dark:text-ink-400">Updated</dt>
93
+ <dd class="font-mono text-ink-700 dark:text-ink-300">{formatRelative(pushedAt) ?? "—"}</dd>
94
+ </div>
95
+ <div class="flex items-baseline gap-1.5">
96
+ <dt class="font-medium uppercase tracking-wider text-ink-500 dark:text-ink-400">License</dt>
97
+ <dd class="font-mono text-ink-700 dark:text-ink-300">{entry.license ?? "—"}</dd>
98
+ </div>
99
+ </dl>
100
+
101
+ <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">
102
+ <a
103
+ href={entry.url}
104
+ class="inline-flex h-8 cursor-pointer items-center justify-center gap-1.5 rounded-[var(--radius)] border border-transparent px-3 text-sm font-medium leading-none no-underline outline-none transition-colors focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/35 border-border bg-secondary text-foreground hover:bg-accent px-2 text-2xs"
105
+ >
106
+ View details →
107
+ </a>
108
+ </footer>
109
+ </article>
@@ -0,0 +1,81 @@
1
+ ---
2
+ /**
3
+ * CollectionTeaser — homepage-friendly subset of `CollectionIndex`.
4
+ * Renders a horizontal grid of 2-3 collection cards with a "View all"
5
+ * link in the header. Generic: no blueprint copy.
6
+ */
7
+ import Container from "../layouts/Container.astro";
8
+ import type { CollectionTeaserModel } from "../server/collections.js";
9
+
10
+ interface Props {
11
+ model: CollectionTeaserModel;
12
+ title?: string;
13
+ description?: string;
14
+ viewAllHref?: string;
15
+ viewAllLabel?: string;
16
+ class?: string;
17
+ }
18
+
19
+ const {
20
+ model,
21
+ title = "Featured collections",
22
+ description = "Hand-picked and auto-generated groupings of related items.",
23
+ viewAllHref = "/collections/",
24
+ viewAllLabel = "View all collections",
25
+ class: className = "",
26
+ } = Astro.props;
27
+ ---
28
+
29
+ <section class={`py-12 sm:py-16 ${className}`}>
30
+ <Container>
31
+ <div class="mb-6 flex flex-wrap items-end justify-between gap-3">
32
+ <div>
33
+ <span class="text-2xs font-medium uppercase tracking-wider text-ink-500 dark:text-ink-400">
34
+ Collections
35
+ </span>
36
+ <h2 class="m-0 mt-1 text-[1.5rem] sm:text-[1.625rem] font-semibold tracking-tight text-ink-900 dark:text-ink-100">
37
+ {title}
38
+ </h2>
39
+ <p class="m-0 mt-2 max-w-[60ch] text-[0.9375rem] text-ink-500 dark:text-ink-400">
40
+ {description}
41
+ </p>
42
+ </div>
43
+ {viewAllHref && (
44
+ <a
45
+ href={viewAllHref}
46
+ class="hidden shrink-0 pb-2 text-2xs font-medium text-ink-500 transition-colors hover:text-ink-900 sm:inline dark:text-ink-400 dark:hover:text-ink-100"
47
+ >
48
+ {viewAllLabel} →
49
+ </a>
50
+ )}
51
+ </div>
52
+
53
+ {model.collections.length === 0 ? (
54
+ <p class="m-0 text-sm text-ink-500 dark:text-ink-400">
55
+ No collections yet.
56
+ </p>
57
+ ) : (
58
+ <div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
59
+ {model.collections.map((c) => (
60
+ <a
61
+ href={c.url}
62
+ class="flex flex-col gap-2 rounded-md border border-ink-200 bg-white p-5 transition-colors hover:border-ink-400 dark:border-ink-800 dark:bg-ink-950 dark:hover:border-ink-600"
63
+ >
64
+ <span class="text-2xs font-medium uppercase tracking-wider text-ink-500 dark:text-ink-400">
65
+ {c.kind === "curated" ? "Curated" : "Generated"}
66
+ </span>
67
+ <span class="text-[1.0625rem] font-semibold tracking-tight text-ink-900 dark:text-ink-100">
68
+ {c.title}
69
+ </span>
70
+ <span class="text-[0.875rem] text-ink-500 dark:text-ink-400">
71
+ {c.description}
72
+ </span>
73
+ <span class="mt-auto pt-2 text-2xs font-medium text-ink-500 dark:text-ink-400">
74
+ {c.count} {c.count === 1 ? "item" : "items"} →
75
+ </span>
76
+ </a>
77
+ ))}
78
+ </div>
79
+ )}
80
+ </Container>
81
+ </section>
@@ -27,9 +27,9 @@ const columns = [
27
27
  >
28
28
  {columns.map((column) => (
29
29
  <div class="rounded-[calc(var(--radius)+0.25rem)] border-0 bg-muted shadow-none transition-colors p-5">
30
- <h3 class="m-0 text-sm font-semibold uppercase tracking-wider text-ink-500 dark:text-ink-400">
30
+ <h2 class="m-0 text-sm font-semibold uppercase tracking-wider text-ink-500 dark:text-ink-400">
31
31
  {column.title}
32
- </h3>
32
+ </h2>
33
33
  <ul class="mt-3 mb-0 flex list-disc flex-col gap-1.5 pl-5">
34
34
  {column.items.map((item) => (
35
35
  <li class="text-sm text-ink-700 dark:text-ink-200">{item}</li>
@@ -0,0 +1,28 @@
1
+ ---
2
+ import type { PageDocument } from "@grove-dev/core";
3
+ interface Props {
4
+ document: PageDocument;
5
+ }
6
+ const { document: doc } = Astro.props;
7
+ ---
8
+ <title>{doc.metadata.title}</title>
9
+ <meta name="description" content={doc.metadata.description} />
10
+ <meta name="robots" content={doc.metadata.robots} />
11
+ <link rel="canonical" href={doc.identity.canonical.toString()} />
12
+ {doc.identity.hreflang?.map((lang) => (
13
+ <link rel="alternate" hreflang={lang} href={doc.identity.canonical.toString()} />
14
+ ))}
15
+ <meta property="og:title" content={doc.metadata.openGraph.title} />
16
+ <meta property="og:description" content={doc.metadata.openGraph.description} />
17
+ <meta property="og:url" content={doc.metadata.openGraph.url} />
18
+ <meta property="og:image" content={doc.metadata.openGraph.image} />
19
+ <meta property="og:type" content={doc.metadata.openGraph.type} />
20
+ {doc.metadata.openGraph.siteName && (
21
+ <meta property="og:site_name" content={doc.metadata.openGraph.siteName} />
22
+ )}
23
+ <meta name="twitter:card" content={doc.metadata.twitter.card} />
24
+ {doc.metadata.twitter.site && <meta name="twitter:site" content={doc.metadata.twitter.site} />}
25
+ {doc.metadata.twitter.creator && <meta name="twitter:creator" content={doc.metadata.twitter.creator} />}
26
+ {doc.structuredData.map((node) => (
27
+ <script type="application/ld+json" set:html={JSON.stringify(node)} />
28
+ ))}
@@ -230,7 +230,6 @@ const trustStats: { value: string; label: string }[] = [
230
230
  {quickFilters.length > 0 && (
231
231
  <p
232
232
  class="mt-2 text-2xs text-ink-500 dark:text-ink-400 m-0"
233
- aria-hidden="true"
234
233
  >
235
234
  Try{" "}
236
235
  {quickFilters.map((f, i) => (
@@ -69,9 +69,9 @@ 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) && (
@@ -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">
@@ -288,7 +281,7 @@ const category = (item as { category?: string }).category;
288
281
 
289
282
  {/* Platform + stack chips, pushed to the bottom. */}
290
283
  {showChips && (
291
- <div class="relative z-10 mt-auto flex flex-col gap-1.5">
284
+ <div class="mt-auto flex flex-col gap-1.5">
292
285
  {visiblePlatforms.length > 0 && (
293
286
  <div class="flex flex-wrap items-center gap-1.5">
294
287
  {visiblePlatforms.map((p) => (
@@ -298,7 +291,7 @@ const category = (item as { category?: string }).category;
298
291
  </span>
299
292
  ))}
300
293
  {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>
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>
302
295
  )}
303
296
  </div>
304
297
  )}
@@ -311,7 +304,7 @@ const category = (item as { category?: string }).category;
311
304
  </span>
312
305
  ))}
313
306
  {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>
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>
315
308
  )}
316
309
  </div>
317
310
  )}
@@ -319,7 +312,7 @@ const category = (item as { category?: string }).category;
319
312
  )}
320
313
 
321
314
  {/* 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">
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">
323
316
  {externalHref && (
324
317
  <a
325
318
  href={externalHref}
@@ -328,7 +321,7 @@ const category = (item as { category?: string }).category;
328
321
  data-event="external_repo_click"
329
322
  data-event-source="card_view_repo"
330
323
  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"
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"
332
325
  >
333
326
  {repoHref ? "View repo" : "Visit source"}
334
327
  <svg viewBox="0 0 16 16" width="10" height="10" aria-hidden="true" fill="currentColor">
@@ -339,7 +332,7 @@ const category = (item as { category?: string }).category;
339
332
  )}
340
333
  <a
341
334
  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"
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"
343
336
  >
344
337
  Details →
345
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
  (() => {
@@ -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";