@grove-dev/astro 0.5.0-next.2 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/lib/index.d.ts +0 -2
- package/dist/lib/index.d.ts.map +1 -1
- package/dist/lib/index.js +0 -2
- package/dist/lib/index.js.map +1 -1
- package/dist/ui/button.d.ts +45 -0
- package/dist/ui/button.d.ts.map +1 -0
- package/dist/ui/button.js +82 -0
- package/dist/ui/button.js.map +1 -0
- package/package.json +9 -5
- package/src/components/CardGrid.astro +41 -0
- package/src/components/CardIcon.astro +67 -0
- package/src/components/CategoryGrid.astro +20 -5
- package/src/components/CollectionCard.astro +56 -0
- package/src/components/CollectionIndex.astro +21 -26
- package/src/components/CollectionPage.astro +36 -13
- package/src/components/CollectionRow.astro +62 -192
- package/src/components/CollectionTeaser.astro +4 -0
- package/src/components/ContributorsGrid.astro +4 -1
- package/src/components/DirectoryIndexClient.astro +127 -30
- package/src/components/EditorialSummary.astro +6 -5
- package/src/components/FilterGroupMenu.astro +24 -10
- package/src/components/FilterOptions.astro +6 -1
- package/src/components/FinalCta.astro +5 -3
- package/src/components/Hero.astro +85 -139
- package/src/components/IndexRow.astro +36 -69
- package/src/components/LanguageBreakdown.astro +5 -1
- package/src/components/OriginalCollection.astro +3 -2
- package/src/components/Pagination.astro +7 -20
- package/src/components/ProjectCard.astro +345 -0
- package/src/components/RecordHeader.astro +111 -82
- package/src/components/RecordSection.astro +14 -10
- package/src/components/RecordSidebar.astro +68 -20
- package/src/components/RefinePanel.astro +41 -62
- package/src/components/SmartLensTabs.astro +3 -7
- package/src/components/StackGrid.astro +26 -6
- package/src/components/SubmissionClient.astro +140 -63
- package/src/components/TableOfContents.astro +31 -5
- package/src/components/WhyThisExists.astro +1 -1
- package/src/index.ts +2 -2
- package/src/layouts/BaseLayout.astro +79 -9
- package/src/layouts/Header.astro +5 -4
- package/src/layouts/SectionHeader.astro +3 -2
- package/src/layouts/Seo.astro +28 -17
- package/src/layouts/ThemeToggle.astro +23 -6
- package/src/lib/index.ts +0 -2
- package/src/server/collections.ts +11 -0
- package/src/server/contrast.test.ts +82 -0
- package/src/server/contrast.ts +117 -0
- package/src/server/directory.test.ts +177 -0
- package/src/server/directory.ts +328 -206
- package/src/server/github-repo.test.ts +88 -0
- package/src/server/github-repo.ts +104 -0
- package/src/server/index.ts +4 -3
- package/src/server/models-home.test.ts +101 -0
- package/src/server/models.test.ts +135 -0
- package/src/server/models.ts +170 -42
- package/src/styles.css +128 -16
- package/src/ui/Badge.astro +38 -0
- package/src/ui/Button.astro +42 -0
- package/src/ui/EmptyState.astro +38 -0
- package/src/ui/FilterDrawer.astro +104 -0
- package/src/ui/PageHeader.astro +39 -0
- package/src/ui/SearchField.astro +50 -0
- package/src/ui/button.test.ts +54 -0
- package/src/ui/button.ts +98 -0
- package/dist/lib/scores.d.ts +0 -2
- package/dist/lib/scores.d.ts.map +0 -1
- package/dist/lib/scores.js +0 -2
- package/dist/lib/scores.js.map +0 -1
- package/src/components/CurationGrid.astro +0 -41
- package/src/components/DecisionRow.astro +0 -89
- package/src/components/ExploreByCategory.astro +0 -67
- package/src/components/ExploreByStack.astro +0 -78
- package/src/components/GroveDocumentHead.astro +0 -28
- package/src/components/ItemCard.astro +0 -324
- package/src/components/MinimalAbout.astro +0 -82
- package/src/components/ScoreBars.astro +0 -102
- 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 {
|
|
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
|
-
|
|
19
|
-
|
|
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:
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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 =
|
|
39
|
-
|
|
40
|
-
|
|
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 =
|
|
44
|
-
|
|
45
|
-
const
|
|
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
|
-
|
|
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
|
|
94
|
+
const parsed = parseRepo(inputs.repo?.value ?? "");
|
|
62
95
|
if (!parsed) return "";
|
|
63
|
-
const tags = String(inputs.tags?.value
|
|
96
|
+
const tags = String(inputs.tags?.value ?? "").split(",").map((tag) => tag.trim()).filter(Boolean);
|
|
64
97
|
const platforms = selectedPlatforms();
|
|
65
|
-
const bestFor = inputs.bestFor
|
|
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
|
|
69
|
-
`name: ${quote(inputs.name
|
|
70
|
-
`description: ${quote(inputs.description
|
|
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
|
|
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
|
-
|
|
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 &&
|
|
101
|
-
|
|
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 &&
|
|
104
|
-
|
|
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
|
|
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
|
|
139
|
-
const parsed = parseRepo(inputs.repo
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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(
|
|
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
|
|
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")
|
|
169
|
-
|
|
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
|
|
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
|
|
10
|
-
*
|
|
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
|
|
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
|
|
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=
|
|
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-
|
|
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
|
|
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/
|
|
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
|
|
18
|
-
import Header from
|
|
19
|
-
import Footer from
|
|
20
|
-
import Seo from
|
|
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?:
|
|
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
|
|
package/src/layouts/Header.astro
CHANGED
|
@@ -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:
|
|
64
|
-
{ label: "Stacks", href:
|
|
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="
|
|
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="
|
|
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
|
-
|
|
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
|