@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.
@@ -0,0 +1,204 @@
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
+ repoUrl?: string;
29
+ links?: { github?: string; website?: string };
30
+ }
31
+
32
+ /**
33
+ * Map a list of full records (shape produced by `records.json`) to
34
+ * the lightweight `CollectionEntry` shape consumed by `runCollection`.
35
+ *
36
+ * - Skips records with `visibility === "hide"` — they should never
37
+ * appear in any collection.
38
+ * - Combines `category` and `tags` into a `categories` array (deduplicated).
39
+ * - Falls back to `stacks[0]` when `stack` is missing.
40
+ * - Prefers `pushedAt`; falls back to `lastCommitAt`.
41
+ * - Constructs `url` as `${routeSlug}/${slug}/` so the value points
42
+ * back to the consumer's detail page.
43
+ * - Populates `repoHref` (from `repoUrl` or `links.github`) and
44
+ * `homepageHref` (from `links.website`) so the row component
45
+ * can render "View repo" / "Visit site" footer links.
46
+ */
47
+ export function recordsToCollectionEntries(
48
+ records: RawRecord[],
49
+ site: RouteHint,
50
+ ): CollectionEntry[] {
51
+ const routeSlug = site.routeSlug ?? site.blueprintConfig?.routeSlug ?? "projects";
52
+ const out: CollectionEntry[] = [];
53
+ for (const r of records) {
54
+ if (r.visibility === "hide") continue;
55
+ if (!r.slug) continue;
56
+ const categories = [
57
+ ...(r.category ? [r.category] : []),
58
+ ...(r.tags ?? []),
59
+ ].filter((value, index, self) => self.indexOf(value) === index);
60
+ out.push({
61
+ slug: r.slug,
62
+ title: r.name ?? r.title ?? r.slug,
63
+ description: r.description ?? "",
64
+ url: `/${routeSlug}/${r.slug}/`,
65
+ repoHref: r.repoUrl ?? r.links?.github,
66
+ homepageHref: r.links?.website,
67
+ stack: r.stack ?? r.stacks?.[0],
68
+ platform: r.platforms,
69
+ license: r.license,
70
+ status: r.visibility,
71
+ stars: r.stars,
72
+ pushedAt: r.pushedAt ?? r.lastCommitAt,
73
+ curationScore: r.scores?.curation,
74
+ activityScore: r.scores?.activity,
75
+ categories: categories.length > 0 ? categories : undefined,
76
+ });
77
+ }
78
+ return out;
79
+ }
80
+
81
+ // ── Collection view-models ──────────────────────────────────────
82
+ //
83
+ // A Collection is a curated or generated grouping of records
84
+ // (see `@grove-dev/core`'s `runCollection` engine). These view-models
85
+ // produce the input the `CollectionPage`, `CollectionIndex`, and
86
+ // `CollectionTeaser` components consume.
87
+
88
+ export interface CollectionPageModel {
89
+ collection: {
90
+ slug: string;
91
+ title: string;
92
+ description: string;
93
+ kind: "curated" | "generated";
94
+ selectionNote?: string;
95
+ introduction?: string;
96
+ };
97
+ total: number;
98
+ isEmpty: boolean;
99
+ entries: CollectionEntry[];
100
+ related: Array<{ slug: string; title: string; url: string }>;
101
+ }
102
+
103
+ export interface CollectionIndexModel {
104
+ total: number;
105
+ collections: Array<{
106
+ slug: string;
107
+ title: string;
108
+ description: string;
109
+ kind: "curated" | "generated";
110
+ count: number;
111
+ url: string;
112
+ }>;
113
+ }
114
+
115
+ export interface CollectionTeaserModel {
116
+ total: number;
117
+ collections: CollectionIndexModel["collections"];
118
+ }
119
+
120
+ export function getCollectionPageModel(
121
+ collection: Collection,
122
+ entries: CollectionEntry[],
123
+ allCollections: Collection[],
124
+ ): CollectionPageModel {
125
+ const result = runCollection(collection, entries);
126
+ const related = findRelated(collection, allCollections, 4).map((c) => ({
127
+ slug: c.slug,
128
+ title: c.title,
129
+ url: `/collections/${c.slug}/`,
130
+ }));
131
+ return {
132
+ collection: {
133
+ slug: collection.slug,
134
+ title: collection.title,
135
+ description: collection.description,
136
+ kind: collection.kind,
137
+ selectionNote: collection.editorial?.selectionNote,
138
+ introduction: collection.editorial?.introduction,
139
+ },
140
+ total: result.entries.length,
141
+ isEmpty: result.isEmpty,
142
+ entries: result.entries,
143
+ related,
144
+ };
145
+ }
146
+
147
+ export function getCollectionIndexModel(
148
+ collections: Collection[],
149
+ entries: CollectionEntry[],
150
+ ): CollectionIndexModel {
151
+ return {
152
+ total: collections.length,
153
+ collections: collections.map((c) => {
154
+ const result = runCollection(c, entries);
155
+ return {
156
+ slug: c.slug,
157
+ title: c.title,
158
+ description: c.description,
159
+ kind: c.kind,
160
+ count: result.entries.length,
161
+ url: `/collections/${c.slug}/`,
162
+ };
163
+ }),
164
+ };
165
+ }
166
+
167
+ export function getCollectionTeaserModel(
168
+ collections: Collection[],
169
+ entries: CollectionEntry[],
170
+ limit = 3,
171
+ ): CollectionTeaserModel {
172
+ const full = getCollectionIndexModel(collections, entries);
173
+ return {
174
+ total: full.total,
175
+ collections: full.collections.slice(0, limit),
176
+ };
177
+ }
178
+
179
+ /**
180
+ * Load all `Collection` YAML files from `<cwd>/data/collections/*.yml`.
181
+ *
182
+ * Returns an empty array if the directory does not exist. Parse
183
+ * errors are NOT swallowed — they surface so real problems
184
+ * (malformed YAML, permission errors) are not hidden.
185
+ */
186
+ export async function loadCollections(cwd: string): Promise<Collection[]> {
187
+ const dir = resolve(cwd, "data/collections");
188
+ let files: string[];
189
+ try {
190
+ files = await readdir(dir);
191
+ } catch (err) {
192
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return [];
193
+ throw err;
194
+ }
195
+ const out: Collection[] = [];
196
+ for (const f of files.filter((f) => f.endsWith(".yml"))) {
197
+ const raw = parseYaml(await readFile(join(dir, f), "utf8"));
198
+ if (!raw || typeof raw !== "object") {
199
+ throw new Error(`Invalid collection YAML: ${f}`);
200
+ }
201
+ out.push(raw as Collection);
202
+ }
203
+ return out;
204
+ }
@@ -1,2 +1,3 @@
1
1
  export * from "./directory.js";
2
2
  export * from "./models.js";
3
+ export * from "./collections.js";