@grove-dev/astro 0.5.0-next.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/dist/index.js +2 -2
  2. package/dist/index.js.map +1 -1
  3. package/dist/lib/index.d.ts +0 -2
  4. package/dist/lib/index.d.ts.map +1 -1
  5. package/dist/lib/index.js +0 -2
  6. package/dist/lib/index.js.map +1 -1
  7. package/dist/ui/button.d.ts +45 -0
  8. package/dist/ui/button.d.ts.map +1 -0
  9. package/dist/ui/button.js +82 -0
  10. package/dist/ui/button.js.map +1 -0
  11. package/package.json +8 -5
  12. package/src/components/CardGrid.astro +41 -0
  13. package/src/components/CardIcon.astro +67 -0
  14. package/src/components/CategoryGrid.astro +20 -5
  15. package/src/components/CollectionCard.astro +56 -0
  16. package/src/components/CollectionIndex.astro +21 -26
  17. package/src/components/CollectionPage.astro +36 -13
  18. package/src/components/CollectionRow.astro +62 -192
  19. package/src/components/CollectionTeaser.astro +4 -0
  20. package/src/components/ContributorsGrid.astro +4 -1
  21. package/src/components/DirectoryIndexClient.astro +127 -30
  22. package/src/components/EditorialSummary.astro +6 -5
  23. package/src/components/FilterGroupMenu.astro +24 -10
  24. package/src/components/FilterOptions.astro +6 -1
  25. package/src/components/FinalCta.astro +5 -3
  26. package/src/components/Hero.astro +85 -139
  27. package/src/components/IndexRow.astro +36 -69
  28. package/src/components/LanguageBreakdown.astro +5 -1
  29. package/src/components/OriginalCollection.astro +3 -2
  30. package/src/components/Pagination.astro +7 -20
  31. package/src/components/ProjectCard.astro +345 -0
  32. package/src/components/RecordHeader.astro +111 -82
  33. package/src/components/RecordSection.astro +14 -10
  34. package/src/components/RecordSidebar.astro +68 -20
  35. package/src/components/RefinePanel.astro +41 -62
  36. package/src/components/SmartLensTabs.astro +3 -7
  37. package/src/components/StackGrid.astro +26 -6
  38. package/src/components/SubmissionClient.astro +140 -63
  39. package/src/components/TableOfContents.astro +31 -5
  40. package/src/components/WhyThisExists.astro +1 -1
  41. package/src/index.ts +2 -2
  42. package/src/layouts/BaseLayout.astro +79 -9
  43. package/src/layouts/Header.astro +5 -4
  44. package/src/layouts/SectionHeader.astro +3 -2
  45. package/src/layouts/Seo.astro +28 -17
  46. package/src/layouts/ThemeToggle.astro +23 -6
  47. package/src/lib/index.ts +0 -2
  48. package/src/server/collections.ts +11 -0
  49. package/src/server/contrast.test.ts +82 -0
  50. package/src/server/contrast.ts +117 -0
  51. package/src/server/directory.test.ts +177 -0
  52. package/src/server/directory.ts +328 -206
  53. package/src/server/github-repo.test.ts +88 -0
  54. package/src/server/github-repo.ts +104 -0
  55. package/src/server/index.ts +4 -3
  56. package/src/server/models-home.test.ts +101 -0
  57. package/src/server/models.test.ts +135 -0
  58. package/src/server/models.ts +170 -42
  59. package/src/styles.css +128 -16
  60. package/dist/lib/scores.d.ts +0 -2
  61. package/dist/lib/scores.d.ts.map +0 -1
  62. package/dist/lib/scores.js +0 -2
  63. package/dist/lib/scores.js.map +0 -1
  64. package/src/components/CurationGrid.astro +0 -41
  65. package/src/components/DecisionRow.astro +0 -89
  66. package/src/components/ExploreByCategory.astro +0 -67
  67. package/src/components/ExploreByStack.astro +0 -78
  68. package/src/components/GroveDocumentHead.astro +0 -28
  69. package/src/components/ItemCard.astro +0 -324
  70. package/src/components/MinimalAbout.astro +0 -82
  71. package/src/components/ScoreBars.astro +0 -102
  72. package/src/lib/scores.ts +0 -1
