@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.
- package/package.json +3 -2
- package/src/components/CollectionIndex.astro +51 -16
- package/src/components/CollectionPage.astro +51 -35
- package/src/components/CollectionRow.astro +209 -72
- package/src/components/CollectionTeaser.astro +21 -59
- package/src/components/EditorialSummary.astro +57 -0
- package/src/components/IndexRow.astro +5 -5
- package/src/components/ItemCard.astro +11 -28
- package/src/components/LanguageBreakdown.astro +63 -0
- package/src/components/MarkdownBody.astro +37 -0
- package/src/components/RecordHeader.astro +156 -0
- package/src/components/RecordSidebar.astro +329 -0
- package/src/components/StackPlatformChips.astro +73 -0
- package/src/components/TableOfContents.astro +115 -0
- package/src/server/collections.test.ts +22 -0
- package/src/server/collections.ts +7 -0
- package/src/server/directory.ts +311 -65
- package/src/server/models.ts +155 -3
- package/src/styles.css +485 -59
|
@@ -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>
|
|
@@ -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>
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* TableOfContents.astro
|
|
4
|
+
*
|
|
5
|
+
* Collapsible on-page nav for a record's Markdown body.
|
|
6
|
+
*
|
|
7
|
+
* Behavior:
|
|
8
|
+
* - Mobile (<lg): rendered as a closed `<details>` dropdown.
|
|
9
|
+
* - Desktop (lg+): forced open via `lg:!open` so the nav stays
|
|
10
|
+
* visible while the user scrolls.
|
|
11
|
+
* - Heading IDs come from the markdown renderer; the TOC entries
|
|
12
|
+
* are pre-computed by `extractToc` and passed in.
|
|
13
|
+
*
|
|
14
|
+
* The scroll-spy + smooth-scroll logic lives in an inline script
|
|
15
|
+
* that finds every `data-toc-link` on the page and highlights the
|
|
16
|
+
* link whose target is closest to the top of the viewport (rAF-
|
|
17
|
+
* throttled). Active styling comes from the `data-toc-active`
|
|
18
|
+
* attribute toggled in the script + CSS rules in `styles.css`.
|
|
19
|
+
*/
|
|
20
|
+
import type { TocEntry } from "@grove-dev/core";
|
|
21
|
+
|
|
22
|
+
interface Props {
|
|
23
|
+
/** Heading entries from `extractToc`. */
|
|
24
|
+
items: TocEntry[];
|
|
25
|
+
/** Optional accessible label override. */
|
|
26
|
+
label?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const { items, label = "On this page" } = Astro.props;
|
|
30
|
+
const hasItems = items.length > 1;
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
{hasItems && (
|
|
34
|
+
<details class="grove-toc mb-8 rounded-[var(--radius-lg)] border border-ink-200 bg-background open:bg-background dark:border-ink-800 lg:!open">
|
|
35
|
+
<summary class="cursor-pointer list-none px-4 py-3 text-2xs font-semibold uppercase tracking-wider text-ink-500 hover:text-ink-900 dark:text-ink-400 dark:hover:text-ink-100 lg:cursor-default lg:pointer-events-none">
|
|
36
|
+
<span class="inline-flex items-center gap-1.5">
|
|
37
|
+
<svg viewBox="0 0 16 16" width="11" height="11" aria-hidden="true" fill="currentColor" class="grove-toc-chevron transition-transform lg:hidden">
|
|
38
|
+
<path d="M6 4l4 4-4 4V4z" />
|
|
39
|
+
</svg>
|
|
40
|
+
{label}
|
|
41
|
+
<span class="ml-1 rounded-full bg-ink-100 px-1.5 py-0.5 text-[10px] font-medium text-ink-500 dark:bg-ink-800 dark:text-ink-300">
|
|
42
|
+
{items.length}
|
|
43
|
+
</span>
|
|
44
|
+
</span>
|
|
45
|
+
</summary>
|
|
46
|
+
<ol class="grove-toc-list mt-1 list-none border-t border-ink-200 p-2 text-sm dark:border-ink-800 lg:border-t-0 lg:p-0">
|
|
47
|
+
{items.map((entry) => (
|
|
48
|
+
<li class="grove-toc-item border-b border-ink-100 last:border-b-0 dark:border-ink-800 lg:border-b-0 lg:py-0.5">
|
|
49
|
+
<a
|
|
50
|
+
href={`#${entry.id}`}
|
|
51
|
+
class="grove-toc-link block truncate px-3 py-2 text-ink-500 no-underline transition-colors hover:text-ink-900 dark:text-ink-400 dark:hover:text-ink-100 lg:px-2 lg:py-1"
|
|
52
|
+
data-toc-link={entry.id}
|
|
53
|
+
>
|
|
54
|
+
{entry.text}
|
|
55
|
+
</a>
|
|
56
|
+
</li>
|
|
57
|
+
))}
|
|
58
|
+
</ol>
|
|
59
|
+
</details>
|
|
60
|
+
)}
|
|
61
|
+
|
|
62
|
+
{hasItems && (
|
|
63
|
+
<script is:inline define:vars={{ tocIds: items.map((i) => i.id) }}>
|
|
64
|
+
(function () {
|
|
65
|
+
// Smooth scroll + history-replace on TOC link clicks.
|
|
66
|
+
function onClick(e) {
|
|
67
|
+
var link = e.target.closest("a[data-toc-link]");
|
|
68
|
+
if (!link) return;
|
|
69
|
+
e.preventDefault();
|
|
70
|
+
var id = link.getAttribute("data-toc-link");
|
|
71
|
+
var target = document.getElementById(id);
|
|
72
|
+
if (!target) return;
|
|
73
|
+
target.scrollIntoView({ behavior: "smooth", block: "start" });
|
|
74
|
+
history.replaceState(null, "", "#" + id);
|
|
75
|
+
}
|
|
76
|
+
document.addEventListener("click", onClick);
|
|
77
|
+
|
|
78
|
+
// Scroll-spy: rAF-throttled, toggles `data-toc-active` on the
|
|
79
|
+
// link whose heading is closest to the top of the viewport.
|
|
80
|
+
// CSS handles the active styling — no dynamic Tailwind classes.
|
|
81
|
+
var headings = tocIds
|
|
82
|
+
.map(function (id) { return document.getElementById(id); })
|
|
83
|
+
.filter(function (el) { return !!el; });
|
|
84
|
+
if (!headings.length) return;
|
|
85
|
+
var links = new Map();
|
|
86
|
+
document.querySelectorAll("a[data-toc-link]").forEach(function (a) {
|
|
87
|
+
links.set(a.getAttribute("data-toc-link"), a);
|
|
88
|
+
});
|
|
89
|
+
var active = null;
|
|
90
|
+
var ticking = false;
|
|
91
|
+
function update() {
|
|
92
|
+
ticking = false;
|
|
93
|
+
var top = window.scrollY + 140;
|
|
94
|
+
var current = headings[0].id;
|
|
95
|
+
for (var i = 0; i < headings.length; i++) {
|
|
96
|
+
if (headings[i].offsetTop <= top) current = headings[i].id;
|
|
97
|
+
else break;
|
|
98
|
+
}
|
|
99
|
+
if (current === active) return;
|
|
100
|
+
active = current;
|
|
101
|
+
links.forEach(function (a, id) {
|
|
102
|
+
if (id === current) a.setAttribute("data-toc-active", "");
|
|
103
|
+
else a.removeAttribute("data-toc-active");
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
function onScroll() {
|
|
107
|
+
if (ticking) return;
|
|
108
|
+
ticking = true;
|
|
109
|
+
requestAnimationFrame(update);
|
|
110
|
+
}
|
|
111
|
+
window.addEventListener("scroll", onScroll, { passive: true });
|
|
112
|
+
update();
|
|
113
|
+
})();
|
|
114
|
+
</script>
|
|
115
|
+
)}
|
|
@@ -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,
|