@grove-dev/astro 0.2.20 → 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 (46) 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 +5 -0
  7. package/dist/theme.test.js.map +1 -1
  8. package/package.json +4 -4
  9. package/src/components/Icon.astro +52 -22
  10. package/src/template-routes.test.ts +103 -0
  11. package/src/theme.test.ts +9 -0
  12. package/templates/default/.github/workflows/build.yml +1 -3
  13. package/templates/default/.github/workflows/cleanup-stale-records.yml +1 -3
  14. package/templates/default/.github/workflows/daily-refresh.yml +1 -3
  15. package/templates/default/.github/workflows/sync-contributors.yml +2 -4
  16. package/templates/default/.github/workflows/sync-github-metadata.yml +1 -3
  17. package/templates/default/.github/workflows/validate-data.yml +1 -3
  18. package/templates/default/grove.config.ts +1 -1
  19. package/templates/default/package.json +9 -4
  20. package/templates/default/public/llms-full.txt +63 -186
  21. package/templates/default/public/llms.txt +6 -107
  22. package/templates/default/public/sitemap.xml +10 -10
  23. package/templates/default/src/data/records.ts +80 -2
  24. package/templates/default/src/pages/[slug]/[recordSlug].astro +9 -7
  25. package/templates/default/src/pages/[slug]/index.astro +215 -11
  26. package/templates/default/src/pages/about.astro +8 -1
  27. package/templates/default/src/pages/apps/[recordSlug].astro +2 -1
  28. package/templates/default/src/pages/index.astro +11 -3
  29. package/templates/default/src/pages/sitemap.xml.ts +3 -3
  30. package/templates/default/src/pages/submit.astro +5 -5
  31. package/templates/default/tsconfig.json +9 -0
  32. package/templates/default/scripts/build-llms.mjs +0 -123
  33. package/templates/default/scripts/build-records-json.mjs +0 -27
  34. package/templates/default/scripts/build-sitemap.mjs +0 -20
  35. package/templates/default/scripts/cleanup-stale-records.mjs +0 -20
  36. package/templates/default/scripts/enrich-github-metadata.mjs +0 -99
  37. package/templates/default/scripts/fetch-icons.mjs +0 -137
  38. package/templates/default/scripts/migrate-legacy-to-schema-v1.mjs +0 -86
  39. package/templates/default/scripts/parse-legacy-readme.mjs +0 -59
  40. package/templates/default/scripts/refresh-records-activity.mjs +0 -24
  41. package/templates/default/scripts/repair-license-format.mjs +0 -72
  42. package/templates/default/scripts/report-cleanup-candidates.mjs +0 -21
  43. package/templates/default/scripts/seed-from-github.mjs +0 -62
  44. package/templates/default/scripts/sync-contributors.mjs +0 -145
  45. package/templates/default/scripts/sync-github-metadata.mjs +0 -28
  46. 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",
