@grove-dev/astro 0.4.1 → 0.5.0-next.2
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/EditorialSummary.astro +57 -0
- package/src/components/IndexRow.astro +2 -2
- package/src/components/ItemCard.astro +2 -1
- package/src/components/LanguageBreakdown.astro +63 -0
- package/src/components/MarkdownBody.astro +37 -0
- package/src/components/RecordHeader.astro +187 -0
- package/src/components/RecordSidebar.astro +339 -0
- package/src/components/SubmissionClient.astro +47 -3
- package/src/components/TableOfContents.astro +115 -0
- package/src/server/collections.ts +77 -1
- package/src/server/directory.ts +311 -65
- package/src/server/models.ts +178 -5
- package/src/styles.css +485 -59
|
@@ -0,0 +1,339 @@
|
|
|
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, licenseDisplay } 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">{licenseDisplay(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}?tag=${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
|
+
{detail.collectionMembership && detail.collectionMembership.length > 0 && (
|
|
323
|
+
<p class="m-0 mt-1.5 leading-relaxed">
|
|
324
|
+
<span class="font-semibold">Also in: </span>
|
|
325
|
+
{detail.collectionMembership.map((c, i) => (
|
|
326
|
+
<>
|
|
327
|
+
<a href={c.url} class="text-inherit no-underline hover:underline">{c.title}</a>{i < detail.collectionMembership.length - 1 ? ", " : ""}
|
|
328
|
+
</>
|
|
329
|
+
))}
|
|
330
|
+
</p>
|
|
331
|
+
)}
|
|
332
|
+
{readingMetrics.wordCount > 0 && (
|
|
333
|
+
<p class="m-0 mt-1 leading-relaxed">
|
|
334
|
+
Notes: {readingMetrics.wordCount.toLocaleString()} words · {readingMetrics.minutes} min read.
|
|
335
|
+
</p>
|
|
336
|
+
)}
|
|
337
|
+
</section>
|
|
338
|
+
)}
|
|
339
|
+
</aside>
|
|
@@ -4,11 +4,17 @@ interface Props {
|
|
|
4
4
|
existingFullNames: string[];
|
|
5
5
|
repoUrl: string;
|
|
6
6
|
fields: { category: boolean; stack: boolean; platforms: boolean; tags: boolean };
|
|
7
|
+
/** Taxonomy ids for client-side validation. */
|
|
8
|
+
taxonomy?: {
|
|
9
|
+
categoryIds?: string[];
|
|
10
|
+
stackIds?: string[];
|
|
11
|
+
platformIds?: string[];
|
|
12
|
+
};
|
|
7
13
|
}
|
|
8
|
-
const { existingSlugs, existingFullNames, repoUrl, fields } = Astro.props;
|
|
14
|
+
const { existingSlugs, existingFullNames, repoUrl, fields, taxonomy } = Astro.props;
|
|
9
15
|
---
|
|
10
16
|
|
|
11
|
-
<script is:inline define:vars={{ existingSlugs, existingFullNames, repoUrl, fields }}>
|
|
17
|
+
<script is:inline define:vars={{ existingSlugs, existingFullNames, repoUrl, fields, taxonomy }}>
|
|
12
18
|
// @ts-nocheck
|
|
13
19
|
const $ = (selector) => document.querySelector(selector);
|
|
14
20
|
const inputs = {
|
|
@@ -24,6 +30,9 @@ const { existingSlugs, existingFullNames, repoUrl, fields } = Astro.props;
|
|
|
24
30
|
const openPrLink = $("#open-pr-link");
|
|
25
31
|
const slugSet = new Set(existingSlugs);
|
|
26
32
|
const repoSet = new Set(existingFullNames);
|
|
33
|
+
const categoryIds = new Set(taxonomy?.categoryIds ?? []);
|
|
34
|
+
const stackIds = new Set(taxonomy?.stackIds ?? []);
|
|
35
|
+
const platformIds = new Set(taxonomy?.platformIds ?? []);
|
|
27
36
|
let repository = null;
|
|
28
37
|
|
|
29
38
|
const slugify = (value) => String(value || "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
@@ -80,10 +89,45 @@ const { existingSlugs, existingFullNames, repoUrl, fields } = Astro.props;
|
|
|
80
89
|
"",
|
|
81
90
|
].join("\n");
|
|
82
91
|
};
|
|
92
|
+
const validationIssues = () => {
|
|
93
|
+
const issues = [];
|
|
94
|
+
if (slugSet.has(slugify(inputs.slug.value))) {
|
|
95
|
+
issues.push("A record with that slug already exists — update the existing one instead of opening a duplicate PR.");
|
|
96
|
+
}
|
|
97
|
+
if (inputs.description.value.trim().length < 40) {
|
|
98
|
+
issues.push("Description must be at least 40 characters.");
|
|
99
|
+
}
|
|
100
|
+
if (fields.category && (!inputs.category.value || inputs.category.value === "uncategorized" || (categoryIds.size > 0 && !categoryIds.has(inputs.category.value)))) {
|
|
101
|
+
issues.push("Choose a category from the taxonomy.");
|
|
102
|
+
}
|
|
103
|
+
if (fields.stack && (!inputs.stack.value || (stackIds.size > 0 && !stackIds.has(inputs.stack.value)))) {
|
|
104
|
+
issues.push("Choose a primary stack from the taxonomy.");
|
|
105
|
+
}
|
|
106
|
+
if (fields.platforms && selectedPlatforms().length === 0) {
|
|
107
|
+
issues.push("Select at least one platform.");
|
|
108
|
+
} else if (fields.platforms && platformIds.size > 0) {
|
|
109
|
+
const bad = selectedPlatforms().find((p) => !platformIds.has(p));
|
|
110
|
+
if (bad) issues.push(`Platform "${bad}" is not in the taxonomy.`);
|
|
111
|
+
}
|
|
112
|
+
return issues;
|
|
113
|
+
};
|
|
83
114
|
const refresh = () => {
|
|
84
115
|
const yaml = generateYaml();
|
|
85
116
|
preview.textContent = yaml || "Paste a GitHub URL to generate a draft.";
|
|
86
|
-
if (!yaml)
|
|
117
|
+
if (!yaml) {
|
|
118
|
+
openPrLink.classList.add("pointer-events-none", "opacity-50");
|
|
119
|
+
copyButton.disabled = true;
|
|
120
|
+
copyButton.classList.add("pointer-events-none", "opacity-50");
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
const issues = validationIssues();
|
|
124
|
+
if (issues.length > 0) {
|
|
125
|
+
setStatus(issues[0], "error");
|
|
126
|
+
openPrLink.classList.add("pointer-events-none", "opacity-50");
|
|
127
|
+
copyButton.disabled = true;
|
|
128
|
+
copyButton.classList.add("pointer-events-none", "opacity-50");
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
87
131
|
const params = new URLSearchParams({ filename: `data/records/${slugify(inputs.slug.value)}.yml`, value: yaml });
|
|
88
132
|
openPrLink.href = `${repoUrl}/new/main?${params}`;
|
|
89
133
|
openPrLink.classList.remove("pointer-events-none", "opacity-50");
|
|
@@ -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
|
+
)}
|
|
@@ -19,7 +19,12 @@ interface RawRecord {
|
|
|
19
19
|
platforms?: string[];
|
|
20
20
|
license?: string;
|
|
21
21
|
visibility?: string;
|
|
22
|
+
/** GitHub stargazers count. Stored on `github.repository.stargazers_count`
|
|
23
|
+
* in the full payload; read explicitly because the index payload
|
|
24
|
+
* does not flatten it. */
|
|
22
25
|
stars?: number;
|
|
26
|
+
/** GitHub fork count. Stored on `github.repository.forks_count`. */
|
|
27
|
+
forks?: number;
|
|
23
28
|
pushedAt?: string;
|
|
24
29
|
lastCommitAt?: string;
|
|
25
30
|
category?: string;
|
|
@@ -27,6 +32,15 @@ interface RawRecord {
|
|
|
27
32
|
scores?: { curation?: number; activity?: number };
|
|
28
33
|
repoUrl?: string;
|
|
29
34
|
links?: { github?: string; website?: string };
|
|
35
|
+
/** GitHub metadata block on the full record. The star / fork counts
|
|
36
|
+
* are read from `github.repository.stargazers_count` /
|
|
37
|
+
* `github.repository.forks_count` here. */
|
|
38
|
+
github?: {
|
|
39
|
+
repository?: {
|
|
40
|
+
stargazers_count?: number;
|
|
41
|
+
forks_count?: number;
|
|
42
|
+
};
|
|
43
|
+
};
|
|
30
44
|
}
|
|
31
45
|
|
|
32
46
|
/**
|
|
@@ -68,7 +82,8 @@ export function recordsToCollectionEntries(
|
|
|
68
82
|
platform: r.platforms,
|
|
69
83
|
license: r.license,
|
|
70
84
|
status: r.visibility,
|
|
71
|
-
stars: r.stars,
|
|
85
|
+
stars: r.stars ?? r.github?.repository?.stargazers_count,
|
|
86
|
+
forks: r.forks ?? r.github?.repository?.forks_count,
|
|
72
87
|
pushedAt: r.pushedAt ?? r.lastCommitAt,
|
|
73
88
|
curationScore: r.scores?.curation,
|
|
74
89
|
activityScore: r.scores?.activity,
|
|
@@ -97,6 +112,10 @@ export interface CollectionPageModel {
|
|
|
97
112
|
total: number;
|
|
98
113
|
isEmpty: boolean;
|
|
99
114
|
entries: CollectionEntry[];
|
|
115
|
+
/** ItemList JSON-LD block. Pass to BaseLayout as the `jsonLd` prop
|
|
116
|
+
* so it ships in the document head and is indexable by search
|
|
117
|
+
* engines as a list of `SoftwareApplication` items. */
|
|
118
|
+
jsonLd?: unknown;
|
|
100
119
|
related: Array<{ slug: string; title: string; url: string }>;
|
|
101
120
|
}
|
|
102
121
|
|
|
@@ -121,6 +140,7 @@ export function getCollectionPageModel(
|
|
|
121
140
|
collection: Collection,
|
|
122
141
|
entries: CollectionEntry[],
|
|
123
142
|
allCollections: Collection[],
|
|
143
|
+
site?: { name?: string; url?: string },
|
|
124
144
|
): CollectionPageModel {
|
|
125
145
|
const result = runCollection(collection, entries);
|
|
126
146
|
const related = findRelated(collection, allCollections, 4).map((c) => ({
|
|
@@ -128,6 +148,29 @@ export function getCollectionPageModel(
|
|
|
128
148
|
title: c.title,
|
|
129
149
|
url: `/collections/${c.slug}/`,
|
|
130
150
|
}));
|
|
151
|
+
// ItemList JSON-LD. Search engines can use this to surface
|
|
152
|
+
// individual entries directly from the collection URL.
|
|
153
|
+
// Cap at 50 entries to keep the JSON-LD payload bounded;
|
|
154
|
+
// search engines don't index beyond that anyway.
|
|
155
|
+
const itemListElement = result.entries.slice(0, 50).map((entry, index) => ({
|
|
156
|
+
"@type": "ListItem",
|
|
157
|
+
position: index + 1,
|
|
158
|
+
item: {
|
|
159
|
+
"@type": "SoftwareApplication",
|
|
160
|
+
name: entry.title,
|
|
161
|
+
url: entry.url,
|
|
162
|
+
description: entry.description,
|
|
163
|
+
},
|
|
164
|
+
}));
|
|
165
|
+
const jsonLd = {
|
|
166
|
+
"@context": "https://schema.org",
|
|
167
|
+
"@type": "ItemList",
|
|
168
|
+
name: collection.title,
|
|
169
|
+
description: collection.description,
|
|
170
|
+
url: site?.url ? `${site.url.replace(/\/$/, "")}/collections/${collection.slug}/` : undefined,
|
|
171
|
+
numberOfItems: result.entries.length,
|
|
172
|
+
itemListElement,
|
|
173
|
+
};
|
|
131
174
|
return {
|
|
132
175
|
collection: {
|
|
133
176
|
slug: collection.slug,
|
|
@@ -141,6 +184,7 @@ export function getCollectionPageModel(
|
|
|
141
184
|
isEmpty: result.isEmpty,
|
|
142
185
|
entries: result.entries,
|
|
143
186
|
related,
|
|
187
|
+
jsonLd,
|
|
144
188
|
};
|
|
145
189
|
}
|
|
146
190
|
|
|
@@ -202,3 +246,35 @@ export async function loadCollections(cwd: string): Promise<Collection[]> {
|
|
|
202
246
|
}
|
|
203
247
|
return out;
|
|
204
248
|
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Reverse lookup — given a record, return the slugs of every
|
|
252
|
+
* curated collection that includes it. Walks each collection's
|
|
253
|
+
* query + ranking once, applies the same filter the collection
|
|
254
|
+
* page uses, and returns the collection slugs that contain the
|
|
255
|
+
* target record. Used by the detail page's sidebar to show
|
|
256
|
+
* "Also in" / "Collection membership" links.
|
|
257
|
+
*
|
|
258
|
+
* The returned array includes `{slug, title}` pairs so the
|
|
259
|
+
* sidebar can render both the link target and a user-facing
|
|
260
|
+
* label without re-loading the collection YAML.
|
|
261
|
+
*/
|
|
262
|
+
export function findCollectionsFor(
|
|
263
|
+
target: { slug?: string; stack?: string; stacks?: string[]; platforms?: string[]; licenses?: string[]; category?: string; visibility?: string; status?: string },
|
|
264
|
+
collections: Collection[],
|
|
265
|
+
entries: CollectionEntry[],
|
|
266
|
+
): { slug: string; title: string; url: string }[] {
|
|
267
|
+
if (!target.slug) return [];
|
|
268
|
+
const result: { slug: string; title: string; url: string }[] = [];
|
|
269
|
+
for (const collection of collections) {
|
|
270
|
+
const filtered = runCollection(collection, entries).entries;
|
|
271
|
+
if (filtered.some((entry) => entry.slug === target.slug)) {
|
|
272
|
+
result.push({
|
|
273
|
+
slug: collection.slug,
|
|
274
|
+
title: collection.title,
|
|
275
|
+
url: `/collections/${collection.slug}/`,
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return result;
|
|
280
|
+
}
|