@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
package/src/lib/search.ts CHANGED
@@ -1,95 +1,24 @@
1
- /**
2
- * URL-driven search/filter/sort state for the directory list page.
3
- *
4
- * Multi-value fields use repeated keys: `?stack=Flutter&stack=RN`.
5
- * Single-value fields (q, sort, page, lens) are scalar. Empty
6
- * / undefined fields mean "no filter on this dimension".
7
- *
8
- * The functions here are generic over a record-like shape that
9
- * carries the index payload (see `toIndexRecord` in
10
- * `@grove-dev/core`). The list page only needs `name`, `slug`,
11
- * `description`, `category`, `tags`, `stack`, `stacks`, `platforms`,
12
- * `repoUrl`, `logoUrl`, `health`, `curation`, and `github.{stars,
13
- * pushedAt}` — all of which are present in the index projection.
14
- */
15
-
16
- import type {
17
- HealthStatus,
18
- ProjectRecord,
19
- } from "@grove-dev/core";
20
- import { labelDisplay, statusDisplay } from "./display.js";
21
- import { applyLens, lensFromSearchParams, type LensId } from "./lenses.js";
22
-
23
- // ── Types ─────────────────────────────────────────────────────────
1
+ import type { IndexRecord } from "@grove-dev/core";
2
+ import { labelDisplay, lensDisplay, statusDisplay } from "./display.js";
24
3
 
25
4
  /**
26
- * Subset of `ProjectRecord` the search lib actually reads. Defined
27
- * inline so the lib works on both full records and the lighter
28
- * index payload. Use `ProjectRecord` from core when you have it;
29
- * pass any object that satisfies `Searchable` otherwise.
5
+ * URL-driven filter state for the /items discovery page.
30
6
  *
31
- * `Searchable` is a superset of `AppLike` (the lens-matching shape
32
- * in `lib/lenses.ts`) both interfaces share the same optional
33
- * field shape, so `Searchable` records can be passed to
34
- * `applyLens()` and `filterApps()` interchangeably.
7
+ * Multi-value fields use repeated keys in the URL: `?stack=Flutter&stack=React+Native`.
8
+ * Empty / undefined fields mean "no filter on this dimension".
35
9
  */
