@grove-dev/astro 0.3.1 → 0.3.4

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 (163) hide show
  1. package/README.md +9 -75
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +11 -1
  4. package/dist/index.js.map +1 -1
  5. package/dist/lib/display.d.ts +1 -26
  6. package/dist/lib/display.d.ts.map +1 -1
  7. package/dist/lib/display.js +1 -103
  8. package/dist/lib/display.js.map +1 -1
  9. package/dist/lib/format.d.ts +1 -28
  10. package/dist/lib/format.d.ts.map +1 -1
  11. package/dist/lib/format.js +1 -71
  12. package/dist/lib/format.js.map +1 -1
  13. package/dist/lib/lenses.d.ts +1 -57
  14. package/dist/lib/lenses.d.ts.map +1 -1
  15. package/dist/lib/lenses.js +1 -198
  16. package/dist/lib/lenses.js.map +1 -1
  17. package/dist/lib/repo.d.ts +1 -19
  18. package/dist/lib/repo.d.ts.map +1 -1
  19. package/dist/lib/repo.js +1 -30
  20. package/dist/lib/repo.js.map +1 -1
  21. package/dist/lib/scores.d.ts +1 -36
  22. package/dist/lib/scores.d.ts.map +1 -1
  23. package/dist/lib/scores.js +1 -75
  24. package/dist/lib/scores.js.map +1 -1
  25. package/dist/lib/search.d.ts +1 -113
  26. package/dist/lib/search.d.ts.map +1 -1
  27. package/dist/lib/search.js +1 -379
  28. package/dist/lib/search.js.map +1 -1
  29. package/dist/lib/taxonomy-counts.d.ts +1 -10
  30. package/dist/lib/taxonomy-counts.d.ts.map +1 -1
  31. package/dist/lib/taxonomy-counts.js +1 -21
  32. package/dist/lib/taxonomy-counts.js.map +1 -1
  33. package/package.json +23 -11
  34. package/src/components/ContributorsGrid.astro +3 -3
  35. package/src/components/CurationGrid.astro +2 -2
  36. package/src/components/DirectoryIndexClient.astro +240 -0
  37. package/src/components/FilterGroupMenu.astro +8 -4
  38. package/src/components/FinalCta.astro +41 -0
  39. package/src/components/Hero.astro +15 -18
  40. package/src/components/Icon.astro +35 -7
  41. package/src/components/IndexRow.astro +2 -2
  42. package/src/components/ItemCard.astro +2 -7
  43. package/src/components/RefinePanel.astro +7 -7
  44. package/src/components/SmartLensTabs.astro +9 -5
  45. package/src/components/StackGrid.astro +1 -1
  46. package/src/components/SubmissionClient.astro +133 -0
  47. package/src/index.ts +11 -1
  48. package/src/layouts/BaseLayout.astro +30 -8
  49. package/src/layouts/Footer.astro +25 -56
  50. package/src/layouts/Header.astro +33 -15
  51. package/src/layouts/ThemeToggle.astro +7 -3
  52. package/src/lib/display.ts +1 -111
  53. package/src/lib/format.ts +1 -64
  54. package/src/lib/lenses.ts +1 -220
  55. package/src/lib/repo.ts +1 -32
  56. package/src/lib/scores.ts +1 -90
  57. package/src/lib/search.ts +1 -418
  58. package/src/lib/taxonomy-counts.ts +1 -26
  59. package/{templates/default/src/data/records.ts → src/server/directory.ts} +12 -8
  60. package/src/server/index.ts +2 -0
  61. package/src/server/models.ts +436 -0
  62. package/dist/template-routes.test.d.ts +0 -2
  63. package/dist/template-routes.test.d.ts.map +0 -1
  64. package/dist/template-routes.test.js +0 -63
  65. package/dist/template-routes.test.js.map +0 -1
  66. package/dist/theme.test.d.ts +0 -2
  67. package/dist/theme.test.d.ts.map +0 -1
  68. package/dist/theme.test.js +0 -60
  69. package/dist/theme.test.js.map +0 -1
  70. package/src/template-routes.test.ts +0 -103
  71. package/src/theme.test.ts +0 -76
  72. package/templates/default/.github/ISSUE_TEMPLATE/bug_report.md +0 -33
  73. package/templates/default/.github/ISSUE_TEMPLATE/feature_request.md +0 -20
  74. package/templates/default/.github/ISSUE_TEMPLATE/record_submission.md +0 -48
  75. package/templates/default/.github/pull_request_template.md +0 -27
  76. package/templates/default/.github/workflows/build.yml +0 -53
  77. package/templates/default/.github/workflows/cleanup-stale-records.yml +0 -46
  78. package/templates/default/.github/workflows/daily-refresh.yml +0 -51
  79. package/templates/default/.github/workflows/sync-contributors.yml +0 -51
  80. package/templates/default/.github/workflows/sync-github-metadata.yml +0 -56
  81. package/templates/default/.github/workflows/validate-data.yml +0 -33
  82. package/templates/default/astro.config.mjs +0 -25
  83. package/templates/default/content/pages/about.md +0 -66
  84. package/templates/default/content/pages/methodology.md +0 -103
  85. package/templates/default/data/records/.gitkeep +0 -0
  86. package/templates/default/data/records/coolify.yml +0 -46
  87. package/templates/default/data/records/gitea.yml +0 -46
  88. package/templates/default/data/records/hoppscotch.yml +0 -47
  89. package/templates/default/data/records/inventree.yml +0 -47
  90. package/templates/default/data/records/label-studio.yml +0 -46
  91. package/templates/default/data/records/logseq.yml +0 -48
  92. package/templates/default/data/records/private-gpt.yml +0 -47
  93. package/templates/default/data/records/wakapi.yml +0 -44
  94. package/templates/default/data/taxonomy/categories.yml +0 -47
  95. package/templates/default/data/taxonomy/distribution-channels.yml +0 -43
  96. package/templates/default/data/taxonomy/platforms.yml +0 -27
  97. package/templates/default/data/taxonomy/stacks.yml +0 -41
  98. package/templates/default/grove.config.ts +0 -36
  99. package/templates/default/package.json +0 -43
  100. package/templates/default/public/icons/platforms/android.svg +0 -1
  101. package/templates/default/public/icons/platforms/apple-dark.svg +0 -1
  102. package/templates/default/public/icons/platforms/apple-light.svg +0 -1
  103. package/templates/default/public/icons/platforms/chrome.svg +0 -1
  104. package/templates/default/public/icons/platforms/chromeos.svg +0 -5
  105. package/templates/default/public/icons/platforms/desktop.svg +0 -4
  106. package/templates/default/public/icons/platforms/embedded.svg +0 -5
  107. package/templates/default/public/icons/platforms/ios.svg +0 -1
  108. package/templates/default/public/icons/platforms/linux.svg +0 -1
  109. package/templates/default/public/icons/platforms/macos.svg +0 -1
  110. package/templates/default/public/icons/platforms/ubuntu.svg +0 -1
  111. package/templates/default/public/icons/platforms/web.svg +0 -5
  112. package/templates/default/public/icons/platforms/windows.svg +0 -6
  113. package/templates/default/public/icons/stacks/android.svg +0 -1
  114. package/templates/default/public/icons/stacks/apple-dark.svg +0 -1
  115. package/templates/default/public/icons/stacks/apple-light.svg +0 -1
  116. package/templates/default/public/icons/stacks/bun.svg +0 -1
  117. package/templates/default/public/icons/stacks/capacitor.svg +0 -14
  118. package/templates/default/public/icons/stacks/clojure.svg +0 -1
  119. package/templates/default/public/icons/stacks/clojurescript.svg +0 -1
  120. package/templates/default/public/icons/stacks/dart.svg +0 -1
  121. package/templates/default/public/icons/stacks/deno.svg +0 -1
  122. package/templates/default/public/icons/stacks/django.svg +0 -1
  123. package/templates/default/public/icons/stacks/docker.svg +0 -1
  124. package/templates/default/public/icons/stacks/firebase.svg +0 -1
  125. package/templates/default/public/icons/stacks/flutter.svg +0 -1
  126. package/templates/default/public/icons/stacks/go.svg +0 -1
  127. package/templates/default/public/icons/stacks/graphql.svg +0 -1
  128. package/templates/default/public/icons/stacks/ionic.svg +0 -4
  129. package/templates/default/public/icons/stacks/java.svg +0 -1
  130. package/templates/default/public/icons/stacks/javascript.svg +0 -1
  131. package/templates/default/public/icons/stacks/kotlin.svg +0 -1
  132. package/templates/default/public/icons/stacks/llm.svg +0 -1
  133. package/templates/default/public/icons/stacks/mongodb.svg +0 -1
  134. package/templates/default/public/icons/stacks/node.js.svg +0 -1
  135. package/templates/default/public/icons/stacks/nodejs.svg +0 -1
  136. package/templates/default/public/icons/stacks/python.svg +0 -1
  137. package/templates/default/public/icons/stacks/rag.svg +0 -7
  138. package/templates/default/public/icons/stacks/react-native.svg +0 -1
  139. package/templates/default/public/icons/stacks/react.svg +0 -1
  140. package/templates/default/public/icons/stacks/rust.svg +0 -1
  141. package/templates/default/public/icons/stacks/solidity.svg +0 -1
  142. package/templates/default/public/icons/stacks/sveltekit.svg +0 -1
  143. package/templates/default/public/icons/stacks/swift.svg +0 -1
  144. package/templates/default/public/icons/stacks/tensorflow.svg +0 -1
  145. package/templates/default/public/icons/stacks/typescript.svg +0 -1
  146. package/templates/default/public/icons/stacks/vue.svg +0 -1
  147. package/templates/default/public/llms-full.txt +0 -83
  148. package/templates/default/public/llms.txt +0 -11
  149. package/templates/default/public/og-image.svg +0 -48
  150. package/templates/default/public/robots.txt +0 -53
  151. package/templates/default/public/sitemap.xml +0 -63
  152. package/templates/default/src/pages/404.astro +0 -64
  153. package/templates/default/src/pages/[slug]/[recordSlug].astro +0 -691
  154. package/templates/default/src/pages/[slug]/index.astro +0 -482
  155. package/templates/default/src/pages/about.astro +0 -189
  156. package/templates/default/src/pages/apps/[recordSlug].astro +0 -34
  157. package/templates/default/src/pages/contributors.astro +0 -175
  158. package/templates/default/src/pages/index.astro +0 -233
  159. package/templates/default/src/pages/sitemap.xml.ts +0 -89
  160. package/templates/default/src/pages/submit.astro +0 -281
  161. package/templates/default/src/styles/global.css +0 -33
  162. package/templates/default/tsconfig.json +0 -9
  163. package/templates/default/wrangler.jsonc +0 -28
