@grove-dev/astro 0.4.1 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@grove-dev/astro",
3
- "version": "0.4.1",
3
+ "version": "0.5.0-next.0",
4
4
  "type": "module",
5
5
  "description": "Composable Astro UI, data adapters, and integration for Grove directories.",
6
6
  "license": "MIT",
@@ -54,8 +54,9 @@
54
54
  "astro": "^7.1.3",
55
55
  "marked": "^18.0.0",
56
56
  "sanitize-html": "^2.17.5",
57
+ "shiki": "^1.29.2",
57
58
  "yaml": "^2.9.0",
58
- "@grove-dev/core": "0.4.1"
59
+ "@grove-dev/core": "0.5.0-next.0"
59
60
  },
60
61
  "peerDependencies": {
61
62
  "astro": "^6.0.0 || ^7.0.0"
@@ -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
+ )}
@@ -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>
@@ -0,0 +1,329 @@
1
+ ---
2
+ /**
3
+ * RecordSidebar.astro
4
+ *
5
+ * Sticky right-hand column on the record detail page. Renders
6
+ * up to four cards, each conditional on the model's `sidebar`
7
+ * visibility flags:
8
+ *
9
+ * - Activity — repo facts (stars, forks, language, license, …)
10
+ * - Freshness — last commit / last fetch / maintenance status
11
+ * - Ecosystem — stack, platforms, category, tags, hygiene files
12
+ * - Source — curation metadata + reading-time summary
13
+ *
14
+ * Empty rows are suppressed entirely — the sidebar never shows "—"
15
+ * or "0" placeholders that confuse "no data" with "zero".
16
+ *
17
+ * Sticky on lg+ (`lg:sticky lg:top-20 lg:max-h-calc(100vh-5rem)
18
+ * lg:overflow-y-auto`) so the cards stay in view while the user
19
+ * scrolls the body. Cards are individually rendered so the
20
+ * sticky container's height is bounded by the actual content.
21
+ */
22
+ import { formatStars, compact } from "@grove-dev/core";
23
+ import type { RecordDetailModel } from "../server/models.js";
24
+ import LanguageBreakdown from "./LanguageBreakdown.astro";
25
+
26
+ interface Props {
27
+ detail: RecordDetailModel;
28
+ }
29
+
30
+ const { detail } = Astro.props;
31
+ const {
32
+ sidebar,
33
+ github,
34
+ stars,
35
+ forks,
36
+ language,
37
+ licenseSpdx,
38
+ pushedAt,
39
+ pushedLabel,
40
+ repoUrl,
41
+ homepageUrl,
42
+ ownerRepo,
43
+ healthLabel,
44
+ activityBadge,
45
+ stacks,
46
+ platforms,
47
+ tags,
48
+ category,
49
+ contributionSignals,
50
+ languages,
51
+ totalLanguageBytes,
52
+ readingMetrics,
53
+ record,
54
+ } = detail;
55
+
56
+ // Visibility helpers for individual rows inside the Activity card.
57
+ // `hasRepo` is the discriminator between "GitHub data synced" and
58
+ // "no data fetched yet" — without it the card would show "Stars 0"
59
+ // even when the field was never populated.
60
+ const repo = github;
61
+ const hasRepo = !!repo;
62
+ const starsNumber = typeof stars === "number" && stars > 0 ? stars : null;
63
+ const forksNumber = typeof forks === "number" && forks > 0 ? forks : null;
64
+ const recentReleases = (repo?.releases ?? []).slice(0, 1);
65
+ const isArchived = !!repo?.archived;
66
+
67
+ // Render a kv row only when both the label and value have content.
68
+ function row(label: string, value: unknown) {
69
+ if (value === null || value === undefined || value === "") return null;
70
+ return { label, value };
71
+ }
72
+ ---
73
+
74
+ <aside class="grove-sidebar flex flex-col gap-6 lg:sticky lg:top-20 lg:max-h-[calc(100vh-5rem)] lg:overflow-y-auto" aria-label="Project data">
75
+
76
+ {/* ── Activity card ──────────────────────────────────────── */}
77
+ {sidebar.showActivity && (
78
+ <section class="grove-card rounded-[var(--radius-lg)] border border-ink-200 bg-background p-5 dark:border-ink-800" aria-label="Activity">
79
+ <h2 class="grove-card-title m-0 mb-3 text-2xs font-semibold uppercase tracking-wider text-ink-500 dark:text-ink-400">
80
+ Activity
81
+ </h2>
82
+ <dl class="grove-kv m-0 flex flex-col gap-2.5 text-sm">
83
+ {row("Stars", starsNumber !== null ? formatStars(starsNumber) ?? compact(starsNumber) : null) && (
84
+ <div class="flex items-baseline justify-between gap-3">
85
+ <dt class="text-ink-500 dark:text-ink-400">Stars</dt>
86
+ <dd class="font-mono tabular-nums text-ink-900 dark:text-ink-100">{starsNumber!.toLocaleString()}</dd>
87
+ </div>
88
+ )}
89
+ {row("Forks", forksNumber !== null ? forksNumber.toLocaleString() : null) && (
90
+ <div class="flex items-baseline justify-between gap-3">
91
+ <dt class="text-ink-500 dark:text-ink-400">Forks</dt>
92
+ <dd class="font-mono tabular-nums text-ink-900 dark:text-ink-100">{forksNumber!.toLocaleString()}</dd>
93
+ </div>
94
+ )}
95
+ {row("Contributors", repo?.contributors_count) && (
96
+ <div class="flex items-baseline justify-between gap-3">
97
+ <dt class="text-ink-500 dark:text-ink-400">Contributors</dt>
98
+ <dd class="font-mono tabular-nums text-ink-900 dark:text-ink-100">{Number(repo!.contributors_count).toLocaleString()}</dd>
99
+ </div>
100
+ )}
101
+ {row("Open issues", repo?.open_issues_count) && (
102
+ <div class="flex items-baseline justify-between gap-3">
103
+ <dt class="text-ink-500 dark:text-ink-400">Open issues</dt>
104
+ <dd class="font-mono tabular-nums text-ink-900 dark:text-ink-100">{Number(repo!.open_issues_count).toLocaleString()}</dd>
105
+ </div>
106
+ )}
107
+ {row("Watchers", repo?.subscribers_count) && (
108
+ <div class="flex items-baseline justify-between gap-3">
109
+ <dt class="text-ink-500 dark:text-ink-400">Watchers</dt>
110
+ <dd class="font-mono tabular-nums text-ink-900 dark:text-ink-100">{Number(repo!.subscribers_count).toLocaleString()}</dd>
111
+ </div>
112
+ )}
113
+ {row("Language", language) && (
114
+ <div class="flex items-baseline justify-between gap-3">
115
+ <dt class="text-ink-500 dark:text-ink-400">Language</dt>
116
+ <dd class="text-ink-900 dark:text-ink-100">{language}</dd>
117
+ </div>
118
+ )}
119
+ {row("License", licenseSpdx) && (
120
+ <div class="flex items-baseline justify-between gap-3">
121
+ <dt class="text-ink-500 dark:text-ink-400">License</dt>
122
+ <dd class="font-mono text-ink-900 dark:text-ink-100">{licenseSpdx}</dd>
123
+ </div>
124
+ )}
125
+ {row("Default branch", repo?.default_branch) && (
126
+ <div class="flex items-baseline justify-between gap-3">
127
+ <dt class="text-ink-500 dark:text-ink-400">Default branch</dt>
128
+ <dd class="font-mono text-ink-900 dark:text-ink-100">{repo!.default_branch}</dd>
129
+ </div>
130
+ )}
131
+ {row("First commit", repo?.created_at ? new Date(String(repo!.created_at)).getUTCFullYear() : null) && (
132
+ <div class="flex items-baseline justify-between gap-3">
133
+ <dt class="text-ink-500 dark:text-ink-400">First commit</dt>
134
+ <dd class="text-ink-900 dark:text-ink-100">
135
+ <time datetime={String(repo!.created_at)}>{new Date(String(repo!.created_at)).getUTCFullYear()}</time>
136
+ </dd>
137
+ </div>
138
+ )}
139
+ {recentReleases[0] && (
140
+ <div class="flex items-baseline justify-between gap-3">
141
+ <dt class="text-ink-500 dark:text-ink-400">Latest release</dt>
142
+ <dd class="text-ink-900 dark:text-ink-100">
143
+ <a
144
+ href={recentReleases[0].html_url ?? repoUrl}
145
+ target="_blank"
146
+ rel="noopener noreferrer"
147
+ class="no-underline hover:underline"
148
+ >
149
+ {recentReleases[0].tag_name ?? recentReleases[0].name ?? "—"}
150
+ </a>
151
+ </dd>
152
+ </div>
153
+ )}
154
+ </dl>
155
+
156
+ <LanguageBreakdown languages={languages} totalBytes={totalLanguageBytes} />
157
+ </section>
158
+ )}
159
+
160
+ {/* ── Freshness card ─────────────────────────────────────── */}
161
+ {sidebar.showFreshness && (
162
+ <section class="grove-card rounded-[var(--radius-lg)] border border-ink-200 bg-background p-5 dark:border-ink-800" aria-label="Freshness">
163
+ <h2 class="grove-card-title m-0 mb-3 text-2xs font-semibold uppercase tracking-wider text-ink-500 dark:text-ink-400">
164
+ Freshness
165
+ </h2>
166
+ <dl class="grove-kv m-0 flex flex-col gap-2.5 text-sm">
167
+ {pushedLabel && pushedLabel !== "—" && (
168
+ <div class="flex items-baseline justify-between gap-3">
169
+ <dt class="text-ink-500 dark:text-ink-400">Last commit</dt>
170
+ <dd class="text-ink-900 dark:text-ink-100">{pushedLabel}</dd>
171
+ </div>
172
+ )}
173
+ {repo?.updated_at && (
174
+ <div class="flex items-baseline justify-between gap-3">
175
+ <dt class="text-ink-500 dark:text-ink-400">Last fetched</dt>
176
+ <dd class="text-ink-900 dark:text-ink-100">
177
+ <time datetime={String(repo.updated_at)}>{new Date(String(repo.updated_at)).toLocaleDateString()}</time>
178
+ </dd>
179
+ </div>
180
+ )}
181
+ {activityBadge && (
182
+ <div class="flex items-baseline justify-between gap-3">
183
+ <dt class="text-ink-500 dark:text-ink-400">Maintenance</dt>
184
+ <dd>
185
+ <span data-activity-tone={activityBadge.tone} class="grove-pill grove-pill--activity inline-flex items-center gap-1 rounded border px-1.5 py-0.5 text-[10px] font-medium">
186
+ {activityBadge.label}
187
+ </span>
188
+ </dd>
189
+ </div>
190
+ )}
191
+ {healthLabel && (
192
+ <div class="flex items-baseline justify-between gap-3">
193
+ <dt class="text-ink-500 dark:text-ink-400">Health</dt>
194
+ <dd class="text-ink-900 dark:text-ink-100">{healthLabel}</dd>
195
+ </div>
196
+ )}
197
+ {isArchived && (
198
+ <div class="flex items-baseline justify-between gap-3">
199
+ <dt class="text-ink-500 dark:text-ink-400">Status</dt>
200
+ <dd class="text-amber-700 dark:text-amber-400">Archived</dd>
201
+ </div>
202
+ )}
203
+ {repo?.homepage && (
204
+ <div class="flex items-baseline justify-between gap-3">
205
+ <dt class="text-ink-500 dark:text-ink-400">Docs</dt>
206
+ <dd class="truncate text-ink-900 dark:text-ink-100">
207
+ <a href={String(repo.homepage)} target="_blank" rel="noopener noreferrer" class="no-underline hover:underline">
208
+ {new URL(String(repo.homepage)).hostname}
209
+ </a>
210
+ </dd>
211
+ </div>
212
+ )}
213
+ {repoUrl && (
214
+ <div class="flex items-baseline justify-between gap-3">
215
+ <dt class="text-ink-500 dark:text-ink-400">Source</dt>
216
+ <dd class="text-ink-900 dark:text-ink-100">
217
+ <a href={repoUrl} target="_blank" rel="noopener noreferrer" class="no-underline hover:underline">
218
+ {ownerRepo ? `${ownerRepo.owner}/${ownerRepo.repo}` : repoUrl}
219
+ </a>
220
+ </dd>
221
+ </div>
222
+ )}
223
+ </dl>
224
+ </section>
225
+ )}
226
+
227
+ {/* ── Ecosystem card ─────────────────────────────────────── */}
228
+ {sidebar.showEcosystem && (
229
+ <section class="grove-card rounded-[var(--radius-lg)] border border-ink-200 bg-background p-5 dark:border-ink-800" aria-label="Ecosystem">
230
+ <h2 class="grove-card-title m-0 mb-3 text-2xs font-semibold uppercase tracking-wider text-ink-500 dark:text-ink-400">
231
+ Ecosystem
232
+ </h2>
233
+
234
+ {stacks.length > 0 && (
235
+ <div class="mb-3">
236
+ <h3 class="m-0 mb-1.5 text-2xs font-medium uppercase tracking-wider text-ink-500 dark:text-ink-400">Stack</h3>
237
+ <ul class="m-0 flex list-none flex-wrap gap-1.5 p-0">
238
+ {stacks.map((s) => (
239
+ <li>
240
+ <a href={`/${detail.slug}?stack=${encodeURIComponent(s)}`} class="rounded-full bg-secondary px-2 py-0.5 text-2xs font-medium text-secondary-foreground no-underline">
241
+ {s}
242
+ </a>
243
+ </li>
244
+ ))}
245
+ </ul>
246
+ </div>
247
+ )}
248
+
249
+ {platforms.length > 0 && (
250
+ <div class="mb-3">
251
+ <h3 class="m-0 mb-1.5 text-2xs font-medium uppercase tracking-wider text-ink-500 dark:text-ink-400">Platforms</h3>
252
+ <ul class="m-0 flex list-none flex-wrap gap-1.5 p-0">
253
+ {platforms.map((p) => (
254
+ <li 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">
255
+ {p}
256
+ </li>
257
+ ))}
258
+ </ul>
259
+ </div>
260
+ )}
261
+
262
+ {category && (
263
+ <div class="mb-3">
264
+ <h3 class="m-0 mb-1.5 text-2xs font-medium uppercase tracking-wider text-ink-500 dark:text-ink-400">Category</h3>
265
+ <a href={`/${detail.slug}?category=${encodeURIComponent(category)}`} class="rounded-full bg-secondary px-2 py-0.5 text-2xs font-medium text-secondary-foreground no-underline">
266
+ {category}
267
+ </a>
268
+ </div>
269
+ )}
270
+
271
+ {tags.length > 0 && (
272
+ <div class="mb-3">
273
+ <h3 class="m-0 mb-1.5 text-2xs font-medium uppercase tracking-wider text-ink-500 dark:text-ink-400">Tags</h3>
274
+ <ul class="m-0 flex list-none flex-wrap gap-1.5 p-0">
275
+ {tags.map((t) => (
276
+ <li>
277
+ <a href={`/${detail.slug}?q=${encodeURIComponent(t)}`} class="rounded-full border border-ink-200 px-2 py-0.5 text-2xs font-medium text-ink-700 no-underline dark:border-ink-800 dark:text-ink-300">
278
+ {t}
279
+ </a>
280
+ </li>
281
+ ))}
282
+ </ul>
283
+ </div>
284
+ )}
285
+
286
+ {contributionSignals.length > 0 && (
287
+ <div>
288
+ <h3 class="m-0 mb-1.5 text-2xs font-medium uppercase tracking-wider text-ink-500 dark:text-ink-400">Repository hygiene</h3>
289
+ <ul class="m-0 flex list-none flex-col gap-1 p-0 text-sm">
290
+ {contributionSignals.map((s) => (
291
+ <li class="inline-flex items-center gap-1.5 text-ink-700 dark:text-ink-200">
292
+ {s.ok ? (
293
+ <svg viewBox="0 0 16 16" width="11" height="11" aria-hidden="true" fill="currentColor" class="text-emerald-600 dark:text-emerald-400">
294
+ <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" />
295
+ </svg>
296
+ ) : (
297
+ <svg viewBox="0 0 16 16" width="11" height="11" aria-hidden="true" fill="currentColor" class="text-ink-400">
298
+ <path d="M3.72 3.72a.75.75 0 0 1 1.06 0L8 6.94l3.22-3.22a.75.75 0 1 1 1.06 1.06L9.06 8l3.22 3.22a.75.75 0 1 1-1.06 1.06L8 9.06l-3.22 3.22a.75.75 0 0 1-1.06-1.06L6.94 8 3.72 4.78a.75.75 0 0 1 0-1.06Z" />
299
+ </svg>
300
+ )}
301
+ {s.label}
302
+ </li>
303
+ ))}
304
+ </ul>
305
+ </div>
306
+ )}
307
+ </section>
308
+ )}
309
+
310
+ {/* ── Source card (small) ────────────────────────────────── */}
311
+ {sidebar.showSource && (
312
+ <section class="grove-card grove-card--muted rounded-[var(--radius-lg)] border border-dashed border-ink-200 bg-background/50 p-4 text-2xs text-ink-500 dark:border-ink-800 dark:text-ink-400" aria-label="Sources">
313
+ <h2 class="grove-card-title m-0 mb-1.5 text-2xs font-semibold uppercase tracking-wider text-ink-500 dark:text-ink-400">
314
+ Source
315
+ </h2>
316
+ <p class="m-0 leading-relaxed">
317
+ Reviewed by {record.curation?.reviewedBy ?? "grove"}
318
+ on <time datetime={String(record.curation?.reviewedAt)}>
319
+ {record.curation?.reviewedAt ? new Date(String(record.curation.reviewedAt)).toLocaleDateString() : "—"}
320
+ </time>.
321
+ </p>
322
+ {readingMetrics.wordCount > 0 && (
323
+ <p class="m-0 mt-1 leading-relaxed">
324
+ Notes: {readingMetrics.wordCount.toLocaleString()} words · {readingMetrics.minutes} min read.
325
+ </p>
326
+ )}
327
+ </section>
328
+ )}
329
+ </aside>