@grove-dev/astro 0.2.19 → 0.3.1

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.
Files changed (72) hide show
  1. package/LICENSE +21 -0
  2. package/dist/template-routes.test.d.ts +2 -0
  3. package/dist/template-routes.test.d.ts.map +1 -0
  4. package/dist/template-routes.test.js +63 -0
  5. package/dist/template-routes.test.js.map +1 -0
  6. package/dist/theme.test.js +19 -0
  7. package/dist/theme.test.js.map +1 -1
  8. package/package.json +4 -4
  9. package/src/components/CategoryGrid.astro +2 -11
  10. package/src/components/ContributorsGrid.astro +2 -11
  11. package/src/components/CurationGrid.astro +1 -1
  12. package/src/components/DecisionRow.astro +1 -1
  13. package/src/components/ExploreByCategory.astro +3 -3
  14. package/src/components/ExploreByStack.astro +3 -3
  15. package/src/components/FilterGroupMenu.astro +5 -5
  16. package/src/components/Hero.astro +8 -8
  17. package/src/components/Icon.astro +56 -53
  18. package/src/components/IndexRow.astro +5 -5
  19. package/src/components/ItemCard.astro +5 -16
  20. package/src/components/MinimalAbout.astro +2 -2
  21. package/src/components/OriginalCollection.astro +4 -4
  22. package/src/components/Pagination.astro +2 -2
  23. package/src/components/RecordSection.astro +1 -1
  24. package/src/components/RefinePanel.astro +1 -1
  25. package/src/components/SmartLensTabs.astro +3 -3
  26. package/src/components/StackGrid.astro +6 -15
  27. package/src/components/WhyThisExists.astro +3 -3
  28. package/src/layouts/Container.astro +4 -4
  29. package/src/layouts/Footer.astro +3 -3
  30. package/src/layouts/Header.astro +5 -5
  31. package/src/layouts/ThemeToggle.astro +1 -1
  32. package/src/styles.css +33 -339
  33. package/src/template-routes.test.ts +103 -0
  34. package/src/theme.test.ts +24 -0
  35. package/templates/default/.github/workflows/build.yml +1 -3
  36. package/templates/default/.github/workflows/cleanup-stale-records.yml +1 -3
  37. package/templates/default/.github/workflows/daily-refresh.yml +1 -3
  38. package/templates/default/.github/workflows/sync-contributors.yml +2 -4
  39. package/templates/default/.github/workflows/sync-github-metadata.yml +1 -3
  40. package/templates/default/.github/workflows/validate-data.yml +1 -3
  41. package/templates/default/grove.config.ts +1 -1
  42. package/templates/default/package.json +9 -4
  43. package/templates/default/public/icons/stacks/rag.svg +7 -0
  44. package/templates/default/public/llms-full.txt +63 -186
  45. package/templates/default/public/llms.txt +6 -107
  46. package/templates/default/public/sitemap.xml +10 -10
  47. package/templates/default/src/data/records.ts +80 -2
  48. package/templates/default/src/pages/404.astro +5 -5
  49. package/templates/default/src/pages/[slug]/[recordSlug].astro +36 -38
  50. package/templates/default/src/pages/[slug]/index.astro +223 -19
  51. package/templates/default/src/pages/about.astro +17 -10
  52. package/templates/default/src/pages/apps/[recordSlug].astro +2 -1
  53. package/templates/default/src/pages/contributors.astro +8 -17
  54. package/templates/default/src/pages/index.astro +11 -3
  55. package/templates/default/src/pages/sitemap.xml.ts +3 -3
  56. package/templates/default/src/pages/submit.astro +28 -36
  57. package/templates/default/tsconfig.json +9 -0
  58. package/templates/default/scripts/build-llms.mjs +0 -123
  59. package/templates/default/scripts/build-records-json.mjs +0 -27
  60. package/templates/default/scripts/build-sitemap.mjs +0 -20
  61. package/templates/default/scripts/cleanup-stale-records.mjs +0 -20
  62. package/templates/default/scripts/enrich-github-metadata.mjs +0 -99
  63. package/templates/default/scripts/fetch-icons.mjs +0 -137
  64. package/templates/default/scripts/migrate-legacy-to-schema-v1.mjs +0 -86
  65. package/templates/default/scripts/parse-legacy-readme.mjs +0 -59
  66. package/templates/default/scripts/refresh-records-activity.mjs +0 -24
  67. package/templates/default/scripts/repair-license-format.mjs +0 -72
  68. package/templates/default/scripts/report-cleanup-candidates.mjs +0 -21
  69. package/templates/default/scripts/seed-from-github.mjs +0 -62
  70. package/templates/default/scripts/sync-contributors.mjs +0 -145
  71. package/templates/default/scripts/sync-github-metadata.mjs +0 -28
  72. package/templates/default/scripts/validate-records.mjs +0 -27