@@ -0,0 +1,240 @@
1
+ ---
2
+ interface Props {
3
+ clientItemsJson: string;
4
+ slug: string;
5
+ singular: string;
6
+ plural: string;
7
+ taxonomy: Record<string, unknown>;
8
+ }
9
+
10
+ const { clientItemsJson, slug, singular, plural, taxonomy } = Astro.props;
11
+ const clientConfigJson = JSON.stringify({ slug, singular, plural, taxonomy }).replace(/</g, "\\u003c");
12
+ ---
13
+
14
+ <script is:inline id="grove-index-data" type="application/json" set:html={clientItemsJson}></script>
15
+ <script is:inline id="grove-index-config" type="application/json" set:html={clientConfigJson}></script>
16
+ <script>
17
+ import {
18
+ LENSES,
19
+ PAGE_SIZE,
20
+ activeFilterChips,
21
+ applySort,
22
+ effectivePage,
23
+ effectiveSort,
24
+ filterRecords,
25
+ filtersFromSearchParams,
26
+ hrefForLens,
27
+ isLensActive,
28
+ removeFilter,
29
+ searchParamsFromFilters,
30
+ sortDisplay,
31
+ totalPages,
32
+ } from "@grove-dev/core/directory";
33
+
34
+ (() => {
35
+ const source = document.querySelector("#grove-index-data");
36
+ const configSource = document.querySelector("#grove-index-config");
37
+ const list = document.querySelector("#results-list");
38
+ if (!source || !configSource || !list) return;
39
+
40
+ const items = JSON.parse(source.textContent || "[]");
41
+ const { slug, singular, plural, taxonomy } = JSON.parse(configSource.textContent || "{}");
42
+ const nodes = new Map(
43
+ [...list.querySelectorAll("[data-record-slug]")].map((node) => [
44
+ (node as HTMLElement).dataset.recordSlug,
45
+ node,
46
+ ]),
47
+ );
48
+ const params = new URLSearchParams(location.search);
49
+ const values = (key) => params.getAll(key).filter(Boolean);
50
+ const stacks = values("stack");
51
+ const platforms = values("platform");
52
+ const categories = values("category");
53
+ const filters = filtersFromSearchParams(params);
54
+ const sort = effectiveSort(filters);
55
+ const requestedPage = effectivePage(filters);
56
+
57
+ function applyClientFilters() {
58
+ const filtered = applySort(filterRecords(items, filters), sort);
59
+ const pageCount = totalPages(filtered.length);
60
+ const page = Math.min(requestedPage, pageCount);
61
+ const visible = new Set(
62
+ filtered
63
+ .slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)
64
+ .map((record) => record.slug),
65
+ );
66
+
67
+ for (const record of filtered) {
68
+ const node = nodes.get(record.slug) as HTMLElement | undefined;
69
+ if (node) list.append(node);
70
+ }
71
+ for (const [recordSlug, node] of nodes) {
72
+ (node as HTMLElement).hidden = !visible.has(recordSlug);
73
+ }
74
+
75
+ const count = document.querySelector("#results-count");
76
+ const pageLabel = document.querySelector("#results-page");
77
+ const empty = document.querySelector("#empty-state");
78
+ if (count) count.textContent = `${filtered.length} ${filtered.length === 1 ? singular : plural}`;
79
+ const sortLabel = document.querySelector("#results-sort");
80
+ if (sortLabel) sortLabel.textContent = ` · sorted by ${sortDisplay(sort)}`;
81
+ if (pageLabel) pageLabel.textContent = pageCount > 1 ? ` · page ${page} of ${pageCount}` : "";
82
+ if (empty) empty.classList.toggle("hidden", filtered.length > 0);
83
+
84
+ const pagination = document.querySelector("#results-pagination");
85
+ if (pagination) {
86
+ pagination.innerHTML = "";
87
+ if (pageCount > 1) {
88
+ for (let target = 1; target <= pageCount; target += 1) {
89
+ const next = searchParamsFromFilters({ ...filters, page: target });
90
+ const link = document.createElement("a");
91
+ link.href = `/${slug}${next.size ? `?${next}` : ""}`;
92
+ link.textContent = String(target);
93
+ link.setAttribute("aria-label", `Page ${target}`);
94
+ if (target === page) link.setAttribute("aria-current", "page");
95
+ link.className = "inline-flex h-8 min-w-8 items-center justify-center rounded-md border border-border px-2 text-sm";
96
+ pagination.append(link);
97
+ }
98
+ }
99
+ }
100
+ }
101
+
102
+ const searchInput = document.querySelector("#search-input");
103
+ if (searchInput instanceof HTMLInputElement) searchInput.value = filters.q || "";
104
+ document.querySelectorAll('select[name="sort"]').forEach((select) => {
105
+ if (select instanceof HTMLSelectElement) select.value = sort;
106
+ });
107
+
108
+ document.querySelector("#search-form")?.addEventListener("submit", (event) => {
109
+ event.preventDefault();
110
+ const next = new URLSearchParams(params);
111
+ const query = searchInput instanceof HTMLInputElement ? searchInput.value.trim() : "";
112
+ if (query) next.set("q", query);
113
+ else next.delete("q");
114
+ next.set("sort", sort);
115
+ next.delete("page");
116
+ location.assign(`/${slug}${next.size ? `?${next}` : ""}`);
117
+ });
118
+ const filterKeys = {
119
+ stacks: "stack",
120
+ platforms: "platform",
121
+ categories: "category",
122
+ tags: "tag",
123
+ licenses: "license",
124
+ };
125
+ const filterLabels = {
126
+ stacks: "Stack",
127
+ platforms: "Platform",
128
+ categories: "Category",
129
+ tags: "Tag",
130
+ licenses: "License",
131
+ };
132
+ const taxonomyKinds = {
133
+ stacks: "stacks",
134
+ platforms: "platforms",
135
+ categories: "categories",
136
+ };
137
+ const taxonomyName = (groupKey, value) =>
138
+ (taxonomy[taxonomyKinds[groupKey]] || []).find((entry) => entry.id === value)?.name ??
139
+ value;
140
+
141
+ // This route is prerendered, so Astro cannot see the browser's query
142
+ // string. Rebuild active-filter controls from the live URL instead of
143
+ // leaving the server-rendered (empty) state in place.
144
+ const activeFilters = document.querySelector("#active-filters");
145
+ if (activeFilters) {
146
+ activeFilters.innerHTML = "";
147
+ const chips = activeFilterChips(filters).map((chip) => ({
148
+ ...chip,
149
+ label: chip.key === "stacks"
150
+ ? `Stack: ${taxonomyName("stacks", chip.value)}`
151
+ : chip.key === "platforms"
152
+ ? `Platform: ${taxonomyName("platforms", chip.value)}`
153
+ : chip.key === "categories"
154
+ ? `Category: ${taxonomyName("categories", chip.value)}`
155
+ : chip.label,
156
+ }));
157
+
158
+ for (const chip of chips) {
159
+ const next = searchParamsFromFilters(removeFilter(filters, chip.key, chip.value));
160
+ const link = document.createElement("a");
161
+ link.href = `/${slug}${next.size ? `?${next}` : ""}`;
162
+ link.className = "inline-flex min-h-5 items-center justify-center gap-1 rounded-full border border-transparent bg-secondary px-2 py-0.5 align-middle text-xs font-medium leading-none text-secondary-foreground transition-colors hover:border-ink-400";
163
+ link.append(document.createTextNode(`${chip.label} `));
164
+ const close = document.createElement("span");
165
+ close.setAttribute("aria-hidden", "true");
166
+ close.textContent = "x";
167
+ link.append(close);
168
+ activeFilters.append(link);
169
+ }
170
+ if (chips.length) {
171
+ const clear = document.createElement("a");
172
+ const clearParams = searchParamsFromFilters({ sort });
173
+ clear.href = `/${slug}${clearParams.size ? `?${clearParams}` : ""}`;
174
+ clear.className = "text-2xs font-medium text-ink-500 hover:text-ink-900";
175
+ clear.textContent = "Clear all";
176
+ activeFilters.append(clear);
177
+ }
178
+ activeFilters.classList.toggle("hidden", chips.length === 0);
179
+ }
180
+
181
+ document.querySelectorAll(".grove-filter").forEach((group) => {
182
+ if (!(group instanceof HTMLElement)) return;
183
+ const selected = values(filterKeys[group.dataset.filterGroupKey]);
184
+ group.querySelectorAll(".grove-filter-input").forEach((input) => {
185
+ if (input instanceof HTMLInputElement) {
186
+ input.checked = selected.includes(input.dataset.filterValue || "");
187
+ }
188
+ });
189
+ const triggerButton = group.querySelector(".grove-filter-trigger");
190
+ const trigger = triggerButton?.querySelector("span");
191
+ const label = filterLabels[group.dataset.filterGroupKey];
192
+ if (trigger) {
193
+ trigger.textContent = selected.length
194
+ ? `${label}: ${
195
+ selected.length === 1
196
+ ? taxonomyName(group.dataset.filterGroupKey, selected[0])
197
+ : `${selected.length} selected`
198
+ }`
199
+ : `${label}: Any`;
200
+ }
201
+ if (triggerButton) {
202
+ triggerButton.classList.toggle("bg-primary", selected.length > 0);
203
+ triggerButton.classList.toggle("text-primary-foreground", selected.length > 0);
204
+ triggerButton.classList.toggle("hover:bg-primary/90", selected.length > 0);
205
+ triggerButton.classList.toggle("hover:text-primary-foreground", selected.length > 0);
206
+ triggerButton.classList.toggle("border-border", selected.length === 0);
207
+ triggerButton.classList.toggle("bg-secondary", selected.length === 0);
208
+ triggerButton.classList.toggle("text-foreground", selected.length === 0);
209
+ triggerButton.classList.toggle("hover:bg-accent", selected.length === 0);
210
+ }
211
+ group.querySelector(".grove-filter-clear")?.classList.toggle("hidden", selected.length === 0);
212
+ });
213
+
214
+ let activeLens = LENSES[0];
215
+ document.querySelectorAll("[data-lens-id]").forEach((node) => {
216
+ if (!(node instanceof HTMLAnchorElement)) return;
217
+ const lens = LENSES.find((candidate) => candidate.id === node.dataset.lensId);
218
+ if (!lens) return;
219
+ const active = isLensActive(lens.id, params);
220
+ node.href = hrefForLens(lens.id, params, `/${slug}`);
221
+ if (active) node.setAttribute("aria-current", "page");
222
+ else node.removeAttribute("aria-current");
223
+ node.classList.toggle("bg-primary", active);
224
+ node.classList.toggle("text-primary-foreground", active);
225
+ node.classList.toggle("hover:bg-primary/90", active);
226
+ node.classList.toggle("hover:text-primary-foreground", active);
227
+ node.classList.toggle("border-border", !active);
228
+ node.classList.toggle("bg-secondary", !active);
229
+ node.classList.toggle("text-foreground", !active);
230
+ node.classList.toggle("hover:bg-accent", !active);
231
+ if (active) activeLens = lens;
232
+ });
233
+ const heading = document.querySelector("#results-heading");
234
+ const description = document.querySelector("#results-description");
235
+ if (heading) heading.textContent = activeLens.id === "all" ? `All ${plural}` : activeLens.label;
236
+ if (description) description.textContent = activeLens.description || `Every ${singular} in the directory.`;
237
+
238
+ applyClientFilters();
239
+ })();
240
+ </script>
@@ -27,7 +27,7 @@ const group = Astro.props;
27
27
  class:list={[
28
28
  "grove-filter-trigger inline-flex h-9 cursor-pointer items-center justify-center gap-1.5 rounded-[var(--radius)] border border-transparent px-3 text-[0.8125rem] font-medium leading-none no-underline outline-none transition-colors focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/35",
29
29
  group.active
30
- ? "bg-primary text-primary-foreground hover:bg-primary/90"
30
+ ? "bg-primary text-primary-foreground hover:bg-primary/90 hover:text-primary-foreground"
31
31
  : "border-border bg-secondary text-foreground hover:bg-accent",
32
32
  ]}
