@grove-dev/astro 0.3.2 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/lib/index.d.ts +1 -0
- package/dist/lib/index.d.ts.map +1 -1
- package/dist/lib/index.js +1 -0
- package/dist/lib/index.js.map +1 -1
- package/dist/lib/load-collections.d.ts +12 -0
- package/dist/lib/load-collections.d.ts.map +1 -0
- package/dist/lib/load-collections.js +34 -0
- package/dist/lib/load-collections.js.map +1 -0
- package/package.json +5 -4
- package/src/components/CollectionIndex.astro +72 -0
- package/src/components/CollectionPage.astro +89 -0
- package/src/components/CollectionRow.astro +109 -0
- package/src/components/CollectionTeaser.astro +81 -0
- package/src/components/CurationGrid.astro +2 -2
- package/src/components/GroveDocumentHead.astro +28 -0
- package/src/components/Hero.astro +0 -1
- package/src/components/Icon.astro +4 -18
- package/src/components/IndexRow.astro +5 -5
- package/src/components/ItemCard.astro +15 -27
- package/src/components/RefinePanel.astro +2 -2
- package/src/components/SmartLensTabs.astro +4 -1
- package/src/layouts/BaseLayout.astro +11 -7
- package/src/layouts/Footer.astro +2 -2
- package/src/lib/index.ts +1 -0
- package/src/lib/load-collections.ts +33 -0
- package/src/server/collections.test.ts +190 -0
- package/src/server/collections.ts +197 -0
- package/src/server/index.ts +1 -0
- package/src/server/models.ts +5 -1
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",
|
|
@@ -51,13 +51,14 @@
|
|
|
51
51
|
"./server": "./src/server/index.ts"
|
|
52
52
|
},
|
|
53
53
|
"dependencies": {
|
|
54
|
-
"astro": "^
|
|
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
|
-
"astro": "^6.0.0"
|
|
61
|
+
"astro": "^6.0.0 || ^7.0.0"
|
|
61
62
|
},
|
|
62
63
|
"devDependencies": {
|
|
63
64
|
"@types/sanitize-html": "^2.16.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
|
+
))}
|
|
@@ -63,8 +63,8 @@ interface Props {
|
|
|
63
63
|
*/
|
|
64
64
|
aliases?: Record<string, string>;
|
|
65
65
|
/**
|
|
66
|
-
*
|
|
67
|
-
*
|
|
66
|
+
* Optional complete asset registry supplied by a consumer. When omitted,
|
|
67
|
+
* Grove attempts the configured asset path and falls back on image error.
|
|
68
68
|
*/
|
|
69
69
|
available?: string[];
|
|
70
70
|
/**
|
|
@@ -124,25 +124,11 @@ function resolveName(raw: string): string {
|
|
|
124
124
|
}
|
|
125
125
|
|
|
126
126
|
const resolved = resolveName(name);
|
|
127
|
-
const bundledIcons: Record<"stack" | "platform", Set<string>> = {
|
|
128
|
-
stack: new Set([
|
|
129
|
-
"android", "apple", "bun", "capacitor", "clojure", "clojurescript",
|
|
130
|
-
"dart", "deno", "django", "docker", "firebase", "flutter", "go",
|
|
131
|
-
"graphql", "ionic", "java", "javascript", "kotlin", "llm", "mongodb",
|
|
132
|
-
"node.js", "nodejs", "python", "rag", "react", "react-native", "rust",
|
|
133
|
-
"solidity", "sveltekit", "swift", "tensorflow", "typescript", "vue",
|
|
134
|
-
]),
|
|
135
|
-
platform: new Set([
|
|
136
|
-
"android", "apple", "chrome", "chromeos", "desktop", "embedded", "ios",
|
|
137
|
-
"linux", "macos", "ubuntu", "web", "windows",
|
|
138
|
-
]),
|
|
139
|
-
};
|
|
140
127
|
const availableIcons = available?.map(slugify);
|
|
141
128
|
const hasAsset =
|
|
142
129
|
category === "brand" ||
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
: bundledIcons[category].has(resolved));
|
|
130
|
+
availableIcons === undefined ||
|
|
131
|
+
availableIcons.includes(resolved);
|
|
146
132
|
const effectiveMode: IconMode = resolved === "apple" ? "auto" : mode;
|
|
147
133
|
const platformStackIcons = new Set(["docker"]);
|
|
148
134
|
const textOnlyPlatforms = new Set(["self-host", "self-hosted"]);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
import type { IndexRecord } from "@grove-dev/core";
|
|
3
3
|
import Icon from "./Icon.astro";
|
|
4
|
-
import { formatRelative, formatStars, getOwnerAndRepoFromRepoUrl, getOwnerAvatarUrl, statusDisplay } from "@grove-dev/astro";
|
|
4
|
+
import { formatRelative, formatStars, getOwnerAndRepoFromRepoUrl, getOwnerAvatarUrl, projectStackIds, statusDisplay } from "@grove-dev/astro";
|
|
5
5
|
|
|
6
6
|
interface Props {
|
|
7
7
|
record: IndexRecord;
|
|
@@ -23,7 +23,7 @@ const repoHref = project?.repoUrl ?? item.links.github;
|
|
|
23
23
|
const { owner, repo } = getOwnerAndRepoFromRepoUrl(repoHref ?? "");
|
|
24
24
|
const avatar = project ? project.logoUrl ?? getOwnerAvatarUrl(owner, 80) : undefined;
|
|
25
25
|
const initials = name.split(/\s+/).map((word) => word[0]).filter(Boolean).slice(0, 2).join("").toUpperCase();
|
|
26
|
-
const stacks = project
|
|
26
|
+
const stacks = projectStackIds(project);
|
|
27
27
|
const platforms = project?.platforms ?? [];
|
|
28
28
|
const stars = project?.github?.stars;
|
|
29
29
|
const updatedAt = project?.github?.pushedAt ?? (item.kind === "resource" ? item.publishedAt : null);
|
|
@@ -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) && (
|