@grove-dev/astro 0.4.0 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@grove-dev/astro",
3
- "version": "0.4.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",
@@ -55,7 +55,7 @@
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
+ "@grove-dev/core": "0.4.1"
59
59
  },
60
60
  "peerDependencies": {
61
61
  "astro": "^6.0.0 || ^7.0.0"
@@ -5,50 +5,85 @@
5
5
  * detail page and shows the entry count.
6
6
  *
7
7
  * Consumes a `CollectionIndexModel` produced by
8
- * `getCollectionIndexModel`. Generic: no blueprint-specific copy.
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>
9
18
  */
10
19
  import Container from "../layouts/Container.astro";
20
+ import SectionHeader from "../layouts/SectionHeader.astro";
11
21
  import type { CollectionIndexModel } from "../server/collections.js";
12
22
 
13
23
  interface Props {
14
24
  model: CollectionIndexModel;
25
+ id?: string;
26
+ eyebrow?: string;
15
27
  title?: string;
16
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;
17
35
  class?: string;
18
36
  }
19
37
 
20
38
  const {
21
39
  model,
40
+ id,
41
+ eyebrow = "Collections",
22
42
  title = "Collections",
23
43
  description = "Curated and generated directories of related items.",
44
+ viewAllHref,
45
+ viewAllLabel = "View all",
46
+ emptyLabel = "No collections yet.",
47
+ limit,
24
48
  class: className = "",
25
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");
26
53
  ---
27
54
 
28
- <section class={`py-12 sm:py-16 ${className}`}>
55
+ <section id={id} class={`py-12 sm:py-16 ${className}`}>
29
56
  <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>
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>
41
73
 
42
- {model.collections.length === 0 ? (
74
+ {cards.length === 0 ? (
43
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">
44
- No collections yet.
76
+ {emptyLabel}
45
77
  </p>
46
78
  ) : (
47
79
  <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) => (
80
+ {cards.map((c) => (
49
81
  <li class="m-0 p-0">
50
82
  <a
51
83
  href={c.url}
84
+ data-event="collection_card_click"
85
+ data-event-source="collection_index"
86
+ data-event-item={c.slug}
52
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"
53
88
  >
54
89
  <span class="text-2xs font-medium uppercase tracking-wider text-ink-500 dark:text-ink-400">
@@ -3,58 +3,71 @@
3
3
  * CollectionPage — renders a single curated/generated collection.
4
4
  *
5
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
6
+ * Renders the header via `SectionHeader` (framework consistency),
7
+ * the matched entries via `CollectionRow`, and a related-collections
8
8
  * section.
9
9
  *
10
- * Generic: no blueprint-specific copy. The `eyebrow` prop lets the
11
- * consumer pick a label (e.g. "Curated" or "Generated").
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.
12
15
  */
13
16
  import CollectionRow from "./CollectionRow.astro";
14
17
  import Container from "../layouts/Container.astro";
18
+ import SectionHeader from "../layouts/SectionHeader.astro";
15
19
  import type { CollectionPageModel } from "../server/collections.js";
16
20
 
17
21
  interface Props {
18
22
  model: CollectionPageModel;
23
+ /** Override the eyebrow text (default: derived from collection kind). */
19
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;
20
33
  class?: string;
21
34
  }
22
35
 
23
36
  const {
24
37
  model,
25
- eyebrow = "Collection",
38
+ eyebrow,
39
+ title,
40
+ description,
41
+ emptyLabel = "This collection is currently empty.",
42
+ id,
26
43
  class: className = "",
27
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");
28
49
  ---
29
50
 
30
- <section class={`py-12 sm:py-16 ${className}`}>
51
+ <section id={id} class={`py-12 sm:py-16 ${className}`}>
31
52
  <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}
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}
41
59
  </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>
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>
54
67
 
55
68
  {model.isEmpty ? (
56
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">
57
- This collection is currently empty.
70
+ {emptyLabel}
58
71
  </p>
59
72
  ) : (
60
73
  <ul class="m-0 flex list-none flex-col gap-3 p-0">
@@ -68,17 +81,20 @@ const {
68
81
 
69
82
  {model.related.length > 0 && (
70
83
  <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">
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">
75
91
  {model.related.map((r) => (
76
- <li>
92
+ <li class="m-0 p-0">
77
93
  <a
78
94
  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"
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"
80
96
  >
81
- {r.title}
97
+ <span class="text-sm font-medium text-ink-900 group-hover:underline dark:text-ink-100">{r.title}</span>
82
98
  </a>
83
99
  </li>
84
100
  ))}
@@ -1,109 +1,246 @@
1
1
  ---
2
2
  /**
3
- * CollectionRow — a single entry inside a curated/generated collection.
3
+ * CollectionRow — card-shaped row for a single entry inside a
4
+ * curated/generated collection.
4
5
  *
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.
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.
11
12
  *
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.
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.
15
32
  */
16
33
  import type { CollectionEntry } from "@grove-dev/core";
17
- import { formatStars, formatRelative } from "@grove-dev/astro";
34
+ import { formatRelative, formatStars, statusDisplay, getOwnerAndRepoFromRepoUrl, getOwnerAvatarUrl } from "@grove-dev/astro";
35
+ import StackPlatformChips from "./StackPlatformChips.astro";
18
36
 
19
37
  interface Props {
20
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;
21
46
  eager?: boolean;
22
47
  class?: string;
23
48
  }
24
49
 
25
- const { entry, eager = false, class: className = "" } = Astro.props;
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;
26
60
 
27
- const initials = entry.title
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
28
70
  .split(/\s+/)
29
- .map((word) => word[0])
71
+ .map((w) => w[0])
30
72
  .filter(Boolean)
31
73
  .slice(0, 2)
32
74
  .join("")
33
75
  .toUpperCase();
34
- const stack = entry.stack ?? "—";
35
- const stars = entry.stars ?? null;
36
- const pushedAt = entry.pushedAt ?? null;
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]) : "";
37
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;
38
102
  ---
39
103
 
40
104
  <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}`}
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}`}
42
109
  >
43
110
  <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 || "·"}
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}
51
128
  </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>
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
+ )}
79
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
+ )}
80
159
  </header>
81
160
 
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>
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
+ )}
85
166
 
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>
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
+ )}
98
202
  </div>
