@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.
- 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 +3 -2
- package/src/components/CollectionIndex.astro +107 -0
- package/src/components/CollectionPage.astro +105 -0
- package/src/components/CollectionRow.astro +246 -0
- package/src/components/CollectionTeaser.astro +43 -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/IndexRow.astro +8 -8
- package/src/components/ItemCard.astro +21 -45
- package/src/components/RefinePanel.astro +2 -2
- package/src/components/StackPlatformChips.astro +73 -0
- 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 +212 -0
- package/src/server/collections.ts +204 -0
- package/src/server/index.ts +1 -0
|
@@ -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
|
+
}
|
package/src/server/index.ts
CHANGED