@grove-dev/astro 0.2.14 → 0.2.18

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.
@@ -13,6 +13,12 @@
13
13
  * `/entities/`) is derived from the blueprint via `getStaticPaths`,
14
14
  * so the same page works for every blueprint without per-blueprint
15
15
  * forks.
16
+ *
17
+ * Navigation model: full-page render. The form posts via `method="get"`
18
+ * so the browser navigates to `?q=...&sort=...&stack=...`, and the
19
+ * server re-runs `filterRecords` / `applySort` / `paginate` from
20
+ * those query params. No client-side filter shim, no duplicated
21
+ * logic. The URL stays the single source of truth for filter state.
16
22
  */
17
23
  import siteConfig from "../../../data/generated/site-config.json";
18
24
  import { items, itemLabel, itemLabelPlural } from "../../data/records";
@@ -21,6 +27,7 @@ import BaseLayout from "@grove-dev/astro/layouts/BaseLayout.astro";
21
27
  import SmartLensTabs from "@grove-dev/astro/components/SmartLensTabs.astro";
22
28
  import RefinePanel from "@grove-dev/astro/components/RefinePanel.astro";
23
29
  import IndexRow from "@grove-dev/astro/components/IndexRow.astro";
30
+ import Pagination from "@grove-dev/astro/components/Pagination.astro";
24
31
  import ExploreByCategory from "@grove-dev/astro/components/ExploreByCategory.astro";
25
32
  import ExploreByStack from "@grove-dev/astro/components/ExploreByStack.astro";
