@grove-dev/astro 0.2.10 → 0.2.13

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 (235) hide show
  1. package/dist/lib/display.d.ts +11 -17
  2. package/dist/lib/display.d.ts.map +1 -1
  3. package/dist/lib/display.js +43 -43
  4. package/dist/lib/display.js.map +1 -1
  5. package/dist/lib/format.d.ts +6 -7
  6. package/dist/lib/format.d.ts.map +1 -1
  7. package/dist/lib/format.js +11 -12
  8. package/dist/lib/format.js.map +1 -1
  9. package/dist/lib/lenses.d.ts +35 -73
  10. package/dist/lib/lenses.d.ts.map +1 -1
  11. package/dist/lib/lenses.js +144 -126
  12. package/dist/lib/lenses.js.map +1 -1
  13. package/dist/lib/repo.d.ts +11 -22
  14. package/dist/lib/repo.d.ts.map +1 -1
  15. package/dist/lib/repo.js +12 -25
  16. package/dist/lib/repo.js.map +1 -1
  17. package/dist/lib/scores.d.ts +15 -16
  18. package/dist/lib/scores.d.ts.map +1 -1
  19. package/dist/lib/scores.js +4 -11
  20. package/dist/lib/scores.js.map +1 -1
  21. package/dist/lib/search.d.ts +47 -129
  22. package/dist/lib/search.d.ts.map +1 -1
  23. package/dist/lib/search.js +195 -180
  24. package/dist/lib/search.js.map +1 -1
  25. package/dist/lib/taxonomy-counts.d.ts +7 -21
  26. package/dist/lib/taxonomy-counts.d.ts.map +1 -1
  27. package/dist/lib/taxonomy-counts.js +12 -31
  28. package/dist/lib/taxonomy-counts.js.map +1 -1
  29. package/package.json +4 -4
  30. package/src/components/AppsIndexRow.astro +55 -184
  31. package/src/components/AppsPagination.astro +45 -108
  32. package/src/components/CategoryGrid.astro +43 -31
  33. package/src/components/ContributorsGrid.astro +3 -3
  34. package/src/components/CurationGrid.astro +41 -0
  35. package/src/components/ExploreByStack.astro +6 -0
  36. package/src/components/FilterGroupMenu.astro +49 -0
  37. package/src/components/FilterOptions.astro +44 -0
  38. package/src/components/Hero.astro +198 -147
  39. package/src/components/Icon.astro +10 -3
  40. package/src/components/ItemCard.astro +266 -248
  41. package/src/components/RecordSection.astro +68 -33
  42. package/src/components/RefinePanel.astro +115 -141
  43. package/src/components/StackGrid.astro +32 -24
  44. package/src/components/WhyThisExists.astro +32 -32
  45. package/src/layouts/BaseLayout.astro +104 -49
  46. package/src/layouts/Container.astro +5 -44
  47. package/src/layouts/Footer.astro +31 -20
  48. package/src/layouts/Header.astro +40 -19
  49. package/src/layouts/SectionHeader.astro +27 -43
  50. package/src/layouts/Seo.astro +38 -56
  51. package/src/layouts/ThemeToggle.astro +35 -48
  52. package/src/lib/display.ts +46 -53
  53. package/src/lib/format.ts +11 -12
  54. package/src/lib/lenses.ts +151 -172
  55. package/src/lib/repo.ts +16 -28
  56. package/src/lib/scores.ts +15 -19
  57. package/src/lib/search.ts +222 -290
  58. package/src/lib/taxonomy-counts.ts +12 -46
  59. package/src/styles.css +207 -1
  60. package/templates/default/.github/ISSUE_TEMPLATE/bug_report.md +33 -0
  61. package/templates/default/.github/ISSUE_TEMPLATE/feature_request.md +20 -0
  62. package/templates/default/.github/ISSUE_TEMPLATE/record_submission.md +48 -0
  63. package/templates/default/.github/pull_request_template.md +27 -0
  64. package/templates/default/.github/workflows/build.yml +55 -0
  65. package/templates/default/.github/workflows/cleanup-stale-records.yml +48 -0
  66. package/templates/default/.github/workflows/daily-refresh.yml +53 -0
  67. package/templates/default/.github/workflows/sync-contributors.yml +53 -0
  68. package/templates/default/.github/workflows/sync-github-metadata.yml +58 -0
  69. package/templates/default/.github/workflows/validate-data.yml +35 -0
  70. package/templates/default/data/records/coolify.yml +46 -0
  71. package/templates/default/data/records/gitea.yml +46 -0
  72. package/templates/default/data/records/hoppscotch.yml +47 -0
  73. package/templates/default/data/records/inventree.yml +47 -0
  74. package/templates/default/data/records/label-studio.yml +46 -0
  75. package/templates/default/data/records/logseq.yml +48 -0
  76. package/templates/default/data/records/private-gpt.yml +47 -0
  77. package/templates/default/data/records/wakapi.yml +44 -0
  78. package/templates/default/data/taxonomy/categories.yml +47 -0
  79. package/templates/default/data/taxonomy/distribution-channels.yml +43 -0
  80. package/templates/default/data/taxonomy/platforms.yml +27 -0
  81. package/templates/default/data/taxonomy/stacks.yml +41 -0
  82. package/templates/default/grove.config.ts +1 -1
  83. package/templates/default/package.json +5 -4
  84. package/templates/default/public/icons/stacks/bun.svg +1 -0
  85. package/templates/default/public/icons/stacks/clojure.svg +1 -0
  86. package/templates/default/public/icons/stacks/clojurescript.svg +1 -0
  87. package/templates/default/public/icons/stacks/deno.svg +1 -0
  88. package/templates/default/public/icons/stacks/django.svg +1 -0
  89. package/templates/default/public/icons/stacks/docker.svg +1 -0
  90. package/templates/default/public/icons/stacks/go.svg +1 -0
  91. package/templates/default/public/icons/stacks/llm.svg +1 -0
  92. package/templates/default/public/icons/stacks/node.js.svg +1 -0
  93. package/templates/default/public/icons/stacks/sveltekit.svg +1 -0
  94. package/templates/default/public/icons/stacks/vue.svg +1 -0
  95. package/templates/default/public/llms-full.txt +197 -5
  96. package/templates/default/public/llms.txt +107 -6
  97. package/templates/default/public/sitemap.xml +63 -0
  98. package/templates/default/scripts/build-llms.mjs +123 -0
  99. package/templates/default/scripts/build-records-json.mjs +27 -0
  100. package/templates/default/scripts/build-sitemap.mjs +20 -0
  101. package/templates/default/scripts/cleanup-stale-records.mjs +20 -0
  102. package/templates/default/scripts/enrich-github-metadata.mjs +99 -0
  103. package/templates/default/scripts/fetch-icons.mjs +137 -0
  104. package/templates/default/scripts/migrate-legacy-to-schema-v1.mjs +86 -0
  105. package/templates/default/scripts/parse-legacy-readme.mjs +59 -0
  106. package/templates/default/scripts/refresh-records-activity.mjs +24 -0
  107. package/templates/default/scripts/repair-license-format.mjs +72 -0
  108. package/templates/default/scripts/report-cleanup-candidates.mjs +21 -0
  109. package/templates/default/scripts/seed-from-github.mjs +62 -0
  110. package/templates/default/scripts/sync-contributors.mjs +145 -0
  111. package/templates/default/scripts/sync-github-metadata.mjs +28 -0
  112. package/templates/default/scripts/validate-records.mjs +27 -0
  113. package/templates/default/src/data/records.ts +134 -51
  114. package/templates/default/src/pages/404.astro +64 -0
  115. package/templates/default/src/pages/{projects/[slug].astro → [slug]/[itemSlug].astro} +236 -66
  116. package/templates/default/src/pages/[slug]/index.astro +476 -0
  117. package/templates/default/src/pages/about.astro +165 -82
  118. package/templates/default/src/pages/apps/[itemSlug].astro +301 -0
  119. package/templates/default/src/pages/apps/index.astro +146 -0
  120. package/templates/default/src/pages/contributors.astro +184 -0
  121. package/templates/default/src/pages/index.astro +119 -162
  122. package/templates/default/src/pages/submit.astro +276 -114
  123. package/templates/default/wrangler.jsonc +28 -0
  124. package/LICENSE +0 -21
  125. package/templates/default/.astro/content.d.ts +0 -155
  126. package/templates/default/.astro/settings.json +0 -5
  127. package/templates/default/.astro/types.d.ts +0 -1
  128. package/templates/default/data/generated/apps.json +0 -1
  129. package/templates/default/data/generated/records.full.json +0 -8
  130. package/templates/default/data/generated/records.index.json +0 -7
  131. package/templates/default/data/generated/records.json +0 -8
  132. package/templates/default/data/generated/repo-stats.json +0 -1
  133. package/templates/default/data/generated/site-config.json +0 -31
  134. package/templates/default/dist/_astro/BaseLayout.Dz_HR-68.css +0 -1
  135. package/templates/default/dist/about/index.html +0 -165
  136. package/templates/default/dist/icons/platforms/android.svg +0 -1
  137. package/templates/default/dist/icons/platforms/apple-dark.svg +0 -1
  138. package/templates/default/dist/icons/platforms/apple-light.svg +0 -1
  139. package/templates/default/dist/icons/platforms/chrome.svg +0 -1
  140. package/templates/default/dist/icons/platforms/chromeos.svg +0 -5
  141. package/templates/default/dist/icons/platforms/desktop.svg +0 -4
  142. package/templates/default/dist/icons/platforms/embedded.svg +0 -5
  143. package/templates/default/dist/icons/platforms/ios.svg +0 -1
  144. package/templates/default/dist/icons/platforms/linux.svg +0 -1
  145. package/templates/default/dist/icons/platforms/macos.svg +0 -1
  146. package/templates/default/dist/icons/platforms/ubuntu.svg +0 -1
  147. package/templates/default/dist/icons/platforms/web.svg +0 -5
  148. package/templates/default/dist/icons/platforms/windows.svg +0 -6
  149. package/templates/default/dist/icons/stacks/android.svg +0 -1
  150. package/templates/default/dist/icons/stacks/apple-dark.svg +0 -1
  151. package/templates/default/dist/icons/stacks/apple-light.svg +0 -1
  152. package/templates/default/dist/icons/stacks/capacitor.svg +0 -14
  153. package/templates/default/dist/icons/stacks/dart.svg +0 -1
  154. package/templates/default/dist/icons/stacks/firebase.svg +0 -1
  155. package/templates/default/dist/icons/stacks/flutter.svg +0 -1
  156. package/templates/default/dist/icons/stacks/graphql.svg +0 -1
  157. package/templates/default/dist/icons/stacks/ionic.svg +0 -4
  158. package/templates/default/dist/icons/stacks/java.svg +0 -1
  159. package/templates/default/dist/icons/stacks/javascript.svg +0 -1
  160. package/templates/default/dist/icons/stacks/kotlin.svg +0 -1
  161. package/templates/default/dist/icons/stacks/mongodb.svg +0 -1
  162. package/templates/default/dist/icons/stacks/nodejs.svg +0 -1
  163. package/templates/default/dist/icons/stacks/python.svg +0 -1
  164. package/templates/default/dist/icons/stacks/react-native.svg +0 -1
  165. package/templates/default/dist/icons/stacks/react.svg +0 -1
  166. package/templates/default/dist/icons/stacks/rust.svg +0 -1
  167. package/templates/default/dist/icons/stacks/solidity.svg +0 -1
  168. package/templates/default/dist/icons/stacks/swift.svg +0 -1
  169. package/templates/default/dist/icons/stacks/tensorflow.svg +0 -1
  170. package/templates/default/dist/icons/stacks/typescript.svg +0 -1
  171. package/templates/default/dist/index.html +0 -55
  172. package/templates/default/dist/llms-full.txt +0 -14
  173. package/templates/default/dist/llms.txt +0 -11
  174. package/templates/default/dist/og-image.svg +0 -48
  175. package/templates/default/dist/projects/index.html +0 -61
  176. package/templates/default/dist/robots.txt +0 -53
  177. package/templates/default/dist/sitemap.xml +0 -21
  178. package/templates/default/dist/submit/index.html +0 -69
  179. package/templates/default/node_modules/.bin/astro +0 -21
  180. package/templates/default/node_modules/.bin/astro-check +0 -21
  181. package/templates/default/node_modules/.bin/grove +0 -21
  182. package/templates/default/node_modules/.bin/jiti +0 -21
  183. package/templates/default/node_modules/.bin/prettier +0 -21
  184. package/templates/default/node_modules/.bin/rollup +0 -21
  185. package/templates/default/node_modules/.bin/tsc +0 -21
  186. package/templates/default/node_modules/.bin/tsserver +0 -21
  187. package/templates/default/node_modules/.bin/tsx +0 -21
  188. package/templates/default/node_modules/.bin/vite +0 -21
  189. package/templates/default/node_modules/.bin/yaml +0 -21
  190. package/templates/default/node_modules/.vite/deps/_metadata.json +0 -79
  191. package/templates/default/node_modules/.vite/deps/astro-KRVZHBC5.js +0 -433
  192. package/templates/default/node_modules/.vite/deps/astro-KRVZHBC5.js.map +0 -7
  193. package/templates/default/node_modules/.vite/deps/astro___aria-query.js +0 -5
  194. package/templates/default/node_modules/.vite/deps/astro___aria-query.js.map +0 -7
  195. package/templates/default/node_modules/.vite/deps/astro___axobject-query.js +0 -5
  196. package/templates/default/node_modules/.vite/deps/astro___axobject-query.js.map +0 -7
  197. package/templates/default/node_modules/.vite/deps/astro___html-escaper.js +0 -9
  198. package/templates/default/node_modules/.vite/deps/astro___html-escaper.js.map +0 -7
  199. package/templates/default/node_modules/.vite/deps/astro_runtime_client_dev-toolbar_entrypoint__js.js +0 -241
  200. package/templates/default/node_modules/.vite/deps/astro_runtime_client_dev-toolbar_entrypoint__js.js.map +0 -7
  201. package/templates/default/node_modules/.vite/deps/audit-GYHQ3ITH.js +0 -1592
  202. package/templates/default/node_modules/.vite/deps/audit-GYHQ3ITH.js.map +0 -7
  203. package/templates/default/node_modules/.vite/deps/chunk-5WRI5ZAA.js +0 -30
  204. package/templates/default/node_modules/.vite/deps/chunk-5WRI5ZAA.js.map +0 -7
  205. package/templates/default/node_modules/.vite/deps/chunk-7P4KIK53.js +0 -186
  206. package/templates/default/node_modules/.vite/deps/chunk-7P4KIK53.js.map +0 -7
  207. package/templates/default/node_modules/.vite/deps/chunk-BQGHFQJH.js +0 -48
  208. package/templates/default/node_modules/.vite/deps/chunk-BQGHFQJH.js.map +0 -7
  209. package/templates/default/node_modules/.vite/deps/chunk-CJCDKWP2.js +0 -6779
  210. package/templates/default/node_modules/.vite/deps/chunk-CJCDKWP2.js.map +0 -7
  211. package/templates/default/node_modules/.vite/deps/chunk-FHJ3XBDE.js +0 -33
  212. package/templates/default/node_modules/.vite/deps/chunk-FHJ3XBDE.js.map +0 -7
  213. package/templates/default/node_modules/.vite/deps/chunk-FQEMNIUQ.js +0 -88
  214. package/templates/default/node_modules/.vite/deps/chunk-FQEMNIUQ.js.map +0 -7
  215. package/templates/default/node_modules/.vite/deps/chunk-HBSVDAQD.js +0 -72
  216. package/templates/default/node_modules/.vite/deps/chunk-HBSVDAQD.js.map +0 -7
  217. package/templates/default/node_modules/.vite/deps/chunk-HDOQ2U3U.js +0 -3757
  218. package/templates/default/node_modules/.vite/deps/chunk-HDOQ2U3U.js.map +0 -7
  219. package/templates/default/node_modules/.vite/deps/chunk-XEBKRCNE.js +0 -54
  220. package/templates/default/node_modules/.vite/deps/chunk-XEBKRCNE.js.map +0 -7
  221. package/templates/default/node_modules/.vite/deps/package.json +0 -3
  222. package/templates/default/node_modules/.vite/deps/settings-C5H4VHXU.js +0 -213
  223. package/templates/default/node_modules/.vite/deps/settings-C5H4VHXU.js.map +0 -7
  224. package/templates/default/node_modules/.vite/deps/toolbar-H5DB6HVP.js +0 -525
  225. package/templates/default/node_modules/.vite/deps/toolbar-H5DB6HVP.js.map +0 -7
  226. package/templates/default/node_modules/.vite/deps/ui-library-JGHU6JE2.js +0 -1020
  227. package/templates/default/node_modules/.vite/deps/ui-library-JGHU6JE2.js.map +0 -7
  228. package/templates/default/node_modules/.vite/deps/xray-EFCQOW4E.js +0 -147
  229. package/templates/default/node_modules/.vite/deps/xray-EFCQOW4E.js.map +0 -7
  230. package/templates/default/src/components/layout/BaseLayout.astro +0 -47
  231. package/templates/default/src/components/layout/Container.astro +0 -18
  232. package/templates/default/src/components/layout/Footer.astro +0 -27
  233. package/templates/default/src/components/layout/Header.astro +0 -43
  234. package/templates/default/src/lib/markdown.ts +0 -171
  235. package/templates/default/src/pages/projects/index.astro +0 -193