@@ -3,51 +3,84 @@ interface Props {
3
3
  existingSlugs: string[];
4
4
  existingFullNames: string[];
5
5
  repoUrl: string;
6
- fields: { category: boolean; stack: boolean; platforms: boolean; tags: boolean };
6
+ fields: { category: boolean; stack: boolean; platforms: boolean; tags: boolean; license?: boolean };
7
7
  /** Taxonomy ids for client-side validation. */
8
8
  taxonomy?: {
9
9
  categoryIds?: string[];
10
10
  stackIds?: string[];
11
11
  platformIds?: string[];
12
12
  };
13
+ /**
14
+ * Optional path of a server-side proxy endpoint that wraps
15
+ * `api.github.com` (SSR consumers with an adapter can wire one and
16
+ * read a `GITHUB_TOKEN` env var to lift the rate limit). When
17
+ * omitted — the static-build default — the visitor's browser calls
18
+ * `https://api.github.com/repos/{owner}/{repo}` directly, which
19
+ * works on any static host but is subject to GitHub's per-IP
20
+ * unauthenticated limit (60 requests/hour).
21
+ */
22
+ githubProxyPath?: string;
13
23
  }
14
- const { existingSlugs, existingFullNames, repoUrl, fields, taxonomy } = Astro.props;
24
+ const {
25
+ existingSlugs,
26
+ existingFullNames,
27
+ repoUrl,
28
+ fields,
29
+ taxonomy,
30
+ githubProxyPath = '',
31
+ } = Astro.props;
15
32
  ---
16
33
 
17
- <script is:inline define:vars={{ existingSlugs, existingFullNames, repoUrl, fields, taxonomy }}>
18
- // @ts-nocheck
19
- const $ = (selector) => document.querySelector(selector);
34
+ <script is:inline define:vars={{ existingSlugs, existingFullNames, repoUrl, fields, taxonomy, githubProxyPath }}>
35
+ /** @typedef {{ name: string; description: string | null; html_url: string; homepage: string | null; language: string | null; private: boolean; topics: string[] }} GitHubRepo */
36
+ /** @type {GitHubRepo | null} */
37
+ let repository = null;
38
+
39
+ const $ = /** @param {string} selector */ (selector) => /** @type {HTMLElement | null} */ (document.querySelector(selector));
40
+ /** @type {Record<string, HTMLInputElement | HTMLTextAreaElement | null>} */
20
41
  const inputs = {
21
- repo: $("#repo-url"), name: $("#item-name"), slug: $("#item-slug"),
22
- description: $("#item-description"), category: $("#item-category"),
23
- stack: $("#primary-stack"), tags: $("#item-tags"),
24
- website: $("#item-website"), bestFor: $("#best-for"),
42
+ repo: /** @type {HTMLInputElement | null} */ ($("#repo-url")),
43
+ name: /** @type {HTMLInputElement | null} */ ($("#item-name")),
44
+ slug: /** @type {HTMLInputElement | null} */ ($("#item-slug")),
45
+ description: /** @type {HTMLInputElement | null} */ ($("#item-description")),
46
+ category: /** @type {HTMLSelectElement | null} */ ($("#item-category")),
47
+ stack: /** @type {HTMLSelectElement | null} */ ($("#primary-stack")),
48
+ tags: /** @type {HTMLInputElement | null} */ ($("#item-tags")),
49
+ license: /** @type {HTMLSelectElement | null} */ ($("#item-license")),
50
+ website: /** @type {HTMLInputElement | null} */ ($("#item-website")),
51
+ bestFor: /** @type {HTMLTextAreaElement | null} */ ($("#best-for")),
25
52
  };
26
- const fetchButton = $("#fetch-repo");
27
- const preview = $("#yaml-preview");
28
- const status = $("#submit-status");
29
- const copyButton = $("#copy-yaml");
30
- const openPrLink = $("#open-pr-link");
53
+ const fetchButton = /** @type {HTMLButtonElement | null} */ ($("#fetch-repo"));
54
+ const preview = /** @type {HTMLElement | null} */ ($("#yaml-preview"));
55
+ const status = /** @type {HTMLElement | null} */ ($("#submit-status"));
56
+ const copyButton = /** @type {HTMLButtonElement | null} */ ($("#copy-yaml"));
57
+ const openPrLink = /** @type {HTMLAnchorElement | null} */ ($("#open-pr-link"));
31
58
  const slugSet = new Set(existingSlugs);
32
59
  const repoSet = new Set(existingFullNames);
33
60
  const categoryIds = new Set(taxonomy?.categoryIds ?? []);
34
61
  const stackIds = new Set(taxonomy?.stackIds ?? []);
35
62
  const platformIds = new Set(taxonomy?.platformIds ?? []);
36
- let repository = null;
37
63
 
