@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
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.1",
|
|
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.1"
|
|
58
59
|
},
|
|
59
60
|
"peerDependencies": {
|
|
60
61
|
"astro": "^6.0.0 || ^7.0.0"
|
|
@@ -0,0 +1,107 @@
|
|
|
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`. Pass `limit` (e.g. 3) for a teaser-style
|
|
9
|
+
* subset; the framework also re-exports this component as
|
|
10
|
+
* `CollectionTeaser` for the homepage use case.
|
|
11
|
+
*
|
|
12
|
+
* API parity with `RecordSection`:
|
|
13
|
+
* - `id`, `eyebrow`, `title`, `description`
|
|
14
|
+
* - `viewAllHref`, `viewAllLabel` — header CTA
|
|
15
|
+
* - `emptyLabel` — copy when there are no collections
|
|
16
|
+
* - `<slot name="header-actions">` — custom buttons in header
|
|
17
|
+
* - `class` — extra class on the outer <section>
|
|
18
|
+
*/
|
|
19
|
+
import Container from "../layouts/Container.astro";
|
|
20
|
+
import SectionHeader from "../layouts/SectionHeader.astro";
|
|
21
|
+
import type { CollectionIndexModel } from "../server/collections.js";
|
|
22
|
+
|
|
23
|
+
interface Props {
|
|
24
|
+
model: CollectionIndexModel;
|
|
25
|
+
id?: string;
|
|
26
|
+
eyebrow?: string;
|
|
27
|
+
title?: string;
|
|
28
|
+
description?: string;
|
|
29
|
+
/** Header "View all" link. Hides when not provided. */
|
|
30
|
+
viewAllHref?: string;
|
|
31
|
+
viewAllLabel?: string;
|
|
32
|
+
emptyLabel?: string;
|
|
33
|
+
/** Cap the number of cards rendered. Omit for "all". */
|
|
34
|
+
limit?: number;
|
|
35
|
+
class?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const {
|
|
39
|
+
model,
|
|
40
|
+
id,
|
|
41
|
+
eyebrow = "Collections",
|
|
42
|
+
title = "Collections",
|
|
43
|
+
description = "Curated and generated directories of related items.",
|
|
44
|
+
viewAllHref,
|
|
45
|
+
viewAllLabel = "View all",
|
|
46
|
+
emptyLabel = "No collections yet.",
|
|
47
|
+
limit,
|
|
48
|
+
class: className = "",
|
|
49
|
+
} = Astro.props;
|
|
50
|
+
|
|
51
|
+
const cards = typeof limit === "number" ? model.collections.slice(0, limit) : model.collections;
|
|
52
|
+
const headerActions = Astro.slots.has("header-actions");
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
<section id={id} class={`py-12 sm:py-16 ${className}`}>
|
|
56
|
+
<Container>
|
|
57
|
+
<div class="mb-6 flex flex-wrap items-end justify-between gap-3">
|
|
58
|
+
<SectionHeader eyebrow={eyebrow} title={title} description={description} />
|
|
59
|
+
{(viewAllHref || headerActions) && (
|
|
60
|
+
<div class="flex shrink-0 items-center gap-2 pb-2">
|
|
61
|
+
{viewAllHref && (
|
|
62
|
+
<a
|
|
63
|
+
href={viewAllHref}
|
|
64
|
+
class="text-2xs font-medium text-ink-500 transition-colors hover:text-ink-900 sm:inline dark:text-ink-400 dark:hover:text-ink-100"
|
|
65
|
+
>
|
|
66
|
+
{viewAllLabel} →
|
|
67
|
+
</a>
|
|
68
|
+
)}
|
|
69
|
+
<slot name="header-actions" />
|
|
70
|
+
</div>
|
|
71
|
+
)}
|
|
72
|
+
</div>
|
|
73
|
+
|
|
74
|
+
{cards.length === 0 ? (
|
|
75
|
+
<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">
|
|
76
|
+
{emptyLabel}
|
|
77
|
+
</p>
|
|
78
|
+
) : (
|
|
79
|
+
<ul class="m-0 grid list-none grid-cols-1 gap-3 p-0 sm:grid-cols-2 lg:grid-cols-3">
|
|
80
|
+
{cards.map((c) => (
|
|
81
|
+
<li class="m-0 p-0">
|
|
82
|
+
<a
|
|
83
|
+
href={c.url}
|
|
84
|
+
data-event="collection_card_click"
|
|
85
|
+
data-event-source="collection_index"
|
|
86
|
+
data-event-item={c.slug}
|
|
87
|
+
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"
|
|
88
|
+
>
|
|
89
|
+
<span class="text-2xs font-medium uppercase tracking-wider text-ink-500 dark:text-ink-400">
|
|
90
|
+
{c.kind === "curated" ? "Curated" : "Generated"}
|
|
91
|
+
</span>
|
|
92
|
+
<span class="text-[1.0625rem] font-semibold tracking-tight text-ink-900 dark:text-ink-100">
|
|
93
|
+
{c.title}
|
|
94
|
+
</span>
|
|
95
|
+
<span class="text-[0.875rem] text-ink-500 dark:text-ink-400">
|
|
96
|
+
{c.description}
|
|
97
|
+
</span>
|
|
98
|
+
<span class="mt-auto pt-2 text-2xs font-medium text-ink-500 dark:text-ink-400">
|
|
99
|
+
{c.count} {c.count === 1 ? "item" : "items"} →
|
|
100
|
+
</span>
|
|
101
|
+
</a>
|
|
102
|
+
</li>
|
|
103
|
+
))}
|
|
104
|
+
</ul>
|
|
105
|
+
)}
|
|
106
|
+
</Container>
|
|
107
|
+
</section>
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* CollectionPage — renders a single curated/generated collection.
|
|
4
|
+
*
|
|
5
|
+
* Consumes a `CollectionPageModel` produced by `getCollectionPageModel`.
|
|
6
|
+
* Renders the header via `SectionHeader` (framework consistency),
|
|
7
|
+
* the matched entries via `CollectionRow`, and a related-collections
|
|
8
|
+
* section.
|
|
9
|
+
*
|
|
10
|
+
* Generic: no blueprint-specific copy. Consumers can override
|
|
11
|
+
* `eyebrow`, `title`, `description`; the `<slot name="intro">` is
|
|
12
|
+
* the natural place for editorial intros (markdown rendered with
|
|
13
|
+
* the consumer's content pipeline). `emptyLabel` matches
|
|
14
|
+
* `RecordSection`'s API for empty-state copy.
|
|
15
|
+
*/
|
|
16
|
+
import CollectionRow from "./CollectionRow.astro";
|
|
17
|
+
import Container from "../layouts/Container.astro";
|
|
18
|
+
import SectionHeader from "../layouts/SectionHeader.astro";
|
|
19
|
+
import type { CollectionPageModel } from "../server/collections.js";
|
|
20
|
+
|
|
21
|
+
interface Props {
|
|
22
|
+
model: CollectionPageModel;
|
|
23
|
+
/** Override the eyebrow text (default: derived from collection kind). */
|
|
24
|
+
eyebrow?: string;
|
|
25
|
+
/** Override the title (default: collection.title). */
|
|
26
|
+
title?: string;
|
|
27
|
+
/** Override the description (default: collection.description). */
|
|
28
|
+
description?: string;
|
|
29
|
+
/** Default copy when the collection has no matching entries. */
|
|
30
|
+
emptyLabel?: string;
|
|
31
|
+
/** Section id (deep link + stable key). */
|
|
32
|
+
id?: string;
|
|
33
|
+
class?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const {
|
|
37
|
+
model,
|
|
38
|
+
eyebrow,
|
|
39
|
+
title,
|
|
40
|
+
description,
|
|
41
|
+
emptyLabel = "This collection is currently empty.",
|
|
42
|
+
id,
|
|
43
|
+
class: className = "",
|
|
44
|
+
} = Astro.props;
|
|
45
|
+
|
|
46
|
+
const finalTitle = title ?? model.collection.title;
|
|
47
|
+
const finalDescription = description ?? model.collection.description;
|
|
48
|
+
const finalEyebrow = eyebrow ?? (model.collection.kind === "curated" ? "Curated collection" : "Generated collection");
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
<section id={id} class={`py-12 sm:py-16 ${className}`}>
|
|
52
|
+
<Container>
|
|
53
|
+
<SectionHeader eyebrow={finalEyebrow} title={finalTitle} description={finalDescription} />
|
|
54
|
+
|
|
55
|
+
{model.collection.selectionNote && (
|
|
56
|
+
<p class="m-0 mb-6 max-w-[64ch] text-[0.875rem] font-medium text-ink-700 dark:text-ink-300">
|
|
57
|
+
<span class="text-2xs font-medium uppercase tracking-wider text-ink-500 dark:text-ink-400">Selection criteria · </span>
|
|
58
|
+
{model.collection.selectionNote}
|
|
59
|
+
</p>
|
|
60
|
+
)}
|
|
61
|
+
|
|
62
|
+
<slot name="intro" />
|
|
63
|
+
|
|
64
|
+
<p class="m-0 mb-4 text-2xs font-medium uppercase tracking-wider text-ink-500 dark:text-ink-400">
|
|
65
|
+
{model.total} {model.total === 1 ? "item" : "items"}
|
|
66
|
+
</p>
|
|
67
|
+
|
|
68
|
+
{model.isEmpty ? (
|
|
69
|
+
<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">
|
|
70
|
+
{emptyLabel}
|
|
71
|
+
</p>
|
|
72
|
+
) : (
|
|
73
|
+
<ul class="m-0 flex list-none flex-col gap-3 p-0">
|
|
74
|
+
{model.entries.map((entry, i) => (
|
|
75
|
+
<li class="m-0 p-0">
|
|
76
|
+
<CollectionRow entry={entry} eager={i < 2} />
|
|
77
|
+
</li>
|
|
78
|
+
))}
|
|
79
|
+
</ul>
|
|
80
|
+
)}
|
|
81
|
+
|
|
82
|
+
{model.related.length > 0 && (
|
|
83
|
+
<section class="mt-12 border-t border-ink-200 pt-8 dark:border-ink-800">
|
|
84
|
+
<SectionHeader
|
|
85
|
+
eyebrow="Related"
|
|
86
|
+
title="Related collections"
|
|
87
|
+
description="Other curated groupings of related items."
|
|
88
|
+
level={3}
|
|
89
|
+
/>
|
|
90
|
+
<ul class="m-0 grid list-none grid-cols-1 gap-2 p-0 sm:grid-cols-2 lg:grid-cols-4">
|
|
91
|
+
{model.related.map((r) => (
|
|
92
|
+
<li class="m-0 p-0">
|
|
93
|
+
<a
|
|
94
|
+
href={r.url}
|
|
95
|
+
class="group flex h-full flex-col gap-1 rounded-md border border-ink-200 bg-white px-3 py-2.5 no-underline transition-colors hover:border-ink-400 dark:border-ink-800 dark:bg-ink-950 dark:hover:border-ink-600"
|
|
96
|
+
>
|
|
97
|
+
<span class="text-sm font-medium text-ink-900 group-hover:underline dark:text-ink-100">{r.title}</span>
|
|
98
|
+
</a>
|
|
99
|
+
</li>
|
|
100
|
+
))}
|
|
101
|
+
</ul>
|
|
102
|
+
</section>
|
|
103
|
+
)}
|
|
104
|
+
</Container>
|
|
105
|
+
</section>
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* CollectionRow — card-shaped row for a single entry inside a
|
|
4
|
+
* curated/generated collection.
|
|
5
|
+
*
|
|
6
|
+
* Mirrors `ItemCard`'s visual treatment so a collection page sitting
|
|
7
|
+
* next to the directory index reads as the same site. The component
|
|
8
|
+
* consumes the lightweight `CollectionEntry` shape directly — the
|
|
9
|
+
* existing `IndexRow` is too tightly coupled to `IndexRecord`
|
|
10
|
+
* (which carries `kind`, `repoUrl`, `github`, `bestFor` etc.) to
|
|
11
|
+
* accept a `CollectionEntry` cleanly.
|
|
12
|
+
*
|
|
13
|
+
* API parity with `ItemCard`:
|
|
14
|
+
* - `entry` — the collection entry (slug, title, description, …)
|
|
15
|
+
* - `href` — full detail URL; wins over `routeSlug`
|
|
16
|
+
* - `routeSlug` — blueprint slug (e.g. "projects"); details derived
|
|
17
|
+
* as `/${routeSlug}/${slug}` when `href` is not provided
|
|
18
|
+
* - `showChips` — render platform + stack chip row (default true)
|
|
19
|
+
* - `showMeta` — render stars/updated/license metadata row (default true)
|
|
20
|
+
* - `showLabel` — render the curated/category label (default true)
|
|
21
|
+
* - `class` — extra class on the outer <article>
|
|
22
|
+
* - `eager` — bypass lazy loading for above-the-fold avatars (first 1-2)
|
|
23
|
+
*
|
|
24
|
+
* Footer links:
|
|
25
|
+
* - "View repo" — when `entry.repoHref` is set
|
|
26
|
+
* - "Visit site" — when `entry.homepageHref` is set
|
|
27
|
+
* - "Details →" — always rendered (links to the consumer's detail page)
|
|
28
|
+
*
|
|
29
|
+
* Analytics: emits `data-event="collection_item_click"` and
|
|
30
|
+
* `data-event-source="collection_row"` so sites wire one handler
|
|
31
|
+
* in BaseLayout.
|
|
32
|
+
*/
|
|
33
|
+
import type { CollectionEntry } from "@grove-dev/core";
|
|
34
|
+
import { formatRelative, formatStars, statusDisplay, getOwnerAndRepoFromRepoUrl, getOwnerAvatarUrl } from "@grove-dev/astro";
|
|
35
|
+
import StackPlatformChips from "./StackPlatformChips.astro";
|
|
36
|
+
|
|
37
|
+
interface Props {
|
|
38
|
+
entry: CollectionEntry;
|
|
39
|
+
/** Full detail-page URL. Wins over `routeSlug`. */
|
|
40
|
+
href?: string;
|
|
41
|
+
/** Blueprint route slug; detail URL = `/${routeSlug}/${slug}`. */
|
|
42
|
+
routeSlug?: string;
|
|
43
|
+
showChips?: boolean;
|
|
44
|
+
showMeta?: boolean;
|
|
45
|
+
showLabel?: boolean;
|
|
46
|
+
eager?: boolean;
|
|
47
|
+
class?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const {
|
|
51
|
+
entry,
|
|
52
|
+
href: hrefProp,
|
|
53
|
+
routeSlug,
|
|
54
|
+
showChips = true,
|
|
55
|
+
showMeta = true,
|
|
56
|
+
showLabel = true,
|
|
57
|
+
eager = false,
|
|
58
|
+
class: className = "",
|
|
59
|
+
} = Astro.props;
|
|
60
|
+
|
|
61
|
+
const slug = entry.slug;
|
|
62
|
+
const base = (routeSlug ?? "").replace(/^\/+|\/+$/g, "");
|
|
63
|
+
const detailHref =
|
|
64
|
+
typeof hrefProp === "string" && hrefProp.length > 0
|
|
65
|
+
? hrefProp
|
|
66
|
+
: entry.url || (base ? `/${base}/${slug}` : `/${slug}`);
|
|
67
|
+
|
|
68
|
+
const title = entry.title;
|
|
69
|
+
const initials = title
|
|
70
|
+
.split(/\s+/)
|
|
71
|
+
.map((w) => w[0])
|
|
72
|
+
.filter(Boolean)
|
|
73
|
+
.slice(0, 2)
|
|
74
|
+
.join("")
|
|
75
|
+
.toUpperCase();
|
|
76
|
+
|
|
77
|
+
// Derive a logo URL from the entry's `repoHref` when present;
|
|
78
|
+
// otherwise fall back to the detail URL. Detail URL is the
|
|
79
|
+
// consumer's page, not necessarily a GitHub owner — so the
|
|
80
|
+
// initials placeholder is the safe default when nothing matches.
|
|
81
|
+
const repoHref = entry.repoHref;
|
|
82
|
+
const { owner } = getOwnerAndRepoFromRepoUrl(repoHref ?? "");
|
|
83
|
+
const avatar = owner ? getOwnerAvatarUrl(owner, 80) : undefined;
|
|
84
|
+
|
|
85
|
+
const stack = entry.stack ? [entry.stack] : [];
|
|
86
|
+
const platform = entry.platform?.slice(0, 4) ?? [];
|
|
87
|
+
const platformOverflow = (entry.platform?.length ?? 0) - platform.length;
|
|
88
|
+
|
|
89
|
+
const stars = entry.stars;
|
|
90
|
+
const starsLabel = formatStars(stars);
|
|
91
|
+
const updatedAt = entry.pushedAt;
|
|
92
|
+
const updatedLabel = formatRelative(updatedAt);
|
|
93
|
+
const license = entry.license ?? null;
|
|
94
|
+
const status = entry.status;
|
|
95
|
+
const statusText = status ? statusDisplay(status as Parameters<typeof statusDisplay>[0]) : "";
|
|
96
|
+
const category = entry.categories?.[0];
|
|
97
|
+
const curated = Boolean(entry.curationScore || (entry.activityScore && entry.activityScore > 0.5));
|
|
98
|
+
|
|
99
|
+
const homepageHref = entry.homepageHref;
|
|
100
|
+
const showViewRepo = typeof repoHref === "string" && repoHref.length > 0;
|
|
101
|
+
const showVisitSite = typeof homepageHref === "string" && homepageHref.length > 0;
|
|
102
|
+
---
|
|
103
|
+
|
|
104
|
+
<article
|
|
105
|
+
data-event="collection_item_click"
|
|
106
|
+
data-event-source="collection_row"
|
|
107
|
+
data-event-item={slug}
|
|
108
|
+
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}`}
|
|
109
|
+
>
|
|
110
|
+
<header class="flex items-start gap-3">
|
|
111
|
+
{avatar ? (
|
|
112
|
+
<img
|
|
113
|
+
src={avatar}
|
|
114
|
+
alt={`${title} avatar`}
|
|
115
|
+
width="40"
|
|
116
|
+
height="40"
|
|
117
|
+
loading={eager ? "eager" : "lazy"}
|
|
118
|
+
fetchpriority={eager ? "high" : undefined}
|
|
119
|
+
decoding="async"
|
|
120
|
+
class="h-10 w-10 shrink-0 rounded-md border border-ink-200 bg-white object-cover dark:border-ink-800 dark:bg-ink-900"
|
|
121
|
+
/>
|
|
122
|
+
) : (
|
|
123
|
+
<span
|
|
124
|
+
aria-hidden="true"
|
|
125
|
+
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-md border border-ink-200 bg-ink-50 text-2xs font-semibold text-ink-700 dark:border-ink-800 dark:bg-ink-900/60 dark:text-ink-300"
|
|
126
|
+
>
|
|
127
|
+
{initials}
|
|
128
|
+
</span>
|
|
129
|
+
)}
|
|
130
|
+
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
|
|
131
|
+
<h3 class="m-0 truncate text-[0.9375rem] font-semibold tracking-tight text-ink-900 group-hover:underline dark:text-ink-100">
|
|
132
|
+
<a href={detailHref} class="inline-flex min-h-6 min-w-6 items-center text-inherit no-underline">
|
|
133
|
+
{title}
|
|
134
|
+
</a>
|
|
135
|
+
</h3>
|
|
136
|
+
{showLabel && (
|
|
137
|
+
<span class="truncate text-2xs text-ink-500 dark:text-ink-400">
|
|
138
|
+
{category}
|
|
139
|
+
{status && (
|
|
140
|
+
<>
|
|
141
|
+
<span class="mx-1 text-ink-300 dark:text-ink-700">·</span>
|
|
142
|
+
<span class="text-ink-500 dark:text-ink-400">{statusText}</span>
|
|
143
|
+
</>
|
|
144
|
+
)}
|
|
145
|
+
</span>
|
|
146
|
+
)}
|
|
147
|
+
</div>
|
|
148
|
+
{curated && showLabel && (
|
|
149
|
+
<span
|
|
150
|
+
class="inline-flex shrink-0 items-center gap-1 text-[10px] font-medium uppercase tracking-wider text-emerald-700 dark:text-emerald-400"
|
|
151
|
+
title="Has curation or activity score"
|
|
152
|
+
>
|
|
153
|
+
<svg viewBox="0 0 16 16" width="10" height="10" aria-hidden="true" fill="currentColor">
|
|
154
|
+
<path d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0Zm3.41 5.09a.75.75 0 0 0-1.06 0L6.75 8.69 5.65 7.59a.75.75 0 1 0-1.06 1.06l1.65 1.65a.75.75 0 0 0 1.06 0l4.11-4.11a.75.75 0 0 0 0-1.06Z" />
|
|
155
|
+
</svg>
|
|
156
|
+
curated
|
|
157
|
+
</span>
|
|
158
|
+
)}
|
|
159
|
+
</header>
|
|
160
|
+
|
|
161
|
+
{entry.description && (
|
|
162
|
+
<p class="m-0 line-clamp-2 text-[0.8125rem] leading-relaxed text-ink-500 dark:text-ink-400">
|
|
163
|
+
{entry.description}
|
|
164
|
+
</p>
|
|
165
|
+
)}
|
|
166
|
+
|
|
167
|
+
{/* Stack + platform rendered as 2 distinct labelled rows so the
|
|
168
|
+
primary/secondary hierarchy is unambiguous. Stack is the more
|
|
169
|
+
identifying signal for an item (it's the main technology) and
|
|
170
|
+
gets a filled pill; platform is secondary and gets an outlined
|
|
171
|
+
pill. Shared with `IndexRow` / `ItemCard` so the pattern is
|
|
172
|
+
identical across all card-shaped components. */}
|
|
173
|
+
<StackPlatformChips
|
|
174
|
+
show={showChips}
|
|
175
|
+
stack={stack[0]}
|
|
176
|
+
platform={platform}
|
|
177
|
+
/>
|
|
178
|
+
|
|
179
|
+
{showMeta && (
|
|
180
|
+
<div class="flex flex-wrap items-center gap-x-3 gap-y-1 text-2xs text-ink-500 dark:text-ink-400">
|
|
181
|
+
{starsLabel !== null && (
|
|
182
|
+
<span class="inline-flex items-center gap-1" title={`${stars ?? 0} stars`}>
|
|
183
|
+
<svg viewBox="0 0 16 16" width="11" height="11" aria-hidden="true" fill="currentColor" class="text-amber-500">
|
|
184
|
+
<path d="M8 .25a.75.75 0 0 1 .673.418l1.882 3.815 4.21.612a.75.75 0 0 1 .416 1.279l-3.046 2.97.719 4.192a.751.751 0 0 1-1.088.791L8 12.347l-3.766 1.98a.75.75 0 0 1-1.088-.79l.72-4.194L.818 6.374a.75.75 0 0 1 .416-1.28l4.21-.611L7.327.668A.75.75 0 0 1 8 .25Z" />
|
|
185
|
+
</svg>
|
|
186
|
+
<span class="font-mono">{starsLabel}</span>
|
|
187
|
+
</span>
|
|
188
|
+
)}
|
|
189
|
+
{updatedAt && (
|
|
190
|
+
<span class="inline-flex items-center gap-1" title={`Last updated ${updatedAt}`}>
|
|
191
|
+
<svg viewBox="0 0 16 16" width="11" height="11" aria-hidden="true" fill="currentColor" class="text-ink-400">
|
|
192
|
+
<path d="M8 2a6 6 0 1 0 0 12A6 6 0 0 0 8 2Zm0 1.5a4.5 4.5 0 1 1 0 9 4.5 4.5 0 0 1 0-9Zm-.5 1.5a.5.5 0 0 0-1 0v3.25c0 .15.07.29.18.39l1.95 1.65a.5.5 0 0 0 .66-.76L7.5 7.55V5Z" />
|
|
193
|
+
</svg>
|
|
194
|
+
<span class="font-mono">Updated {updatedLabel}</span>
|
|
195
|
+
</span>
|
|
196
|
+
)}
|
|
197
|
+
{license && (
|
|
198
|
+
<span class="inline-flex items-center gap-1" title={`License: ${license}`}>
|
|
199
|
+
<span class="font-mono uppercase tracking-wide text-[10px]">{license}</span>
|
|
200
|
+
</span>
|
|
201
|
+
)}
|
|
202
|
+
</div>
|
|
203
|
+
)}
|
|
204
|
+
|
|
205
|
+
{/* Footer: source links (View repo / Visit site) + Details. */}
|
|
206
|
+
<footer class="mt-1 flex items-center justify-between gap-3 border-t border-ink-100 pt-2 text-2xs dark:border-ink-900">
|
|
207
|
+
<div class="flex min-w-0 items-center gap-3">
|
|
208
|
+
{showViewRepo && (
|
|
209
|
+
<a
|
|
210
|
+
href={repoHref}
|
|
211
|
+
target="_blank"
|
|
212
|
+
rel="noopener noreferrer"
|
|
213
|
+
data-event="external_repo_click"
|
|
214
|
+
data-event-source="collection_row_view_repo"
|
|
215
|
+
data-event-item={slug}
|
|
216
|
+
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"
|
|
217
|
+
>
|
|
218
|
+
View repo
|
|
219
|
+
<svg viewBox="0 0 16 16" width="10" height="10" aria-hidden="true" fill="currentColor">
|
|
220
|
+
<path d="M3.75 2A1.75 1.75 0 0 0 2 3.75v8.5C2 13.216 2.784 14 3.75 14h8.5A1.75 1.75 0 0 0 14 12.25v-3.5a.75.75 0 0 0-1.5 0v3.5a.25.25 0 0 1-.25.25h-8.5a.25.25 0 0 1-.25-.25v-8.5a.25.25 0 0 1 .25-.25h3.5a.75.75 0 0 0 0-1.5h-3.5Z" />
|
|
221
|
+
<path d="M10 0a.75.75 0 0 0-.75.75v.75h-3a.75.75 0 0 0 0 1.5h3v.75A.75.75 0 0 0 10.75 4.5h2.5A.75.75 0 0 0 14 3.75v-2.5A.75.75 0 0 0 13.25.5H10Z" />
|
|
222
|
+
</svg>
|
|
223
|
+
</a>
|
|
224
|
+
)}
|
|
225
|
+
{showVisitSite && (
|
|
226
|
+
<a
|
|
227
|
+
href={homepageHref}
|
|
228
|
+
target="_blank"
|
|
229
|
+
rel="noopener noreferrer"
|
|
230
|
+
data-event="external_link_click"
|
|
231
|
+
data-event-source="collection_row_visit_site"
|
|
232
|
+
data-event-item={slug}
|
|
233
|
+
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"
|
|
234
|
+
>
|
|
235
|
+
Visit site →
|
|
236
|
+
</a>
|
|
237
|
+
)}
|
|
238
|
+
</div>
|
|
239
|
+
<a
|
|
240
|
+
href={detailHref}
|
|
241
|
+
class="inline-flex shrink-0 items-center gap-1 font-medium text-ink-500 transition-colors hover:text-ink-900 dark:text-ink-400 dark:hover:text-ink-100"
|
|
242
|
+
>
|
|
243
|
+
Details →
|
|
244
|
+
</a>
|
|
245
|
+
</footer>
|
|
246
|
+
</article>
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* CollectionTeaser — homepage-friendly subset of `CollectionIndex`.
|
|
4
|
+
*
|
|
5
|
+
* Thin wrapper that defaults `limit` to 3 and sets homepage-friendly
|
|
6
|
+
* "View all collections" link + copy. For custom card counts or
|
|
7
|
+
* different copy, use `CollectionIndex` directly with explicit
|
|
8
|
+
* `limit` and `viewAllHref` props.
|
|
9
|
+
*/
|
|
10
|
+
import CollectionIndex from "./CollectionIndex.astro";
|
|
11
|
+
import type { CollectionIndexModel } from "../server/collections.js";
|
|
12
|
+
|
|
13
|
+
interface Props {
|
|
14
|
+
model: CollectionIndexModel;
|
|
15
|
+
title?: string;
|
|
16
|
+
description?: string;
|
|
17
|
+
viewAllHref?: string;
|
|
18
|
+
viewAllLabel?: string;
|
|
19
|
+
/** Cap the number of cards rendered. Default: 3. */
|
|
20
|
+
limit?: number;
|
|
21
|
+
class?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const {
|
|
25
|
+
model,
|
|
26
|
+
title = "Featured collections",
|
|
27
|
+
description = "Hand-picked and auto-generated groupings of related items.",
|
|
28
|
+
viewAllHref = "/collections/",
|
|
29
|
+
viewAllLabel = "View all collections",
|
|
30
|
+
limit = 3,
|
|
31
|
+
class: className,
|
|
32
|
+
} = Astro.props;
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
<CollectionIndex
|
|
36
|
+
model={model}
|
|
37
|
+
title={title}
|
|
38
|
+
description={description}
|
|
39
|
+
viewAllHref={viewAllHref}
|
|
40
|
+
viewAllLabel={viewAllLabel}
|
|
41
|
+
limit={limit}
|
|
42
|
+
class={className}
|
|
43
|
+
/>
|
|
@@ -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
|
+
))}
|