@@ -1,23 +1,30 @@
1
1
  ---
2
2
  /**
3
- * RecordSection — a titled "lens" of records on the home page.
3
+ * RecordSection — generic lens-style section wrapper for the
4
+ * home page.
4
5
  *
5
6
  * Renders a SectionHeader + a 3-column grid of `ItemCard`s, with an
6
- * optional "View all" link in the header. Generic: passes the
7
- * record list + per-record href through to `ItemCard`.
7
+ * optional "View all" link in the header. Generic: takes any
8
+ * `IndexRecord[]` (the slim list-page shape, discriminated union
9
+ * over `kind`).
8
10
  *
9
- * The component is the workhorse of the home page — featured, hot,
10
- * new, mature, "by category", "by stack" all of them are just a
11
- * `RecordSection` with a different `id` + `title` + sliced list.
11
+ * Two equivalent APIs are supported so the same template works
12
+ * against both the workspace source and the published
13
+ * `@grove-dev/astro@0.2.10` package:
12
14
  *
13
- * V1: items are `IndexProjectRecord`s (the slim list-page shape).
14
- * Other blueprints (`resource-hub`, `ecosystem-map`) can pass their
15
- * own `IndexResourceRecord` / `IndexEntityRecord` arrays; the
16
- * component does not branch on the kind.
15
+ * - **`hrefFor(record)`** callback V1 published API. Caller
16
+ * passes a function that returns the detail-page URL for
17
+ * each item.
18
+ * - **`pathPrefix` string** convenience API for the common
19
+ * case `${pathPrefix}/${item.slug}`. Mutually exclusive with
20
+ * `hrefFor`; if both are provided, `hrefFor` wins.
21
+ *
22
+ * Empty-state copy is rendered when the list has no items, so the
23
+ * homepage never collapses to a blank section.
17
24
  */
