@grove-dev/astro 0.2.20 → 0.3.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.
Files changed (171) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +9 -75
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +11 -1
  5. package/dist/index.js.map +1 -1
  6. package/dist/lib/display.d.ts +1 -26
  7. package/dist/lib/display.d.ts.map +1 -1
  8. package/dist/lib/display.js +1 -103
  9. package/dist/lib/display.js.map +1 -1
  10. package/dist/lib/format.d.ts +1 -28
  11. package/dist/lib/format.d.ts.map +1 -1
  12. package/dist/lib/format.js +1 -71
  13. package/dist/lib/format.js.map +1 -1
  14. package/dist/lib/lenses.d.ts +1 -57
  15. package/dist/lib/lenses.d.ts.map +1 -1
  16. package/dist/lib/lenses.js +1 -198
  17. package/dist/lib/lenses.js.map +1 -1
  18. package/dist/lib/repo.d.ts +1 -19
  19. package/dist/lib/repo.d.ts.map +1 -1
  20. package/dist/lib/repo.js +1 -30
  21. package/dist/lib/repo.js.map +1 -1
  22. package/dist/lib/scores.d.ts +1 -36
  23. package/dist/lib/scores.d.ts.map +1 -1
  24. package/dist/lib/scores.js +1 -75
  25. package/dist/lib/scores.js.map +1 -1
  26. package/dist/lib/search.d.ts +1 -113
  27. package/dist/lib/search.d.ts.map +1 -1
  28. package/dist/lib/search.js +1 -379
  29. package/dist/lib/search.js.map +1 -1
  30. package/dist/lib/taxonomy-counts.d.ts +1 -10
  31. package/dist/lib/taxonomy-counts.d.ts.map +1 -1
  32. package/dist/lib/taxonomy-counts.js +1 -21
  33. package/dist/lib/taxonomy-counts.js.map +1 -1
  34. package/package.json +22 -10
  35. package/src/components/ContributorsGrid.astro +3 -3
  36. package/src/components/CurationGrid.astro +2 -2
  37. package/src/components/DirectoryIndexClient.astro +240 -0
  38. package/src/components/FilterGroupMenu.astro +8 -4
  39. package/src/components/FinalCta.astro +41 -0
  40. package/src/components/Hero.astro +15 -18
  41. package/src/components/Icon.astro +99 -27
  42. package/src/components/RefinePanel.astro +7 -7
  43. package/src/components/SmartLensTabs.astro +5 -4
  44. package/src/components/StackGrid.astro +1 -1
  45. package/src/components/SubmissionClient.astro +133 -0
  46. package/src/index.ts +11 -1
  47. package/src/layouts/BaseLayout.astro +19 -1
  48. package/src/layouts/Footer.astro +25 -56
  49. package/src/layouts/Header.astro +33 -15
  50. package/src/layouts/ThemeToggle.astro +7 -3
  51. package/src/lib/display.ts +1 -111
  52. package/src/lib/format.ts +1 -64
  53. package/src/lib/lenses.ts +1 -220
  54. package/src/lib/repo.ts +1 -32
  55. package/src/lib/scores.ts +1 -90
  56. package/src/lib/search.ts +1 -418
  57. package/src/lib/taxonomy-counts.ts +1 -26
  58. package/{templates/default/src/data/records.ts → src/server/directory.ts} +90 -8
  59. package/src/server/index.ts +2 -0
  60. package/src/server/models.ts +432 -0
  61. package/dist/theme.test.d.ts +0 -2
  62. package/dist/theme.test.d.ts.map +0 -1
  63. package/dist/theme.test.js +0 -55
  64. package/dist/theme.test.js.map +0 -1
  65. package/src/theme.test.ts +0 -67
  66. package/templates/default/.github/ISSUE_TEMPLATE/bug_report.md +0 -33
  67. package/templates/default/.github/ISSUE_TEMPLATE/feature_request.md +0 -20
  68. package/templates/default/.github/ISSUE_TEMPLATE/record_submission.md +0 -48
  69. package/templates/default/.github/pull_request_template.md +0 -27
  70. package/templates/default/.github/workflows/build.yml +0 -55
  71. package/templates/default/.github/workflows/cleanup-stale-records.yml +0 -48
  72. package/templates/default/.github/workflows/daily-refresh.yml +0 -53
  73. package/templates/default/.github/workflows/sync-contributors.yml +0 -53
  74. package/templates/default/.github/workflows/sync-github-metadata.yml +0 -58
  75. package/templates/default/.github/workflows/validate-data.yml +0 -35
  76. package/templates/default/astro.config.mjs +0 -25
  77. package/templates/default/content/pages/about.md +0 -66
  78. package/templates/default/content/pages/methodology.md +0 -103
  79. package/templates/default/data/records/.gitkeep +0 -0
  80. package/templates/default/data/records/coolify.yml +0 -46
  81. package/templates/default/data/records/gitea.yml +0 -46
  82. package/templates/default/data/records/hoppscotch.yml +0 -47
  83. package/templates/default/data/records/inventree.yml +0 -47
  84. package/templates/default/data/records/label-studio.yml +0 -46
  85. package/templates/default/data/records/logseq.yml +0 -48
  86. package/templates/default/data/records/private-gpt.yml +0 -47
  87. package/templates/default/data/records/wakapi.yml +0 -44
  88. package/templates/default/data/taxonomy/categories.yml +0 -47
  89. package/templates/default/data/taxonomy/distribution-channels.yml +0 -43
  90. package/templates/default/data/taxonomy/platforms.yml +0 -27
  91. package/templates/default/data/taxonomy/stacks.yml +0 -41
  92. package/templates/default/grove.config.ts +0 -36
  93. package/templates/default/package.json +0 -38
  94. package/templates/default/public/icons/platforms/android.svg +0 -1
  95. package/templates/default/public/icons/platforms/apple-dark.svg +0 -1
  96. package/templates/default/public/icons/platforms/apple-light.svg +0 -1
  97. package/templates/default/public/icons/platforms/chrome.svg +0 -1
  98. package/templates/default/public/icons/platforms/chromeos.svg +0 -5
  99. package/templates/default/public/icons/platforms/desktop.svg +0 -4
  100. package/templates/default/public/icons/platforms/embedded.svg +0 -5
  101. package/templates/default/public/icons/platforms/ios.svg +0 -1
  102. package/templates/default/public/icons/platforms/linux.svg +0 -1
  103. package/templates/default/public/icons/platforms/macos.svg +0 -1
  104. package/templates/default/public/icons/platforms/ubuntu.svg +0 -1
  105. package/templates/default/public/icons/platforms/web.svg +0 -5
  106. package/templates/default/public/icons/platforms/windows.svg +0 -6
  107. package/templates/default/public/icons/stacks/android.svg +0 -1
  108. package/templates/default/public/icons/stacks/apple-dark.svg +0 -1
  109. package/templates/default/public/icons/stacks/apple-light.svg +0 -1
  110. package/templates/default/public/icons/stacks/bun.svg +0 -1
  111. package/templates/default/public/icons/stacks/capacitor.svg +0 -14
  112. package/templates/default/public/icons/stacks/clojure.svg +0 -1
  113. package/templates/default/public/icons/stacks/clojurescript.svg +0 -1
  114. package/templates/default/public/icons/stacks/dart.svg +0 -1
  115. package/templates/default/public/icons/stacks/deno.svg +0 -1
  116. package/templates/default/public/icons/stacks/django.svg +0 -1
  117. package/templates/default/public/icons/stacks/docker.svg +0 -1
  118. package/templates/default/public/icons/stacks/firebase.svg +0 -1
  119. package/templates/default/public/icons/stacks/flutter.svg +0 -1
  120. package/templates/default/public/icons/stacks/go.svg +0 -1
  121. package/templates/default/public/icons/stacks/graphql.svg +0 -1
  122. package/templates/default/public/icons/stacks/ionic.svg +0 -4
  123. package/templates/default/public/icons/stacks/java.svg +0 -1
  124. package/templates/default/public/icons/stacks/javascript.svg +0 -1
  125. package/templates/default/public/icons/stacks/kotlin.svg +0 -1
  126. package/templates/default/public/icons/stacks/llm.svg +0 -1
  127. package/templates/default/public/icons/stacks/mongodb.svg +0 -1
  128. package/templates/default/public/icons/stacks/node.js.svg +0 -1
  129. package/templates/default/public/icons/stacks/nodejs.svg +0 -1
  130. package/templates/default/public/icons/stacks/python.svg +0 -1
  131. package/templates/default/public/icons/stacks/rag.svg +0 -7
  132. package/templates/default/public/icons/stacks/react-native.svg +0 -1
  133. package/templates/default/public/icons/stacks/react.svg +0 -1
  134. package/templates/default/public/icons/stacks/rust.svg +0 -1
  135. package/templates/default/public/icons/stacks/solidity.svg +0 -1
  136. package/templates/default/public/icons/stacks/sveltekit.svg +0 -1
  137. package/templates/default/public/icons/stacks/swift.svg +0 -1
  138. package/templates/default/public/icons/stacks/tensorflow.svg +0 -1
  139. package/templates/default/public/icons/stacks/typescript.svg +0 -1
  140. package/templates/default/public/icons/stacks/vue.svg +0 -1
  141. package/templates/default/public/llms-full.txt +0 -206
  142. package/templates/default/public/llms.txt +0 -112
  143. package/templates/default/public/og-image.svg +0 -48
  144. package/templates/default/public/robots.txt +0 -53
  145. package/templates/default/public/sitemap.xml +0 -63
  146. package/templates/default/scripts/build-llms.mjs +0 -123
  147. package/templates/default/scripts/build-records-json.mjs +0 -27
  148. package/templates/default/scripts/build-sitemap.mjs +0 -20
  149. package/templates/default/scripts/cleanup-stale-records.mjs +0 -20
  150. package/templates/default/scripts/enrich-github-metadata.mjs +0 -99
  151. package/templates/default/scripts/fetch-icons.mjs +0 -137
  152. package/templates/default/scripts/migrate-legacy-to-schema-v1.mjs +0 -86
  153. package/templates/default/scripts/parse-legacy-readme.mjs +0 -59
  154. package/templates/default/scripts/refresh-records-activity.mjs +0 -24
  155. package/templates/default/scripts/repair-license-format.mjs +0 -72
  156. package/templates/default/scripts/report-cleanup-candidates.mjs +0 -21
  157. package/templates/default/scripts/seed-from-github.mjs +0 -62
  158. package/templates/default/scripts/sync-contributors.mjs +0 -145
  159. package/templates/default/scripts/sync-github-metadata.mjs +0 -28
  160. package/templates/default/scripts/validate-records.mjs +0 -27
  161. package/templates/default/src/pages/404.astro +0 -64
  162. package/templates/default/src/pages/[slug]/[recordSlug].astro +0 -689
  163. package/templates/default/src/pages/[slug]/index.astro +0 -278
  164. package/templates/default/src/pages/about.astro +0 -182
  165. package/templates/default/src/pages/apps/[recordSlug].astro +0 -33
  166. package/templates/default/src/pages/contributors.astro +0 -175
  167. package/templates/default/src/pages/index.astro +0 -225
  168. package/templates/default/src/pages/sitemap.xml.ts +0 -89
  169. package/templates/default/src/pages/submit.astro +0 -281
  170. package/templates/default/src/styles/global.css +0 -33
  171. package/templates/default/wrangler.jsonc +0 -28