38
- const slugify = (value) => String(value || "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
39
- const parseRepo = (value) => {
40
- const match = String(value).match(/github\.com\/([^/]+)\/([^/?#]+)/);
64
+ const slugify = /** @param {unknown} value */ (value) =>
65
+ String(value || "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
66
+ const parseRepo = /** @param {unknown} value */ (value) => {
67
+ const match = String(value ?? "").match(/github\.com\/([^/]+)\/([^/?#]+)/);
41
68
  return match ? { owner: match[1], repo: match[2].replace(/\.git$/, "") } : null;
42
69
  };
43
- const quote = (value) => `"${String(value ?? "").replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
44
- const lines = (values, indent = 2) => values.map((value) => `${" ".repeat(indent)}- ${value}`).join("\n");
45
- const selectedPlatforms = () => [...document.querySelectorAll("input[name=platform]:checked")].map((input) => input.value);
70
+ const quote = /** @param {unknown} value */ (value) =>
71
+ `"${String(value ?? "").replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
72
+ const lines = /** @param {string[]} values */ (values, indent = 2) =>
73
+ values.map((value) => `${" ".repeat(indent)}- ${value}`).join("\n");
74
+ const selectedPlatforms = () =>
75
+ /** @type {string[]} */ ([...document.querySelectorAll("input[name=platform]:checked")].map((input) => /** @type {HTMLInputElement} */ (input).value));
46
76
  const setStatus = (text, tone = "muted") => {
77
+ if (!status) return;
47
78
  status.textContent = text;
48
- 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"}`;
79
+ // Status tokens (danger/warning/success) so the submit states use
80
+ // the same semantic roles as the rest of the system.
81
+ status.className = `mt-3 min-h-5 text-2xs ${tone === "error" ? "text-danger" : tone === "warn" ? "text-warning" : tone === "success" ? "text-success" : "text-muted-foreground"}`;
49
82
  };
50
- const inferStack = (repo) => {
83
+ const inferStack = /** @param {GitHubRepo} repo */ (repo) => {
51
84
  const language = String(repo.language || "").toLowerCase();
52
85
  const topics = (repo.topics || []).map((topic) => String(topic).toLowerCase());
53
86
  if (topics.includes("flutter") || language === "dart") return "flutter";
@@ -58,25 +91,26 @@ const { existingSlugs, existingFullNames, repoUrl, fields, taxonomy } = Astro.pr
58
91
  };
59
92
  const generateYaml = () => {
60
93
  if (!repository) return "";
61
- const parsed = parseRepo(inputs.repo.value);
94
+ const parsed = parseRepo(inputs.repo?.value ?? "");
62
95
  if (!parsed) return "";
63
- const tags = String(inputs.tags?.value || "").split(",").map((tag) => tag.trim()).filter(Boolean);
96
+ const tags = String(inputs.tags?.value ?? "").split(",").map((tag) => tag.trim()).filter(Boolean);
64
97
  const platforms = selectedPlatforms();
65
- const bestFor = inputs.bestFor.value.split("\n").map((item) => item.trim()).filter(Boolean);
98
+ const bestFor = String(inputs.bestFor?.value ?? "").split("\n").map((item) => item.trim()).filter(Boolean);
66
99
  return [
67
100
  "kind: project",
68
- `slug: ${slugify(inputs.slug.value || repository.name)}`,
69
- `name: ${quote(inputs.name.value)}`,
70
- `description: ${quote(inputs.description.value)}`,
101
+ `slug: ${slugify(inputs.slug?.value ?? repository.name)}`,
102
+ `name: ${quote(inputs.name?.value ?? "")}`,
103
+ `description: ${quote(inputs.description?.value ?? "")}`,
71
104
  ...(fields.category ? [`category: ${inputs.category?.value || "uncategorized"}`] : []),
72
105
  "projectType: real-app",
73
106
  ...(fields.stack && inputs.stack?.value ? [`stack: ${quote(inputs.stack.value)}`] : []),
74
107
  ...(fields.platforms ? ["platforms:", platforms.length ? lines(platforms) : " []"] : []),
75
108
  ...(fields.tags ? ["tags:", tags.length ? lines(tags) : " []"] : []),
109
+ ...(fields.license && inputs.license?.value ? ["licenses:", lines([inputs.license.value])] : []),
76
110
  `repoUrl: ${repository.html_url}`,
77
111
  "links:",
78
112
  ` github: ${repository.html_url}`,
79
- ...(inputs.website.value ? [` website: ${inputs.website.value}`] : []),
113
+ ...(inputs.website?.value ? [` website: ${inputs.website.value}`] : []),
80
114
  "bestFor:", bestFor.length ? lines(bestFor) : " []",
81
115
  "source:",
82
116
  " type: manual",
@@ -91,17 +125,24 @@ const { existingSlugs, existingFullNames, repoUrl, fields, taxonomy } = Astro.pr
91
125
  };
92
126
  const validationIssues = () => {
93
127
  const issues = [];
94
- if (slugSet.has(slugify(inputs.slug.value))) {
128
+ if (inputs.slug && slugSet.has(slugify(inputs.slug.value))) {
95
129
  issues.push("A record with that slug already exists — update the existing one instead of opening a duplicate PR.");
96
130
  }
97
- if (inputs.description.value.trim().length < 40) {
131
+ const description = String(inputs.description?.value ?? "");
132
+ if (description.trim().length < 40) {
98
133
  issues.push("Description must be at least 40 characters.");
99
134
  }
100
- if (fields.category && (!inputs.category.value || inputs.category.value === "uncategorized" || (categoryIds.size > 0 && !categoryIds.has(inputs.category.value)))) {
101
- issues.push("Choose a category from the taxonomy.");
135
+ if (fields.category && inputs.category) {
136
+ const v = inputs.category.value;
137
+ if (!v || v === "uncategorized" || (categoryIds.size > 0 && !categoryIds.has(v))) {
138
+ issues.push("Choose a category from the taxonomy.");
139
+ }
102
140
  }
103
- if (fields.stack && (!inputs.stack.value || (stackIds.size > 0 && !stackIds.has(inputs.stack.value)))) {
104
- issues.push("Choose a primary stack from the taxonomy.");
141
+ if (fields.stack && inputs.stack) {
142
+ const v = inputs.stack.value;
143
+ if (!v || (stackIds.size > 0 && !stackIds.has(v))) {
144
+ issues.push("Choose a primary stack from the taxonomy.");
145
+ }
105
146
  }
106
147
  if (fields.platforms && selectedPlatforms().length === 0) {
107
148
  issues.push("Select at least one platform.");
@@ -113,13 +154,8 @@ const { existingSlugs, existingFullNames, repoUrl, fields, taxonomy } = Astro.pr
113
154
  };
114
155
  const refresh = () => {
115
156
  const yaml = generateYaml();
116
- preview.textContent = yaml || "Paste a GitHub URL to generate a draft.";
117
- if (!yaml) {
118
- openPrLink.classList.add("pointer-events-none", "opacity-50");
119
- copyButton.disabled = true;
120
- copyButton.classList.add("pointer-events-none", "opacity-50");
121
- return;
122
- }
157
+ if (preview) preview.textContent = yaml || "Paste a GitHub URL to generate a draft.";
158
+ if (!yaml || !openPrLink || !copyButton) return;
123
159
  const issues = validationIssues();
124
160
  if (issues.length > 0) {
125
161
  setStatus(issues[0], "error");
@@ -128,47 +164,88 @@ const { existingSlugs, existingFullNames, repoUrl, fields, taxonomy } = Astro.pr
128
164
  copyButton.classList.add("pointer-events-none", "opacity-50");
129
165
  return;
130
166
  }
131
- const params = new URLSearchParams({ filename: `data/records/${slugify(inputs.slug.value)}.yml`, value: yaml });
167
+ const params = new URLSearchParams({ filename: `data/records/${slugify(inputs.slug?.value ?? "")}.yml`, value: yaml });
132
168
  openPrLink.href = `${repoUrl}/new/main?${params}`;
133
169
  openPrLink.classList.remove("pointer-events-none", "opacity-50");
134
170
  copyButton.disabled = false;
135
171
  copyButton.classList.remove("pointer-events-none", "opacity-50");
136
172
  };
137
173
 
138
- fetchButton.addEventListener("click", async () => {
139
- const parsed = parseRepo(inputs.repo.value);
174
+ fetchButton?.addEventListener("click", async () => {
175
+ const parsed = parseRepo(inputs.repo?.value ?? "");
140
176
  if (!parsed) return setStatus("Enter a valid GitHub repository URL.", "error");
141
177
  const fullName = `${parsed.owner}/${parsed.repo}`.toLowerCase();
142
178
  if (repoSet.has(fullName)) setStatus("This repository is already listed. The draft can update its record.", "warn");
143
- fetchButton.disabled = true;
179
+ if (fetchButton) fetchButton.disabled = true;
144
180
  setStatus("Fetching GitHub metadata...");
145
181
  try {
146
- const response = await fetch(`https://api.github.com/repos/${parsed.owner}/${parsed.repo}`, { headers: { Accept: "application/vnd.github+json" } });
147
- if (response.status === 404) throw new Error("Repository not found or not public.");
148
- if (response.status === 403) throw new Error("GitHub rate limit reached. Try again shortly.");
149
- if (!response.ok) throw new Error(`GitHub returned ${response.status}.`);
150
- repository = await response.json();
182
+ if (githubProxyPath) {
183
+ // Server-side proxy (SSR consumers) the endpoint reads an
184
+ // optional `GITHUB_TOKEN` env var and forwards the request
185
+ // with a Bearer header (or unauthenticated, if no token).
186
+ const proxyUrl = `${githubProxyPath}?owner=${encodeURIComponent(parsed.owner)}&repo=${encodeURIComponent(parsed.repo)}`;
187
+ const response = await fetch(proxyUrl);
188
+ const payload = /** @type {{ ok: boolean; status?: number; message?: string; data?: GitHubRepo }} */ (await response.json());
189
+ if (!response.ok || !payload.ok || !payload.data) {
190
+ throw new Error(payload.message || `GitHub returned ${response.status}.`);
191
+ }
192
+ repository = payload.data;
193
+ } else {
194
+ // Static-build default — call api.github.com directly. The
195
+ // raw payload carries the same field names the form fill
196
+ // logic consumes (name, description, html_url, homepage,
197
+ // language, private, topics).
198
+ const response = await fetch(
199
+ `https://api.github.com/repos/${encodeURIComponent(parsed.owner)}/${encodeURIComponent(parsed.repo)}`,
200
+ { headers: { Accept: "application/vnd.github+json" } },
201
+ );
202
+ if (response.status === 404) {
203
+ throw new Error("Repository not found or not public.");
204
+ }
205
+ if (response.status === 403 || response.status === 429) {
206
+ throw new Error(
207
+ "GitHub rate limit reached (60 requests/hour per visitor). Try again later or fill the fields manually.",
208
+ );
209
+ }
210
+ if (!response.ok) {
211
+ throw new Error(`GitHub returned ${response.status}.`);
212
+ }
213
+ const data = /** @type {GitHubRepo & { topics?: string[] }} */ (await response.json());
214
+ repository = {
215
+ name: data.name,
216
+ description: data.description ?? null,
217
+ html_url: data.html_url,
218
+ homepage: data.homepage ?? null,
219
+ language: data.language ?? null,
220
+ private: Boolean(data.private),
221
+ topics: Array.isArray(data.topics) ? data.topics : [],
222
+ };
223
+ }
151
224
  if (repository.private) throw new Error("Private repositories cannot be submitted.");
152
- inputs.name.value = repository.name || parsed.repo;
153
- inputs.slug.value = slugify(repository.name || parsed.repo);
154
- inputs.description.value = repository.description || "";
225
+ if (inputs.name) inputs.name.value = repository.name || parsed.repo;
226
+ if (inputs.slug) inputs.slug.value = slugify(repository.name || parsed.repo);
227
+ if (inputs.description) inputs.description.value = repository.description || "";
155
228
  if (inputs.stack) inputs.stack.value = inferStack(repository);
156
229
  if (inputs.tags) inputs.tags.value = (repository.topics || []).slice(0, 8).join(", ");
157
- inputs.website.value = repository.homepage || "";
158
- const duplicate = slugSet.has(inputs.slug.value);
159
- 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");
230
+ if (inputs.website) inputs.website.value = repository.homepage || "";
231
+ const duplicate = inputs.slug ? slugSet.has(inputs.slug.value) : false;
232
+ setStatus(
233
+ duplicate ? "That slug already exists. Choose another or update the existing record." : "Draft generated. Edit any field and the YAML will refresh.",
234
+ duplicate ? "warn" : "success",
235
+ );
160
236
  refresh();
161
237
  } catch (error) {
162
238
  repository = null;
163
- setStatus(error.message || "Unable to fetch repository metadata.", "error");
239
+ setStatus(error instanceof Error ? error.message : "Unable to fetch repository metadata.", "error");
164
240
  } finally {
165
- fetchButton.disabled = false;
241
+ if (fetchButton) fetchButton.disabled = false;
166
242
  }
167
243
  });
168
- $("#submit-form").addEventListener("input", refresh);
169
- copyButton.addEventListener("click", async () => {
244
+ $("#submit-form")?.addEventListener("input", refresh);
245
+ $("#submit-form")?.addEventListener("change", refresh);
246
+ copyButton?.addEventListener("click", async () => {
170
247
  try {
171
- await navigator.clipboard.writeText(preview.textContent || "");
248
+ await navigator.clipboard.writeText(preview?.textContent || "");
172
249
  setStatus("Copied YAML to clipboard.", "success");
173
250
  } catch {
174
251
  setStatus("Couldn't copy automatically. Select the YAML and copy it.", "error");
@@ -6,8 +6,9 @@
6
6
  *
7
7
  * Behavior:
8
8
  * - Mobile (<lg): rendered as a closed `<details>` dropdown.
9
- * - Desktop (lg+): forced open via `lg:!open` so the nav stays
10
- * visible while the user scrolls.
9
+ * - Desktop (lg+): forced open by the `.grove-toc` media rule in
10
+ * `styles.css` (CSS reveals the list without the `open`
11
+ * attribute) so the nav stays visible while the user scrolls.
11
12
  * - Heading IDs come from the markdown renderer; the TOC entries
12
13
  * are pre-computed by `extractToc` and passed in.
13
14
  *
@@ -31,7 +32,7 @@ const hasItems = items.length > 1;
31
32
  ---
32
33
 
33
34
  {hasItems && (
34
- <details class="grove-toc mb-8 rounded-[var(--radius-lg)] border border-ink-200 bg-background open:bg-background dark:border-ink-800 lg:!open">
35
+ <details class="grove-toc rounded-[var(--radius-lg)] border border-ink-200 bg-card open:bg-card dark:border-ink-800">
35
36
  <summary class="cursor-pointer list-none px-4 py-3 text-2xs font-semibold uppercase tracking-wider text-ink-500 hover:text-ink-900 dark:text-ink-400 dark:hover:text-ink-100 lg:cursor-default lg:pointer-events-none">
36
37
  <span class="inline-flex items-center gap-1.5">
37
38
  <svg viewBox="0 0 16 16" width="11" height="11" aria-hidden="true" fill="currentColor" class="grove-toc-chevron transition-transform lg:hidden">
@@ -43,12 +44,23 @@ const hasItems = items.length > 1;
43
44
  </span>
44
45
  </span>
45
46
  </summary>
47
+ {/* Full list, no inner scroll — the TOC lives in the right rail
48
+ and every entry stays visible. h3 entries indent under their
49
+ h2 parents. */}
46
50
  <ol class="grove-toc-list mt-1 list-none border-t border-ink-200 p-2 text-sm dark:border-ink-800 lg:border-t-0 lg:p-0">
47
51
  {items.map((entry) => (
48
- <li class="grove-toc-item border-b border-ink-100 last:border-b-0 dark:border-ink-800 lg:border-b-0 lg:py-0.5">
52
+ <li
53
+ class:list={[
54
+ "grove-toc-item border-b border-ink-100 last:border-b-0 dark:border-ink-800 lg:border-b-0 lg:py-0.5",
55
+ entry.depth >= 3 && "ml-4 lg:ml-3",
56
+ ]}
57
+ >
49
58
  <a
50
59
  href={`#${entry.id}`}
51
- class="grove-toc-link block truncate px-3 py-2 text-ink-500 no-underline transition-colors hover:text-ink-900 dark:text-ink-400 dark:hover:text-ink-100 lg:px-2 lg:py-1"
60
+ class:list={[
61
+ "grove-toc-link block truncate px-3 py-2 text-ink-500 no-underline transition-colors hover:text-ink-900 dark:text-ink-400 dark:hover:text-ink-100 lg:px-2 lg:py-1",
62
+ entry.depth >= 3 && "text-[0.8125rem]",
63
+ ]}
52
64
  data-toc-link={entry.id}
53
65
  >
54
66
  {entry.text}
@@ -62,6 +74,20 @@ const hasItems = items.length > 1;
62
74
  {hasItems && (
63
75
  <script is:inline define:vars={{ tocIds: items.map((i) => i.id) }}>
64
76
  (function () {
77
+ // Desktop (lg+): force the details open so the nav stays visible
78
+ // while scrolling; the summary is pointer-events-none there. On
79
+ // narrow viewports restore the closed dropdown default. CSS in
80
+ // styles.css covers the no-JS case.
81
+ var toc = document.querySelector(".grove-toc");
82
+ if (toc) {
83
+ var mq = window.matchMedia("(min-width: 64rem)");
84
+ var syncOpen = function () { toc.open = mq.matches; };
85
+ if (typeof mq.addEventListener === "function") {
86
+ mq.addEventListener("change", syncOpen);
87
+ }
88
+ syncOpen();
89
+ }
90
+
65
91
  // Smooth scroll + history-replace on TOC link clicks.
66
92
  function onClick(e) {
67
93
  var link = e.target.closest("a[data-toc-link]");
@@ -82,7 +82,7 @@ const items = points && points.length > 0 ? points : defaultPoints;
82
82
  aria-label="Why this directory"
83
83
  >
84
84
  {items.map((p) => (
85
- <li class="rounded-[calc(var(--radius)+0.25rem)] border-0 bg-muted shadow-none transition-colors p-4">
85
+ <li class="rounded-[calc(var(--radius)+0.25rem)] border border-border bg-card shadow-none transition-colors p-4">
86
86
  <div
87
87
  aria-hidden="true"
88
88
  class="mb-3 flex h-8 w-8 items-center justify-center rounded-md border border-ink-200 bg-white text-ink-700 dark:border-ink-800 dark:bg-ink-950 dark:text-ink-300"
package/src/index.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  * - `default` export: an Astro integration that wires the
6
6
  * `@grove-dev/astro/components/*` and `@grove-dev/astro/layouts/*`
7
7
  * subpath imports into Vite so consumer projects can write:
8
- * import ItemCard from "@grove-dev/astro/components/ItemCard.astro";
8
+ * import ProjectCard from "@grove-dev/astro/components/ProjectCard.astro";
9
9
  * and have Vite resolve the source `.astro` file (the package's
10
10
  * `dist/` only contains TypeScript helpers, not the components
11
11
  * themselves).
@@ -61,7 +61,7 @@ export default function groveAstro(): AstroIntegration {
61
61
  "astro:config:setup": async ({ config, updateConfig, injectScript }) => {
62
62
  // Alias the components/layouts source directories so
63
63
  // consumer builds can import from
64
- // @grove-dev/astro/components/ItemCard.astro
64
+ // @grove-dev/astro/components/ProjectCard.astro
65
65
  // without us shipping a glob-shaped `exports` map that
66
66
  // Vite/Rollup does not expand reliably.
67
67
  const consumerRoot = fileURLToPath(config.root);
@@ -14,10 +14,11 @@
14
14
  * GA4 can be configured once through `site.analytics.googleAnalyticsId`
15
15
  * or overridden per page with `gaId`. Omit both for privacy-first deployments.
16
16
  */
17
- import "../styles.css";
18
- import Header from "./Header.astro";
19
- import Footer from "./Footer.astro";
20
- import Seo from "./Seo.astro";
17
+ import '../styles.css';
18
+ import Header from './Header.astro';
19
+ import Footer from './Footer.astro';
20
+ import Seo from './Seo.astro';
21
+ import { derivePrimaryPalette } from '../server/contrast';
21
22
 
22
23
  interface NavItem {
23
24
  label: string;
@@ -68,6 +69,18 @@ interface Site {
68
69
  * at generate time. Components use it to render hero counts,
69
70
  * origin card stats, and footer summary lines. */
70
71
  stats?: SiteStats;
72
+ /** Theme knobs from grove.config.ts — resolved into CSS custom
73
+ * properties on `<html>` so the package stylesheet picks them up.
74
+ * Values are plain strings because the prop usually arrives from a
75
+ * JSON import; unknown values fall back to the package defaults. */
76
+ theme?: {
77
+ primaryColor?: string;
78
+ /** "none" | "soft" | "round" */
79
+ radius?: string;
80
+ /** "compact" | "comfortable" | "spacious" */
81
+ density?: string;
82
+ containerWidth?: string;
83
+ };
71
84
  blueprintConfig?: {
72
85
  routeSlug?: string;
73
86
  labelSingular?: string;
@@ -79,7 +92,7 @@ interface Props {
79
92
  title: string;
80
93
  description: string;
81
94
  site: Site;
82
- type?: "website" | "article" | "profile";
95
+ type?: 'website' | 'article' | 'profile';
83
96
  noindex?: boolean;
84
97
  jsonLd?: Record<string, unknown> | Record<string, unknown>[];
85
98
  /** Optional Google Analytics 4 Measurement ID (e.g. "G-XXXXXX"). */
@@ -104,16 +117,67 @@ const {
104
117
  submitLabel,
105
118
  submitHref,
106
119
  } = Astro.props;
107
- const directoryPath =
108
- searchPath ?? `/${site.blueprintConfig?.routeSlug ?? "items"}`;
120
+ const directoryPath = searchPath ?? `/${site.blueprintConfig?.routeSlug ?? 'items'}`;
109
121
  const effectiveGaId = gaId ?? site.analytics?.googleAnalyticsId;
110
122
 
123
+ // ── Theme wiring ─────────────────────────────────────────────────
124
+ // `theme.*` from grove.config.ts resolves to CSS custom properties
125
+ // on the <html> style attribute. An attribute (not an inline
126
+ // <style>) so the override wins over the bundled stylesheet
127
+ // regardless of how Vite orders the CSS output.
128
+ const RADIUS_PRESETS: Record<string, [string, string, string]> = {
129
+ none: ["0rem", "0rem", "0rem"],
130
+ soft: ["0.625rem", "0.5rem", "0.75rem"],
131
+ round: ["0.875rem", "0.75rem", "1rem"],
132
+ };
133
+ const DENSITY_SPACING: Record<string, string> = {
134
+ compact: "0.225rem",
135
+ comfortable: "0.25rem",
136
+ spacious: "0.28rem",
137
+ };
138
+
139
+ // Brand fill and text derive from ONE WCAG computation (contrast.ts)
140
+ // so background and foreground can never drift apart across themes.
141
+
142
+ const themeConfig = site.theme;
143
+ const themeStyleParts: string[] = [];
144
+ if (themeConfig) {
145
+ const radius = RADIUS_PRESETS[themeConfig.radius ?? "soft"];
146
+ if (radius && themeConfig.radius && themeConfig.radius !== "soft") {
147
+ themeStyleParts.push(
148
+ `--grove-radius: ${radius[0]}`,
149
+ `--grove-radius-lg: ${radius[1]}`,
150
+ `--grove-radius-xl: ${radius[2]}`,
151
+ );
152
+ }
153
+ const spacing = DENSITY_SPACING[themeConfig.density ?? "comfortable"];
154
+ if (spacing && themeConfig.density && themeConfig.density !== "comfortable") {
155
+ themeStyleParts.push(`--spacing: ${spacing}`);
156
+ }
157
+ if (themeConfig.containerWidth) {
158
+ themeStyleParts.push(`--grove-container: ${themeConfig.containerWidth}`);
159
+ }
160
+ const primary = themeConfig.primaryColor;
161
+ if (typeof primary === "string" && /^#[0-9a-fA-F]{3,8}$/.test(primary)) {
162
+ const palette = derivePrimaryPalette(primary);
163
+ if (palette) {
164
+ themeStyleParts.push(
165
+ `--grove-theme-primary: ${palette.solid}`,
166
+ `--grove-theme-primary-foreground: ${palette.solidForeground}`,
167
+ `--grove-theme-primary-dark: ${palette.dark}`,
168
+ `--grove-theme-primary-dark-foreground: ${palette.darkForeground}`,
169
+ );
170
+ }
171
+ }
172
+ }
173
+ const themeStyle = themeStyleParts.length > 0 ? `${themeStyleParts.join("; ")};` : undefined;
174
+
111
175
  const favicon =
112
176
  "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Crect width='16' height='16' rx='3' fill='%2318181b'/%3E%3C/svg%3E";
113
177
  ---
114
178
 
115
179
  <!doctype html>
116
- <html lang="en">
180
+ <html lang="en" style={themeStyle}>
117
181
  <head>
118
182
  <meta charset="UTF-8" />
119
183
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
@@ -209,6 +273,12 @@ const favicon =
209
273
  </script>
210
274
  </head>
211
275
  <body class="min-h-screen bg-background text-foreground">
276
+ <a
277
+ href="#main"
278
+ class="sr-only z-50 rounded-md bg-background px-3 py-2 text-sm font-medium text-foreground outline-none ring-2 ring-ring focus:not-sr-only focus:fixed focus:left-3 focus:top-3"
279
+ >
280
+ Skip to content
281
+ </a>
212
282
  <slot name="header">
213
283
  <Header
214
284
  site={site}
@@ -217,7 +287,7 @@ const favicon =
217
287
  />
218
288
  </slot>
219
289
 
220
- <main>
290
+ <main id="main">
221
291
  <slot />
222
292
  </main>
223
293
 
@@ -14,6 +14,7 @@
14
14
  */
15
15
 
16
16
  import ThemeToggle from "./ThemeToggle.astro";
17
+ import { buttonClass } from "../ui/button.js";
17
18
 
18
19
  interface NavItem {
19
20
  label: string;
@@ -60,8 +61,8 @@ const defaultNavItems = site.blueprintConfig
60
61
  ? [
61
62
  { label: "Recently added", href: `${directoryPath}?sort=recently-added` },
62
63
  { label: "Trending", href: `${directoryPath}?label=hot` },
63
- { label: "Categories", href: directoryPath },
64
- { label: "Stacks", href: directoryPath },
64
+ { label: "Categories", href: "/categories" },
65
+ { label: "Stacks", href: "/stacks" },
65
66
  { label: "About", href: "/about" },
66
67
  ]
67
68
  : [];
@@ -128,7 +129,7 @@ const starsLabel =
128
129
  {submitHref && (
129
130
  <a
130
131
  href={submitHref}
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"
132
+ class={buttonClass("secondary", "sm", "max-sm:!hidden")}
132
133
  >
133
134
  <svg
134
135
  viewBox="0 0 16 16"
@@ -150,7 +151,7 @@ const starsLabel =
150
151
  rel="noopener noreferrer"
151
152
  aria-label={`${repoLabel} on GitHub${starsLabel ? ` — ${stars} stars` : ""}`}
152
153
  title={`${repoLabel} on GitHub`}
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"
154
+ class={buttonClass("secondary", "bare", "h-8 overflow-hidden p-0 text-sm")}
154
155
  >
155
156
  <span class="flex items-center gap-1.5 px-2.5 py-1.5 text-ink-900 dark:text-ink-100">
156
157
  <svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" fill="currentColor">
@@ -4,11 +4,12 @@ interface Props {
4
4
  title: string;
5
5
  description?: string;
6
6
  align?: "left" | "center";
7
- level?: 2 | 3;
7
+ /** Pass 1 when this header IS the page title (one h1 per route). */
8
+ level?: 1 | 2 | 3;
8
9
  }
9
10
 
10
11
  const { eyebrow, title, description, align = "left", level = 2 } = Astro.props;
11
- const Heading = `h${level}` as "h2" | "h3";
12
+ const Heading = `h${level}` as "h1" | "h2" | "h3";
12
13
  ---
13
14
 
14
15
  <header