33
33
  aria-haspopup="listbox"
@@ -42,9 +42,13 @@ const group = Astro.props;
42
42
  <div id={group.popoverId} class="rounded-[calc(var(--radius)+0.25rem)] border border-border bg-popover text-popover-foreground shadow-xl grove-filter-popover absolute left-0 top-full z-40 mt-1 hidden w-72 max-w-[calc(100vw-2rem)] p-1.5" role="listbox" hidden>
43
43
  <FilterOptions filterKey={group.filterKey} options={group.options} initial={group.initial} single={group.single} />
44
44
  <div class="mt-1 flex items-center justify-between border-t border-ink-100 px-2 pt-1.5 dark:border-ink-900">
45
- {group.active && (
46
- <button type="button" class="grove-filter-clear text-2xs font-medium text-ink-500 hover:text-ink-900 dark:text-ink-400 dark:hover:text-ink-100">Clear {group.label.toLowerCase()}</button>
47
- )}
45
+ <button
46
+ type="button"
47
+ class:list={[
48
+ "grove-filter-clear text-2xs font-medium text-ink-500 hover:text-ink-900 dark:text-ink-400 dark:hover:text-ink-100",
49
+ !group.active && "hidden",
50
+ ]}
51
+ >Clear {group.label.toLowerCase()}</button>
48
52
  <button type="button" class="grove-filter-apply 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 bg-primary text-primary-foreground hover:bg-primary/90 ml-auto px-2.5 text-2xs">Apply</button>
