@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
@@ -0,0 +1,133 @@
1
+ ---
2
+ interface Props {
3
+ existingSlugs: string[];
4
+ existingFullNames: string[];
5
+ repoUrl: string;
6
+ fields: { category: boolean; stack: boolean; platforms: boolean; tags: boolean };
7
+ }
8
+ const { existingSlugs, existingFullNames, repoUrl, fields } = Astro.props;
9
+ ---
10
+
11
+ <script is:inline define:vars={{ existingSlugs, existingFullNames, repoUrl, fields }}>
12
+ // @ts-nocheck
13
+ const $ = (selector) => document.querySelector(selector);
14
+ const inputs = {
15
+ repo: $("#repo-url"), name: $("#item-name"), slug: $("#item-slug"),
16
+ description: $("#item-description"), category: $("#item-category"),
17
+ stack: $("#primary-stack"), tags: $("#item-tags"),
18
+ website: $("#item-website"), bestFor: $("#best-for"),
19
+ };
20
+ const fetchButton = $("#fetch-repo");
21
+ const preview = $("#yaml-preview");
22
+ const status = $("#submit-status");
23
+ const copyButton = $("#copy-yaml");
24
+ const openPrLink = $("#open-pr-link");
25
+ const slugSet = new Set(existingSlugs);
26
+ const repoSet = new Set(existingFullNames);
27
+ let repository = null;
28
+
29
+ const slugify = (value) => String(value || "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
30
+ const parseRepo = (value) => {
31
+ const match = String(value).match(/github\.com\/([^/]+)\/([^/?#]+)/);
32
+ return match ? { owner: match[1], repo: match[2].replace(/\.git$/, "") } : null;
33
+ };
34
+ const quote = (value) => `"${String(value ?? "").replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
35
+ const lines = (values, indent = 2) => values.map((value) => `${" ".repeat(indent)}- ${value}`).join("\n");
36
+ const selectedPlatforms = () => [...document.querySelectorAll("input[name=platform]:checked")].map((input) => input.value);
37
+ const setStatus = (text, tone = "muted") => {
38
+ status.textContent = text;
39
+ 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"}`;
40
+ };
41
+ const inferStack = (repo) => {
42
+ const language = String(repo.language || "").toLowerCase();
43
+ const topics = (repo.topics || []).map((topic) => String(topic).toLowerCase());
44
+ if (topics.includes("flutter") || language === "dart") return "flutter";
45
+ if (topics.includes("react-native")) return "react-native";
46
+ if (language === "swift" || language === "objective-c") return "ios";
47
+ if (language === "kotlin" || language === "java") return "android";
48
+ return language;
49
+ };
50
+ const generateYaml = () => {
51
+ if (!repository) return "";
52
+ const parsed = parseRepo(inputs.repo.value);
53
+ if (!parsed) return "";
54
+ const tags = String(inputs.tags?.value || "").split(",").map((tag) => tag.trim()).filter(Boolean);
55
+ const platforms = selectedPlatforms();
56
+ const bestFor = inputs.bestFor.value.split("\n").map((item) => item.trim()).filter(Boolean);
57
+ return [
58
+ "kind: project",
59
+ `slug: ${slugify(inputs.slug.value || repository.name)}`,
60
+ `name: ${quote(inputs.name.value)}`,
61
+ `description: ${quote(inputs.description.value)}`,
62
+ ...(fields.category ? [`category: ${inputs.category?.value || "uncategorized"}`] : []),
63
+ "projectType: real-app",
64
+ ...(fields.stack && inputs.stack?.value ? [`stack: ${quote(inputs.stack.value)}`] : []),
65
+ ...(fields.platforms ? ["platforms:", platforms.length ? lines(platforms) : " []"] : []),
66
+ ...(fields.tags ? ["tags:", tags.length ? lines(tags) : " []"] : []),
67
+ `repoUrl: ${repository.html_url}`,
68
+ "links:",
69
+ ` github: ${repository.html_url}`,
70
+ ...(inputs.website.value ? [` website: ${inputs.website.value}`] : []),
71
+ "bestFor:", bestFor.length ? lines(bestFor) : " []",
72
+ "source:",
73
+ " type: manual",
74
+ ` owner: ${parsed.owner}`,
75
+ ` repo: ${parsed.repo}`,
76
+ "curation:",
77
+ " reviewed: false",
78
+ " labels: []",
79
+ " lenses: []",
80
+ "",
81
+ ].join("\n");
82
+ };
83
+ const refresh = () => {
84
+ const yaml = generateYaml();
85
+ preview.textContent = yaml || "Paste a GitHub URL to generate a draft.";
86
+ if (!yaml) return;
87
+ const params = new URLSearchParams({ filename: `data/records/${slugify(inputs.slug.value)}.yml`, value: yaml });
88
+ openPrLink.href = `${repoUrl}/new/main?${params}`;
89
+ openPrLink.classList.remove("pointer-events-none", "opacity-50");
90
+ copyButton.disabled = false;
91
+ copyButton.classList.remove("pointer-events-none", "opacity-50");
92
+ };
93
+
94
+ fetchButton.addEventListener("click", async () => {
95
+ const parsed = parseRepo(inputs.repo.value);
96
+ if (!parsed) return setStatus("Enter a valid GitHub repository URL.", "error");
97
+ const fullName = `${parsed.owner}/${parsed.repo}`.toLowerCase();
98
+ if (repoSet.has(fullName)) setStatus("This repository is already listed. The draft can update its record.", "warn");
99
+ fetchButton.disabled = true;
100
+ setStatus("Fetching GitHub metadata...");
101
+ try {
102
+ const response = await fetch(`https://api.github.com/repos/${parsed.owner}/${parsed.repo}`, { headers: { Accept: "application/vnd.github+json" } });
103
+ if (response.status === 404) throw new Error("Repository not found or not public.");
104
+ if (response.status === 403) throw new Error("GitHub rate limit reached. Try again shortly.");
105
+ if (!response.ok) throw new Error(`GitHub returned ${response.status}.`);
106
+ repository = await response.json();
107
+ if (repository.private) throw new Error("Private repositories cannot be submitted.");
108
+ inputs.name.value = repository.name || parsed.repo;
109
+ inputs.slug.value = slugify(repository.name || parsed.repo);
110
+ inputs.description.value = repository.description || "";
111
+ if (inputs.stack) inputs.stack.value = inferStack(repository);
112
+ if (inputs.tags) inputs.tags.value = (repository.topics || []).slice(0, 8).join(", ");
113
+ inputs.website.value = repository.homepage || "";
114
+ const duplicate = slugSet.has(inputs.slug.value);
115
+ 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");
116
+ refresh();
117
+ } catch (error) {
118
+ repository = null;
119
+ setStatus(error.message || "Unable to fetch repository metadata.", "error");
120
+ } finally {
121
+ fetchButton.disabled = false;
122
+ }
123
+ });
124
+ $("#submit-form").addEventListener("input", refresh);
125
+ copyButton.addEventListener("click", async () => {
126
+ try {
127
+ await navigator.clipboard.writeText(preview.textContent || "");
128
+ setStatus("Copied YAML to clipboard.", "success");
129
+ } catch {
130
+ setStatus("Couldn't copy automatically. Select the YAML and copy it.", "error");
131
+ }
132
+ });
133
+ </script>
package/src/index.ts CHANGED
@@ -28,6 +28,7 @@ import { existsSync } from "node:fs";
28
28
  import { fileURLToPath } from "node:url";
29
29
  import { dirname, resolve } from "node:path";
30
30
  import type { AstroIntegration } from "astro";
31
+ import { loadConfig, prepareDirectory } from "@grove-dev/core";
31
32
 
32
33
  export * from "@grove-dev/core";
33
34
 
@@ -57,13 +58,21 @@ export default function groveAstro(): AstroIntegration {
57
58
  return {
58
59
  name: "@grove-dev/astro",
59
60
  hooks: {
60
- "astro:config:setup": ({ config, updateConfig, injectScript }) => {
61
+ "astro:config:setup": async ({ config, updateConfig, injectScript }) => {
61
62
  // Alias the components/layouts source directories so
62
63
  // consumer builds can import from
63
64
  // @grove-dev/astro/components/ItemCard.astro
64
65
  // without us shipping a glob-shaped `exports` map that
65
66
  // Vite/Rollup does not expand reliably.
66
67
  const consumerRoot = fileURLToPath(config.root);
68
+ const groveConfigPath = resolve(consumerRoot, "grove.config.ts");
69
+ if (existsSync(groveConfigPath)) {
70
+ await prepareDirectory(consumerRoot);
71
+ // Preparation is the integration's only site-level side effect.
72
+ // Routes belong to the consumer's `src/pages`, where they remain
73
+ // visible, editable, and safe from future Grove syncs.
74
+ await loadConfig(consumerRoot);
75
+ }
67
76
  const globalCssPath = resolve(consumerRoot, "src/styles/global.css");
68
77
  const globalCssExists = existsSync(globalCssPath);
69
78
 
@@ -73,6 +82,7 @@ export default function groveAstro(): AstroIntegration {
73
82
  alias: {
74
83
  "@grove-dev/astro/components": componentsDir,
75
84
  "@grove-dev/astro/layouts": layoutsDir,
85
+ "@grove/generated": resolve(consumerRoot, "data/generated"),
76
86
  },
77
87
  },
78
88
  plugins: [
@@ -30,6 +30,9 @@ interface SiteStats {
30
30
  totalStacks?: number;
31
31
  totalOwners?: number;
32
32
  totalStars?: number;
33
+ repositoryStars?: number;
34
+ repositoryForks?: number;
35
+ repositoryContributors?: number;
33
36
  originalRepo?: string;
34
37
  originalStars?: number;
35
38
  originalForks?: number;
@@ -41,6 +44,21 @@ interface Site {
41
44
  tagline?: string;
42
45
  description?: string;
43
46
  nav: NavItem[];
47
+ footer?: {
48
+ columns?: Array<{
49
+ heading: string;
50
+ items: Array<{ label: string; href: string; external?: boolean }>;
51
+ }>;
52
+ copyright?: string;
53
+ license?: string;
54
+ };
55
+ submission?: {
56
+ eyebrow?: string;
57
+ title?: string;
58
+ description?: string;
59
+ good?: string[];
60
+ avoid?: string[];
61
+ };
44
62
  repoUrl?: string;
45
63
  url?: string;
46
64
  /** Optional directory-level stats — populated from site-config.json
@@ -215,7 +233,7 @@ const favicon =
215
233
  ...(site.stats?.totalApps
216
234
  ? [{ label: "Browse all", href: directoryPath }]
217
235
  : []),
218
- { label: "Contributors", href: "/contributors" },
236
+ { label: "Community", href: "/contributors" },
219
237
  ...(site.stats?.originalRepo
220
238
  ? [{ label: "View on GitHub", href: site.stats.originalRepo, external: true }]
221
239
  : []),
@@ -30,6 +30,11 @@ interface Site {
30
30
  tagline?: string;
31
31
  description?: string;
32
32
  repoUrl?: string;
33
+ footer?: {
34
+ columns?: FooterColumn[];
35
+ copyright?: string;
36
+ license?: string;
37
+ };
33
38
  stats?: {
34
39
  totalApps?: number;
35
40
  totalCategories?: number;
@@ -74,8 +79,9 @@ const {
74
79
  } = Astro.props;
75
80
 
76
81
  const year = new Date().getFullYear();
77
- const holder = copyrightHolder ?? site.name;
82
+ const holder = copyrightHolder ?? site.footer?.copyright ?? site.name;
78
83
  const gh = site.repoUrl;
84
+ const showOwners = (stats?.totalOwners ?? 0) > 0;
79
85
 
80
86
  const defaultDiscover: NavItem[] = gh
81
87
  ? [
@@ -100,6 +106,15 @@ const defaultProject: NavItem[] = gh
100
106
  const discoverItems = discover.items.length ? discover.items : defaultDiscover;
101
107
  const contributeItems = contribute.items.length ? contribute.items : defaultContribute;
102
108
  const projectItems = project.items.length ? project.items : defaultProject;
109
+ const configuredColumns = site.footer?.columns ?? [];
110
+ const columns = configuredColumns.length
111
+ ? configuredColumns
112
+ : [
113
+ { heading: discover.heading, items: discoverItems },
114
+ { heading: contribute.heading, items: contributeItems },
115
+ { heading: project.heading, items: projectItems },
116
+ ].filter((column) => column.items.length > 0);
117
+ const resolvedLicenseLine = licenseLine ?? site.footer?.license;
103
118
  ---
104
119
 
105
120
  <footer
@@ -133,7 +148,7 @@ const projectItems = project.items.length ? project.items : defaultProject;
133
148
  <span class="font-mono">{stats.totalApps}</span> {itemsLabel}
134
149
  </>
135
150
  )}
136
- {stats.totalApps !== undefined && (stats.totalCategories !== undefined || stats.totalOwners !== undefined) && (
151
+ {stats.totalApps !== undefined && (stats.totalCategories !== undefined || showOwners) && (
137
152
  <span class="mx-1 text-ink-300 dark:text-ink-700">·</span>
138
153
  )}
139
154
  {stats.totalCategories !== undefined && (
@@ -141,71 +156,25 @@ const projectItems = project.items.length ? project.items : defaultProject;
141
156
  <span class="font-mono">{stats.totalCategories}</span> categories
142
157
  </>
143
158
  )}
144
- {stats.totalCategories !== undefined && stats.totalOwners !== undefined && (
159
+ {stats.totalCategories !== undefined && showOwners && (
145
160
  <span class="mx-1 text-ink-300 dark:text-ink-700">·</span>
146
161
  )}
147
- {stats.totalOwners !== undefined && (
162
+ {showOwners && (
148
163
  <>
149
- <span class="font-mono">{stats.totalOwners}</span> contributors
164
+ <span class="font-mono">{stats.totalOwners}</span> project owners
150
165
  </>
151
166
  )}
152
167
  </p>
153
168
  )}
154
169
  </div>
155
170
 
156
- {discoverItems.length > 0 && (
157
- <div>
158
- <h4 class="mb-3 text-2xs font-semibold uppercase tracking-wider text-ink-900 dark:text-ink-100">
159
- {discover.heading}
160
- </h4>
161
- <ul class="m-0 flex list-none flex-col gap-2 p-0">
162
- {discoverItems.map((item) => (
163
- <li>
164
- <a
165
- href={item.href}
166
- class="text-sm transition-colors hover:text-ink-900 dark:hover:text-ink-100"
167
- {...(item.external
168
- ? { target: "_blank", rel: "noopener noreferrer" }
169
- : {})}
170
- >
171
- {item.label}
172
- </a>
173
- </li>
174
- ))}
175
- </ul>
176
- </div>
177
- )}
178
-
179
- {contributeItems.length > 0 && (
180
- <div>
181
- <h4 class="mb-3 text-2xs font-semibold uppercase tracking-wider text-ink-900 dark:text-ink-100">
182
- {contribute.heading}
183
- </h4>
184
- <ul class="m-0 flex list-none flex-col gap-2 p-0">
185
- {contributeItems.map((item) => (
186
- <li>
187
- <a
188
- href={item.href}
189
- class="text-sm transition-colors hover:text-ink-900 dark:hover:text-ink-100"
190
- {...(item.external
191
- ? { target: "_blank", rel: "noopener noreferrer" }
192
- : {})}
193
- >
194
- {item.label}
195
- </a>
196
- </li>
197
- ))}
198
- </ul>
199
- </div>
200
- )}
201
-
202
- {projectItems.length > 0 && (
171
+ {columns.map((column) => (
203
172
  <div>
204
173
  <h4 class="mb-3 text-2xs font-semibold uppercase tracking-wider text-ink-900 dark:text-ink-100">
205
- {project.heading}
174
+ {column.heading}
206
175
  </h4>
207
176
  <ul class="m-0 flex list-none flex-col gap-2 p-0">
208
- {projectItems.map((item) => (
177
+ {column.items.map((item) => (
209
178
  <li>
210
179
  <a
211
180
  href={item.href}
@@ -220,14 +189,14 @@ const projectItems = project.items.length ? project.items : defaultProject;
220
189
  ))}
221
190
  </ul>
222
191
  </div>
223
- )}
192
+ ))}
224
193
  </div>
225
194
 
226
195
  <div class="border-t border-ink-200 dark:border-ink-800">
227
196
  <div
228
197
  class="mx-auto flex w-full max-w-wide flex-wrap items-center gap-3 px-5 py-4 text-2xs text-ink-500 sm:px-7 dark:text-ink-400"
229
198
  >
230
- <span>© {year} {holder}{licenseLine ? `. ${licenseLine}` : "."}</span>
199
+ <span>© {year} {holder}{resolvedLicenseLine ? `. ${resolvedLicenseLine}` : "."}</span>
231
200
  {gh && (
232
201
  <>
233
202
  <span class="text-ink-300 dark:text-ink-700">·</span>
@@ -28,7 +28,8 @@ interface Site {
28
28
  repoUrl?: string;
29
29
  url?: string;
30
30
  stats?: {
31
- totalStars?: number;
31
+ /** Stars on this site's own source repository. */
32
+ repositoryStars?: number;
32
33
  };
33
34
  blueprintConfig?: {
34
35
  routeSlug?: string;
@@ -55,20 +56,21 @@ const {
55
56
  class: className = "",
56
57
  } = Astro.props;
57
58
  const directoryPath = `/${site.blueprintConfig?.routeSlug ?? "items"}`;
58
- const navItems = site.blueprintConfig
59
+ const defaultNavItems = site.blueprintConfig
59
60
  ? [
60
- { label: "Recently added", href: `${directoryPath}?label=new` },
61
+ { label: "Recently added", href: `${directoryPath}?sort=recently-added` },
61
62
  { label: "Trending", href: `${directoryPath}?label=hot` },
62
63
  { label: "Categories", href: directoryPath },
63
64
  { label: "Stacks", href: directoryPath },
64
65
  { label: "About", href: "/about" },
65
66
  ]
66
- : site.nav;
67
- const stars = site.stats?.totalStars ?? 0;
67
+ : [];
68
+ const navItems = site.nav?.length ? site.nav : defaultNavItems;
69
+ const stars = site.stats?.repositoryStars;
68
70
  const starsLabel =
69
- stars >= 1000
71
+ typeof stars === "number" && stars >= 1000
70
72
  ? `${(stars / 1000).toFixed(stars >= 10000 ? 0 : 1)}k`
71
- : String(stars);
73
+ : typeof stars === "number" ? String(stars) : null;
72
74
  ---
73
75
 
74
76
  <header
@@ -78,17 +80,17 @@ const starsLabel =
78
80
  ]}
79
81
  >
80
82
  <div
81
- class="mx-auto flex w-full max-w-wide items-center gap-6 px-5 py-3 sm:px-7"
83
+ class="mx-auto flex w-full max-w-wide items-center gap-3 px-5 py-3 sm:gap-6 sm:px-7"
82
84
  >
83
85
  <a
84
86
  href="/"
85
- class="inline-flex items-center gap-2 text-[0.95rem] font-semibold tracking-tight text-ink-900 dark:text-ink-100"
87
+ class="inline-flex min-w-0 shrink items-center gap-2 text-[0.95rem] font-semibold tracking-tight text-ink-900 dark:text-ink-100"
86
88
  >
87
89
  <span
88
90
  aria-hidden="true"
89
91
  class="inline-block h-[18px] w-[18px] rounded bg-ink-900 dark:bg-ink-100"
90
92
  ></span>
91
- <span>{site.name}</span>
93
+ <span class="truncate">{site.name}</span>
92
94
  </a>
93
95
 
94
96
  <nav class="hidden flex-1 md:block" aria-label="Primary">
@@ -107,10 +109,26 @@ const starsLabel =
107
109
  </nav>
108
110
 
109
111
  <div class="ml-auto flex items-center gap-2">
112
+ <details class="group relative md:hidden">
113
+ <summary class="inline-flex h-9 cursor-pointer list-none items-center justify-center rounded-[var(--radius)] border border-border bg-secondary px-2.5 text-sm font-medium text-foreground hover:bg-accent focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/35">
114
+ Menu
115
+ </summary>
116
+ <nav aria-label="Mobile primary" class="absolute right-0 top-[calc(100%+0.5rem)] z-50 w-56 rounded-[var(--radius)] border border-border bg-background p-2 shadow-lg">
117
+ <ul class="m-0 flex list-none flex-col gap-1 p-0">
118
+ {navItems.map((item) => (
119
+ <li><a href={item.href} class="block rounded-md px-3 py-2 text-sm font-medium text-foreground no-underline hover:bg-accent">{item.label}</a></li>
120
+ ))}
121
+ {submitHref && (
122
+ <li class="border-t border-border pt-1"><a href={submitHref} class="block rounded-md px-3 py-2 text-sm font-medium text-foreground no-underline hover:bg-accent">{submitLabel ?? "Submit"}</a></li>
123
+ )}
124
+ </ul>
125
+ </nav>
126
+ </details>
127
+
110
128
  {submitHref && (
111
129
  <a
112
130
  href={submitHref}
113
- 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 hidden sm:inline-flex"
131
+ 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 max-sm:!hidden"
114
132
  >
115
133
  <svg
116
134
  viewBox="0 0 16 16"
@@ -130,7 +148,7 @@ const starsLabel =
130
148
  href={site.repoUrl}
131
149
  target="_blank"
132
150
  rel="noopener noreferrer"
133
- aria-label={`${repoLabel} on GitHub — ${stars} stars`}
151
+ aria-label={`${repoLabel} on GitHub${starsLabel ? ` — ${stars} stars` : ""}`}
134
152
  title={`${repoLabel} on GitHub`}
135
153
  class="inline-flex h-8 cursor-pointer items-center justify-center gap-1.5 overflow-hidden rounded-[var(--radius)] border border-border bg-secondary p-0 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"
136
154
  >
@@ -138,14 +156,14 @@ const starsLabel =
138
156
  <svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" fill="currentColor">
139
157
  <path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38v-1.33c-2.22.48-2.69-1.07-2.69-1.07-.36-.92-.89-1.17-.89-1.17-.73-.5.05-.49.05-.49.8.06 1.23.83 1.23.83.72 1.22 1.87.87 2.33.67.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48v2.19c0 .21.15.46.55.38A8 8 0 0 0 8 0Z"></path>
140
158
  </svg>
141
- <span>{repoLabel}</span>
159
+ <span class="hidden lg:inline">{repoLabel}</span>
142
160
  </span>
143
- <span class="flex items-center gap-1 border-s border-border bg-secondary px-2 py-1.5 text-secondary-foreground">
161
+ {starsLabel && <span class="hidden items-center gap-1 border-s border-border bg-secondary px-2 py-1.5 text-secondary-foreground sm:flex">
144
162
  <svg viewBox="0 0 16 16" width="11" height="11" aria-hidden="true" fill="currentColor" class="text-amber-500">
145
163
  <path d="M8 .25a.75.75 0 0 1 .673.418l1.882 3.815 4.21.612a.75.75 0 0 1 .416 1.279l-3.046 2.97.719 4.192a.751.751 0 0 1-1.088.791L8 12.347l-3.766 1.98a.75.75 0 0 1-1.088-.79l.72-4.194L.818 6.374a.75.75 0 0 1 .416-1.28l4.21-.611L7.327.668A.75.75 0 0 1 8 .25Z"></path>
146
164
  </svg>
147
165
  <span class="font-mono">{starsLabel}</span>
148
- </span>
166
+ </span>}
149
167
  </a>
150
168
  )}
151
169
 
@@ -24,11 +24,13 @@ const { class: className = "" } = Astro.props;
24
24
  data-theme-toggle
25
25
  aria-label="Toggle theme"
26
26
  title="Toggle theme"
27
- 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 relative h-8 w-8 p-0 ${className}`}
27
+ class={`inline-flex h-9 w-9 shrink-0 cursor-pointer items-center justify-center rounded-[var(--radius)] border border-border bg-secondary p-0 text-foreground outline-none transition-colors hover:bg-accent focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/35 ${className}`}
28
28
  >
29
29
  {/* Sun — shown in dark mode (click to go light) */}
30
30
  <svg
31
- class="h-4 w-4 hidden dark:block"
31
+ width="18"
32
+ height="18"
33
+ class="hidden shrink-0 dark:block"
32
34
  viewBox="0 0 24 24"
33
35
  fill="none"
34
36
  stroke="currentColor"
@@ -42,7 +44,9 @@ const { class: className = "" } = Astro.props;
42
44
  </svg>
43
45
  {/* Moon — shown in light mode (click to go dark) */}
44
46
  <svg
45
- class="h-4 w-4 block dark:hidden"
47
+ width="18"
48
+ height="18"
49
+ class="block shrink-0 dark:hidden"
46
50
  viewBox="0 0 24 24"
47
51
  fill="none"
48
52
  stroke="currentColor"
@@ -1,111 +1 @@
1
- /**
2
- * Display-name helpers for curation labels, statuses, lenses, and sort
3
- * options. Internal ids (e.g. "hot", "stale", "production-like") stay
4
- * stable for URL state and data files; this module owns the mapping
5
- * from id → user-facing string.
6
- *
7
- * Every export here is dependency-free so it can be imported by both
8
- * server-rendered Astro components and any client-side script.
9
- */
10
-
11
- // ── Label ids (the `labels` array on an item) ────────────────────────
12
- export type LabelId = "new" | "hot" | "mature" | "featured";
13
-
14
- export const LABEL_DISPLAY: Record<LabelId, string> = {
15
- new: "Recently added",
16
- hot: "Trending",
17
- mature: "Established",
18
- featured: "Featured",
19
- };
20
-
21
- /** Returns a human-readable name for a label id, or null if unknown. */
22
- export function labelDisplay(id: string | null | undefined): string | null {
23
- if (!id) return null;
24
- return LABEL_DISPLAY[id as LabelId] ?? null;
25
- }
26
-
27
- // ── Status ids (the `status` field on an item) ───────────────────────
28
- /**
29
- * Canonical status ids, including the curated "needs-maintainer"
30
- * composite that's emitted by the `needs-maintainer` lens (it expands
31
- * to "stale,quiet" in URL state but presents as one chip).
32
- */
33
- export const STATUS_DISPLAY: Record<string, string> = {
34
- active: "Active",
35
- mature: "Mature",
36
- quiet: "Quiet",
37
- stale: "Not recently active",
38
- inactive: "Inactive",
39
- "needs-maintainer": "Needs maintainer",
40
- archived: "Archived",
41
- historical: "Historical",
42
- needs_review: "Needs review",
43
- unavailable: "Unavailable",
44
- unknown: "Unknown",
45
- };
46
-
47
- export function statusDisplay(s: string | null | undefined): string {
48
- if (!s) return STATUS_DISPLAY.unknown ?? "";
49
- return STATUS_DISPLAY[s] ?? STATUS_DISPLAY.unknown ?? "";
50
- }
51
-
52
- // ── Lens ids (the curated tabs on /items) ────────────────────────────
53
- export const LENS_DISPLAY: Record<string, string> = {
54
- all: "All items",
55
- new: "Recently added",
56
- hot: "Trending",
57
- mature: "Established",
58
- "good-to-learn": "Good to learn",
59
- "production-like": "Production-like",
60
- "beginner-friendly": "Beginner friendly",
61
- "contribution-ready": "Contribution ready",
62
- launches: "Launches",
63
- "actively-developed": "Active",
64
- "needs-maintainer": "Needs maintainer",
65
- };
66
-
67
- export function lensDisplay(id: string | null | undefined): string {
68
- if (!id) return LENS_DISPLAY.all ?? "All";
69
- return LENS_DISPLAY[id] ?? id;
70
- }
71
-
72
- // ── Sort ids (the /items sort dropdown) ──────────────────────────────
73
- export const SORT_DISPLAY: Record<string, string> = {
74
- "recently-updated": "Recently updated",
75
- "most-starred": "Most starred",
76
- "recently-added": "Recently added",
77
- "best-overall": "Best overall",
78
- alphabetical: "Alphabetical",
79
- };
80
-
81
- export function sortDisplay(id: string | null | undefined): string {
82
- if (!id) return SORT_DISPLAY["recently-updated"] ?? "";
83
- return SORT_DISPLAY[id] ?? id;
84
- }
85
-
86
- export function prettySlug(value: string | null | undefined): string {
87
- if (!value) return "";
88
- return value
89
- .split(/[-_]+/)
90
- .filter(Boolean)
91
- .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
92
- .join(" ");
93
- }
94
-
95
- // ── Status option list (used by filter dropdowns) ───────────────────
96
- // "needs-maintainer" is included even though it's a derived/composite
97
- // status — filter dropdowns and chip rendering need to offer it
98
- // alongside the raw AppStatus values.
99
- export const STATUS_OPTIONS: string[] = [
100
- "active",
101
- "mature",
102
- "quiet",
103
- "stale",
104
- "inactive",
105
- "needs-maintainer",
106
- "archived",
107
- "historical",
108
- "needs_review",
109
- "unavailable",
110
- "unknown",
111
- ];
1
+ export * from "@grove-dev/core";