@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.
@@ -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) return;
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
+ }