49
53
  </div>
50
54
  </div>
@@ -0,0 +1,41 @@
1
+ ---
2
+ interface Props {
3
+ itemLabel?: string;
4
+ itemsLabel?: string;
5
+ browseHref?: string;
6
+ submitHref?: string;
7
+ class?: string;
8
+ }
9
+
10
+ const {
11
+ itemLabel = "item",
12
+ itemsLabel = "items",
13
+ browseHref = "/items",
14
+ submitHref = "/submit",
15
+ class: className = "",
16
+ } = Astro.props;
17
+ ---
18
+
19
+ <section class:list={["border-t border-ink-200 py-10 sm:py-12 dark:border-ink-800", className]} aria-labelledby="grove-final-cta-heading">
20
+ <div class="mx-auto w-full max-w-container px-5 sm:px-7">
21
+ <div class="rounded-[calc(var(--radius)+0.25rem)] border border-ink-200 bg-muted px-6 py-7 sm:flex sm:items-center sm:justify-between sm:gap-10 sm:px-8 sm:py-8 dark:border-ink-800">
22
+ <div class="max-w-2xl">
23
+ <p class="m-0 text-2xs font-semibold uppercase tracking-wider text-ink-500 dark:text-ink-400">Keep the directory useful</p>
24
+ <h2 id="grove-final-cta-heading" class="m-0 mt-1.5 text-[1.5rem] font-semibold tracking-tight text-ink-900 sm:text-[1.75rem] dark:text-ink-100">
25
+ Know {itemLabel === "app" ? "an" : "a"} {itemLabel} that belongs here?
26
+ </h2>
27
+ <p class="m-0 mt-2 max-w-xl text-sm leading-relaxed text-ink-500 dark:text-ink-400">
28
+ Add a real, inspectable project or improve the metadata behind an existing listing.
29
+ </p>
30
+ </div>
31
+ <div class="mt-5 flex shrink-0 flex-wrap items-center gap-2 sm:mt-0 sm:justify-end">
32
+ <a href={submitHref} class="inline-flex min-h-9 items-center justify-center rounded-[var(--radius)] bg-primary px-4 text-sm font-semibold text-primary-foreground no-underline hover:bg-primary/90">
33
+ Submit {itemLabel}
34
+ </a>
35
+ <a href={browseHref} class="inline-flex min-h-9 items-center justify-center rounded-[var(--radius)] border border-border bg-background px-4 text-sm font-semibold text-foreground no-underline hover:bg-accent">
36
+ Explore {itemsLabel}
37
+ </a>
38
+ </div>
39
+ </div>
40
+ </div>
41
+ </section>
@@ -37,6 +37,7 @@ interface Props {
37
37
  totalStacks?: number;
38
38
  totalOwners?: number;
39
39
  totalStars?: number;
40
+ repositoryContributors?: number;
40
41
  originalRepo?: string;
41
42
  };