99
- </dl>
203
+ )}
100
204
 
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">
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>
102
239
  <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"
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"
105
242
  >
106
- View details
243
+ Details
107
244
  </a>
108
245
  </footer>
109
246
  </article>
@@ -1,18 +1,23 @@
1
1
  ---
2
2
  /**
3
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.
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.
6
9
  */
7
- import Container from "../layouts/Container.astro";
8
- import type { CollectionTeaserModel } from "../server/collections.js";
10
+ import CollectionIndex from "./CollectionIndex.astro";
11
+ import type { CollectionIndexModel } from "../server/collections.js";
9
12
 
10
13
  interface Props {
11
- model: CollectionTeaserModel;
14
+ model: CollectionIndexModel;
12
15
  title?: string;
13
16
  description?: string;
14
17
  viewAllHref?: string;
15
18
  viewAllLabel?: string;
19
+ /** Cap the number of cards rendered. Default: 3. */
20
+ limit?: number;
16
21
  class?: string;
17
22
  }
18
23
 
@@ -22,60 +27,17 @@ const {
22
27
  description = "Hand-picked and auto-generated groupings of related items.",
23
28
  viewAllHref = "/collections/",
24
29
  viewAllLabel = "View all collections",
25
- class: className = "",
30
+ limit = 3,
31
+ class: className,
26
32
  } = Astro.props;
27
33
  ---
28
34
 
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>
35
+ <CollectionIndex
36
+ model={model}
37
+ title={title}
38
+ description={description}
39
+ viewAllHref={viewAllHref}
40
+ viewAllLabel={viewAllLabel}
41
+ limit={limit}
42
+ class={className}
43
+ />
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  import type { IndexRecord } from "@grove-dev/core";
3
- import Icon from "./Icon.astro";
3
+ import StackPlatformChips from "./StackPlatformChips.astro";
4
4
  import { formatRelative, formatStars, getOwnerAndRepoFromRepoUrl, getOwnerAvatarUrl, projectStackIds, statusDisplay } from "@grove-dev/astro";
5
5
 
