@grove-dev/astro 0.4.0 → 0.5.0-next.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.
@@ -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
+ />
@@ -0,0 +1,57 @@
1
+ ---
2
+ /**
3
+ * EditorialSummary.astro
4
+ *
5
+ * Compact card at the top of the record body that surfaces the
6
+ * curator's "Best for" and "Consider before using" notes. Renders
7
+ * nothing when both arrays are empty.
8
+ *
9
+ * This is the editorial half of the page: the sidebar (right) is
10
+ * auto-generated facts; the summary (left) is human-authored
11
+ * curation.
12
+ */
13
+ interface Props {
14
+ bestFor: string[];
15
+ caveats: string[];
16
+ }
17
+
18
+ const { bestFor, caveats } = Astro.props;
19
+ const hasAny = bestFor.length > 0 || caveats.length > 0;
20
+ ---
21
+
22
+ {hasAny && (
23
+ <section class="grove-editorial mb-10 rounded-[var(--radius-lg)] border border-ink-200 bg-background p-5 dark:border-ink-800" aria-label="Editorial summary">
24
+ <div class="grid grid-cols-1 gap-5 sm:grid-cols-2">
25
+ {bestFor.length > 0 && (
26
+ <div>
27
+ <h2 class="m-0 flex items-center gap-1.5 text-2xs font-semibold uppercase tracking-wider text-emerald-700 dark:text-emerald-400">
28
+ <svg viewBox="0 0 16 16" width="11" height="11" aria-hidden="true" fill="currentColor">
29
+ <path d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.75.75 0 0 1 1.06-1.06L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z" />
30
+ </svg>
31
+ Best for
32
+ </h2>
33
+ <ul class="mt-2 m-0 flex list-none flex-col gap-1.5 p-0 text-sm text-ink-700 dark:text-ink-200">
34
+ {bestFor.map((b) => (
35
+ <li class="leading-relaxed">{b}</li>
36
+ ))}
37
+ </ul>
38
+ </div>
39
+ )}
40
+ {caveats.length > 0 && (
41
+ <div>
42
+ <h2 class="m-0 flex items-center gap-1.5 text-2xs font-semibold uppercase tracking-wider text-amber-700 dark:text-amber-400">
43
+ <svg viewBox="0 0 16 16" width="11" height="11" aria-hidden="true" fill="currentColor">
44
+ <path d="M8 1.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13zM0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8zm9 3a1 1 0 1 1-2 0 1 1 0 0 1 2 0zm-.25-6.25a.75.75 0 0 0-1.5 0v3.5a.75.75 0 0 0 .4 1.04l1.6.8a.75.75 0 1 0 .67-1.34l-1.17-.59V4.75z" />
45
+ </svg>
46
+ Consider before using
47
+ </h2>
48
+ <ul class="mt-2 m-0 flex list-none flex-col gap-1.5 p-0 text-sm text-ink-700 dark:text-ink-200">
49
+ {caveats.map((c) => (
50
+ <li class="leading-relaxed">{c}</li>
51
+ ))}
52
+ </ul>
53
+ </div>
54
+ )}
55
+ </div>
56
+ </section>
57
+ )}
@@ -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,63 @@
1
+ ---
2
+ /**
3
+ * LanguageBreakdown.astro
4
+ *
5
+ * Visual GitHub-Linguist-style code-composition bar plus a legend
6
+ * showing each language's percentage. Reads from the record's
7
+ * pre-computed `languages` array (top-5 by bytes) and `langColor`
8
+ * (in @grove-dev/core).
9
+ *
10
+ * Used inside the Activity sidebar card.
11
+ */
12
+ import { langColor } from "@grove-dev/core";
13
+
14
+ interface LangEntry {
15
+ name: string;
16
+ bytes: number;
17
+ }
18
+
19
+ interface Props {
20
+ /** Top-N languages as `[name, bytes]` tuples (already sorted). */
21
+ languages: ReadonlyArray<readonly [string, number]>;
22
+ /** Total bytes across all languages (denominator). */
23
+ totalBytes: number;
24
+ /** Optional max segments to show. */
25
+ maxSegments?: number;
26
+ }
27
+
28
+ const { languages, totalBytes, maxSegments = 5 } = Astro.props;
29
+
30
+ const segments = languages.slice(0, maxSegments).map(([name, bytes]) => {
31
+ const pct = Math.round((bytes / Math.max(1, totalBytes)) * 100);
32
+ return { name, pct, color: langColor(name) };
33
+ });
34
+ ---
35
+
36
+ {segments.length > 0 && (
37
+ <div class="grove-langbreak mt-4 border-t border-ink-100 pt-4 dark:border-ink-800">
38
+ <h3 class="m-0 text-2xs font-semibold uppercase tracking-wider text-ink-500 dark:text-ink-400">
39
+ Code composition
40
+ </h3>
41
+ <div class="grove-langbreak-bar mt-2 flex h-2 w-full overflow-hidden rounded-full bg-ink-100 dark:bg-ink-800">
42
+ {segments.map((s) => (
43
+ <span
44
+ class="grove-langbreak-seg h-full"
45
+ style={`width:${s.pct}%;background-color:${s.color}`}
46
+ title={`${s.name}: ${s.pct}%`}
47
+ />
48
+ ))}
49
+ </div>
50
+ <ul class="grove-langbreak-list mt-2 flex flex-wrap gap-x-3 gap-y-1 text-2xs text-ink-500 dark:text-ink-400">
51
+ {segments.map((s) => (
52
+ <li class="inline-flex items-center gap-1">
53
+ <span
54
+ class="inline-block h-2 w-2 rounded-sm"
55
+ style={`background-color:${s.color}`}
56
+ />
57
+ <span class="font-mono">{s.name}</span>
58
+ <span>{s.pct}%</span>
59
+ </li>
60
+ ))}
61
+ </ul>
62
+ </div>
63
+ )}
@@ -0,0 +1,37 @@
1
+ ---
2
+ /**
3
+ * MarkdownBody.astro
4
+ *
5
+ * Renders a record's pre-sanitized Markdown body (`getContentHtml`)
6
+ * inside the package's `.grove-prose` stylesheet.
7
+ *
8
+ * The renderer itself lives in `@grove-dev/astro/server/directory.ts`
9
+ * (`renderMarkdownToSafeHtml`) and runs once per record at module
10
+ * load; this component is the presentation half of that pipeline.
11
+ *
12
+ * Props:
13
+ * - `recordSlug`: the **record's** slug (not the route prefix).
14
+ * `getContentHtml` is keyed by record slug — `detail.slug` is the
15
+ * route prefix (e.g. "projects"), `detail.record.slug` is the
16
+ * record (e.g. "crewai").
17
+ * - `fallback`: optional content to render when the record has no
18
+ * body (e.g. an empty-state message).
19
+ */
20
+ import { getContentHtml } from "../server/directory.js";
21
+
22
+ interface Props {
23
+ /** Record slug — looks up the pre-rendered HTML in the module map. */
24
+ recordSlug: string;
25
+ /** Optional fallback content for records without a body. */
26
+ fallback?: string;
27
+ }
28
+
29
+ const { recordSlug, fallback } = Astro.props;
30
+ const html = getContentHtml(recordSlug);
31
+ ---
32
+
33
+ {html ? (
34
+ <div class="grove-prose" set:html={html} />
35
+ ) : fallback ? (
36
+ <p class="grove-prose text-ink-500 dark:text-ink-400">{fallback}</p>
37
+ ) : null}
@@ -0,0 +1,156 @@
1
+ ---
2
+ /**
3
+ * RecordHeader.astro
4
+ *
5
+ * Compact project-identity header used at the top of every record
6
+ * detail page. The header carries:
7
+ * - avatar / initials
8
+ * - pills row (category + curation labels + "Curated" check +
9
+ * optional activity badge)
10
+ * - record name + owner/repo line
11
+ * - short description
12
+ * - GitHub + Homepage CTAs
13
+ *
14
+ * Tags + activity metadata are surfaced separately (sidebar), so
15
+ * the header stays narrow on mobile and doesn't compete with the
16
+ * two-column layout below.
17
+ *
18
+ * Tone classes for the activity badge are static `[data-activity-tone]`
19
+ * selectors in `styles.css` — Tailwind purges any classes built from
20
+ * dynamic strings, so the package ships the rules statically and
21
+ * the badge's `data-activity-tone` attribute picks one at runtime.
22
+ */
23
+ import type { RecordDetailModel } from "../server/models.js";
24
+
25
+ interface Props {
26
+ detail: RecordDetailModel;
27
+ }
28
+
29
+ const { detail } = Astro.props;
30
+ const {
31
+ record,
32
+ repoUrl,
33
+ homepageUrl,
34
+ ownerRepo,
35
+ avatarSrc,
36
+ initials,
37
+ curationLabels,
38
+ activityBadge,
39
+ category,
40
+ description,
41
+ name,
42
+ } = detail;
43
+ ---
44
+
45
+ <header class="mb-10 flex flex-col gap-5 sm:flex-row sm:items-start sm:gap-6">
46
+ {avatarSrc ? (
47
+ <img
48
+ src={avatarSrc}
49
+ alt=""
50
+ width="64"
51
+ height="64"
52
+ loading="lazy"
53
+ decoding="async"
54
+ class="h-14 w-14 shrink-0 rounded-lg border border-ink-200 object-contain bg-white p-1 dark:border-ink-800 dark:bg-ink-950 sm:h-16 sm:w-16"
55
+ />
56
+ ) : (
57
+ <span
58
+ aria-hidden="true"
59
+ class="inline-flex h-14 w-14 shrink-0 items-center justify-center rounded-lg border border-ink-200 bg-ink-50 text-xl font-semibold text-ink-700 dark:border-ink-800 dark:bg-ink-900 dark:text-ink-200 sm:h-16 sm:w-16"
60
+ >
61
+ {initials || "·"}
62
+ </span>
63
+ )}
64
+
65
+ <div class="min-w-0 flex-1">
66
+ {/* Pills row — category + curation + Curated check + activity badge. */}
67
+ <div class="flex flex-wrap items-center gap-1.5">
68
+ {category && (
69
+ <span class="rounded-full border border-ink-200 px-2 py-0.5 text-2xs font-medium text-ink-700 dark:border-ink-800 dark:text-ink-300">
70
+ {category}
71
+ </span>
72
+ )}
73
+ {curationLabels.map((l) => (
74
+ <span class="rounded-full bg-secondary px-2 py-0.5 text-2xs font-medium text-secondary-foreground">
75
+ {l}
76
+ </span>
77
+ ))}
78
+ {record.curation?.reviewed && (
79
+ <span class="inline-flex items-center gap-1 rounded border border-emerald-300/60 bg-emerald-50 px-1.5 py-0.5 text-[10px] font-medium text-emerald-800 dark:border-emerald-800/60 dark:bg-emerald-950/40 dark:text-emerald-300">
80
+ <svg viewBox="0 0 16 16" width="10" height="10" aria-hidden="true" fill="currentColor">
81
+ <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" />
82
+ </svg>
83
+ Curated
84
+ </span>
85
+ )}
86
+ {activityBadge && (
87
+ <span
88
+ data-activity-tone={activityBadge.tone}
89
+ class="grove-pill grove-pill--activity inline-flex items-center gap-1 rounded border px-1.5 py-0.5 text-[10px] font-medium"
90
+ >
91
+ <span class="relative flex h-1.5 w-1.5">
92
+ <span class="absolute inline-flex h-full w-full animate-ping rounded-full bg-current opacity-60"></span>
93
+ <span class="relative inline-flex h-1.5 w-1.5 rounded-full bg-current"></span>
94
+ </span>
95
+ {activityBadge.label}
96
+ </span>
97
+ )}
98
+ </div>
99
+
100
+ <h1 class="m-0 mt-2 text-[2rem] font-semibold leading-tight tracking-tight text-ink-900 dark:text-ink-100 sm:text-[2.25rem]">
101
+ {name}
102
+ </h1>
103
+
104
+ {ownerRepo && (
105
+ <p class="m-0 mt-1 text-sm text-ink-500 dark:text-ink-400">
106
+ <a
107
+ href={`https://github.com/${ownerRepo.owner}`}
108
+ target="_blank"
109
+ rel="noopener noreferrer"
110
+ class="text-inherit no-underline hover:text-ink-900 dark:hover:text-ink-100"
111
+ >
112
+ @{ownerRepo.owner}
113
+ </a>
114
+ <span class="mx-1 text-ink-300 dark:text-ink-700">/</span>
115
+ <a
116
+ href={repoUrl}
117
+ target="_blank"
118
+ rel="noopener noreferrer"
119
+ class="text-inherit no-underline hover:text-ink-900 dark:hover:text-ink-100"
120
+ >
121
+ {ownerRepo.repo}
122
+ </a>
123
+ </p>
124
+ )}
125
+
126
+ {description && (
127
+ <p class="m-0 mt-3 max-w-[68ch] text-base leading-relaxed text-ink-700 dark:text-ink-200">
128
+ {description}
129
+ </p>
130
+ )}
131
+
132
+ {/* CTA row — primary actions only. */}
133
+ <div class="mt-4 flex flex-wrap items-center gap-2">
134
+ {repoUrl && (
135
+ <a
136
+ href={repoUrl}
137
+ target="_blank"
138
+ rel="noopener noreferrer"
139
+ 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 bg-primary text-primary-foreground hover:bg-primary/90"
140
+ >
141
+ View on GitHub →
142
+ </a>
143
+ )}
144
+ {homepageUrl && (
145
+ <a
146
+ href={homepageUrl}
147
+ target="_blank"
148
+ rel="noopener noreferrer"
149
+ class="inline-flex h-8 cursor-pointer items-center justify-center gap-1.5 rounded-[var(--radius)] border border-border bg-secondary 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 text-foreground hover:bg-accent"
150
+ >
151
+ Homepage
152
+ </a>
153
+ )}
154
+ </div>
155
+ </div>
156
+ </header>