42
43
  /** Search form target — the list page. */
@@ -94,11 +95,11 @@ const trustStats: { value: string; label: string }[] = [
94
95
  ...(stats.totalStacks !== undefined
95
96
  ? [{ value: String(stats.totalStacks), label: "stacks" }]
96
97
  : []),
97
- ...(stats.totalOwners !== undefined
98
- ? [{ value: compact(stats.totalOwners) ?? "0", label: "contributors" }]
98
+ ...(stats.totalOwners !== undefined && stats.totalOwners > 0
99
+ ? [{ value: compact(stats.totalOwners) ?? "0", label: "project owners" }]
99
100
  : []),
100
- ...(stats.totalStars !== undefined
101
- ? [{ value: compact(stats.totalStars) ?? "0", label: "GitHub stars" }]
101
+ ...(stats.totalStars !== undefined && stats.totalStars > 0
102
+ ? [{ value: compact(stats.totalStars) ?? "0", label: "stars across projects" }]
102
103
  : []),
103
104
  ];
104
105
  ---
@@ -121,7 +122,7 @@ const trustStats: { value: string; label: string }[] = [
121
122
  ></div>
122
123
 
123
124
  <Container>
124
- <div class="relative py-14 sm:py-20">
125
+ <div class="relative py-12 sm:py-16">
125
126
  <div class="max-w-3xl">
126
127
  <span
127
128
  class="inline-flex animate-fade-in items-center gap-2 text-2xs font-medium text-ink-500 dark:text-ink-400"
@@ -167,7 +168,13 @@ const trustStats: { value: string; label: string }[] = [
167
168
  {/* Trust stats row */}
168
169
  {trustStats.length > 0 && (
169
170
  <dl
170
- class="mt-7 grid grid-cols-2 gap-x-4 gap-y-3 sm:grid-cols-4"
171
+ class:list={[
172
+ "mt-7 grid max-w-3xl grid-cols-2 gap-x-6 gap-y-3",
173
+ trustStats.length === 2 && "sm:grid-cols-2",
174
+ trustStats.length === 3 && "grid-cols-3",
175
+ trustStats.length === 4 && "sm:grid-cols-4",
176
+ trustStats.length >= 5 && "sm:grid-cols-5",
177
+ ]}
171
178
  aria-label="Directory at a glance"
172
179
  >
173
180
  {trustStats.map((s) => (
@@ -184,9 +191,9 @@ const trustStats: { value: string; label: string }[] = [
184
191
  </dl>
185
192
  )}
186
193
 
187
- {/* Search + filters */}
194
+ {/* Search */}
188
195
  <form
189
- class="mt-7 flex flex-col gap-2 sm:flex-row sm:items-stretch"
196
+ class="mt-7 flex max-w-4xl flex-col gap-2 sm:flex-row sm:items-stretch"
190
197
  role="search"
191
198
  action={searchHref}
192
199
  method="get"
@@ -212,16 +219,6 @@ const trustStats: { value: string; label: string }[] = [
212
219
  class="h-10 w-full rounded-md border border-ink-200 bg-white pl-10 pr-3 text-[0.9375rem] 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 dark:placeholder:text-ink-500 dark:focus:border-ink-100 dark:focus:ring-ink-100/15"
213
220
  />
214
221
  </label>
215
- <a
216
- href={searchHref}
217
- 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 h-10"
218
- aria-label={`Browse all ${itemsLabel} with filters`}
219
- >
220
- <svg viewBox="0 0 16 16" width="13" height="13" aria-hidden="true" fill="currentColor">
221
- <path d="M.75 3h14.5a.75.75 0 0 1 .55 1.26l-5.55 6.1v4.39a.75.75 0 0 1-1.2.6l-2.5-1.87a.75.75 0 0 1-.3-.6v-2.52L.2 4.26A.75.75 0 0 1 .75 3Z"/>
222
- </svg>
223
- <span>Filters</span>
224
- </a>
225
222
  <button
226
223
  type="submit"
227
224
  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 bg-primary text-primary-foreground hover:bg-primary/90 h-10 px-4 sm:px-5"
@@ -62,6 +62,11 @@ interface Props {
62
62
  * {name}.svg` (case-insensitive).
63
63
  */
64
64
  aliases?: Record<string, string>;
65
+ /**
66
+ * Optional complete asset registry supplied by a consumer. When omitted,
67
+ * Grove attempts the configured asset path and falls back on image error.
68
+ */
69
+ available?: string[];
65
70
  /**
66
71
  * Initials to render when the icon file is missing. Defaults to
67
72
  * the first 1–2 characters of `name`.
@@ -82,6 +87,7 @@ const {
82
87
  title,
83
88
  mode = "static",
84
89
  aliases,
90
+ available,
85
91
  fallbackInitials,
86
92
  base = "/icons",
87
93
  } = Astro.props;
@@ -94,9 +100,23 @@ function slugify(s: string): string {
94
100
  // Resolve `name` through the aliases map.
95
101
  function resolveName(raw: string): string {
96
102
  if (!raw) return "";
97
- if (aliases) {
103
+ const defaultAliases: Partial<Record<IconCategory, Record<string, string>>> = {
104
+ stack: {
105
+ ios: "apple",
106
+ "native-ios": "apple",
107
+ ipados: "apple",
108
+ macos: "apple",
109
+ "objective-c": "apple",
110
+ spritekit: "apple",
111
+ swiftui: "apple",
112
+ kmp: "kotlin",
113
+ "kotlin-multiplatform": "kotlin",
114
+ },
115
+ };
116
+ for (const registry of [aliases, defaultAliases[category]]) {
117
+ if (!registry) continue;
98
118
  const lower = raw.toLowerCase();
99
- for (const [k, v] of Object.entries(aliases)) {
119
+ for (const [k, v] of Object.entries(registry)) {
100
120
  if (k.toLowerCase() === lower) return slugify(v);
101
121
  }
102
122
  }
@@ -104,6 +124,12 @@ function resolveName(raw: string): string {
104
124
  }
105
125
 
106
126
  const resolved = resolveName(name);
127
+ const availableIcons = available?.map(slugify);
128
+ const hasAsset =
129
+ category === "brand" ||
130
+ availableIcons === undefined ||
131
+ availableIcons.includes(resolved);
132
+ const effectiveMode: IconMode = resolved === "apple" ? "auto" : mode;
107
133
  const platformStackIcons = new Set(["docker"]);
108
134
  const textOnlyPlatforms = new Set(["self-host", "self-hosted"]);
109
135
  const folder =
@@ -123,7 +149,9 @@ const init = (fallbackInitials ?? name ?? "")
123
149
  .slice(0, 2)
124
150
  .toUpperCase();
125
151
  const showFallback =
126
- !resolved || (category === "platform" && textOnlyPlatforms.has(resolved));
152
+ !resolved ||
153
+ !hasAsset ||
154
+ (category === "platform" && textOnlyPlatforms.has(resolved));
127
155
 
128
156
  // Build aria-label.
129
157
  const ariaLabel = title ?? name;
@@ -144,14 +172,14 @@ const a11y: Record<string, string> = title
144
172
  >
145
173
  {init || "·"}
146
174
  </span>
147
- ) : mode === "auto" ? (
175
+ ) : effectiveMode === "auto" ? (
148
176
  <>
149
177
  <img
150
- src={staticSrc}
178
+ src={`${baseUrl}-light.svg`}
151
179
  data-grove-icon
152
180
  data-grove-icon-light={`${baseUrl}-light.svg`}
153
181
  data-grove-icon-dark={`${baseUrl}-dark.svg`}
154
- data-grove-icon-fallback={staticSrc}
182
+ data-grove-icon-fallback={`${baseUrl}-light.svg`}
155
183
  width={size}
156
184
  height={size}
157
185
  alt=""
@@ -201,7 +229,7 @@ const a11y: Record<string, string> = title
201
229
  )
202
230
  }
203
231
 
204
- {mode === "auto" && (
232
+ {effectiveMode === "auto" && (
205
233
  <script is:inline>
206
234
  // Theme-aware icon swap. Runs after THEME_INIT (BaseLayout) so
207
235
  // `data-resolved-theme` is already populated. Idempotent — the
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  import type { IndexRecord } from "@grove-dev/core";
3
3
  import Icon from "./Icon.astro";
4
- import { formatRelative, formatStars, getOwnerAndRepoFromRepoUrl, getOwnerAvatarUrl, statusDisplay } from "@grove-dev/astro";
4
+ import { formatRelative, formatStars, getOwnerAndRepoFromRepoUrl, getOwnerAvatarUrl, projectStackIds, statusDisplay } from "@grove-dev/astro";
5
5
 
6
6
  interface Props {
7
7
  record: IndexRecord;
@@ -23,7 +23,7 @@ const repoHref = project?.repoUrl ?? item.links.github;
23
23
  const { owner, repo } = getOwnerAndRepoFromRepoUrl(repoHref ?? "");
24
24
  const avatar = project ? project.logoUrl ?? getOwnerAvatarUrl(owner, 80) : undefined;
25
25
  const initials = name.split(/\s+/).map((word) => word[0]).filter(Boolean).slice(0, 2).join("").toUpperCase();
26
- const stacks = project ? [project.stack, ...project.stacks].filter(Boolean) : [];
26
+ const stacks = projectStackIds(project);
27
27
  const platforms = project?.platforms ?? [];
28
28
  const stars = project?.github?.stars;
29
29
  const updatedAt = project?.github?.pushedAt ?? (item.kind === "resource" ? item.publishedAt : null);
@@ -25,7 +25,7 @@
25
25
  */
26
26
  import type { IndexRecord, Resource, ProjectRecord, IndexProjectRecord } from "@grove-dev/core";
27
27
  import Icon from "./Icon.astro";
28
- import { getOwnerAndRepoFromRepoUrl, getOwnerAvatarUrl } from "@grove-dev/astro";
28
+ import { getOwnerAndRepoFromRepoUrl, getOwnerAvatarUrl, projectStackIds } from "@grove-dev/astro";
29
29
  import { formatStars, formatRelative } from "@grove-dev/astro";
30
30
  import { statusDisplay } from "@grove-dev/astro";
31
31
 
@@ -126,12 +126,7 @@ const platforms = isProject ? (item as { platforms?: string[] }).platforms ?? []
126
126
  const visiblePlatforms = platforms.slice(0, 4);
127
127
  const platformOverflow = platforms.length - visiblePlatforms.length;
128
128
 
129
- const allStacks = isProject
130
- ? [
131
- (item as { stack?: string }).stack,
132
- ...((item as { stacks?: string[] }).stacks ?? []),
133
- ].filter((s): s is string => Boolean(s))
134
- : [];
129
+ const allStacks = isProject ? projectStackIds(item as ProjectRecord) : [];
135
130
  const visibleStacks = allStacks.slice(0, 3);
136
131
  const stackOverflow = allStacks.length - visibleStacks.length;
137
132
 
@@ -29,6 +29,7 @@ interface Facets {
29
29
  stacks?: FacetValue[];
30
30
  platforms?: FacetValue[];
31
31
  categories?: FacetValue[];
32
+ tags?: FacetValue[];
32
33
  labels?: FacetValue[];
33
34
  licenses?: FacetValue[];
34
35
  }
@@ -39,7 +40,7 @@ interface Props {
39
40
  pathPrefix?: string;
40
41
  class?: string;
41
42
  /** Hide specific facets. Default: all shown. */
42
- hide?: Array<"stack" | "platform" | "category" | "license" | "sort">;
43
+ hide?: Array<"stack" | "platform" | "category" | "tag" | "license" | "sort">;
43
44
  }
44
45
 
45
46
  const {
@@ -52,7 +53,7 @@ const {
52
53
 
53
54
  interface FilterGroup {
54
55
  label: string;
55
- filterKey: "stacks" | "platforms" | "categories" | "licenses";
56
+ filterKey: "stacks" | "platforms" | "categories" | "tags" | "licenses";
56
57
  options: FacetValue[];
57
58
  initial: string[];
58
59
  single?: boolean;
@@ -68,6 +69,7 @@ const baseGroups: FilterGroup[] = [
68
69
  { label: "Stack", filterKey: "stacks", options: facets.stacks ?? [], initial: initial.stacks ?? [] },
69
70
  { label: "Platform", filterKey: "platforms", options: facets.platforms ?? [], initial: initial.platforms ?? [] },
70
71
  { label: "Category", filterKey: "categories", options: facets.categories ?? [], initial: initial.categories ?? [] },
72
+ { label: "Tag", filterKey: "tags", options: facets.tags ?? [], initial: initial.tags ?? [] },
71
73
  { label: "License", filterKey: "licenses", options: facets.licenses ?? [], initial: initial.licenses ?? [], single: true },
72
74
  ];
73
75
 
@@ -86,7 +88,7 @@ function triggerLabel(group: FilterGroup): string {
86
88
 
87
89
  const groups: RenderedFilterGroup[] = baseGroups
88
90
  .filter((group) => {
89
- const singular = group.filterKey.slice(0, -1) as "stack" | "platform" | "category" | "license";
91
+ const singular = group.filterKey.slice(0, -1) as "stack" | "platform" | "category" | "tag" | "license";
90
92
  return !hide.includes(singular) && group.options.length > 0;
91
93
  })
92
94
  .map((group) => ({
@@ -97,8 +99,7 @@ const groups: RenderedFilterGroup[] = baseGroups
97
99
  }));
98
100
  ---
99
101
 
100
- <section class={className}>
101
- <div class="flex flex-wrap items-center gap-2" role="toolbar" aria-label="Filter items">
102
+ <section class:list={["flex flex-wrap items-center gap-2", className]} role="toolbar" aria-label="Filter items">
102
103
  {groups.map((group) => <FilterGroupMenu {...group} />)}
103
104
 
104
105
  {!hide.includes("sort") && (
@@ -112,13 +113,12 @@ const groups: RenderedFilterGroup[] = baseGroups
112
113
  </svg>
113
114
  </label>
114
115
  )}
115
- </div>
116
116
  </section>
117
117
 
118
118
  <script is:inline define:vars={{ pathPrefix }}>
119
119
  (() => {
120
120
  const groups = document.querySelectorAll(".grove-filter");
121
- const keys = { stacks: "stack", platforms: "platform", categories: "category", licenses: "license" };
121
+ const keys = { stacks: "stack", platforms: "platform", categories: "category", tags: "tag", licenses: "license" };
122
122
  function closeAll(except) {
123
123
  groups.forEach((group) => {
124
124
  if (group === except) return;
@@ -2,8 +2,8 @@
2
2
  /**
3
3
  * SmartLensTabs — curated single-select lens tabs.
4
4
  *
5
- * Renders the primary lenses (All / Trending / Recently added /
6
- * Established / Featured / Needs review) as a horizontal tab bar.
5
+ * Renders configured curated lenses as a horizontal tab bar. Sort choices,
6
+ * including Recently added, belong in the separate sort control.
7
7
  * Clicking a tab navigates to the same list page with the `lens=`
8
8
  * query param updated. Other filter params (q, stack, platform,
9
9
  * category, sort) are preserved.
@@ -24,6 +24,8 @@ interface Props {
24
24
  class?: string;
25
25
  /** Override the visible lens list (defaults to LENSES). */
26
26
  lensIds?: string[];
27
+ /** Consumer noun for the unfiltered lens, e.g. "All apps". */
28
+ allLabel?: string;
27
29
  }
28
30
 
29
31
  const {
@@ -31,6 +33,7 @@ const {
31
33
  pathPrefix = "/",
32
34
  class: className = "",
33
35
  lensIds,
36
+ allLabel,
34
37
  } = Astro.props;
35
38
 
36
39
  // If `lensIds` is provided, filter LENSES to those ids in that order.
@@ -55,16 +58,17 @@ const lenses =
55
58
  <a
56
59
  href={hrefForLens(lens.id, currentParams, pathPrefix)}
57
60
  class:list={[
58
- "inline-flex h-8 cursor-pointer items-center justify-center gap-1.5 rounded-[var(--radius)] border border-transparent px-3 text-2xs font-medium leading-none no-underline outline-none transition-colors focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/35",
61
+ "inline-flex h-9 cursor-pointer items-center justify-center gap-1.5 rounded-[var(--radius)] border border-transparent px-3 text-[0.8125rem] font-medium leading-none no-underline outline-none transition-colors focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/35",
59
62
  active
60
- ? "bg-primary text-primary-foreground hover:bg-primary/90"
63
+ ? "bg-primary text-primary-foreground hover:bg-primary/90 hover:text-primary-foreground"
61
64
  : "border-border bg-secondary text-foreground hover:bg-accent",
62
65
  ]}
63
66
  aria-current={active ? "page" : undefined}
67
+ data-lens-id={lens.id}
64
68
  data-event="lens_tab_click"
65
69
  data-event-source="smart_lens_tabs"
66
70
  >
67
- {lensDisplay(lens.id)}
71
+ {lens.id === "all" && allLabel ? allLabel : lensDisplay(lens.id)}
68
72
  </a>
69
73
  );
70
74
  })}
@@ -94,7 +94,7 @@ function statusLabel(s: StackEntry["status"]): string | null {
94
94
  data-event-source="stack_grid"
95
95
  >
96
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} />
97
+ <Icon name={iconAliases?.[s.slug] ?? s.slug} category="stack" size={18} />
98
98
  </div>
99
99
 
100
100
  <div class="flex min-w-0 flex-1 flex-col gap-0.5">