@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.
- 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 +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/IndexRow.astro +3 -3
- package/src/components/ItemCard.astro +13 -20
- package/src/components/RefinePanel.astro +2 -2
- 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/dist/lib/index.d.ts
CHANGED
package/dist/lib/index.d.ts.map
CHANGED
|
@@ -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
package/dist/lib/index.js.map
CHANGED
|
@@ -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
|
+
"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
|
-
"
|
|
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
|
-
<
|
|
30
|
+
<h2 class="m-0 text-sm font-semibold uppercase tracking-wider text-ink-500 dark:text-ink-400">
|
|
31
31
|
{column.title}
|
|
32
|
-
</
|
|
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
|
+
))}
|
|
@@ -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="
|
|
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).
|
|
182
|
-
stretched link via z-index. */}
|
|
175
|
+
{/* Name → detail page (not GitHub repo). */}
|
|
183
176
|
<a
|
|
184
177
|
href={detailHref}
|
|
185
|
-
class="
|
|
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="
|
|
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
|
|
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="
|
|
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="
|
|
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="
|
|
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
|
|
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
|
|
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="
|
|
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="
|
|
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="
|
|
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
|
-
<
|
|
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
|
(() => {
|
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