@@ -20,7 +20,13 @@
20
20
  * logic. The URL stays the single source of truth for filter state.
21
21
  */
22
22
  import siteConfig from "../../../data/generated/site-config.json";
23
- import { items, itemLabel, itemLabelPlural } from "../../data/records";
23
+ import {
24
+ items,
25
+ itemLabel,
26
+ itemLabelPlural,
27
+ taxonomy,
28
+ taxonomyLabel,
29
+ } from "../../data/records";
24
30
  import type { IndexFilters } from "@grove-dev/astro";
25
31
  import BaseLayout from "@grove-dev/astro/layouts/BaseLayout.astro";
26
32
  import SmartLensTabs from "@grove-dev/astro/components/SmartLensTabs.astro";
@@ -41,19 +47,32 @@ import {
41
47
  totalPages,
42
48
  } from "@grove-dev/astro";
43
49
 
44
- // This template ships with the `project-directory` blueprint, so the
45
- // only directory slug it ever needs is `projects`. While it ships
46
- // as the project directory, pre-generate the single path the build
47
- // actually needs. Swapping in a different blueprint is a one-config
48
- // change that flows through the static import at the top.
49
50
  export function getStaticPaths() {
50
- return [{ params: { slug: "projects" } }];
51
+ const routeSlug = siteConfig.blueprintConfig?.routeSlug ?? "projects";
52
+ return [{ params: { slug: routeSlug } }];
51
53
  }
52
54
 
53
55
  const slug = Astro.params.slug as string;
54
56
  const total = items.length;
55
57
  const filters = filtersFromSearchParams(Astro.url.searchParams);
56
- const facets = buildFacets(items);
58
+ function labelFacetsWithTaxonomy(facets: ReturnType<typeof buildFacets>) {
59
+ return {
60
+ ...facets,
61
+ stacks: facets.stacks.map((option) => ({
62
+ ...option,
63
+ label: taxonomyLabel("stacks", option.value),
64
+ })),
65
+ platforms: facets.platforms.map((option) => ({
66
+ ...option,
67
+ label: taxonomyLabel("platforms", option.value),
68
+ })),
69
+ categories: facets.categories.map((option) => ({
70
+ ...option,
71
+ label: taxonomyLabel("categories", option.value),
72
+ })),
73
+ };
74
+ }
75
+ const facets = labelFacetsWithTaxonomy(buildFacets(items));
57
76
  const sort = effectiveSort(filters);
58
77
  const requestedPage = effectivePage(filters);
59
78
  const filtered = filterRecords(items, filters);
@@ -61,6 +80,7 @@ const sorted = applySort(filtered, sort);
61
80
  const pages = totalPages(sorted.length);
62
81
  const page = Math.min(requestedPage, pages);
63
82
  const chips = activeFilterChips(filters);
83
+ const clientItemsJson = JSON.stringify(items).replace(/</g, "\\u003c");
64
84
 
65
85
  const title = `Browse ${slug} — ${siteConfig.name}`;
66
86
  const description = `A searchable directory of ${itemLabelPlural()} for ${siteConfig.name}.`;