@@ -250,7 +269,7 @@ function hrefForRemove(key: keyof IndexFilters, value: string): string {
250
269
  </p>
251
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>
@@ -266,7 +285,7 @@ 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"}
288
+ href="/submit"
270
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()}`}
@@ -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>
@@ -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
@@ -177,6 +182,8 @@ const item = itemLabel();
177
182
  )}
178
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
 
@@ -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: {
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  import siteConfig from "../../data/generated/site-config.json";
3
- import { fullItems, itemLabel } from "../data/records";
3
+ import { fullProjects, itemLabel } from "../data/records";
4
4
  import BaseLayout from "@grove-dev/astro/layouts/BaseLayout.astro";
5
5
  import Container from "@grove-dev/astro/layouts/Container.astro";
6
6
 
@@ -10,12 +10,12 @@ const description = `Submit a new ${singular} to ${siteConfig.name}.`;
10
10
  const repoUrl = siteConfig.repoUrl ?? "https://github.com/tortuvshin/grove";
11
11
 
12
12
  const values = (key: "category" | "stack") =>
13
- [...new Set(fullItems.map((item) => String(item[key] ?? "")).filter(Boolean))]
13
+ [...new Set(fullProjects.map((item) => String(item[key] ?? "")).filter(Boolean))]
14
14
  .sort((a, b) => a.localeCompare(b));
15
15
  const categories = values("category");
16
16
  const stacks = values("stack");
17
- const existingSlugs = fullItems.map((item) => item.slug).filter(Boolean);
18
- const existingFullNames = fullItems
17
+ const existingSlugs = fullProjects.map((item) => item.slug).filter(Boolean);
18
+ const existingFullNames = fullProjects
19
19
  .map((item) => {
20
20
  const url = String(item.repoUrl ?? item.links?.github ?? "");
21
21
  const match = url.match(/github\.com\/([^/]+)\/([^/?#]+)/i);
@@ -217,7 +217,7 @@ const pleaseAvoid = [
217
217
  ...(inputs.website.value ? [` website: ${inputs.website.value}`] : []),
218
218
  "bestFor:", bestFor.length ? lines(bestFor) : " []",
219
219
  "source:",
220
- " type: github",
220
+ " type: manual",
221
221
  ` owner: ${parsed.owner}`,
222
222
  ` repo: ${parsed.repo}`,
223
223
  "curation:",
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "astro/tsconfigs/base",
3
+ "compilerOptions": {
4
+ "moduleResolution": "Bundler",
5
+ "types": ["node"]
6
+ },
7
+ "include": [".astro/types.d.ts", "**/*"],
8
+ "exclude": ["dist"]
9
+ }
@@ -1,123 +0,0 @@
1
- #!/usr/bin/env node
2
- // SPDX-License-Identifier: MIT
3
-
4
- /**
5
- * build-llms.mjs — emit `public/llms-full.txt` (per-record
6
- * markdown mirror) only.
7
- *
8
- * `public/llms.txt` is a static file committed to the template —
9
- * it does NOT get regenerated by this script. The build pipeline
10
- * treats the committed version as the canonical, hand-curated
11
- * LLM-facing index. The script only refreshes the per-record
12
- * `llms-full.txt` from the latest `data/generated/records.full.json`.
13
- *
14
- * This is intentional: openapps' `llms.txt` is also a
15
- * hand-curated 30+ line document, not an auto-generated stub.
16
- *
17
- * Usage: node scripts/build-llms.mjs
18
- */
19
- import { mkdir, readFile, writeFile, access } from "node:fs/promises";
20
- import { dirname, join, resolve } from "node:path";
21
- import { fileURLToPath } from "node:url";
22
-
23
- const __dirname = dirname(fileURLToPath(import.meta.url));
24
- const ROOT = resolve(__dirname, "..");
25
- const SOURCE = join(ROOT, "data", "generated", "records.full.json");
26
- const OUT_DIR = join(ROOT, "public");
27
- const FULL = join(OUT_DIR, "llms-full.txt");
28
-
29
- const SITE = process.env.GROVE_SITE ?? "https://example.com";
30
-
31
- function compactStars(n) {
32
- if (typeof n !== "number" || !Number.isFinite(n)) return "—";
33
- if (n >= 1_000_000) return (n / 1_000_000).toFixed(1).replace(/\.0$/, "") + "M";
34
- if (n >= 1_000) {
35
- const k = n / 1_000;
36
- return (k >= 10 ? Math.round(k).toString() : k.toFixed(1).replace(/\.0$/, "")) + "k";
37
- }
38
- return n.toString();
39
- }
40
-
41
- function relativeTime(iso) {
42
- if (!iso) return "—";
43
- const d = new Date(iso);
44
- if (isNaN(d.valueOf())) return "—";
45
- const days = Math.floor((Date.now() - d.valueOf()) / 86_400_000);
46
- if (days === 0) return "today";
47
- if (days === 1) return "1 day ago";
48
- if (days < 30) return `${days} days ago`;
49
- if (days < 365) return `${Math.floor(days / 30)} months ago`;
50
- return `${Math.floor(days / 365)} years ago`;
51
- }
52
-
53
- function buildFullTxt(records) {
54
- const lines = [];
55
- lines.push(`# ${SITE.replace(/^https?:\/\//, "")} — full directory`);
56
- lines.push("");
57
- lines.push(`Generated: ${new Date().toISOString()}`);
58
- lines.push(`Records: ${records.length}`);
59
- lines.push("");
60
- lines.push("---");
61
- lines.push("");
62
-
63
- for (const r of records) {
64
- const name = r.name ?? r.title ?? r.slug;
65
- const stack = r.stack ?? (Array.isArray(r.stacks) ? r.stacks.join(", ") : "");
66
- const platforms = Array.isArray(r.platforms) ? r.platforms.join(", ") : "";
67
- const tags = Array.isArray(r.tags) ? r.tags.join(", ") : "";
68
- const stars = compactStars(r.stars ?? r.github?.stars ?? 0);
69
- const updated = relativeTime(r.lastCommitAt ?? r.github?.pushedAt ?? null);
70
- const repoUrl = r.repoUrl ?? r.links?.github ?? "";
71
- const url = `${SITE.replace(/\/$/, "")}/${r.slug}`;
72
- const desc = r.description ?? "";
73
-
74
- lines.push(`## ${name}`);
75
- lines.push("");
76
- if (desc) lines.push(desc);
77
- lines.push("");
78
- lines.push(`- **URL**: ${url}`);
79
- if (repoUrl) lines.push(`- **Repository**: ${repoUrl}`);
80
- if (stack) lines.push(`- **Stack**: ${stack}`);
81
- if (platforms) lines.push(`- **Platforms**: ${platforms}`);
82
- if (r.category) lines.push(`- **Category**: ${r.category}`);
83
- if (tags) lines.push(`- **Tags**: ${tags}`);
84
- if (r.license) lines.push(`- **License**: ${r.license}`);
85
- lines.push(`- **Stars**: ${stars}`);
86
- lines.push(`- **Last commit**: ${updated}`);
87
- lines.push("");
88
- if (Array.isArray(r.bestFor) && r.bestFor.length) {
89
- lines.push("**Best for:**");
90
- for (const b of r.bestFor) lines.push(`- ${b}`);
91
- lines.push("");
92
- }
93
- if (Array.isArray(r.whyListed) && r.whyListed.length) {
94
- lines.push("**Why listed:**");
95
- for (const w of r.whyListed) lines.push(`- ${w}`);
96
- lines.push("");
97
- }
98
- lines.push("---");
99
- lines.push("");
100
- }
101
- return lines.join("\n");
102
- }
103
-
104
- async function main() {
105
- if (!(await access(SOURCE).then(() => true).catch(() => false))) {
106
- console.error(`No ${SOURCE} found. Run \`grove generate\` first.`);
107
- process.exit(1);
108
- }
109
- const raw = JSON.parse(await readFile(SOURCE, "utf8"));
110
- const records = (raw.records ?? []).filter(
111
- (r) => r.visibility !== "hide" && r.visibility !== "remove",
112
- );
113
-
114
- await mkdir(OUT_DIR, { recursive: true });
115
- await writeFile(FULL, buildFullTxt(records), "utf8");
116
- console.log(`[llms] ${records.length} indexed → ${FULL}`);
117
- console.log(`[llms] public/llms.txt is committed (NOT regenerated) — see scripts/build-llms.mjs`);
118
- }
119
-
120
- main().catch((err) => {
121
- console.error("build-llms failed:", err);
122
- process.exit(1);
123
- });
@@ -1,27 +0,0 @@
1
- #!/usr/bin/env node
2
- // SPDX-License-Identifier: MIT
3
-
4
- /**
5
- * build-records-json.mjs — yml → normalized JSON.
6
- *
7
- * Wraps `@grove-dev/cli generate`, which produces:
8
- * - data/generated/records.full.json (every record, all fields)
9
- * - data/generated/records.index.json (slim projection, visible only)
10
- * - data/generated/records.json (alias of records.full.json)
11
- * - data/generated/site-config.json (current blueprint + stats)
12
- *
13
- * Run by `pnpm run build:data` (also pre-build).
14
- */
15
- import { spawnSync } from "node:child_process";
16
-
17
- const result = spawnSync("grove", ["generate"], {
18
- stdio: "inherit",
19
- env: process.env,
20
- });
21
-
22
- if (result.error) {
23
- console.error("Failed to spawn `grove generate`:", result.error.message);
24
- process.exit(1);
25
- }
26
-
27
- process.exit(result.status ?? 0);
@@ -1,20 +0,0 @@
1
- #!/usr/bin/env node
2
- // SPDX-License-Identifier: MIT
3
-
4
- /**
5
- * build-sitemap.mjs — emit public/sitemap.xml from the generated
6
- * index JSON. Thin wrapper around `@grove-dev/cli sitemap`.
7
- */
8
- import { spawnSync } from "node:child_process";
9
-
10
- const result = spawnSync("grove", ["sitemap"], {
11
- stdio: "inherit",
12
- env: process.env,
13
- });
14
-
15
- if (result.error) {
16
- console.error("Failed to spawn `grove sitemap`:", result.error.message);
17
- process.exit(1);
18
- }
19
-
20
- process.exit(result.status ?? 0);
@@ -1,20 +0,0 @@
1
- #!/usr/bin/env node
2
- // SPDX-License-Identifier: MIT
3
-
4
- /**
5
- * cleanup-stale-records.mjs — flag records that look like they
6
- * should be archived. Wrapper around `grove cleanup stale`.
7
- */
8
- import { spawnSync } from "node:child_process";
9
-
10
- const result = spawnSync("grove", ["cleanup", "stale"], {
11
- stdio: "inherit",
12
- env: process.env,
13
- });
14
-
15
- if (result.error) {
16
- console.error("Failed to spawn `grove cleanup stale`:", result.error.message);
17
- process.exit(1);
18
- }
19
-
20
- process.exit(result.status ?? 0);