18
-
19
- import type { IndexProjectRecord } from "@grove-dev/core";
25
+ import type { IndexRecord } from "@grove-dev/core";
20
26
  import ItemCard from "./ItemCard.astro";
27
+ import Container from "../layouts/Container.astro";
21
28
  import SectionHeader from "../layouts/SectionHeader.astro";
22
29
 
23
30
  interface Props {
@@ -25,9 +32,11 @@ interface Props {
25
32
  id: string;
26
33
  title: string;
27
34
  description?: string;
28
- items: IndexProjectRecord[];
29
- /** Detail page href builder. */
30
- hrefFor: (record: IndexProjectRecord) => string;
35
+ items: IndexRecord[];
36
+ /** Detail-page href builder (V1 published API). */
37
+ hrefFor?: (record: IndexRecord) => string;
38
+ /** Detail-page URL prefix (convenience API). */
39
+ pathPrefix?: string;
31
40
  /** Optional "View all" link rendered in the header. */
32
41
  viewAllHref?: string;
33
42
  viewAllLabel?: string;
@@ -45,44 +54,70 @@ const {
45
54
  description,
46
55
  items,
47
56
  hrefFor,
57
+ pathPrefix = "/items",
48
58
  viewAllHref,
49
59
  viewAllLabel = "View all",
50
60
  eyebrow,
51
61
  emptyLabel = "No records yet.",
52
62
  class: className = "",
53
63
  } = Astro.props;
64
+
65
+ const visible = items.slice(0, 6);
54
66
  ---
55
67
 
56
68
  <section
57
69
  id={id}
58
- class:list={["grove-section", className]}
59
- aria-labelledby={`${id}-heading`}
70
+ class={`grove-section scroll-mt-20 ${className}`}
60
71
  >
61
- <div class="grove-container-wide mx-auto px-5 sm:px-7">
62
- <SectionHeader
63
- eyebrow={eyebrow}
64
- title={title}
65
- description={description}
66
- >
72
+ <Container>
73
+ <div class="mb-6 flex flex-wrap items-end justify-between gap-3">
74
+ <SectionHeader
75
+ eyebrow={eyebrow}
76
+ title={title}
77
+ description={description}
78
+ />
67
79
  {viewAllHref && (
68
80
  <a
69
- slot="actions"
70
81
  href={viewAllHref}
71
- class="text-sm font-medium text-ink-500 hover:text-ink-900 dark:text-ink-400 dark:hover:text-ink-100"
82
+ class="hidden shrink-0 pb-2 text-2xs font-medium text-ink-500 transition-colors hover:text-ink-900 sm:inline dark:text-ink-400 dark:hover:text-ink-100"
72
83
  >
73
84
  {viewAllLabel} →
74
85
  </a>
75
86
  )}
76
- </SectionHeader>
87
+ </div>
77
88
 
78
- {items.length === 0 ? (
79
- <p class="m-0 text-sm text-ink-500 dark:text-ink-400">{emptyLabel}</p>
89
+ {visible.length === 0 ? (
90
+ <p class="m-0 text-sm text-ink-500 dark:text-ink-400">
91
+ {emptyLabel}
92
+ </p>
80
93
  ) : (
81
- <div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
82
- {items.map((record) => (
83
- <ItemCard record={record} href={hrefFor(record)} />
84
- ))}
94
+ <div
95
+ class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3"
96
+ >
97
+ {visible.map((item) => {
98
+ // Resolve href: caller-supplied `hrefFor` callback wins;
99
+ // otherwise fall back to `${pathPrefix}/${slug}`. The href
100
+ // is what each card actually navigates to — getting it
101
+ // right is the difference between a working home page
102
+ // and a 404-storm of `/items/<slug>` links.
103
+ const base = pathPrefix.replace(/\/$/, "");
104
+ const fallbackHref = `${base}/${item.slug}`;
105
+ const finalHref =
106
+ typeof hrefFor === "function" ? hrefFor(item) : fallbackHref;
107
+ return (
108
+ <ItemCard record={item} href={finalHref} />
109
+ );
110
+ })}
85
111
  </div>
86
112
  )}
87
- </div>
113
+
114
+ {viewAllHref && (
115
+ <div class="mt-4 sm:hidden">
116
+ <a
117
+ href={viewAllHref}
118
+ class="text-2xs font-medium text-ink-500 transition-colors hover:text-ink-900 dark:text-ink-400 dark:hover:text-ink-100"
119
+ >{viewAllLabel} →</a>
120
+ </div>
121
+ )}
122
+ </Container>
88
123
  </section>
@@ -11,12 +11,13 @@
11
11
  * repeated key. Empty values are dropped, so the URL stays clean.
12
12
  *
13
13
  * Generic: takes a `facets` map of `{ value, label, count }[]` per
14
- * dimension and an `initial` `AppsFilters` for preselecting the
14
+ * dimension and an `initial` `ItemsFilters` for preselecting the
15
15
  * current URL state.
16
16
  */
17
17
 
18
- import type { AppsFilters } from "../lib/search";
18
+ import type { ItemsFilters } from "../lib/search";
19
19
  import { SORT_OPTIONS } from "../lib/search";
20
+ import FilterGroupMenu from "./FilterGroupMenu.astro";
20
21
 
21
22
  interface FacetValue {
22
23
  value: string;
@@ -34,7 +35,7 @@ interface Facets {
34
35
 
35
36
  interface Props {
36
37
  facets: Facets;
37
- initial: AppsFilters;
38
+ initial: ItemsFilters;
38
39
  pathPrefix?: string;
39
40
  class?: string;
40
41
  /** Hide specific facets. Default: all shown. */
@@ -49,149 +50,122 @@ const {
49
50
  hide = [],
50
51
  } = Astro.props;
51
52
 
52
- const baseAction = pathPrefix.replace(/\/$/, "") || "/";
53
+ interface FilterGroup {
54
+ label: string;
55
+ filterKey: "stacks" | "platforms" | "categories" | "licenses";
56
+ options: FacetValue[];
57
+ initial: string[];
58
+ single?: boolean;
59
+ }
53
60
 
54
- const initialStacks = new Set(initial.stacks ?? []);
55
- const initialPlatforms = new Set(initial.platforms ?? []);
56
- const initialCategories = new Set(initial.categories ?? []);
57
- const initialLicenses = new Set(initial.licenses ?? []);
58
- const initialSort = initial.sort ?? "";
59
- ---
61
+ interface RenderedFilterGroup extends FilterGroup {
62
+ active: boolean;
63
+ popoverId: string;
64
+ trigger: string;
65
+ }
60
66
 
61
- <form
62
- method="get"
63
- action={baseAction}
64
- class:list={[
65
- "grove-refine-panel flex flex-wrap items-end gap-3",
66
- className,
67
- ]}
68
- data-grove-refine-panel
69
- >
70
- {initial.q && <input type="hidden" name="q" value={initial.q} />}
71
- {initial.lens && <input type="hidden" name="lens" value={initial.lens} />}
67
+ const baseGroups: FilterGroup[] = [
68
+ { label: "Stack", filterKey: "stacks", options: facets.stacks ?? [], initial: initial.stacks ?? [] },
69
+ { label: "Platform", filterKey: "platforms", options: facets.platforms ?? [], initial: initial.platforms ?? [] },
70
+ { label: "Category", filterKey: "categories", options: facets.categories ?? [], initial: initial.categories ?? [] },
71
+ { label: "License", filterKey: "licenses", options: facets.licenses ?? [], initial: initial.licenses ?? [], single: true },
72
+ ];
72
73
 
73
- {!hide.includes("stack") && (facets.stacks?.length ?? 0) > 0 && (
74
- <label class="flex flex-col gap-1">
75
- <span class="text-2xs font-medium uppercase tracking-wider text-ink-500 dark:text-ink-400">
76
- Stack
77
- </span>
78
- <select
79
- name="stack"
80
- multiple
81
- size="1"
82
- onchange="this.form?.requestSubmit()"
83
- class="grove-select min-w-[10rem] rounded-md border border-ink-200 bg-white py-1.5 pl-2 pr-7 text-sm 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"
84
- >
85
- {facets.stacks!.map((f) => (
86
- <option
87
- value={f.value}
88
- selected={initialStacks.has(f.value)}
89
- >
90
- {f.label ?? f.value}
91
- {typeof f.count === "number" ? ` (${f.count})` : ""}
92
- </option>
93
- ))}
94
- </select>
95
- </label>
96
- )}
74
+ function pretty(value: string): string {
75
+ return value
76
+ .split(/[-_]/)
77
+ .map((part) => part ? part[0].toUpperCase() + part.slice(1) : part)
78
+ .join(" ");
79
+ }
97
80
 
98
- {!hide.includes("platform") && (facets.platforms?.length ?? 0) > 0 && (
99
- <label class="flex flex-col gap-1">
100
- <span class="text-2xs font-medium uppercase tracking-wider text-ink-500 dark:text-ink-400">
101
- Platform
102
- </span>
103
- <select
104
- name="platform"
105
- multiple
106
- size="1"
107
- onchange="this.form?.requestSubmit()"
108
- class="grove-select min-w-[10rem] rounded-md border border-ink-200 bg-white py-1.5 pl-2 pr-7 text-sm 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"
109
- >
110
- {facets.platforms!.map((f) => (
111
- <option
112
- value={f.value}
113
- selected={initialPlatforms.has(f.value)}
114
- >
115
- {f.label ?? f.value}
116
- {typeof f.count === "number" ? ` (${f.count})` : ""}
117
- </option>
118
- ))}
119
- </select>
120
- </label>
121
- )}
81
+ function triggerLabel(group: FilterGroup): string {
82
+ if (group.initial.length === 0) return `${group.label}: Any`;
83
+ if (group.initial.length === 1) return `${group.label}: ${pretty(group.initial[0])}`;
84
+ return `${group.label}: ${group.initial.length} selected`;
85
+ }
122
86
 
123
- {!hide.includes("category") && (facets.categories?.length ?? 0) > 0 && (
124
- <label class="flex flex-col gap-1">
125
- <span class="text-2xs font-medium uppercase tracking-wider text-ink-500 dark:text-ink-400">
126
- Category
127
- </span>
128
- <select
129
- name="category"
130
- multiple
131
- size="1"
132
- onchange="this.form?.requestSubmit()"
133
- class="grove-select min-w-[10rem] rounded-md border border-ink-200 bg-white py-1.5 pl-2 pr-7 text-sm 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"
134
- >
135
- {facets.categories!.map((f) => (
136
- <option
137
- value={f.value}
138
- selected={initialCategories.has(f.value)}
139
- >
140
- {f.label ?? f.value}
141
- {typeof f.count === "number" ? ` (${f.count})` : ""}
142
- </option>
143
- ))}
144
- </select>
145
- </label>
146
- )}
87
+ const groups: RenderedFilterGroup[] = baseGroups
88
+ .filter((group) => {
89
+ const singular = group.filterKey.slice(0, -1) as "stack" | "platform" | "category" | "license";
90
+ return !hide.includes(singular) && group.options.length > 0;
91
+ })
92
+ .map((group) => ({
93
+ ...group,
94
+ active: group.initial.length > 0,
95
+ popoverId: `filter-popover-${group.filterKey}`,
96
+ trigger: triggerLabel(group),
97
+ }));
98
+ ---
147
99
 
148
- {!hide.includes("license") && (facets.licenses?.length ?? 0) > 0 && (
149
- <label class="flex flex-col gap-1">
150
- <span class="text-2xs font-medium uppercase tracking-wider text-ink-500 dark:text-ink-400">
151
- License
152
- </span>
153
- <select
154
- name="license"
155
- multiple
156
- size="1"
157
- onchange="this.form?.requestSubmit()"
158
- class="grove-select min-w-[10rem] rounded-md border border-ink-200 bg-white py-1.5 pl-2 pr-7 text-sm 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"
159
- >
160
- {facets.licenses!.map((f) => (
161
- <option
162
- value={f.value}
163
- selected={initialLicenses.has(f.value)}
164
- >
165
- {f.label ?? f.value}
166
- {typeof f.count === "number" ? ` (${f.count})` : ""}
167
- </option>
168
- ))}
169
- </select>
170
- </label>
171
- )}
100
+ <section class={className}>
101
+ <div class="flex flex-wrap items-center gap-2" role="toolbar" aria-label="Filter items">
102
+ {groups.map((group) => <FilterGroupMenu {...group} />)}
172
103
 
173
- {!hide.includes("sort") && (
174
- <label class="flex flex-col gap-1">
175
- <span class="text-2xs font-medium uppercase tracking-wider text-ink-500 dark:text-ink-400">
176
- Sort
177
- </span>
178
- <select
179
- name="sort"
180
- onchange="this.form?.requestSubmit()"
181
- class="grove-select min-w-[12rem] rounded-md border border-ink-200 bg-white py-1.5 pl-2 pr-7 text-sm 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"
182
- >
183
- {SORT_OPTIONS.map((o) => (
184
- <option value={o.value} selected={initialSort === o.value}>
185
- {o.label}
186
- </option>
187
- ))}
188
- </select>
189
- </label>
190
- )}
104
+ {!hide.includes("sort") && (
105
+ <label class="relative">
106
+ <span class="sr-only">Sort items</span>
107
+ <select name="sort" class="h-9 cursor-pointer appearance-none rounded-md border border-ink-200 bg-white py-1.5 pl-3 pr-8 text-[0.8125rem] font-medium text-ink-700 dark:border-ink-800 dark:bg-ink-950 dark:text-ink-300">
108
+ {SORT_OPTIONS.map((option) => <option value={option.value} selected={(initial.sort ?? "recently-updated") === option.value}>{option.label}</option>)}
109
+ </select>
110
+ <svg viewBox="0 0 16 16" width="11" height="11" aria-hidden="true" class="pointer-events-none absolute right-2.5 top-1/2 -translate-y-1/2 text-ink-400" fill="currentColor">
111
+ <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"/>
112
+ </svg>
113
+ </label>
114
+ )}
115
+ </div>
116
+ </section>
191
117
 
192
- <noscript>
193
- <button type="submit" class="grove-btn grove-btn-outline">
194
- Apply
195
- </button>
196
- </noscript>
197
- </form>
118
+ <script is:inline define:vars={{ pathPrefix }}>
119
+ (() => {
120
+ const groups = document.querySelectorAll(".grove-filter");
121
+ const keys = { stacks: "stack", platforms: "platform", categories: "category", licenses: "license" };
122
+ function closeAll(except) {
123
+ groups.forEach((group) => {
124
+ if (group === except) return;
125
+ const popover = group.querySelector(".grove-filter-popover");
126
+ const trigger = group.querySelector(".grove-filter-trigger");
127
+ if (popover && trigger) {
128
+ popover.hidden = true;
129
+ popover.classList.add("hidden");
130
+ trigger.setAttribute("aria-expanded", "false");
131
+ }
132
+ });
133
+ }
134
+ function navigate(group, values) {
135
+ const params = new URLSearchParams(location.search);
136
+ const key = keys[group.dataset.filterGroupKey];
137
+ params.delete(key);
138
+ params.delete("page");
139
+ values.forEach((value) => params.append(key, value));
140
+ location.assign(params.toString() ? `${pathPrefix}?${params}` : pathPrefix);
141
+ }
142
+ groups.forEach((group) => {
143
+ const trigger = group.querySelector(".grove-filter-trigger");
144
+ const popover = group.querySelector(".grove-filter-popover");
145
+ trigger?.addEventListener("click", (event) => {
146
+ event.stopPropagation();
147
+ const opening = trigger.getAttribute("aria-expanded") !== "true";
148
+ closeAll(opening ? group : undefined);
149
+ popover.hidden = !opening;
150
+ popover.classList.toggle("hidden", !opening);
151
+ trigger.setAttribute("aria-expanded", String(opening));
152
+ });
153
+ group.querySelector(".grove-filter-apply")?.addEventListener("click", () => {
154
+ navigate(group, [...group.querySelectorAll(".grove-filter-input:checked")].map((input) => input.dataset.filterValue));
155
+ });
156
+ group.querySelector(".grove-filter-clear")?.addEventListener("click", () => navigate(group, []));
157
+ if (group.dataset.filterSingle === "true") {
158
+ group.querySelectorAll(".grove-filter-input").forEach((input) => input.addEventListener("change", () => navigate(group, [input.dataset.filterValue])));
159
+ }
160
+ popover?.addEventListener("click", (event) => event.stopPropagation());
161
+ });
162
+ document.addEventListener("click", () => closeAll());
163
+ document.addEventListener("keydown", (event) => { if (event.key === "Escape") closeAll(); });
164
+ document.querySelector('select[name="sort"]')?.addEventListener("change", (event) => {
165
+ const params = new URLSearchParams(location.search);
166
+ params.set("sort", event.target.value);
167
+ params.delete("page");
168
+ location.assign(params.toString() ? `${pathPrefix}?${params}` : pathPrefix);
169
+ });
170
+ })();
171
+ </script>
@@ -1,4 +1,5 @@
1
1
  ---
2
+ import Icon from "./Icon.astro";
2
3
  /**
3
4
  * StackGrid — browse-by-stack grid for the home page.
4
5
  *
@@ -30,6 +31,7 @@ interface Props {
30
31
  class?: string;
31
32
  /** Hide the section eyebrow / header entirely. */
32
33
  bare?: boolean;
34
+ itemLabelPlural?: string;
33
35
  }
34
36
 
35
37
  const {
@@ -38,6 +40,7 @@ const {
38
40
  iconAliases,
39
41
  class: className = "",
40
42
  bare = false,
43
+ itemLabelPlural = "items",
41
44
  } = Astro.props;
42
45
 
43
46
  function buildHref(slug: string): string {
@@ -48,7 +51,7 @@ function buildHref(slug: string): string {
48
51
  function statusLabel(s: StackEntry["status"]): string | null {
49
52
  switch (s) {
50
53
  case "live":
51
- return "Live";
54
+ return "Available now";
52
55
  case "expanding":
53
56
  return "Expanding";
54
57
  case "planned":
@@ -60,52 +63,57 @@ function statusLabel(s: StackEntry["status"]): string | null {
60
63
  ---
61
64
 
62
65
  <section
63
- class:list={["grove-section", className]}
66
+ id="stacks"
67
+ class:list={["grove-section scroll-mt-20 border-b border-ink-200 dark:border-ink-800", className]}
64
68
  aria-labelledby={bare ? undefined : "grove-stackgrid-heading"}
65
69
  >
66
- <div class="grove-container-wide mx-auto px-5 sm:px-7">
70
+ <div class="grove-container mx-auto px-5 sm:px-7">
67
71
  {!bare && (
68
- <header class="mb-6 flex flex-col gap-2">
72
+ <header class="mb-6">
73
+ <span class="text-2xs font-medium uppercase tracking-wider text-ink-500 dark:text-ink-400">
74
+ Stacks
75
+ </span>
69
76
  <h2
70
77
  id="grove-stackgrid-heading"
71
- class="m-0 text-[1.5rem] sm:text-[1.625rem] font-semibold tracking-tight text-ink-900 dark:text-ink-100"
78
+ class="m-0 mt-1 text-[1.5rem] sm:text-[1.625rem] font-semibold tracking-tight text-ink-900 dark:text-ink-100"
72
79
  >
73
80
  Browse by stack
74
81
  </h2>
75
82
  <p class="m-0 max-w-[60ch] text-[0.9375rem] text-ink-500 dark:text-ink-400">
76
- Every record is tagged with the technologies it depends on. Pick a
77
- stack to see what's shipping in that world.
83
+ Pick a stack to see real {itemLabelPlural} built with it.
78
84
  </p>
79
85
  </header>
80
86
  )}
81
87
 
82
- <div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
88
+ <div class="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
83
89
  {stacks.map((s) => (
84
90
  <a
85
91
  href={buildHref(s.slug)}
86
- class="grove-card grove-card-hover group flex flex-col gap-1.5 p-4 no-underline"
92
+ class="card card-hover group flex h-full items-center gap-3 p-3.5 no-underline"
87
93
  data-event="stack_grid_click"
88
94
  data-event-source="stack_grid"
89
95
  >
90
- <div class="flex items-baseline justify-between gap-2">
91
- <h3 class="m-0 text-[0.9375rem] font-semibold tracking-tight text-ink-900 group-hover:underline dark:text-ink-100">
92
- {s.name}
93
- </h3>
94
- {typeof s.count === "number" && (
95
- <span class="font-mono text-2xs text-ink-500 dark:text-ink-400">
96
- {s.count}
97
- </span>
98
- )}
96
+ <div class="flex h-9 w-9 shrink-0 items-center justify-center rounded-md border border-ink-200 bg-white dark:border-ink-800 dark:bg-ink-950" aria-hidden="true">
97
+ <Icon name={iconAliases?.[s.slug] ?? s.name} category="stack" size={18} />
98
+ </div>
99
+
100
+ <div class="flex min-w-0 flex-1 flex-col gap-0.5">
101
+ <div class="flex items-center gap-2">
102
+ <h3 class="m-0 text-[0.875rem] font-semibold tracking-tight text-ink-900 group-hover:underline dark:text-ink-100">
103
+ {s.name}
104
+ </h3>
105
+ {typeof s.count === "number" && (
106
+ <span class="inline-flex items-center rounded-md border border-ink-200 bg-ink-50 px-1.5 py-0.5 font-mono text-[10px] font-medium text-ink-600 dark:border-ink-800 dark:bg-ink-900/60 dark:text-ink-400">
107
+ {s.count} {itemLabelPlural}
108
+ </span>
109
+ )}
110
+ </div>
111
+ {s.blurb && <p class="m-0 truncate text-2xs text-ink-500 dark:text-ink-400">{s.blurb}</p>}
99
112
  </div>
100
- {s.blurb && (
101
- <p class="m-0 line-clamp-2 text-[0.8125rem] leading-snug text-ink-500 dark:text-ink-400">
102
- {s.blurb}
103
- </p>
104
- )}
105
113
  {statusLabel(s.status) && (
106
114
  <span
107
115
  class:list={[
108
- "grove-badge mt-1 self-start",
116
+ "shrink-0 inline-flex items-center rounded-md border px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wider",
109
117
  s.status === "live"
110
118
  ? "grove-badge-new"
111
119
  : s.status === "expanding"