@@ -73,7 +93,6 @@ function hrefForPage(target: number): string {
73
93
 
74
94
  function hrefForRemove(key: keyof IndexFilters, value: string): string {
75
95
  const params = new URLSearchParams();
76
- const next: IndexFilters = { ...filters, page: 1 };
77
96
  const urlKey: Partial<Record<keyof IndexFilters, string>> = {
78
97
  stacks: "stack",
79
98
  platforms: "platform",
@@ -110,7 +129,7 @@ function hrefForRemove(key: keyof IndexFilters, value: string): string {
110
129
  submitLabel={`Submit ${itemLabel()}`}
111
130
  >
112
131
  <section class="border-b border-ink-200 bg-white dark:border-ink-800 dark:bg-ink-950">
113
- <div class="grove-container-wide mx-auto px-5 pt-10 sm:px-7 sm:pt-12">
132
+ <div class="mx-auto w-full max-w-wide px-5 pt-10 sm:px-7 sm:pt-12">
114
133
  <span class="text-2xs font-medium uppercase tracking-wider text-ink-500 dark:text-ink-400">
115
134
  {slug}
116
135
  </span>
@@ -171,7 +190,7 @@ function hrefForRemove(key: keyof IndexFilters, value: string): string {
171
190
  <path d="M4.22 6.22a.75.75 0 0 1 1.06 0L8 8.94l2.72-2.72a.75.75 0 1 1 1.06 1.06l-3.25 3.25a.75.75 0 0 1-1.06 0L4.22 7.28a.75.75 0 0 1 0-1.06Z" />
172
191
  </svg>
173
192
  </label>
174
- <button type="submit" class="grove-btn grove-btn-primary">Search</button>
193
+ <button type="submit" 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">Search</button>
175
194
  </form>
176
195
 
177
196
  <div class="mt-4">
@@ -192,8 +211,8 @@ function hrefForRemove(key: keyof IndexFilters, value: string): string {
192
211
  </div>
193
212
  </section>
194
213
 
195
- <section class="grove-section-tight">
196
- <div class="grove-container-wide mx-auto px-5 sm:px-7">
214
+ <section class="py-10 sm:py-12">
215
+ <div class="mx-auto w-full max-w-wide px-5 sm:px-7">
197
216
  <header class="mb-6">
198
217
  <h2 class="m-0 text-[1.5rem] sm:text-[1.625rem] font-semibold tracking-tight text-ink-900 dark:text-ink-100">
199
218
  {`All ${itemLabelPlural()}`}
@@ -220,7 +239,7 @@ function hrefForRemove(key: keyof IndexFilters, value: string): string {
220
239
  {chips.map((chip) => (
221
240
  <a
222
241
  href={hrefForRemove(chip.key, chip.value)}
223
- class="grove-badge transition-colors hover:border-ink-400"
242
+ class="inline-flex min-h-5 items-center justify-center gap-1 rounded-full border border-transparent bg-secondary px-2 py-0.5 align-middle text-xs font-medium leading-none text-secondary-foreground transition-colors hover:border-ink-400"
224
243
  >
225
244
  {chip.label} <span aria-hidden="true">x</span>
226
245
  </a>
@@ -248,16 +267,16 @@ function hrefForRemove(key: keyof IndexFilters, value: string): string {
248
267
  <p class="m-0 text-sm font-medium text-ink-700 dark:text-ink-200">
249
268
  {`No ${itemLabel()}s match these filters.`}
250
269
  </p>
251
- <a href={`/${slug}`} class="grove-btn grove-btn-outline mt-4">Clear filters</a>
270
+ <a href={`/${slug}`} 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 border-border bg-secondary text-foreground hover:bg-accent mt-4">Clear filters</a>
252
271
  </div>
253
- <nav class="mt-8 flex flex-wrap items-center justify-center gap-1.5" aria-label="Pagination">
272
+ <nav id="results-pagination" class="mt-8 flex flex-wrap items-center justify-center gap-1.5" aria-label="Pagination">
254
273
  <Pagination current={page} total={pages} hrefFor={hrefForPage} />
255
274
  </nav>
256
275
  </div>
257
276
  </section>
258
277
 
259
278
  <section class="border-t border-ink-200 dark:border-ink-800">
260
- <div class="grove-container-wide mx-auto px-5 sm:px-7">
279
+ <div class="mx-auto w-full max-w-wide px-5 sm:px-7">
261
280
  <div class="flex flex-col items-center gap-3 py-12 text-center">
262
281
  <h2 class="m-0 text-xl font-semibold tracking-tight text-ink-900 dark:text-ink-100">
263
282
  {`Know a real ${itemLabel()} that belongs here?`}
@@ -266,8 +285,8 @@ function hrefForRemove(key: keyof IndexFilters, value: string): string {
266
285
  {`Open a pull request with a YAML file. We review every submission against the bar — no marketing, no tutorials.`}
267
286
  </p>
268
287
  <a
269
- href={siteConfig.submitUrl ?? "/submit"}
270
- class="grove-btn grove-btn-primary mt-2"
288
+ href="/submit"
289
+ 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 mt-2"
271
290
  >
272
291
  {`Submit ${itemLabel()}`}
273
292
  </a>
@@ -275,4 +294,189 @@ function hrefForRemove(key: keyof IndexFilters, value: string): string {
275
294
  </div>
276
295
  </div>
277
296
  </section>
297
+
298
+ <script is:inline id="grove-index-data" type="application/json" set:html={clientItemsJson}></script>
299
+ <script
300
+ is:inline
301
+ define:vars={{
302
+ slug,
303
+ singular: itemLabel(),
304
+ plural: itemLabelPlural(),
305
+ taxonomy,
306
+ }}
307
+ >
308
+ (() => {
309
+ const PAGE_SIZE = 20;
310
+ const source = document.querySelector("#grove-index-data");
311
+ const list = document.querySelector("#results-list");
312
+ if (!source || !list) return;
313
+
314
+ const items = JSON.parse(source.textContent || "[]");
315
+ const nodes = new Map(
316
+ [...list.querySelectorAll("[data-record-slug]")].map((node) => [
317
+ node.dataset.recordSlug,
318
+ node,
319
+ ]),
320
+ );
321
+ const params = new URLSearchParams(location.search);
322
+ const values = (key) => params.getAll(key).filter(Boolean);
323
+ const q = (params.get("q") || "").trim().toLowerCase();
324
+ const stacks = values("stack");
325
+ const platforms = values("platform");
326
+ const categories = values("category");
327
+ const labels = values("label");
328
+ const licenses = values("license");
329
+ const statuses = values("status").flatMap((value) => value.split(",")).filter(Boolean);
330
+ const lens = params.get("lens") || "";
331
+ const sort = params.get("sort") || "recently-updated";
332
+ const requestedPage = Math.max(1, Number.parseInt(params.get("page") || "1", 10) || 1);
333
+
334
+ const nameOf = (record) => record.kind === "resource" ? record.title : record.name;
335
+ const projectValues = (record) => record.kind === "project"
336
+ ? [record.stack, ...(record.stacks || [])].filter(Boolean)
337
+ : [];
338
+ const matchesAny = (actual, expected) =>
339
+ expected.length === 0 || actual.some((value) => expected.includes(value));
340
+ const matches = (record) => {
341
+ if (q) {
342
+ const repo = record.kind === "project"
343
+ ? record.repoUrl || record.links?.github || ""
344
+ : record.links?.github || "";
345
+ const owner = /github\.com\/([^/]+)\//.exec(repo)?.[1] || "";
346
+ const projectFields = record.kind === "project"
347
+ ? [
348
+ record.stack,
349
+ ...(record.stacks || []),
350
+ ...(record.platforms || []),
351
+ record.projectType,
352
+ ...(record.bestFor || []),
353
+ ...(record.whyListed || []),
354
+ record.github?.license,
355
+ record.health?.status,
356
+ ]
357
+ : [];
358
+ const haystack = [
359
+ nameOf(record),
360
+ owner,
361
+ record.description,
362
+ record.category,
363
+ ...(record.tags || []),
364
+ ...projectFields,
365
+ ].filter(Boolean).join(" ").toLowerCase();
366
+ if (!haystack.includes(q)) return false;
367
+ }
368
+ if (!matchesAny(projectValues(record), stacks)) return false;
369
+ if (!matchesAny(record.kind === "project" ? record.platforms || [] : [], platforms)) return false;
370
+ if (categories.length && !categories.includes(record.category)) return false;
371
+ if (!matchesAny(record.curation?.labels || [], labels)) return false;
372
+ if (licenses.length && !licenses.includes(record.kind === "project" ? record.github?.license : "")) return false;
373
+ if (statuses.length && !statuses.includes(record.kind === "project" ? record.health?.status : "")) return false;
374
+ if (lens && !(record.curation?.lenses || []).includes(lens)) return false;
375
+ return true;
376
+ };
377
+ const time = (value) => value ? new Date(value).valueOf() || 0 : 0;
378
+ const stars = (record) => record.kind === "project" ? record.github?.stars || 0 : 0;
379
+ const compare = {
380
+ "most-starred": (a, b) => stars(b) - stars(a),
381
+ "recently-updated": (a, b) =>
382
+ time(b.kind === "project" ? b.github?.pushedAt : b.publishedAt) -
383
+ time(a.kind === "project" ? a.github?.pushedAt : a.publishedAt),
384
+ "recently-added": (a, b) =>
385
+ time(b.curation?.reviewedAt) - time(a.curation?.reviewedAt),
386
+ "best-overall": (a, b) =>
387
+ Number(Boolean(b.curation?.reviewed)) - Number(Boolean(a.curation?.reviewed)) ||
388
+ stars(b) - stars(a),
389
+ "alphabetical": (a, b) => String(nameOf(a)).localeCompare(String(nameOf(b))),
390
+ }[sort];
391
+
392
+ function applyClientFilters() {
393
+ const filtered = items.filter(matches);
394
+ if (compare) filtered.sort(compare);
395
+ const pageCount = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
396
+ const page = Math.min(requestedPage, pageCount);
397
+ const visible = new Set(
398
+ filtered
399
+ .slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)
400
+ .map((record) => record.slug),
401
+ );
402
+
403
+ for (const record of filtered) {
404
+ const node = nodes.get(record.slug);
405
+ if (node) list.append(node);
406
+ }
407
+ for (const [recordSlug, node] of nodes) {
408
+ node.hidden = !visible.has(recordSlug);
409
+ }
410
+
411
+ const count = document.querySelector("#results-count");
412
+ const pageLabel = document.querySelector("#results-page");
413
+ const empty = document.querySelector("#empty-state");
414
+ if (count) count.textContent = `${filtered.length} ${filtered.length === 1 ? singular : plural}`;
415
+ if (pageLabel) pageLabel.textContent = pageCount > 1 ? ` · page ${page} of ${pageCount}` : "";
416
+ if (empty) empty.classList.toggle("hidden", filtered.length > 0);
417
+
418
+ const pagination = document.querySelector("#results-pagination");
419
+ if (pagination) {
420
+ pagination.innerHTML = "";
421
+ if (pageCount > 1) {
422
+ for (let target = 1; target <= pageCount; target += 1) {
423
+ const next = new URLSearchParams(params);
424
+ if (target === 1) next.delete("page");
425
+ else next.set("page", String(target));
426
+ const link = document.createElement("a");
427
+ link.href = `/${slug}${next.size ? `?${next}` : ""}`;
428
+ link.textContent = String(target);
429
+ link.setAttribute("aria-label", `Page ${target}`);
430
+ if (target === page) link.setAttribute("aria-current", "page");
431
+ link.className = "inline-flex h-8 min-w-8 items-center justify-center rounded-md border border-border px-2 text-sm";
432
+ pagination.append(link);
433
+ }
434
+ }
435
+ }
436
+ }
437
+
438
+ const searchInput = document.querySelector("#search-input");
439
+ if (searchInput) searchInput.value = params.get("q") || "";
440
+ document.querySelectorAll('select[name="sort"]').forEach((select) => {
441
+ select.value = sort;
442
+ });
443
+ const filterKeys = {
444
+ stacks: "stack",
445
+ platforms: "platform",
446
+ categories: "category",
447
+ licenses: "license",
448
+ };
449
+ const filterLabels = {
450
+ stacks: "Stack",
451
+ platforms: "Platform",
452
+ categories: "Category",
453
+ licenses: "License",
454
+ };
455
+ const taxonomyKinds = {
456
+ stacks: "stacks",
457
+ platforms: "platforms",
458
+ categories: "categories",
459
+ };
460
+ const taxonomyName = (groupKey, value) =>
461
+ (taxonomy[taxonomyKinds[groupKey]] || []).find((entry) => entry.id === value)?.name ??
462
+ value;
463
+ document.querySelectorAll(".grove-filter").forEach((group) => {
464
+ const selected = values(filterKeys[group.dataset.filterGroupKey]);
465
+ group.querySelectorAll(".grove-filter-input").forEach((input) => {
466
+ input.checked = selected.includes(input.dataset.filterValue);
467
+ });
468
+ const trigger = group.querySelector(".grove-filter-trigger span");
469
+ if (trigger && selected.length) {
470
+ const label = filterLabels[group.dataset.filterGroupKey];
471
+ trigger.textContent = `${label}: ${
472
+ selected.length === 1
473
+ ? taxonomyName(group.dataset.filterGroupKey, selected[0])
474
+ : `${selected.length} selected`
475
+ }`;
476
+ }
477
+ });
478
+
479
+ applyClientFilters();
480
+ })();
481
+ </script>
278
482
  </BaseLayout>
@@ -13,7 +13,7 @@
13
13
  * - New: "A growing community knowledge site. A curated, health-
14
14
  * aware developer directory…"
15
15
  *
16
- * The card utility classes (`grove-card`, `grove-btn-*`) match the
16
+ * The Tailwind card and button utilities match the
17
17
  * rest of the framework, and the data fields it references
18
18
  * (`siteConfig.tagline`, `siteConfig.repoUrl`) all flow from the
19
19
  * site config — no hardcoded brand or copy.
@@ -21,13 +21,14 @@
21
21
  import BaseLayout from "@grove-dev/astro/layouts/BaseLayout.astro";
22
22
  import Container from "@grove-dev/astro/layouts/Container.astro";
23
23
  import siteConfig from "../../data/generated/site-config.json";
24
- import { indexSlug, itemLabel } from "../data/records";
24
+ import { getPageContentHtml, indexSlug, itemLabel } from "../data/records";
25
25
 
26
26
  const githubUrl = siteConfig.repoUrl;
27
27
  const title = `About — ${siteConfig.name}`;
28
28
  const description = `${siteConfig.tagline} Built with Grove.`;
29
29
  const slug = indexSlug();
30
30
  const item = itemLabel();
31
+ const pageContentHtml = getPageContentHtml("about");
31
32
  ---
32
33
 
33
34
  <BaseLayout title={title} description={description} site={siteConfig}>
@@ -39,6 +40,10 @@ const item = itemLabel();
39
40
  <span class="text-ink-700 dark:text-ink-300">About</span>
40
41
  </nav>
41
42
 
43
+ {pageContentHtml ? (
44
+ <section class="grove-prose mt-8" set:html={pageContentHtml} />
45
+ ) : (
46
+ <>
42
47
  <header class="mt-5">
43
48
  <span class="text-2xs font-medium uppercase tracking-wider text-ink-500 dark:text-ink-400">
44
49
  About
@@ -57,7 +62,7 @@ const item = itemLabel();
57
62
  </p>
58
63
  </header>
59
64
 
60
- <section class="grove-card mt-8 p-5 sm:p-6">
65
+ <section class="rounded-[calc(var(--radius)+0.25rem)] border-0 bg-muted shadow-none transition-colors mt-8 p-5 sm:p-6">
61
66
  <span class="text-2xs font-medium uppercase tracking-wider text-ink-500 dark:text-ink-400">
62
67
  Why
63
68
  </span>
@@ -78,7 +83,7 @@ const item = itemLabel();
78
83
  </p>
79
84
  </section>
80
85
 
81
- <section class="grove-card mt-4 p-5 sm:p-6">
86
+ <section class="rounded-[calc(var(--radius)+0.25rem)] border-0 bg-muted shadow-none transition-colors mt-4 p-5 sm:p-6">
82
87
  <span class="text-2xs font-medium uppercase tracking-wider text-ink-500 dark:text-ink-400">
83
88
  Why real {item}s
84
89
  </span>
@@ -95,7 +100,7 @@ const item = itemLabel();
95
100
  </p>
96
101
  </section>
97
102
 
98
- <section class="grove-card mt-4 p-5 sm:p-6">
103
+ <section class="rounded-[calc(var(--radius)+0.25rem)] border-0 bg-muted shadow-none transition-colors mt-4 p-5 sm:p-6">
99
104
  <span class="text-2xs font-medium uppercase tracking-wider text-ink-500 dark:text-ink-400">
100
105
  What you get
101
106
  </span>
@@ -130,7 +135,7 @@ const item = itemLabel();
130
135
  </ul>
131
136
  </section>
132
137
 
133
- <section class="grove-card mt-4 p-5 sm:p-6">
138
+ <section class="rounded-[calc(var(--radius)+0.25rem)] border-0 bg-muted shadow-none transition-colors mt-4 p-5 sm:p-6">
134
139
  <span class="text-2xs font-medium uppercase tracking-wider text-ink-500 dark:text-ink-400">
135
140
  Generic by design
136
141
  </span>
@@ -145,7 +150,7 @@ const item = itemLabel();
145
150
  </p>
146
151
  </section>
147
152
 
148
- <section class="grove-card mt-4 p-5 sm:p-6">
153
+ <section class="rounded-[calc(var(--radius)+0.25rem)] border-0 bg-muted shadow-none transition-colors mt-4 p-5 sm:p-6">
149
154
  <span class="text-2xs font-medium uppercase tracking-wider text-ink-500 dark:text-ink-400">
150
155
  Free to fork, free to extend
151
156
  </span>
@@ -164,19 +169,21 @@ const item = itemLabel();
164
169
  </section>
165
170
 
166
171
  <section class="mt-8 flex flex-wrap items-center gap-3">
167
- <a href={`/${slug}`} class="grove-btn grove-btn-primary">Browse the directory</a>
172
+ <a href={`/${slug}`} 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">Browse the directory</a>
168
173
  {githubUrl && (
169
174
  <a
170
175
  href={githubUrl}
171
176
  target="_blank"
172
177
  rel="noopener noreferrer"
173
- class="grove-btn grove-btn-outline"
178
+ 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 border-border bg-secondary text-foreground hover:bg-accent"
174
179
  >
175
180
  View on GitHub
176
181
  </a>
177
182
  )}
178
- <a href="/submit" class="grove-btn grove-btn-ghost">Submit a {item} →</a>
183
+ <a href="/submit" 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-transparent text-foreground hover:bg-accent hover:text-accent-foreground">Submit a {item} →</a>
179
184
  </section>
185
+ </>
186
+ )}
180
187
  </article>
181
188
  </Container>
182
189
  </BaseLayout>
@@ -10,9 +10,10 @@
10
10
  * this page is a pure redirect: any `/apps/<slug>` request is
11
11
  * sent to the blueprint-canonical path.
12
12
  */
13
- import { indexSlug, fullRecords, projectBySlug, recordBySlug } from "../../data/records";
13
+ import { indexSlug, fullRecords, recordBySlug } from "../../data/records";
14
14
 
15
15
  export async function getStaticPaths() {
16
+ if (indexSlug() === "apps") return [];
16
17
  return fullRecords.map((r) => ({ params: { recordSlug: r.slug } }));
17
18
  }
18
19
 
@@ -82,8 +82,8 @@ const contributorsGraphHref = repoUrl ? `${repoUrl.replace(/\/$/, "")}/graphs/co
82
82
  ---
83
83
 
84
84
  <BaseLayout title={title} description={description} site={siteConfig}>
85
- <section class="grove-section">
86
- <div class="grove-container-wide mx-auto px-5 py-12 sm:px-7 sm:py-16">
85
+ <section class="py-12 sm:py-16">
86
+ <div class="mx-auto w-full max-w-wide px-5 py-12 sm:px-7 sm:py-16">
87
87
  <span class="text-2xs font-medium uppercase tracking-wider text-ink-500 dark:text-ink-400">
88
88
  Contributors
89
89
  </span>
@@ -112,9 +112,9 @@ const contributorsGraphHref = repoUrl ? `${repoUrl.replace(/\/$/, "")}/graphs/co
112
112
  </section>
113
113
 
114
114
  {sorted.length === 0 ? (
115
- <section class="grove-section">
116
- <div class="grove-container mx-auto px-5 pb-16 sm:px-7">
117
- <div class="grove-card flex flex-col items-center gap-3 p-8 text-center">
115
+ <section class="py-12 sm:py-16">
116
+ <div class="mx-auto w-full max-w-container px-5 pb-16 sm:px-7">
117
+ <div class="rounded-[calc(var(--radius)+0.25rem)] border-0 bg-muted shadow-none transition-colors flex flex-col items-center gap-3 p-8 text-center">
118
118
  <p class="m-0 text-sm text-ink-500 dark:text-ink-400">
119
119
  No contributors synced yet. Once the first sync runs, every
120
120
  contributor avatar lands here.
@@ -123,8 +123,8 @@ const contributorsGraphHref = repoUrl ? `${repoUrl.replace(/\/$/, "")}/graphs/co
123
123
  </div>
124
124
  </section>
125
125
  ) : (
126
- <section class="grove-section" aria-label="Contributors list">
127
- <div class="grove-container-wide mx-auto px-5 pb-16 sm:px-7">
126
+ <section class="py-12 sm:py-16" aria-label="Contributors list">
127
+ <div class="mx-auto w-full max-w-wide px-5 pb-16 sm:px-7">
128
128
  <ul
129
129
  class="m-0 grid list-none grid-cols-2 gap-3 p-0 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6"
130
130
  aria-label="Contributors"
@@ -140,7 +140,7 @@ const contributorsGraphHref = repoUrl ? `${repoUrl.replace(/\/$/, "")}/graphs/co
140
140
  href={href}
141
141
  target="_blank"
142
142
  rel="noopener noreferrer"
143
- class="group grove-card flex flex-col items-center gap-2 p-3 text-center no-underline transition-colors hover:border-ink-300 dark:hover:border-ink-700"
143
+ class="group rounded-[calc(var(--radius)+0.25rem)] border-0 bg-muted shadow-none transition-colors flex flex-col items-center gap-2 p-3 text-center no-underline hover:border-ink-300 dark:hover:border-ink-700"
144
144
  data-event="contributor_click"
145
145
  data-event-source="contributors_page"
146
146
  >
@@ -173,12 +173,3 @@ const contributorsGraphHref = repoUrl ? `${repoUrl.replace(/\/$/, "")}/graphs/co
173
173
  </section>
174
174
  )}
175
175
  </BaseLayout>
176
-
177
- <style>
178
- .line-clamp-1 {
179
- display: -webkit-box;
180
- -webkit-line-clamp: 1;
181
- -webkit-box-orient: vertical;
182
- overflow: hidden;
183
- }
184
- </style>
@@ -20,7 +20,7 @@ import CategoryGrid from "@grove-dev/astro/components/CategoryGrid.astro";
20
20
  import ContributorsGrid from "@grove-dev/astro/components/ContributorsGrid.astro";
21
21
  import OriginalCollection from "@grove-dev/astro/components/OriginalCollection.astro";
22
22
 
23
- import { items, fullRecords, fullItems } from "../data/records";
23
+ import { items, fullItems, taxonomyLabel } from "../data/records";
24
24
  import { existsSync, readFileSync } from "node:fs";
25
25
  import { fileURLToPath } from "node:url";
26
26
  import { dirname, resolve } from "node:path";
@@ -87,10 +87,18 @@ for (const r of fullItems) {
87
87
  const stacks = [...stackMap.entries()]
88
88
  .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
89
89
  .slice(0, 12)
90
- .map(([name, count]) => ({ name, slug: name, count }));
90
+ .map(([name, count]) => ({
91
+ name: taxonomyLabel("stacks", name),
92
+ slug: name,
93
+ count,
94
+ }));
91
95
  const categories = [...catMap.entries()]
92
96
  .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
93
- .map(([name, count]) => ({ name, slug: name, count }));
97
+ .map(([name, count]) => ({
98
+ name: taxonomyLabel("categories", name),
99
+ slug: name,
100
+ count,
101
+ }));
94
102
 
95
103
  // Contributors (best-effort from data/generated/contributors.json)
96
104
  const here = dirname(fileURLToPath(import.meta.url));
@@ -30,8 +30,8 @@ function absoluteUrl(siteUrl: string, path: string): string {
30
30
  return `${base}${tail}`;
31
31
  }
32
32
 
33
- function buildEntries(siteUrl: string, blueprint: string | undefined): SitemapEntry[] {
34
- const slug = indexSlug(blueprint);
33
+ function buildEntries(siteUrl: string): SitemapEntry[] {
34
+ const slug = indexSlug();
35
35
  const now = new Date().toISOString().slice(0, 10);
36
36
 
37
37
  const top: SitemapEntry[] = [
@@ -78,7 +78,7 @@ function renderXml(entries: SitemapEntry[]): string {
78
78
  }
79
79
 
80
80
  export const GET: APIRoute = () => {
81
- const xml = renderXml(buildEntries(siteConfig.siteUrl ?? "", siteConfig.blueprint));
81
+ const xml = renderXml(buildEntries(siteConfig.siteUrl ?? ""));
82
82
  return new Response(xml, {
83
83
  status: 200,
84
84
  headers: {