6
6
  interface Props {
@@ -75,10 +75,10 @@ const curated = Boolean(item.curation?.reviewed || (project && (project.bestFor.
75
75
  </dl>
76
76
 
77
77
  {showChips && (platforms.length > 0 || stacks.length > 0) && (
78
- <div class="flex flex-col gap-1.5">
79
- <div class="flex flex-wrap gap-1.5">{platforms.slice(0, 4).map((platform) => <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"><Icon name={platform} category="platform" size={11} />{platform}</span>)}</div>
80
- <div class="flex flex-wrap gap-1.5">{stacks.slice(0, 4).map((stack) => <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"><Icon name={stack} category="stack" size={11} />{stack}</span>)}</div>
81
- </div>
78
+ <StackPlatformChips
79
+ stack={stacks[0]}
80
+ platform={platforms}
81
+ />
82
82
  )}
83
83
 
84
84
  <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">
@@ -24,7 +24,7 @@
24
24
  * pattern is to pass `routeSlug` (or `href` directly).
25
25
  */
26
26
  import type { IndexRecord, Resource, ProjectRecord, IndexProjectRecord } from "@grove-dev/core";
27
- import Icon from "./Icon.astro";
27
+ import StackPlatformChips from "./StackPlatformChips.astro";
28
28
  import { getOwnerAndRepoFromRepoUrl, getOwnerAvatarUrl, projectStackIds } from "@grove-dev/astro";
29
29
  import { formatStars, formatRelative } from "@grove-dev/astro";
30
30
  import { statusDisplay } from "@grove-dev/astro";
@@ -279,34 +279,17 @@ const category = (item as { category?: string }).category;
279
279
  </div>
280
280
  )}
281
281
 
282
- {/* Platform + stack chips, pushed to the bottom. */}
282
+ {/* Platform + stack chips, pushed to the bottom. Shared with
283
+ `IndexRow` / `CollectionRow` so the labelled-row pattern is
284
+ consistent across all card-shaped components. */}
283
285
  {showChips && (
284
- <div class="mt-auto flex flex-col gap-1.5">
285
- {visiblePlatforms.length > 0 && (
286
- <div class="flex flex-wrap items-center gap-1.5">
287
- {visiblePlatforms.map((p) => (
288
- <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">
289
- <Icon name={p} category="platform" size={11} />
290
- <span>{p}</span>
291
- </span>
292
- ))}
293
- {platformOverflow > 0 && (
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>
295
- )}
296
- </div>
297
- )}
298
- {visibleStacks.length > 0 && (
299
- <div class="flex flex-wrap items-center gap-1.5">
300
- {visibleStacks.map((s) => (
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 !bg-muted !text-muted-foreground">
302
- <Icon name={s} category="stack" size={11} />
303
- <span>{s}</span>
304
- </span>
305
- ))}
306
- {stackOverflow > 0 && (
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>
308
- )}
309
- </div>
286
+ <div class="mt-auto">
287
+ <StackPlatformChips
288
+ stack={visibleStacks[0]}
289
+ platform={visiblePlatforms}
290
+ />
291
+ {stackOverflow > 0 && (
292
+ <span class="text-2xs text-ink-400 dark:text-ink-600">+{stackOverflow} more stacks</span>
310
293
  )}
311
294
  </div>
312
295
  )}
@@ -0,0 +1,73 @@
1
+ ---
2
+ /**
3
+ * StackPlatformChips — labelled Stack + Platform chip rows.
4
+ *
5
+ * Renders up to two rows: one for stack (filled pills, primary) and
6
+ * one for platform (outlined pills, secondary). Each row is prefixed
7
+ * with a `w-14` (56px) label column so the pills always start at
8
+ * the same horizontal position, even when the stack/label is wider
9
+ * than the longest platform.
10
+ *
11
+ * Used by every card-shaped component in the framework so the
12
+ * primary/secondary distinction is unambiguous across surfaces
13
+ * (homepage grid, directory index, collection rows).
14
+ */
15
+ import Icon from "./Icon.astro";
16
+
17
+ interface Props {
18
+ /** Primary technology — filled pill, one entry per item. */
19
+ stack?: string;
20
+ /** Secondary target platforms — outlined pills, multiple per item. */
21
+ platform?: string[];
22
+ /** Cap the number of platform pills rendered. Default 4. */
23
+ maxPlatform?: number;
24
+ /** Hide the row(s) entirely. Default true. */
25
+ show?: boolean;
26
+ class?: string;
27
+ }
28
+
29
+ const {
30
+ stack,
31
+ platform = [],
32
+ maxPlatform = 4,
33
+ show = true,
34
+ class: className = "",
35
+ } = Astro.props;
36
+
37
+ if (!show || (!stack && platform.length === 0)) {
38
+ return Astro.redirect ? null : null;
39
+ }
40
+
41
+ const visiblePlatform = platform.slice(0, maxPlatform);
42
+ const platformOverflow = platform.length - visiblePlatform.length;
43
+ ---
44
+
45
+ <div class={`flex flex-col gap-1 ${className}`}>
46
+ {stack && (
47
+ <div class="flex flex-wrap items-center gap-x-1.5 gap-y-1">
48
+ <span class="w-14 shrink-0 text-2xs font-medium uppercase tracking-wider text-ink-500 dark:text-ink-400">
49
+ Stack
50
+ </span>
51
+ <span class="inline-flex min-h-5 items-center gap-1 rounded-full border border-transparent bg-secondary px-2 py-0.5 text-2xs font-medium leading-none text-secondary-foreground">
52
+ <Icon name={stack} category="stack" size={10} />
53
+ <span>{stack}</span>
54
+ </span>
55
+ </div>
56
+ )}
57
+ {platform.length > 0 && (
58
+ <div class="flex flex-wrap items-center gap-x-1.5 gap-y-1">
59
+ <span class="w-14 shrink-0 text-2xs font-medium uppercase tracking-wider text-ink-500 dark:text-ink-400">
60
+ Platform
61
+ </span>
62
+ {visiblePlatform.map((p) => (
63
+ <span class="inline-flex min-h-5 items-center gap-1 rounded-full border border-ink-200 bg-transparent px-2 py-0.5 text-2xs font-medium leading-none text-ink-500 dark:border-ink-800 dark:text-ink-400">
64
+ <Icon name={p} category="platform" size={10} />
65
+ <span>{p}</span>
66
+ </span>
67
+ ))}
68
+ {platformOverflow > 0 && (
69
+ <span class="text-2xs text-ink-400 dark:text-ink-600">+{platformOverflow}</span>
70
+ )}
71
+ </div>
72
+ )}
73
+ </div>
@@ -78,6 +78,28 @@ describe("recordsToCollectionEntries", () => {
78
78
  const entries = recordsToCollectionEntries(records as never, {});
79
79
  expect(entries[0].url).toBe("/projects/x/");
80
80
  });
81
+
82
+ it("populates repoHref from repoUrl and homepageHref from links.website", () => {
83
+ const records = [
84
+ {
85
+ slug: "crewai",
86
+ name: "CrewAI",
87
+ repoUrl: "https://github.com/crewAIInc/crewAI",
88
+ links: { github: "https://github.com/other/repo", website: "https://crewai.com" },
89
+ },
90
+ ];
91
+ const entries = recordsToCollectionEntries(records as never, {});
92
+ expect(entries[0].repoHref).toBe("https://github.com/crewAIInc/crewAI");
93
+ expect(entries[0].homepageHref).toBe("https://crewai.com");
94
+ });
95
+
96
+ it("falls back to links.github when repoUrl is missing", () => {
97
+ const records = [
98
+ { slug: "x", name: "X", links: { github: "https://github.com/foo/bar" } },
99
+ ];
100
+ const entries = recordsToCollectionEntries(records as never, {});
101
+ expect(entries[0].repoHref).toBe("https://github.com/foo/bar");
102
+ });
81
103
  });
82
104
 
83
105
  const collection: Collection = {
@@ -25,6 +25,8 @@ interface RawRecord {
25
25
  category?: string;
26
26
  tags?: string[];
27
27
  scores?: { curation?: number; activity?: number };
28
+ repoUrl?: string;
29
+ links?: { github?: string; website?: string };
28
30
  }
29
31
 
30
32
  /**
@@ -38,6 +40,9 @@ interface RawRecord {
38
40
  * - Prefers `pushedAt`; falls back to `lastCommitAt`.
39
41
  * - Constructs `url` as `${routeSlug}/${slug}/` so the value points
40
42
  * back to the consumer's detail page.
43
+ * - Populates `repoHref` (from `repoUrl` or `links.github`) and
44
+ * `homepageHref` (from `links.website`) so the row component
45
+ * can render "View repo" / "Visit site" footer links.
41
46
  */
42
47
  export function recordsToCollectionEntries(
43
48
  records: RawRecord[],
@@ -57,6 +62,8 @@ export function recordsToCollectionEntries(
57
62
  title: r.name ?? r.title ?? r.slug,
58
63
  description: r.description ?? "",
59
64
  url: `/${routeSlug}/${r.slug}/`,
65
+ repoHref: r.repoUrl ?? r.links?.github,
66
+ homepageHref: r.links?.website,
60
67
  stack: r.stack ?? r.stacks?.[0],
61
68
  platform: r.platforms,
62
69
  license: r.license,