26
33
  import {
@@ -33,23 +40,19 @@ import {
33
40
  effectiveSort,
34
41
  filterRecords,
35
42
  filtersFromSearchParams,
36
- paginate,
37
43
  searchParamsFromFilters,
38
44
  sortDisplay,
45
+ SORT_OPTIONS,
39
46
  totalPages,
40
47
  } from "@grove-dev/astro";
41
48
 
42
- export async function getStaticPaths() {
43
- let activeSlug = "projects";
44
- try {
45
- const raw = await import("../../../data/generated/site-config.json");
46
- activeSlug = raw.default?.blueprintConfig?.routeSlug ?? "projects";
47
- } catch {
48
- /* not present */
49
- }
50
- const all = ["projects", "resources", "entities"];
51
- if (!all.includes(activeSlug)) all.push(activeSlug);
52
- return all.map((slug) => ({ params: { slug } }));
49
+ // This template ships with the `project-directory` blueprint, so the
50
+ // only directory slug it ever needs is `projects`. While it ships
51
+ // as the project directory, pre-generate the single path the build
52
+ // actually needs. Swapping in a different blueprint is a one-config
53
+ // change that flows through the static import at the top.
54
+ export function getStaticPaths() {
55
+ return [{ params: { slug: "projects" } }];
53
56
  }
54
57
 
55
58
  const slug = Astro.params.slug as string;
@@ -62,36 +65,7 @@ const filtered = filterRecords(items, filters);
62
65
  const sorted = applySort(filtered, sort);
63
66
  const pages = totalPages(sorted.length);
64
67
  const page = Math.min(requestedPage, pages);
65
- const visibleItems = paginate(sorted, page);
66
68
  const chips = activeFilterChips(filters);
67
- const clientItems = items.map((item) => ({
68
- slug: item.slug,
69
- name: item.kind === "resource" ? item.title : item.name,
70
- description: item.description,
71
- category: item.category,
72
- tags: item.tags,
73
- labels: item.curation?.labels ?? [],
74
- lenses: item.curation?.lenses ?? [],
75
- reviewed: Boolean(item.curation?.reviewed),
76
- addedAt: item.curation?.reviewedAt ?? "",
77
- stacks: item.kind === "project" ? [item.stack, ...item.stacks].filter(Boolean) : [],
78
- platforms: item.kind === "project" ? item.platforms : [],
79
- license: item.kind === "project" ? item.github?.license ?? "" : "",
80
- status: item.kind === "project" ? item.health?.status ?? "" : "",
81
- stars: item.kind === "project" ? item.github?.stars ?? 0 : 0,
82
- updatedAt:
83
- item.kind === "project"
84
- ? item.github?.pushedAt ?? ""
85
- : item.kind === "resource"
86
- ? item.publishedAt ?? ""
87
- : "",
88
- extra:
89
- item.kind === "project"
90
- ? [item.repoUrl, item.projectType, item.difficulty, item.codebaseSize, ...item.bestFor, ...item.whyListed]
91
- : item.kind === "resource"
92
- ? [item.type, item.topic, item.author ?? ""]
93
- : [item.type, item.location ?? "", item.parent ?? ""],
94
- }));
95
69
 
96
70
  const title = `Browse ${slug} — ${siteConfig.name}`;
97
71
  const description = `A searchable directory of ${itemLabelPlural()} for ${siteConfig.name}.`;
@@ -165,6 +139,7 @@ function hrefForRemove(key: keyof IndexFilters, value: string): string {
165
139
  <form
166
140
  method="get"
167
141
  action={`/${slug}`}
142
+ id="search-form"
168
143
  class="mt-6 flex flex-col gap-2 sm:flex-row"
169
144
  role="search"
170
145
  >
@@ -178,9 +153,10 @@ function hrefForRemove(key: keyof IndexFilters, value: string): string {
178
153
  class="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-ink-400"
179
154
  fill="currentColor"
180
155
  >
181
- <path d="M10.68 11.74a6 6 0 0 1-7.922-8.982 6 6 0 0 1 8.982 7.922l3.04 3.04a.75.75 0 0 1-1.06 1.06l-3.04-3.04ZM11.5 7a4.5 4.5 0 1 0-9 0 4.5 4.5 0 0 0 9 0Z" />
156
+ <path d="M10.68 11.74a6 6 0 0 1-7.922-8.982 6 6 0 0 1 8.982 7.922l3.04 3.04a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215ZM11.5 7a4.5 4.5 0 1 0-9 0 4.5 4.5 0 0 0 9 0Z" />
182
157
  </svg>
183
158
  <input
159
+ id="search-input"
184
160
  type="search"
185
161
  name="q"
186
162
  value={filters.q ?? ""}
@@ -188,7 +164,28 @@ function hrefForRemove(key: keyof IndexFilters, value: string): string {
188
164
  class="h-10 w-full rounded-md border border-ink-200 bg-white py-2 pl-9 pr-3 text-sm text-ink-900 placeholder:text-ink-400 focus:border-ink-900 focus:outline-none focus:ring-2 focus:ring-ink-900/10 dark:border-ink-800 dark:bg-ink-950 dark:text-ink-100"
189
165
  />
190
166
  </label>
191
- {filters.sort && <input type="hidden" name="sort" value={filters.sort} />}
167
+ <label class="relative">
168
+ <span class="sr-only">{`Sort ${itemLabelPlural()}`}</span>
169
+ <select
170
+ id="sort-select"
171
+ name="sort"
172
+ class="h-10 cursor-pointer appearance-none rounded-md border border-ink-200 bg-white py-1.5 pl-3 pr-8 text-sm font-medium text-ink-700 transition-colors hover:border-ink-300 focus:border-ink-900 focus:outline-none focus:ring-2 focus:ring-ink-900/10 dark:border-ink-800 dark:bg-ink-950 dark:text-ink-300 dark:hover:border-ink-700 dark:focus:border-ink-100 dark:focus:ring-ink-100/15"
173
+ >
174
+ {SORT_OPTIONS.map((o) => (
175
+ <option value={o.value} selected={o.value === sort}>{o.label}</option>
176
+ ))}
177
+ </select>
178
+ <svg
179
+ viewBox="0 0 16 16"
180
+ width="11"
181
+ height="11"
182
+ aria-hidden="true"
183
+ class="pointer-events-none absolute right-2.5 top-1/2 -translate-y-1/2 text-ink-400"
184
+ fill="currentColor"
185
+ >
186
+ <path d="M4.22 6.22a.75.75 0 0 1 1.06 0L8 8.94l2.72-2.72a.75.75 0 1 1 1.06 1.06l-3.25 3.25a.75.75 0 0 1-1.06 0L4.22 7.28a.75.75 0 0 1 0-1.06Z" />
187
+ </svg>
188
+ </label>
192
189
  <button type="submit" class="grove-btn grove-btn-primary">Search</button>
193
190
  </form>
194
191
 
@@ -266,9 +263,13 @@ function hrefForRemove(key: keyof IndexFilters, value: string): string {
266
263
  </div>
267
264
 
268
265
  <ul id="results-list" class="m-0 flex list-none flex-col gap-3 p-0">
269
- {sorted.map((item) => (
266
+ {sorted.map((item, i) => (
270
267
  <li class="m-0 p-0" data-record-slug={item.slug}>
271
- <IndexRow record={item as Parameters<typeof IndexRow>[0]["record"]} detailHref={`/${slug}/${item.slug}`} />
268
+ <IndexRow
269
+ record={item as Parameters<typeof IndexRow>[0]["record"]}
270
+ detailHref={`/${slug}/${item.slug}`}
271
+ eager={i < 2}
272
+ />
272
273
  </li>
273
274
  ))}
274
275
  </ul>
@@ -278,200 +279,29 @@ function hrefForRemove(key: keyof IndexFilters, value: string): string {
278
279
  </p>
279
280
  <a href={`/${slug}`} class="grove-btn grove-btn-outline mt-4">Clear filters</a>
280
281
  </div>
281
- <nav id="client-pagination" class="mt-8 flex flex-wrap items-center justify-center gap-1.5" aria-label="Pagination"></nav>
282
+ <nav class="mt-8 flex flex-wrap items-center justify-center gap-1.5" aria-label="Pagination">
283
+ <Pagination current={page} total={pages} hrefFor={hrefForPage} />
284
+ </nav>
282
285
  </div>
283
286
  </section>
284
287
 
285
- <script is:inline define:vars={{ clientItems, slug, itemSingular: itemLabel(), itemPlural: itemLabelPlural() }}>
286
- (function () {
287
- var PAGE_SIZE = 20;
288
- var list = document.getElementById("results-list");
289
- if (!list) return;
290
-
291
- function values(params, key) {
292
- return params.getAll(key).filter(Boolean);
293
- }
294
-
295
- function display(value) {
296
- return String(value || "")
297
- .split(/[-_]+/)
298
- .filter(Boolean)
299
- .map(function (part) { return part.charAt(0).toUpperCase() + part.slice(1); })
300
- .join(" ");
301
- }
302
-
303
- function sortLabel(value) {
304
- return {
305
- "recently-updated": "Recently updated",
306
- "most-starred": "Most starred",
307
- "recently-added": "Recently added",
308
- "best-overall": "Best overall",
309
- alphabetical: "Alphabetical",
310
- }[value] || "Recently updated";
311
- }
312
-
313
- function pageHref(params, page) {
314
- var next = new URLSearchParams(params);
315
- if (page <= 1) next.delete("page");
316
- else next.set("page", String(page));
317
- var query = next.toString();
318
- return query ? "/" + slug + "?" + query : "/" + slug;
319
- }
320
-
321
- function apply() {
322
- var params = new URLSearchParams(location.search);
323
- var query = (params.get("q") || "").trim().toLowerCase();
324
- var stacks = values(params, "stack");
325
- var platforms = values(params, "platform");
326
- var categories = values(params, "category");
327
- var labels = values(params, "label");
328
- var licenses = values(params, "license");
329
- var statuses = (params.get("status") || "").split(",").filter(Boolean);
330
- var lens = params.get("lens") || "";
331
- var sort = params.get("sort") || "recently-updated";
332
- var page = Math.max(1, Number(params.get("page")) || 1);
333
-
334
- var filtered = clientItems.filter(function (item) {
335
- var haystack = [
336
- item.name,
337
- item.description,
338
- item.category,
339
- item.tags,
340
- item.stacks,
341
- item.platforms,
342
- item.license,
343
- item.status,
344
- item.extra,
345
- ].flat(3).join(" ").toLowerCase();
346
- if (query && !haystack.includes(query)) return false;
347
- if (stacks.length && !item.stacks.some(function (value) { return stacks.includes(value); })) return false;
348
- if (platforms.length && !item.platforms.some(function (value) { return platforms.includes(value); })) return false;
349
- if (categories.length && !categories.includes(item.category)) return false;
350
- if (labels.length && !item.labels.some(function (value) { return labels.includes(value); })) return false;
351
- if (licenses.length && !licenses.includes(item.license)) return false;
352
- if (statuses.length && !statuses.includes(item.status)) return false;
353
- if (lens && !item.lenses.includes(lens)) return false;
354
- return true;
355
- });
356
-
357
- filtered.sort(function (a, b) {
358
- if (sort === "most-starred") return b.stars - a.stars;
359
- if (sort === "recently-added") return Date.parse(b.addedAt || 0) - Date.parse(a.addedAt || 0);
360
- if (sort === "best-overall") return Number(b.reviewed) - Number(a.reviewed) || b.stars - a.stars;
361
- if (sort === "alphabetical") return a.name.localeCompare(b.name);
362
- return Date.parse(b.updatedAt || 0) - Date.parse(a.updatedAt || 0);
363
- });
364
-
365
- var totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
366
- page = Math.min(page, totalPages);
367
- var visible = new Set(
368
- filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE).map(function (item) { return item.slug; }),
369
- );
370
- var order = new Map(filtered.map(function (item, index) { return [item.slug, index]; }));
371
- Array.from(list.children)
372
- .sort(function (a, b) {
373
- return (order.get(a.dataset.recordSlug) ?? Number.MAX_SAFE_INTEGER) -
374
- (order.get(b.dataset.recordSlug) ?? Number.MAX_SAFE_INTEGER);
375
- })
376
- .forEach(function (node) {
377
- node.hidden = !visible.has(node.dataset.recordSlug);
378
- list.appendChild(node);
379
- });
380
-
381
- var count = document.getElementById("results-count");
382
- if (count) count.textContent = filtered.length + " " + (filtered.length === 1 ? itemSingular : itemPlural);
383
- var sortText = document.getElementById("results-sort");
384
- if (sortText) sortText.textContent = " · sorted by " + sortLabel(sort);
385
- var pageText = document.getElementById("results-page");
386
- if (pageText) pageText.textContent = totalPages > 1 ? " · page " + page + " of " + totalPages : "";
387
- document.getElementById("empty-state")?.classList.toggle("hidden", filtered.length > 0);
388
-
389
- var search = document.querySelector('input[name="q"]');
390
- if (search) search.value = params.get("q") || "";
391
- document.querySelectorAll("select").forEach(function (select) {
392
- var selected = values(params, select.name);
393
- if (select.name === "sort") selected = [sort];
394
- Array.from(select.options).forEach(function (option) {
395
- option.selected = selected.includes(option.value);
396
- });
397
- });
398
-
399
- var pager = document.getElementById("client-pagination");
400
- if (pager) {
401
- pager.innerHTML = "";
402
- if (totalPages > 1) {
403
- for (var number = 1; number <= totalPages; number += 1) {
404
- var link = document.createElement("a");
405
- link.href = pageHref(params, number);
406
- link.textContent = String(number);
407
- link.className = "grove-btn min-w-9 px-2 py-1.5 text-2xs " +
408
- (number === page ? "grove-btn-primary" : "grove-btn-outline");
409
- if (number === page) link.setAttribute("aria-current", "page");
410
- pager.appendChild(link);
411
- }
412
- }
413
- }
414
-
415
- var chipHost = document.getElementById("active-filters");
416
- if (chipHost) {
417
- chipHost.innerHTML = "";
418
- var specs = [
419
- ["q", params.get("q") ? "“" + params.get("q") + "”" : ""],
420
- ...stacks.map(function (v) { return ["stack", "Stack: " + v]; }),
421
- ...platforms.map(function (v) { return ["platform", "Platform: " + v]; }),
422
- ...categories.map(function (v) { return ["category", "Category: " + v]; }),
423
- ...labels.map(function (v) { return ["label", display(v)]; }),
424
- ...licenses.map(function (v) { return ["license", "License: " + v]; }),
425
- ...(lens ? [["lens", display(lens)]] : []),
426
- ...statuses.map(function (v) { return ["status", display(v)]; }),
427
- ].filter(function (entry) { return entry[1]; });
428
- specs.forEach(function (entry) {
429
- var key = entry[0];
430
- var chip = document.createElement("a");
431
- var next = new URLSearchParams(params);
432
- if (key === "status") {
433
- var remaining = statuses.filter(function (value) { return display(value) !== entry[1]; });
434
- next.delete("status");
435
- if (remaining.length) next.set("status", remaining.join(","));
436
- } else {
437
- next.delete(key);
438
- }
439
- next.delete("page");
440
- chip.href = next.toString() ? "/" + slug + "?" + next : "/" + slug;
441
- chip.className = "grove-badge transition-colors hover:border-ink-400";
442
- chip.textContent = entry[1] + " x";
443
- chipHost.appendChild(chip);
444
- });
445
- if (specs.length) {
446
- var clear = document.createElement("a");
447
- clear.href = "/" + slug;
448
- clear.className = "text-2xs font-medium text-ink-500 hover:text-ink-900";
449
- clear.textContent = "Clear all";
450
- chipHost.appendChild(clear);
451
- }
452
- chipHost.classList.toggle("hidden", specs.length === 0);
453
- }
454
- }
288
+ <section class="border-t border-ink-200 dark:border-ink-800">
289
+ <div class="grove-container-wide mx-auto px-5 sm:px-7">
290
+ <div class="flex flex-col items-center gap-3 py-12 text-center">
291
+ <h2 class="m-0 text-xl font-semibold tracking-tight text-ink-900 dark:text-ink-100">
292
+ {`Know a real ${itemLabel()} that belongs here?`}
293
+ </h2>
294
+ <p class="m-0 max-w-2xl text-sm text-ink-500 dark:text-ink-400">
295
+ {`Open a pull request with a YAML file. We review every submission against the bar — no marketing, no tutorials.`}
296
+ </p>
297
+ <a
298
+ href={siteConfig.submitUrl ?? "/submit"}
299
+ class="grove-btn grove-btn-primary mt-2"
300
+ >
301
+ {`Submit ${itemLabel()}`}
302
+ </a>
455
303
 
456
- document.querySelectorAll("form").forEach(function (form) {
457
- form.addEventListener("submit", function (event) {
458
- if (!form.closest("main")) return;
459
- event.preventDefault();
460
- var params = new URLSearchParams(location.search);
461
- form.querySelectorAll("input, select").forEach(function (field) {
462
- if (!field.name) return;
463
- params.delete(field.name);
464
- if (field instanceof HTMLSelectElement && field.multiple) {
465
- Array.from(field.selectedOptions).forEach(function (option) { params.append(field.name, option.value); });
466
- } else if (field.value) {
467
- params.set(field.name, field.value);
468
- }
469
- });
470
- params.delete("page");
471
- location.assign(params.toString() ? "?" + params : location.pathname);
472
- });
473
- });
474
- apply();
475
- })();
476
- </script>
304
+ </div>
305
+ </div>
306
+ </section>
477
307
  </BaseLayout>