36
- export interface Searchable {
37
- slug?: string;
38
- name: string;
39
- description?: string;
40
- category?: string;
41
- tags?: string[];
42
- stack?: string;
43
- stacks?: string[];
44
- platforms?: string[];
45
- /** Canonical repo URL — used for owner extraction in text search. */
46
- repoUrl?: string;
47
- logoUrl?: string;
48
- /** Curated labels (new, hot, mature, featured). */
49
- curation?: { labels?: string[] };
50
- /** Health block — drives status filter + tier fallback. */
51
- health?: {
52
- status?: HealthStatus;
53
- tier?: "curated" | "listed" | "experimental" | "hidden";
54
- visibility?: "highlight" | "keep" | "needs_review" | "hide" | "remove" | "historical";
55
- cleanupCandidate?: boolean;
56
- };
57
- /** GitHub metadata used for sort + text search. */
58
- github?: {
59
- stars?: number;
60
- forks?: number;
61
- pushedAt?: string | null;
62
- license?: string | null;
63
- };
64
- /** Score block — used for "best-overall" sort. */
65
- scores?: {
66
- activity?: number;
67
- maturity?: number;
68
- learning?: number;
69
- contribution?: number;
70
- docs?: number;
71
- overall?: number;
72
- };
73
- }
74
-
75
- export type AppsFilters = {
10
+ export type ItemsFilters = {
76
11
  q?: string;
77
- /** Stack slugs, repeated URL key `stack=`. Matches against `stack` AND `stacks[]`. */
78
12
  stacks?: string[];
79
- /** Platform slugs, repeated URL key `platform=`. Matches `platforms[]`. */
80
13
  platforms?: string[];
81
- /** Category names, repeated URL key `category=`. */
82
14
  categories?: string[];
83
- /** Curated label ids (new/hot/mature/featured), repeated URL key `label=`. */
84
15
  labels?: string[];
85
- /** SPDX license ids, repeated URL key `license=`. */
86
16
  licenses?: string[];
87
- /** Health status values, comma-separated repeated URL key `status=`. */
88
17
  statuses?: string[];
89
- /** A curated lens (single-select view). */
90
- lens?: LensId;
18
+ /** A curated lens (e.g. "good-to-learn", "production-like"). Single value. */
19
+ lens?: string;
91
20
  /** Sort order. Single value, defaults to "recently-updated". */
92
- sort?: AppsSort;
21
+ sort?: ItemsSort;
93
22
  /** 1-based page number. */
94
23
  page?: number;
95
24
  };
@@ -103,16 +32,11 @@ export const SORT_OPTIONS = [
103
32
  { value: "alphabetical", label: "Alphabetical" },
104
33
  ] as const;
105
34
 
106
- export type AppsSort = (typeof SORT_OPTIONS)[number]["value"];
35
+ export type ItemsSort = (typeof SORT_OPTIONS)[number]["value"];
107
36
 
108
- const DEFAULT_SORT: AppsSort = "recently-updated";
37
+ const DEFAULT_SORT: ItemsSort = "recently-updated";
109
38
  const DEFAULT_PAGE = 1;
110
-
111
- /**
112
- * How many records per page. Constant for now; future versions may
113
- * vary per blueprint.
114
- */
115
- export const PAGE_SIZE = 24;
39
+ const DEFAULT_PAGE_SIZE = 20;
116
40
 
117
41
  const KEYS = {
118
42
  q: "q",
@@ -127,25 +51,20 @@ const KEYS = {
127
51
  page: "page",
128
52
  } as const;
129
53
 
130
- // ── Search param round-tripping ──────────────────────────────────
131
-
132
54
  /**
133
- * Read filters from a URLSearchParams (or anything URLSearchParams-
134
- * shaped, e.g. the result of `new URL(req.url).searchParams`).
55
+ * Read filters from a URLSearchParams (or anything URLSearchParams-shaped,
56
+ * e.g. the result of `new URL(req.url).searchParams`).
135
57
  */
136
- export function filtersFromSearchParams(
137
- sp: URLSearchParams,
138
- ): AppsFilters {
58
+ export function filtersFromSearchParams(sp: URLSearchParams): ItemsFilters {
139
59
  const rawSort = sp.get(KEYS.sort);
140
60
  // Backwards-compat: old "most-mature" sort now maps to "best-overall".
141
61
  const normalizedSort = rawSort === "most-mature" ? "best-overall" : rawSort;
142
- const sort: AppsSort | undefined =
62
+ const sort: ItemsSort | undefined =
143
63
  normalizedSort && SORT_OPTIONS.some((o) => o.value === normalizedSort)
144
- ? (normalizedSort as AppsSort)
64
+ ? (normalizedSort as ItemsSort)
145
65
  : undefined;
146
66
  const rawPage = Number(sp.get(KEYS.page));
147
- const page =
148
- Number.isFinite(rawPage) && rawPage >= 1 ? Math.floor(rawPage) : undefined;
67
+ const page = Number.isFinite(rawPage) && rawPage >= 1 ? Math.floor(rawPage) : undefined;
149
68
  return {
150
69
  q: sp.get(KEYS.q) ?? undefined,
151
70
  stacks: sp.getAll(KEYS.stacks).filter(Boolean),
@@ -153,22 +72,19 @@ export function filtersFromSearchParams(
153
72
  categories: sp.getAll(KEYS.categories).filter(Boolean),
154
73
  labels: sp.getAll(KEYS.labels).filter(Boolean),
155
74
  licenses: sp.getAll(KEYS.licenses).filter(Boolean),
156
- statuses: sp
157
- .getAll(KEYS.statuses)
158
- .flatMap((s) => s.split(","))
159
- .filter(Boolean),
160
- lens: lensFromSearchParams(sp) || undefined,
75
+ statuses: sp.getAll(KEYS.statuses).flatMap((s) => s.split(",")).filter(Boolean),
76
+ lens: sp.get(KEYS.lens) ?? undefined,
161
77
  sort,
162
78
  page,
163
79
  };
164
80
  }
165
81
 
166
82
  /**
167
- * Serialize a filters object back to URLSearchParams. Undefined /
168
- * empty values are dropped. Defaults (sort, page 1) are also dropped
169
- * to keep URLs short and canonical.
83
+ * Serialize a filters object back to URLSearchParams.
84
+ * Undefined / empty values are dropped. Defaults (sort, page 1, comfortable
85
+ * density) are also dropped to keep URLs short and canonical.
170
86
  */
171
- export function searchParamsFromFilters(f: AppsFilters): URLSearchParams {
87
+ export function searchParamsFromFilters(f: ItemsFilters): URLSearchParams {
172
88
  const sp = new URLSearchParams();
173
89
  if (f.q) sp.set(KEYS.q, f.q);
174
90
  for (const v of f.stacks ?? []) sp.append(KEYS.stacks, v);
@@ -183,13 +99,8 @@ export function searchParamsFromFilters(f: AppsFilters): URLSearchParams {
183
99
  return sp;
184
100
  }
185
101
 
186
- // ── Filter application ───────────────────────────────────────────
187
-
188
- /**
189
- * True if any filter is active (i.e. the result list isn't "all
190
- * records, default sort").
191
- */
192
- export function hasAnyFilter(f: AppsFilters): boolean {
102
+ /** True if any filter is active (i.e. the result list isn't "all items"). */
103
+ export function hasAnyFilter(f: ItemsFilters): boolean {
193
104
  if (f.q && f.q.trim()) return true;
194
105
  if (f.lens) return true;
195
106
  return Boolean(
@@ -202,148 +113,59 @@ export function hasAnyFilter(f: AppsFilters): boolean {
202
113
  );
203
114
  }
204
115
 
116
+ /** How many items per page. Constant for now; future versions may vary. */
117
+ export const PAGE_SIZE = DEFAULT_PAGE_SIZE;
118
+
205
119
  /**
206
- * Return the subset of records that match all active filters (AND
207
- * across dimensions, OR within a dimension).
208
- *
209
- * `q` is a case-insensitive substring search across name, owner,
210
- * description, category, stack/stacks/platforms, tags, and license.
211
- * Other dimensions are exact match.
120
+ * Sort items in-place-free: returns a new array. Stable for ties.
121
+ * Apps missing the sort key (e.g. no stars) sink to the bottom rather
122
+ * than vanishing.
212
123
  */
213
- export function filterApps<T extends Searchable>(
214
- records: T[],
215
- f: AppsFilters,
216
- ): T[] {
217
- const q = f.q?.trim().toLowerCase();
218
- const stacks = f.stacks?.length ? new Set(f.stacks) : null;
219
- const platforms = f.platforms?.length ? new Set(f.platforms) : null;
220
- const categories = f.categories?.length ? new Set(f.categories) : null;
221
- const labels = f.labels?.length ? new Set(f.labels) : null;
222
- const licenses = f.licenses?.length ? new Set(f.licenses) : null;
223
- const statuses = f.statuses?.length ? new Set(f.statuses) : null;
224
-
225
- return records.filter((r) => {
226
- if (q) {
227
- const ownerMatch =
228
- /github\.com\/([^/]+)\//.exec(r.repoUrl ?? "")?.[1]?.toLowerCase() ??
229
- "";
230
- const haystack = [
231
- r.name,
232
- ownerMatch,
233
- r.description ?? "",
234
- r.category ?? "",
235
- r.stack ?? "",
236
- ...(r.stacks ?? []),
237
- ...(r.platforms ?? []),
238
- ...(r.tags ?? []),
239
- r.github?.license ?? "",
240
- r.health?.status ? statusDisplay(r.health.status) : "",
241
- ]
242
- .join(" ")
243
- .toLowerCase();
244
- if (!haystack.includes(q)) return false;
245
- }
246
-
247
- if (stacks) {
248
- const allStacks = [r.stack, ...(r.stacks ?? [])].filter(
249
- (s): s is string => Boolean(s),
250
- );
251
- if (!allStacks.some((s) => stacks.has(s))) return false;
252
- }
253
-
254
- if (platforms) {
255
- if (!r.platforms?.some((p) => platforms.has(p))) return false;
256
- }
257
-
258
- if (categories) {
259
- if (!r.category || !categories.has(r.category)) return false;
260
- }
261
-
262
- if (labels) {
263
- if (!r.curation?.labels?.some((l) => labels.has(l))) return false;
264
- }
265
-
266
- if (licenses) {
267
- const license = r.github?.license;
268
- if (!license || !licenses.has(license)) return false;
269
- }
270
-
271
- if (statuses) {
272
- const status = r.health?.status;
273
- if (!status || !statuses.has(status)) return false;
274
- }
124
+ function recordName(record: IndexRecord): string {
125
+ return record.kind === "resource" ? record.title : record.name;
126
+ }
275
127
 
276
- return true;
277
- });
128
+ function projectStars(record: IndexRecord): number {
129
+ return record.kind === "project" ? record.github?.stars ?? 0 : 0;
278
130
  }
279
131
 
280
- // ── Lenses, sort, pagination ─────────────────────────────────────
132
+ function recordUpdatedAt(record: IndexRecord): string | null {
133
+ if (record.kind === "project") return record.github?.pushedAt ?? null;
134
+ if (record.kind === "resource") return record.publishedAt ?? null;
135
+ return null;
136
+ }
281
137
 
282
- /**
283
- * Apply lens → filter → sort pipeline. Lens is applied first because
284
- * it's a single-select "view" that may be broader or narrower than
285
- * the regular filter dimensions.
286
- */
287
- export function pipeline<T extends Searchable>(
288
- records: T[],
289
- filters: AppsFilters,
290
- ): T[] {
291
- const afterLens = applyLens(records, filters.lens ?? "");
292
- const afterFilter = filterApps(afterLens, filters);
293
- return applySort(afterFilter, effectiveSort(filters));
138
+ function recordAddedAt(record: IndexRecord): string | null {
139
+ return record.curation?.reviewedAt ?? null;
294
140
  }
295
141
 
296
- /**
297
- * Sort records in-place-free: returns a new array. Stable for ties.
298
- * Records missing the sort key (e.g. no stars) sink to the bottom
299
- * rather than vanishing.
300
- */
301
- export function applySort<T extends Searchable>(
302
- records: T[],
303
- sort: AppsSort,
304
- ): T[] {
305
- const arr = records.slice();
142
+ export function applySort(items: IndexRecord[], sort: ItemsSort): IndexRecord[] {
143
+ const arr = items.slice();
306
144
  const ts = (s?: string | null): number => (s ? new Date(s).valueOf() : 0);
307
145
  switch (sort) {
308
146
  case "most-starred":
309
- arr.sort((a, b) => (b.github?.stars ?? 0) - (a.github?.stars ?? 0));
147
+ arr.sort((a, b) => projectStars(b) - projectStars(a));
310
148
  break;
311
149
  case "recently-updated":
312
- arr.sort((a, b) => ts(b.github?.pushedAt) - ts(a.github?.pushedAt));
150
+ arr.sort((a, b) => ts(recordUpdatedAt(b)) - ts(recordUpdatedAt(a)));
313
151
  break;
314
152
  case "recently-added":
315
- // Index payload doesn't have a dedicated "addedAt" yet — fall
316
- // back to `pushedAt` so the sort still works. Once we add an
317
- // explicit addedAt field, swap the key here.
318
- arr.sort((a, b) => ts(b.github?.pushedAt) - ts(a.github?.pushedAt));
153
+ arr.sort((a, b) => ts(recordAddedAt(b)) - ts(recordAddedAt(a)));
319
154
  break;
320
155
  case "best-overall": {
321
- // Composite: scores.overall scores.maturity scores.activity
322
- // active status license present → stars. Avoids ranking by
323
- // stars alone.
324
- const score = (
325
- x: T,
326
- k: "overall" | "maturity" | "activity",
327
- ): number => (typeof x.scores?.[k] === "number" ? x.scores![k]! : 0);
328
- const active = (x: T): number => (x.health?.status === "active" ? 1 : 0);
329
- const licensed = (x: T): number => (x.github?.license ? 1 : 0);
156
+ const reviewed = (a: IndexRecord) => (a.curation?.reviewed ? 1 : 0);
157
+ const visible = (a: IndexRecord) => (a.visibility === "keep" ? 1 : 0);
330
158
  arr.sort((a, b) => {
331
- const sOverall = score(b, "overall") - score(a, "overall");
332
- if (sOverall !== 0) return sOverall;
333
- const sMaturity = score(b, "maturity") - score(a, "maturity");
334
- if (sMaturity !== 0) return sMaturity;
335
- const sActivity = score(b, "activity") - score(a, "activity");
336
- if (sActivity !== 0) return sActivity;
337
- const sActive = active(b) - active(a);
338
- if (sActive !== 0) return sActive;
339
- const sLicensed = licensed(b) - licensed(a);
340
- if (sLicensed !== 0) return sLicensed;
341
- return (b.github?.stars ?? 0) - (a.github?.stars ?? 0);
159
+ const reviewedDelta = reviewed(b) - reviewed(a);
160
+ if (reviewedDelta !== 0) return reviewedDelta;
161
+ const visibleDelta = visible(b) - visible(a);
162
+ if (visibleDelta !== 0) return visibleDelta;
163
+ return projectStars(b) - projectStars(a);
342
164
  });
343
165
  break;
344
166
  }
345
167
  case "alphabetical":
346
- arr.sort((a, b) => a.name.localeCompare(b.name));
168
+ arr.sort((a, b) => recordName(a).localeCompare(recordName(b)));
347
169
  break;
348
170
  }
349
171
  return arr;
@@ -361,25 +183,123 @@ export function totalPages(itemCount: number, pageSize = PAGE_SIZE): number {
361
183
  }
362
184
 
363
185
  /** Resolve the effective sort from a filters object, applying default. */
364
- export function effectiveSort(f: AppsFilters): AppsSort {
186
+ export function effectiveSort(f: ItemsFilters): ItemsSort {
365
187
  return f.sort ?? DEFAULT_SORT;
366
188
  }
367
189
 
368
190
  /** Resolve the effective page from a filters object, applying default. */
369
- export function effectivePage(f: AppsFilters): number {
191
+ export function effectivePage(f: ItemsFilters): number {
370
192
  return f.page ?? DEFAULT_PAGE;
371
193
  }
372
194
 
373
- // ── Facets ───────────────────────────────────────────────────────
374
-
375
195
  /**
376
- * Return a flat list of active-filter chips for display + removal.
377
- * Used by the "x" button on each chip in the UI.
196
+ * Return the subset of items that match all active filters (AND across
197
+ * dimensions, OR within a dimension).
198
+ *
199
+ * `q` is a case-insensitive substring search across name, owner,
200
+ * description, and category. Other dimensions are exact match.
378
201
  */
202
+ export function filterItems(items: IndexRecord[], f: ItemsFilters): IndexRecord[] {
203
+ const q = f.q?.trim().toLowerCase();
204
+ const stacks = f.stacks?.length ? new Set(f.stacks) : null;
205
+ const platforms = f.platforms?.length ? new Set(f.platforms) : null;
206
+ const categories = f.categories?.length ? new Set(f.categories) : null;
207
+ const labels = f.labels?.length ? new Set(f.labels) : null;
208
+ const licenses = f.licenses?.length ? new Set(f.licenses) : null;
209
+ const statuses = f.statuses?.length ? new Set(f.statuses) : null;
210
+
211
+ return items.filter((a) => {
212
+ // Text search — match name, owner, description, category, stack,
213
+ // stacks, platforms, tags, backend, architecture, stateManagement,
214
+ // bestFor, whyListed, license, status.
215
+ if (q) {
216
+ const repoUrl = a.kind === "project" ? a.repoUrl : a.links.github ?? "";
217
+ const ownerMatch =
218
+ /github\.com\/([^/]+)\//.exec(repoUrl ?? "")?.[1]?.toLowerCase() ?? "";
219
+ const projectFields =
220
+ a.kind === "project"
221
+ ? [
222
+ a.stack,
223
+ ...a.stacks,
224
+ ...a.platforms,
225
+ a.projectType,
226
+ a.difficulty ?? "",
227
+ a.codebaseSize ?? "",
228
+ ...a.bestFor,
229
+ ...a.whyListed,
230
+ a.github?.license ?? "",
231
+ a.health?.status ? statusDisplay(a.health.status) : "",
232
+ ]
233
+ : [];
234
+ const resourceFields =
235
+ a.kind === "resource"
236
+ ? [a.type, a.topic, a.author ?? ""]
237
+ : [];
238
+ const entityFields =
239
+ a.kind === "entity"
240
+ ? [a.type, a.location ?? "", a.parent ?? ""]
241
+ : [];
242
+ const haystack = [
243
+ recordName(a),
244
+ ownerMatch,
245
+ a.description,
246
+ a.category,
247
+ ...a.tags,
248
+ ...projectFields,
249
+ ...resourceFields,
250
+ ...entityFields,
251
+ ]
252
+ .join(" ")
253
+ .toLowerCase();
254
+ if (!haystack.includes(q)) return false;
255
+ }
256
+
257
+ if (stacks) {
258
+ if (a.kind !== "project") return false;
259
+ const allStacks = [a.stack, ...a.stacks].filter(
260
+ (stack): stack is string => Boolean(stack),
261
+ );
262
+ if (!allStacks.some((s) => stacks.has(s))) return false;
263
+ }
264
+
265
+ if (platforms) {
266
+ if (a.kind !== "project") return false;
267
+ if (!a.platforms.some((p) => platforms.has(p))) return false;
268
+ }
269
+
270
+ if (categories) {
271
+ if (!categories.has(a.category)) return false;
272
+ }
273
+
274
+ if (labels) {
275
+ if (!a.curation?.labels?.some((l) => labels.has(l))) return false;
276
+ }
277
+
278
+ if (licenses) {
279
+ if (a.kind !== "project") return false;
280
+ const license = a.github?.license;
281
+ if (!license || !licenses.has(license)) return false;
282
+ }
283
+
284
+ if (statuses) {
285
+ if (a.kind !== "project") return false;
286
+ const status = a.health?.status;
287
+ if (!status || !statuses.has(status)) return false;
288
+ }
289
+
290
+ if (f.lens) {
291
+ if (!a.curation?.lenses?.includes(f.lens)) return false;
292
+ }
293
+
294
+ return true;
295
+ });
296
+ }
297
+
298
+ /** Build a flat list of "active filter" chips for display + removal. */
379
299
  export function activeFilterChips(
380
- f: AppsFilters,
381
- ): { key: keyof AppsFilters; value: string; label: string }[] {
382
- const out: { key: keyof AppsFilters; value: string; label: string }[] = [];
300
+ f: ItemsFilters,
301
+ ): { key: keyof ItemsFilters; value: string; label: string }[] {
302
+ const out: { key: keyof ItemsFilters; value: string; label: string }[] = [];
383
303
 
384
304
  if (f.q && f.q.trim()) {
385
305
  out.push({ key: "q", value: "", label: `“${f.q}”` });
@@ -400,21 +320,17 @@ export function activeFilterChips(
400
320
  out.push({ key: "licenses", value: v, label: `License: ${v}` });
401
321
  }
402
322
  if (f.lens) {
403
- // Inline map keeps this function dependency-free for unit tests
404
- // (no need to import from `lenses.ts` to render a chip label).
405
- const LENS_CHIP_LABELS: Record<LensId, string> = {
406
- all: "All",
407
- hot: "Trending",
408
- new: "Recently added",
409
- mature: "Established",
410
- featured: "Featured",
411
- "needs-review": "Needs review",
412
- };
413
- out.push({ key: "lens", value: f.lens, label: LENS_CHIP_LABELS[f.lens] ?? f.lens });
323
+ out.push({ key: "lens", value: f.lens, label: lensDisplay(f.lens) });
414
324
  }
325
+ // Status: collapse the "stale,quiet" composite into a single chip.
415
326
  if (f.statuses && f.statuses.length) {
416
- for (const v of f.statuses) {
417
- out.push({ key: "statuses", value: v, label: statusDisplay(v) });
327
+ const joined = f.statuses.join(",");
328
+ if (joined === "stale,quiet") {
329
+ out.push({ key: "statuses", value: "stale,quiet", label: statusDisplay("needs-maintainer") });
330
+ } else {
331
+ for (const v of f.statuses) {
332
+ out.push({ key: "statuses", value: v, label: statusDisplay(v) });
333
+ }
418
334
  }
419
335
  }
420
336
 
@@ -422,11 +338,36 @@ export function activeFilterChips(
422
338
  }
423
339
 
424
340
  /**
425
- * Build the full list of facet values from the record array,
426
- * preserving a sensible order (frequency desc, then alphabetical
427
- * for ties).
341
+ * Return a new filters object with one (key, value) removed.
342
+ * - key="q" clears the search string
343
+ * - multi-value keys → removes the single matching value
344
+ * Used by the "x" button on each active filter chip.
345
+ * Also resets page to 1 so the user doesn't get stranded on a high page
346
+ * that no longer has results.
347
+ */
348
+ export function removeFilter(
349
+ f: ItemsFilters,
350
+ key: keyof ItemsFilters,
351
+ value: string,
352
+ ): ItemsFilters {
353
+ let next: ItemsFilters;
354
+ if (key === "q") {
355
+ next = { ...f, q: undefined };
356
+ } else {
357
+ const current = f[key] as string[] | undefined;
358
+ if (!current) return f;
359
+ const filtered = current.filter((v) => v !== value);
360
+ next = { ...f, [key]: filtered.length ? filtered : undefined };
361
+ }
362
+ next.page = 1;
363
+ return next;
364
+ }
365
+
366
+ /**
367
+ * Build the full list of facet values from the items array, preserving
368
+ * a sensible order (frequency desc, then alphabetical for ties).
428
369
  */
429
- export function buildFacets<T extends Searchable>(records: T[]) {
370
+ export function buildFacets(items: IndexRecord[]) {
430
371
  const counts = {
431
372
  stack: new Map<string, number>(),
432
373
  platform: new Map<string, number>(),
@@ -434,31 +375,18 @@ export function buildFacets<T extends Searchable>(records: T[]) {
434
375
  label: new Map<string, number>(),
435
376
  license: new Map<string, number>(),
436
377
  };
437
- for (const r of records) {
438
- const allStacks = new Set(
439
- [r.stack, ...(r.stacks ?? [])].filter(
440
- (s): s is string => Boolean(s),
441
- ),
442
- );
443
- for (const s of allStacks) {
444
- counts.stack.set(s, (counts.stack.get(s) ?? 0) + 1);
445
- }
446
- for (const p of r.platforms ?? []) {
447
- counts.platform.set(p, (counts.platform.get(p) ?? 0) + 1);
448
- }
449
- if (r.category) {
450
- counts.category.set(
451
- r.category,
452
- (counts.category.get(r.category) ?? 0) + 1,
378
+ for (const a of items) {
379
+ if (a.kind === "project") {
380
+ const allStacks = new Set(
381
+ [a.stack, ...a.stacks].filter((stack): stack is string => Boolean(stack)),
453
382
  );
383
+ for (const s of allStacks) counts.stack.set(s, (counts.stack.get(s) ?? 0) + 1);
384
+ for (const p of a.platforms) counts.platform.set(p, (counts.platform.get(p) ?? 0) + 1);
385
+ const license = a.github?.license;
386
+ if (license) counts.license.set(license, (counts.license.get(license) ?? 0) + 1);
454
387
  }
455
- for (const l of r.curation?.labels ?? []) {
456
- counts.label.set(l, (counts.label.get(l) ?? 0) + 1);
457
- }
458
- const license = r.github?.license;
459
- if (license) {
460
- counts.license.set(license, (counts.license.get(license) ?? 0) + 1);
461
- }
388
+ counts.category.set(a.category, (counts.category.get(a.category) ?? 0) + 1);
389
+ for (const l of a.curation?.labels ?? []) counts.label.set(l, (counts.label.get(l) ?? 0) + 1);
462
390
  }
463
391
  const sortByCountThenName = (m: Map<string, number>) =>
464
392
  [...m.entries()]
@@ -473,9 +401,13 @@ export function buildFacets<T extends Searchable>(records: T[]) {
473
401
  };
474
402
  }
475
403
 
476
- /**
477
- * Re-export the `ProjectRecord` type so consumers of the search lib
478
- * can write `import type { ProjectRecord } from "@grove-dev/astro/lib"`
479
- * without a second import.
480
- */
481
- export type { ProjectRecord };
404
+ // ── V1-published API aliases ─────────────────────────────────────────
405
+ // The published `@grove-dev/astro@0.2.10` package exports these
406
+ // under different names (`filterApps` for the filter function and
407
+ // `AppsFilters` for the filter-state type). Re-export them under
408
+ // the V1 names so templates can use either spelling, and so the
409
+ // template's `apps/index.astro` (built against the published API)
410
+ // also works against the local workspace source.
411
+ export const filterApps = filterItems;
412
+ export type AppsFilters = ItemsFilters;
413
+ export type AppsSort = ItemsSort;