@@ -1,225 +0,0 @@
1
- ---
2
- /**
3
- * Home page — generic.
4
- *
5
- * Mirrors the openapps structure: Hero → WhyThisExists → three
6
- * lens sections (hot / new / mature) → StackGrid → CategoryGrid →
7
- * ContributorsGrid → OriginalCollection.
8
- *
9
- * All data derives from `data/generated/records.{index,full}.json`
10
- * and `data/generated/site-config.json`. Nothing here is hardcoded
11
- * to a specific blueprint — section copy uses singular/plural
12
- * labels from `itemLabel()`/`itemLabelPlural()`.
13
- */
14
- import BaseLayout from "@grove-dev/astro/layouts/BaseLayout.astro";
15
- import Hero from "@grove-dev/astro/components/Hero.astro";
16
- import WhyThisExists from "@grove-dev/astro/components/WhyThisExists.astro";
17
- import RecordSection from "@grove-dev/astro/components/RecordSection.astro";
18
- import StackGrid from "@grove-dev/astro/components/StackGrid.astro";
19
- import CategoryGrid from "@grove-dev/astro/components/CategoryGrid.astro";
20
- import ContributorsGrid from "@grove-dev/astro/components/ContributorsGrid.astro";
21
- import OriginalCollection from "@grove-dev/astro/components/OriginalCollection.astro";
22
-
23
- import { items, fullRecords, fullItems } from "../data/records";
24
- import { existsSync, readFileSync } from "node:fs";
25
- import { fileURLToPath } from "node:url";
26
- import { dirname, resolve } from "node:path";
27
- import siteConfig from "../../data/generated/site-config.json";
28
-
29
- const slug = (siteConfig.blueprintConfig?.routeSlug ?? "projects") as string;
30
- const itemSingular = (siteConfig.blueprintConfig?.labelSingular ?? "project") as string;
31
- const itemPlural = (siteConfig.blueprintConfig?.labelPlural ?? "projects") as string;
32
-
33
- // ── Lens sectioning (Hot / New / Mature) ────────────────────────
34
- const DAY_MS = 86_400_000;
35
- function daysSince(iso: string | null | undefined): number {
36
- if (!iso) return Infinity;
37
- const d = new Date(iso);
38
- if (Number.isNaN(d.valueOf())) return Infinity;
39
- return (Date.now() - d.valueOf()) / DAY_MS;
40
- }
41
- function starsOf(r: typeof items[number]): number {
42
- return (r as { github?: { stars?: number } }).github?.stars ?? 0;
43
- }
44
- function pushedAtOf(r: typeof items[number]): string | null {
45
- return (r as { github?: { pushedAt?: string | null } }).github?.pushedAt ?? null;
46
- }
47
- function reviewedAtOf(r: typeof items[number]): string | null {
48
- return r.curation?.reviewedAt ?? null;
49
- }
50
- function labelsOf(r: typeof items[number]): string[] {
51
- return r.curation?.labels ?? [];
52
- }
53
- function tierOf(r: typeof items[number]): string | undefined {
54
- return (r as { health?: { tier?: string } }).health?.tier;
55
- }
56
-
57
- const hotList = items
58
- .filter((r) => r.kind === "project")
59
- .filter((r) => {
60
- const labelledHot = labelsOf(r).includes("hot");
61
- return labelledHot || starsOf(r) >= 1000 || daysSince(pushedAtOf(r)) <= 30;
62
- })
63
- .slice(0, 6);
64
- const newList = items
65
- .filter((r) => r.kind === "project")
66
- .filter((r) => {
67
- const labelledNew = labelsOf(r).includes("new");
68
- return daysSince(reviewedAtOf(r)) <= 21 || labelledNew;
69
- })
70
- .slice(0, 6);
71
- const matureList = items
72
- .filter((r) => r.kind === "project")
73
- .filter((r) => labelsOf(r).includes("mature") || tierOf(r) === "curated")
74
- .slice(0, 6);
75
-
76
- // Stacks and categories derived from items
77
- const stackMap = new Map<string, number>();
78
- const catMap = new Map<string, number>();
79
- for (const r of fullItems) {
80
- const cat = (r as { category?: string }).category;
81
- if (cat) catMap.set(cat, (catMap.get(cat) ?? 0) + 1);
82
- const s1 = (r as { stack?: string }).stack;
83
- if (s1) stackMap.set(s1, (stackMap.get(s1) ?? 0) + 1);
84
- const s2 = (r as { stacks?: string[] }).stacks ?? [];
85
- for (const s of s2) stackMap.set(s, (stackMap.get(s) ?? 0) + 1);
86
- }
87
- const stacks = [...stackMap.entries()]
88
- .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
89
- .slice(0, 12)
90
- .map(([name, count]) => ({ name, slug: name, count }));
91
- const categories = [...catMap.entries()]
92
- .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
93
- .map(([name, count]) => ({ name, slug: name, count }));
94
-
95
- // Contributors (best-effort from data/generated/contributors.json)
96
- const here = dirname(fileURLToPath(import.meta.url));
97
- const contributorsPath = [
98
- resolve(here, "..", "..", "data", "generated", "contributors.json"),
99
- resolve(process.cwd(), "data", "generated", "contributors.json"),
100
- ].find((p) => existsSync(p));
101
- interface ContributorsFile {
102
- contributors?: Array<{
103
- username: string;
104
- name?: string;
105
- avatarUrl?: string;
106
- profileUrl?: string;
107
- contributions?: number;
108
- }>;
109
- }
110
- let contributors: ContributorsFile["contributors"] = [];
111
- if (contributorsPath) {
112
- try {
113
- const parsed = JSON.parse(readFileSync(contributorsPath, "utf8")) as ContributorsFile;
114
- contributors = parsed.contributors ?? [];
115
- } catch {
116
- contributors = [];
117
- }
118
- }
119
-
120
- const stats = {
121
- originalRepo: siteConfig.repoUrl ?? "",
122
- apps: siteConfig.stats?.totalApps ?? 0,
123
- categories: siteConfig.stats?.totalCategories ?? 0,
124
- stacks: siteConfig.stats?.totalStacks ?? 0,
125
- contributors: siteConfig.stats?.totalOwners ?? 0,
126
- stars: siteConfig.stats?.totalStars ?? 0,
127
- };
128
-
129
- const title = `${siteConfig.name} — ${siteConfig.tagline}`;
130
- const description =
131
- siteConfig.description ??
132
- `A searchable directory of real ${itemPlural} — organized by stack, category, platform, license, activity, and maturity.`;
133
- ---
134
-
135
- <BaseLayout title={title} description={description} site={siteConfig}>
136
- <Hero
137
- name={`Find ${itemPlural} you can actually`}
138
- tagline="run, study, and contribute to."
139
- description={description}
140
- eyebrow={`A directory of real curated ${itemPlural}`}
141
- eyebrowBadge="Beta"
142
- stats={{
143
- totalApps: stats.apps,
144
- totalCategories: stats.categories,
145
- totalStacks: stats.stacks,
146
- totalOwners: stats.contributors,
147
- totalStars: stats.stars,
148
- originalRepo: stats.originalRepo,
149
- }}
150
- itemsLabel={itemPlural}
151
- itemLabel={itemSingular}
152
- searchHref={`/${slug}`}
153
- searchPlaceholder={`Search ${itemPlural}, stacks, categories, licenses, platforms...`}
154
- quickFilters={[
155
- { label: `Trending ${itemPlural}`, href: `/${slug}?label=hot` },
156
- { label: "Recently added", href: `/${slug}?label=new` },
157
- { label: "Established", href: `/${slug}?label=mature` },
158
- ]}
159
- ctas={[
160
- { label: `Browse ${itemPlural}`, href: `/${slug}`, variant: "primary" },
161
- { label: `Submit ${itemSingular} via GitHub`, href: "/submit", variant: "outline" },
162
- ...(siteConfig.repoUrl
163
- ? [{ label: "View source", href: siteConfig.repoUrl, variant: "outline" as const }]
164
- : []),
165
- ]}
166
- />
167
-
168
- <WhyThisExists />
169
-
170
- <RecordSection
171
- id="hot"
172
- title="Trending now"
173
- description={`${itemPlural.charAt(0).toUpperCase() + itemPlural.slice(1)} gaining stars, activity, or attention recently.`}
174
- eyebrow="Hot"
175
- items={hotList}
176
- hrefFor={(r) => `/${slug}/${r.slug}`}
177
- viewAllHref={`/${slug}?label=hot`}
178
- viewAllLabel={`View all trending ${itemPlural}`}
179
- />
180
-
181
- <RecordSection
182
- id="new"
183
- title="Recently added"
184
- description={`${itemPlural.charAt(0).toUpperCase() + itemPlural.slice(1)} added to the directory in the last few weeks.`}
185
- eyebrow="New"
186
- items={newList}
187
- hrefFor={(r) => `/${slug}/${r.slug}`}
188
- viewAllHref={`/${slug}?label=new`}
189
- viewAllLabel={`View all recently added`}
190
- />
191
-
192
- <RecordSection
193
- id="mature"
194
- title="Established"
195
- description={`Long-running curated ${itemPlural} with strong history, active maintainers, and real-world usage.`}
196
- eyebrow="Mature"
197
- items={matureList}
198
- hrefFor={(r) => `/${slug}/${r.slug}`}
199
- viewAllHref={`/${slug}?label=mature`}
200
- viewAllLabel={`View all established`}
201
- />
202
-
203
- <StackGrid stacks={stacks} pathPrefix={`/${slug}`} itemLabelPlural={itemPlural} />
204
- <CategoryGrid categories={categories} pathPrefix={`/${slug}`} itemLabelPlural={itemPlural} />
205
-
206
- {contributors.length > 0 && (
207
- <ContributorsGrid
208
- contributors={contributors}
209
- total={contributors.length}
210
- viewAllHref={`/contributors`}
211
- viewAllLabel="View all contributors"
212
- />
213
- )}
214
-
215
- {siteConfig.stats?.originalRepo && (
216
- <OriginalCollection
217
- repoUrl={siteConfig.stats.originalRepo}
218
- stats={{
219
- stars: siteConfig.stats.originalStars,
220
- forks: siteConfig.stats.originalForks,
221
- contributors: siteConfig.stats.originalContributors,
222
- }}
223
- />
224
- )}
225
- </BaseLayout>
@@ -1,89 +0,0 @@
1
- /**
2
- * sitemap.xml.ts — Astro endpoint that serves the site map.
3
- *
4
- * The CLI's `grove sitemap` command also writes a sitemap into
5
- * `public/sitemap.xml`. This endpoint is the consumer-facing
6
- * version: it builds the sitemap from the live record set at
7
- * request time, so a manual `astro build` (without running the
8
- * CLI first) still gets a correct map.
9
- *
10
- * In practice the CLI's output is preferred for SEO (it's
11
- * deterministic and includes the build timestamp); the consumer
12
- * is expected to wire `public/sitemap.xml` and this endpoint to
13
- * the same source. We keep the endpoint thin so it can serve as
14
- * either a fallback or a live override.
15
- */
16
- import type { APIRoute } from "astro";
17
- import { fullRecords, indexSlug } from "../data/records";
18
- import siteConfig from "../../data/generated/site-config.json";
19
-
20
- interface SitemapEntry {
21
- loc: string;
22
- lastmod?: string;
23
- changefreq?: "daily" | "weekly" | "monthly" | "yearly";
24
- priority?: number;
25
- }
26
-
27
- function absoluteUrl(siteUrl: string, path: string): string {
28
- const base = siteUrl.replace(/\/$/, "");
29
- const tail = path.startsWith("/") ? path : `/${path}`;
30
- return `${base}${tail}`;
31
- }
32
-
33
- function buildEntries(siteUrl: string, blueprint: string | undefined): SitemapEntry[] {
34
- const slug = indexSlug(blueprint);
35
- const now = new Date().toISOString().slice(0, 10);
36
-
37
- const top: SitemapEntry[] = [
38
- { loc: absoluteUrl(siteUrl, "/"), lastmod: now, changefreq: "daily", priority: 1.0 },
39
- { loc: absoluteUrl(siteUrl, "/about"), lastmod: now, changefreq: "monthly", priority: 0.5 },
40
- { loc: absoluteUrl(siteUrl, "/submit"), lastmod: now, changefreq: "yearly", priority: 0.3 },
41
- ];
42
-
43
- if (fullRecords.length > 0) {
44
- top.push({
45
- loc: absoluteUrl(siteUrl, `/${slug}`),
46
- lastmod: now,
47
- changefreq: "daily",
48
- priority: 0.9,
49
- });
50
- }
51
-
52
- const recordEntries: SitemapEntry[] = fullRecords
53
- .filter((r) => r.kind === "project")
54
- .map((r) => ({
55
- loc: absoluteUrl(siteUrl, `/${slug}/${r.slug}`),
56
- // detail pages don't carry a `pushedAt` in the typed union;
57
- // we use the build date so crawlers see a recent timestamp.
58
- lastmod: now,
59
- changefreq: "weekly",
60
- priority: 0.7,
61
- }));
62
-
63
- return [...top, ...recordEntries];
64
- }
65
-
66
- function renderXml(entries: SitemapEntry[]): string {
67
- const body = entries
68
- .map((e) => {
69
- const lines: string[] = [` <url>`, ` <loc>${e.loc}</loc>`];
70
- if (e.lastmod) lines.push(` <lastmod>${e.lastmod}</lastmod>`);
71
- if (e.changefreq) lines.push(` <changefreq>${e.changefreq}</changefreq>`);
72
- if (e.priority !== undefined) lines.push(` <priority>${e.priority.toFixed(1)}</priority>`);
73
- lines.push(` </url>`);
74
- return lines.join("\n");
75
- })
76
- .join("\n");
77
- return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${body}\n</urlset>\n`;
78
- }
79
-
80
- export const GET: APIRoute = () => {
81
- const xml = renderXml(buildEntries(siteConfig.siteUrl ?? "", siteConfig.blueprint));
82
- return new Response(xml, {
83
- status: 200,
84
- headers: {
85
- "Content-Type": "application/xml; charset=utf-8",
86
- "Cache-Control": "public, max-age=3600",
87
- },
88
- });
89
- };
@@ -1,281 +0,0 @@
1
- ---
2
- import siteConfig from "../../data/generated/site-config.json";
3
- import { fullItems, itemLabel } from "../data/records";
4
- import BaseLayout from "@grove-dev/astro/layouts/BaseLayout.astro";
5
- import Container from "@grove-dev/astro/layouts/Container.astro";
6
-
7
- const singular = itemLabel();
8
- const title = `Submit ${singular} - ${siteConfig.name}`;
9
- const description = `Submit a new ${singular} to ${siteConfig.name}.`;
10
- const repoUrl = siteConfig.repoUrl ?? "https://github.com/tortuvshin/grove";
11
-
12
- const values = (key: "category" | "stack") =>
13
- [...new Set(fullItems.map((item) => String(item[key] ?? "")).filter(Boolean))]
14
- .sort((a, b) => a.localeCompare(b));
15
- const categories = values("category");
16
- const stacks = values("stack");
17
- const existingSlugs = fullItems.map((item) => item.slug).filter(Boolean);
18
- const existingFullNames = fullItems
19
- .map((item) => {
20
- const url = String(item.repoUrl ?? item.links?.github ?? "");
21
- const match = url.match(/github\.com\/([^/]+)\/([^/?#]+)/i);
22
- return match ? `${match[1]}/${match[2].replace(/\.git$/, "")}`.toLowerCase() : null;
23
- })
24
- .filter(Boolean);
25
-
26
- const platformOptions = ["iOS", "Android", "Web", "macOS", "Windows", "Linux"];
27
- const goodSubmissions = [
28
- `A real, usable ${singular}, not a tutorial or one-screen demo`,
29
- "A public repository with a clear open-source license",
30
- "Enough documentation to understand, run, or contribute",
31
- "Recent maintenance, releases, or meaningful project history",
32
- ];
33
- const pleaseAvoid = [
34
- "Snippets, exercises, abandoned experiments, or duplicate entries",
35
- "Private repositories or projects without source",
36
- "Marketing pages with no usable project behind them",
37
- "Records with no license or verifiable repository",
38
- ];
39
- ---
40
-
41
- <BaseLayout title={title} description={description} site={siteConfig} noindex>
42
- <Container>
43
- <section class="py-10 sm:py-14">
44
- <div class="max-w-3xl">
45
- <p class="m-0 text-2xs font-semibold uppercase tracking-wider text-ink-500 dark:text-ink-400">
46
- {singular} submission
47
- </p>
48
- <h1 class="mt-2 text-[2rem] font-semibold tracking-tight text-ink-900 dark:text-ink-100">
49
- Add {singular === "app" ? "an" : "a"} {singular} to the directory
50
- </h1>
51
- <p class="m-0 mt-3 max-w-[60ch] text-[0.9375rem] leading-relaxed text-ink-500 dark:text-ink-400">
52
- Paste a GitHub URL to pull in the basics, edit the generated draft,
53
- then open a pull request. The data stays in this repository.
54
- </p>
55
- </div>
56
-
57
- <ol class="mt-6 grid grid-cols-1 gap-2 text-2xs sm:grid-cols-3 sm:gap-3" aria-label="Submission steps">
58
- {[
59
- ["1", "Paste a GitHub URL", "We'll pull the metadata automatically."],
60
- ["2", "Edit the draft", "Fix the category, stack, platforms, and notes."],
61
- ["3", "Open a pull request", "Reviewers will validate and merge the record."],
62
- ].map(([number, heading, copy]) => (
63
- <li class="rounded-[calc(var(--radius)+0.25rem)] border-0 bg-muted shadow-none transition-colors flex items-start gap-2 p-3">
64
- <span class="mt-0.5 inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full border border-ink-300 font-mono font-semibold text-ink-700 dark:border-ink-700 dark:text-ink-300">{number}</span>
65
- <div>
66
- <div class="font-medium text-ink-900 dark:text-ink-100">{heading}</div>
67
- <div class="text-ink-500 dark:text-ink-400">{copy}</div>
68
- </div>
69
- </li>
70
- ))}
71
- </ol>
72
-
73
- <div class="mt-8 grid grid-cols-1 gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(360px,0.8fr)]">
74
- <form id="submit-form" class="rounded-[calc(var(--radius)+0.25rem)] border-0 bg-muted shadow-none transition-colors p-5">
75
- <div class="grid gap-5">
76
- <label class="grid gap-1.5">
77
- <span class="text-2xs font-medium uppercase tracking-[.05em] text-muted-foreground">GitHub URL</span>
78
- <div class="flex flex-col gap-2 sm:flex-row">
79
- <input id="repo-url" type="url" required placeholder="https://github.com/owner/repo" class="min-h-10 flex-1 rounded-[var(--radius)] border border-input bg-background px-3 py-2 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/35" />
80
- <button id="fetch-repo" type="button" class="inline-flex h-8 cursor-pointer items-center justify-center gap-1.5 whitespace-nowrap rounded-[var(--radius)] border border-transparent bg-primary px-3 text-sm font-medium leading-none text-primary-foreground no-underline outline-none transition-colors hover:bg-primary/90 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/35 min-h-10 justify-center">Generate draft</button>
81
- </div>
82
- <span class="text-2xs text-muted-foreground">The repository must be public. We never write to it.</span>
83
- </label>
84
-
85
- <div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
86
- <label class="grid gap-1.5"><span class="text-2xs font-medium uppercase tracking-[.05em] text-muted-foreground">Name</span><input id="item-name" type="text" class="min-h-10 rounded-[var(--radius)] border border-input bg-background px-3 py-2 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/35" /></label>
87
- <label class="grid gap-1.5"><span class="text-2xs font-medium uppercase tracking-[.05em] text-muted-foreground">Slug</span><input id="item-slug" type="text" class="min-h-10 rounded-[var(--radius)] border border-input bg-background px-3 py-2 font-mono text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/35" /><span class="text-2xs text-muted-foreground">URL fragment, auto-filled from the repository name.</span></label>
88
- </div>
89
-
90
- <label class="grid gap-1.5">
91
- <span class="text-2xs font-medium uppercase tracking-[.05em] text-muted-foreground">Description</span>
92
- <textarea id="item-description" rows="3" class="min-h-10 resize-y rounded-[var(--radius)] border border-input bg-background px-3 py-2 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/35"></textarea>
93
- <span class="text-2xs text-muted-foreground">One or two sentences in plain language.</span>
94
- </label>
95
-
96
- <div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
97
- <label class="grid gap-1.5">
98
- <span class="text-2xs font-medium uppercase tracking-[.05em] text-muted-foreground">Category</span>
99
- <input id="item-category" list="category-options" class="min-h-10 rounded-[var(--radius)] border border-input bg-background px-3 py-2 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/35" />
100
- <datalist id="category-options">{categories.map((value) => <option value={value} />)}</datalist>
101
- </label>
102
- <label class="grid gap-1.5">
103
- <span class="text-2xs font-medium uppercase tracking-[.05em] text-muted-foreground">Primary stack</span>
104
- <input id="primary-stack" list="stack-options" class="min-h-10 rounded-[var(--radius)] border border-input bg-background px-3 py-2 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/35" />
105
- <datalist id="stack-options">{stacks.map((value) => <option value={value} />)}</datalist>
106
- </label>
107
- </div>
108
-
109
- <fieldset class="grid gap-2">
110
- <legend class="text-2xs font-medium uppercase tracking-[.05em] text-muted-foreground">Platforms</legend>
111
- <div class="grid grid-cols-2 gap-2 sm:grid-cols-3">
112
- {platformOptions.map((platform) => (
113
- <label class="flex min-h-9 items-center gap-2 rounded-md border border-ink-200 px-2 text-sm dark:border-ink-800">
114
- <input type="checkbox" name="platform" value={platform.toLowerCase()} class="h-4 w-4" />
115
- <span>{platform}</span>
116
- </label>
117
- ))}
118
- </div>
119
- </fieldset>
120
-
121
- <label class="grid gap-1.5"><span class="text-2xs font-medium uppercase tracking-[.05em] text-muted-foreground">Tags</span><input id="item-tags" type="text" placeholder="self-hosted, developer-tools, offline-first" class="min-h-10 rounded-[var(--radius)] border border-input bg-background px-3 py-2 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/35" /></label>
122
- <label class="grid gap-1.5"><span class="text-2xs font-medium uppercase tracking-[.05em] text-muted-foreground">Website</span><input id="item-website" type="url" placeholder="https://example.com" class="min-h-10 rounded-[var(--radius)] border border-input bg-background px-3 py-2 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/35" /></label>
123
- <label class="grid gap-1.5"><span class="text-2xs font-medium uppercase tracking-[.05em] text-muted-foreground">Best for</span><textarea id="best-for" rows="3" class="min-h-10 resize-y rounded-[var(--radius)] border border-input bg-background px-3 py-2 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/35" placeholder="One use case per line"></textarea></label>
124
- </div>
125
- </form>
126
-
127
- <aside class="flex flex-col gap-4">
128
- <div class="rounded-[calc(var(--radius)+0.25rem)] border-0 bg-muted shadow-none transition-colors p-5">
129
- <h2 class="m-0 text-[0.9375rem] font-semibold text-ink-900 dark:text-ink-100">Submission criteria</h2>
130
- <p class="m-0 mt-1 text-2xs text-ink-500 dark:text-ink-400">Use this checklist before opening the draft.</p>
131
- <div class="mt-4 grid grid-cols-1 gap-4 text-2xs sm:grid-cols-2 lg:grid-cols-1 xl:grid-cols-2">
132
- <div>
133
- <div class="font-semibold uppercase tracking-wider text-emerald-700 dark:text-emerald-400">Good submissions</div>
134
- <ul class="m-0 mt-2 list-none space-y-1 p-0 text-ink-700 dark:text-ink-200">{goodSubmissions.map((item) => <li class="flex gap-2"><span class="text-emerald-600">✓</span><span>{item}</span></li>)}</ul>
135
- </div>
136
- <div>
137
- <div class="font-semibold uppercase tracking-wider text-amber-700 dark:text-amber-400">Please avoid</div>
138
- <ul class="m-0 mt-2 list-none space-y-1 p-0 text-ink-700 dark:text-ink-200">{pleaseAvoid.map((item) => <li class="flex gap-2"><span class="text-amber-600">·</span><span>{item}</span></li>)}</ul>
139
- </div>
140
- </div>
141
- </div>
142
-
143
- <div class="rounded-[calc(var(--radius)+0.25rem)] border-0 bg-muted shadow-none transition-colors flex min-h-[420px] flex-col p-5">
144
- <div class="flex flex-wrap items-center justify-between gap-3">
145
- <h2 class="m-0 text-base font-semibold text-ink-900 dark:text-ink-100">YAML draft</h2>
146
- <div class="flex items-center gap-2">
147
- <button id="copy-yaml" type="button" class="inline-flex h-8 cursor-pointer items-center justify-center gap-1.5 whitespace-nowrap rounded-[var(--radius)] border border-border bg-secondary px-3 text-sm font-medium leading-none text-foreground no-underline outline-none transition-colors hover:bg-accent focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/35 pointer-events-none opacity-50" disabled>Copy YAML</button>
148
- <a id="open-pr-link" href="#" target="_blank" rel="noopener noreferrer" class="inline-flex h-8 cursor-pointer items-center justify-center gap-1.5 whitespace-nowrap rounded-[var(--radius)] border border-border bg-secondary px-3 text-sm font-medium leading-none text-foreground no-underline outline-none transition-colors hover:bg-accent focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/35 pointer-events-none opacity-50">Open PR draft</a>
149
- </div>
150
- </div>
151
- <pre class="mt-4 min-h-0 flex-1 overflow-auto rounded-md border border-ink-200 bg-ink-50 p-3 text-xs leading-relaxed text-ink-800 dark:border-ink-800 dark:bg-ink-900/60 dark:text-ink-200"><code id="yaml-preview">Paste a GitHub URL to generate a draft.</code></pre>
152
- <p id="submit-status" class="mt-3 min-h-5 text-2xs text-ink-500 dark:text-ink-400" aria-live="polite"></p>
153
- </div>
154
- </aside>
155
- </div>
156
- </section>
157
- </Container>
158
-
159
- <script is:inline define:vars={{ existingSlugs, existingFullNames, repoUrl }}>
160
- // @ts-nocheck
161
- const $ = (selector) => document.querySelector(selector);
162
- const inputs = {
163
- repo: $("#repo-url"), name: $("#item-name"), slug: $("#item-slug"),
164
- description: $("#item-description"), category: $("#item-category"),
165
- stack: $("#primary-stack"), tags: $("#item-tags"),
166
- website: $("#item-website"), bestFor: $("#best-for"),
167
- };
168
- const fetchButton = $("#fetch-repo");
169
- const preview = $("#yaml-preview");
170
- const status = $("#submit-status");
171
- const copyButton = $("#copy-yaml");
172
- const openPrLink = $("#open-pr-link");
173
- const slugSet = new Set(existingSlugs);
174
- const repoSet = new Set(existingFullNames);
175
- let repository = null;
176
-
177
- const slugify = (value) => String(value || "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
178
- const parseRepo = (value) => {
179
- const match = String(value).match(/github\.com\/([^/]+)\/([^/?#]+)/);
180
- return match ? { owner: match[1], repo: match[2].replace(/\.git$/, "") } : null;
181
- };
182
- const quote = (value) => `"${String(value ?? "").replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
183
- const lines = (values, indent = 2) => values.map((value) => `${" ".repeat(indent)}- ${value}`).join("\n");
184
- const selectedPlatforms = () => [...document.querySelectorAll("input[name=platform]:checked")].map((input) => input.value);
185
- const setStatus = (text, tone = "muted") => {
186
- status.textContent = text;
187
- status.className = `mt-3 min-h-5 text-2xs ${tone === "error" ? "text-red-600 dark:text-red-400" : tone === "warn" ? "text-amber-700 dark:text-amber-400" : tone === "success" ? "text-emerald-700 dark:text-emerald-400" : "text-ink-500 dark:text-ink-400"}`;
188
- };
189
- const inferStack = (repo) => {
190
- const language = String(repo.language || "").toLowerCase();
191
- const topics = (repo.topics || []).map((topic) => String(topic).toLowerCase());
192
- if (topics.includes("flutter") || language === "dart") return "Flutter";
193
- if (topics.includes("react-native")) return "React Native";
194
- if (language === "typescript" || language === "javascript") return "TypeScript";
195
- return repo.language || "";
196
- };
197
- const generateYaml = () => {
198
- if (!repository) return "";
199
- const parsed = parseRepo(inputs.repo.value);
200
- if (!parsed) return "";
201
- const tags = inputs.tags.value.split(",").map((tag) => tag.trim()).filter(Boolean);
202
- const platforms = selectedPlatforms();
203
- const bestFor = inputs.bestFor.value.split("\n").map((item) => item.trim()).filter(Boolean);
204
- return [
205
- "kind: project",
206
- `slug: ${slugify(inputs.slug.value || repository.name)}`,
207
- `name: ${quote(inputs.name.value)}`,
208
- `description: ${quote(inputs.description.value)}`,
209
- `category: ${inputs.category.value || "other"}`,
210
- "projectType: real-app",
211
- `stack: ${quote(inputs.stack.value)}`,
212
- "platforms:", platforms.length ? lines(platforms) : " []",
213
- "tags:", tags.length ? lines(tags) : " []",
214
- `repoUrl: ${repository.html_url}`,
215
- "links:",
216
- ` github: ${repository.html_url}`,
217
- ...(inputs.website.value ? [` website: ${inputs.website.value}`] : []),
218
- "bestFor:", bestFor.length ? lines(bestFor) : " []",
219
- "source:",
220
- " type: github",
221
- ` owner: ${parsed.owner}`,
222
- ` repo: ${parsed.repo}`,
223
- "curation:",
224
- " reviewed: false",
225
- " labels: []",
226
- " lenses: []",
227
- "",
228
- ].join("\n");
229
- };
230
- const refresh = () => {
231
- const yaml = generateYaml();
232
- preview.textContent = yaml || "Paste a GitHub URL to generate a draft.";
233
- if (!yaml) return;
234
- const params = new URLSearchParams({ filename: `data/records/${slugify(inputs.slug.value)}.yml`, value: yaml });
235
- openPrLink.href = `${repoUrl}/new/main?${params}`;
236
- openPrLink.classList.remove("pointer-events-none", "opacity-50");
237
- copyButton.disabled = false;
238
- copyButton.classList.remove("pointer-events-none", "opacity-50");
239
- };
240
-
241
- fetchButton.addEventListener("click", async () => {
242
- const parsed = parseRepo(inputs.repo.value);
243
- if (!parsed) return setStatus("Enter a valid GitHub repository URL.", "error");
244
- const fullName = `${parsed.owner}/${parsed.repo}`.toLowerCase();
245
- if (repoSet.has(fullName)) setStatus("This repository is already listed. The draft can update its record.", "warn");
246
- fetchButton.disabled = true;
247
- setStatus("Fetching GitHub metadata...");
248
- try {
249
- const response = await fetch(`https://api.github.com/repos/${parsed.owner}/${parsed.repo}`, { headers: { Accept: "application/vnd.github+json" } });
250
- if (response.status === 404) throw new Error("Repository not found or not public.");
251
- if (response.status === 403) throw new Error("GitHub rate limit reached. Try again shortly.");
252
- if (!response.ok) throw new Error(`GitHub returned ${response.status}.`);
253
- repository = await response.json();
254
- if (repository.private) throw new Error("Private repositories cannot be submitted.");
255
- inputs.name.value = repository.name || parsed.repo;
256
- inputs.slug.value = slugify(repository.name || parsed.repo);
257
- inputs.description.value = repository.description || "";
258
- inputs.stack.value = inferStack(repository);
259
- inputs.tags.value = (repository.topics || []).slice(0, 8).join(", ");
260
- inputs.website.value = repository.homepage || "";
261
- const duplicate = slugSet.has(inputs.slug.value);
262
- setStatus(duplicate ? "That slug already exists. Choose another or update the existing record." : "Draft generated. Edit any field and the YAML will refresh.", duplicate ? "warn" : "success");
263
- refresh();
264
- } catch (error) {
265
- repository = null;
266
- setStatus(error.message || "Unable to fetch repository metadata.", "error");
267
- } finally {
268
- fetchButton.disabled = false;
269
- }
270
- });
271
- $("#submit-form").addEventListener("input", refresh);
272
- copyButton.addEventListener("click", async () => {
273
- try {
274
- await navigator.clipboard.writeText(preview.textContent || "");
275
- setStatus("Copied YAML to clipboard.", "success");
276
- } catch {
277
- setStatus("Couldn't copy automatically. Select the YAML and copy it.", "error");
278
- }
279
- });
280
- </script>
281
- </BaseLayout>
@@ -1,33 +0,0 @@
1
- @import "tailwindcss";
2
-
3
- /*
4
- * Grove V1 design tokens. Theme is overridable from `grove.config.ts`
5
- * at scaffold time; we expose the same names as CSS variables so
6
- * consumers can layer their own brand colors in without forking.
7
- *
8
- * The package owns the Starlight-aligned palette, semantic theme tokens,
9
- * font stacks, and light/dark base styles. This consumer sheet contains
10
- * only scaffold-level overrides so those foundations cannot drift when
11
- * the package theme changes.
12
- *
13
- * NOTE: do NOT add `@custom-variant dark` here. `@grove-dev/astro`'s
14
- * `styles.css` already declares the `.dark`-class variant, and
15
- * Tailwind v4 only honors one definition per build — adding a
16
- * second one in this consumer file has been observed to confuse
17
- * the variant resolution and produce utilities that key off
18
- * `prefers-color-scheme` instead of the `.dark` class on <html>.
19
- * The net effect: `<html class="dark">` is set but the page stays
20
- * in light mode because the `dark:` utilities never match.
21
- */
22
- @theme {
23
- --color-grove-primary: var(--color-foreground);
24
- }
25
-
26
- @layer base {
27
- :root {
28
- --grove-color-primary: var(--color-grove-primary);
29
- --grove-radius: 0.5rem;
30
- --grove-container-width: 72rem;
31
-
32
- }
33
- }
@@ -1,28 +0,0 @@
1
- {
2
- // SPDX-License-Identifier: MIT
3
- //
4
- // Cloudflare Pages configuration for the Grove Astro template.
5
- //
6
- // `name` is the Pages project name (`grove-site` by default —
7
- // rename to match your Cloudflare account, or override via
8
- // `wrangler pages deploy --project-name <name>`).
9
- //
10
- // `compatibility_date` pins the runtime API surface. Bump it
11
- // when you want to opt into newer Pages features; older dates
12
- // are fine for stable behavior.
13
- //
14
- // `pages_build_output_dir` is the directory that `astro build`
15
- // writes to. Astro 6's default is `./dist`, which matches.
16
- //
17
- // The deploy workflow in `.github/workflows/build.yml` runs
18
- // pnpm run build
19
- // npx wrangler pages deploy dist --project-name <name>
20
- // after this file is committed.
21
- "$schema": "node_modules/wrangler/config-schema.json",
22
- "name": "grove-site",
23
- "compatibility_date": "2026-06-07",
24
- "pages_build_output_dir": "./dist",
25
- "observability": {
26
- "enabled": true
27
- }
28
- }