@grove-dev/astro 0.1.5 → 0.2.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/dist/index.d.ts +4 -25
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +56 -22
- package/dist/index.js.map +1 -1
- package/dist/layouts/index.d.ts +25 -0
- package/dist/layouts/index.d.ts.map +1 -0
- package/dist/layouts/index.js +25 -0
- package/dist/layouts/index.js.map +1 -0
- package/dist/lib/display.d.ts +33 -0
- package/dist/lib/display.d.ts.map +1 -0
- package/dist/lib/display.js +104 -0
- package/dist/lib/display.js.map +1 -0
- package/dist/lib/format.d.ts +30 -0
- package/dist/lib/format.d.ts.map +1 -0
- package/dist/lib/format.js +73 -0
- package/dist/lib/format.js.map +1 -0
- package/dist/lib/index.d.ts +26 -0
- package/dist/lib/index.d.ts.map +1 -0
- package/dist/lib/index.js +26 -0
- package/dist/lib/index.js.map +1 -0
- package/dist/lib/lenses.d.ts +96 -0
- package/dist/lib/lenses.d.ts.map +1 -0
- package/dist/lib/lenses.js +181 -0
- package/dist/lib/lenses.js.map +1 -0
- package/dist/lib/repo.d.ts +30 -0
- package/dist/lib/repo.d.ts.map +1 -0
- package/dist/lib/repo.js +44 -0
- package/dist/lib/repo.js.map +1 -0
- package/dist/lib/scores.d.ts +38 -0
- package/dist/lib/scores.d.ts.map +1 -0
- package/dist/lib/scores.js +83 -0
- package/dist/lib/scores.js.map +1 -0
- package/dist/lib/search.d.ts +199 -0
- package/dist/lib/search.d.ts.map +1 -0
- package/dist/lib/search.js +357 -0
- package/dist/lib/search.js.map +1 -0
- package/dist/lib/taxonomy-counts.d.ts +25 -0
- package/dist/lib/taxonomy-counts.d.ts.map +1 -0
- package/dist/lib/taxonomy-counts.js +41 -0
- package/dist/lib/taxonomy-counts.js.map +1 -0
- package/package.json +7 -7
- package/src/components/AppsIndexRow.astro +204 -0
- package/src/components/AppsPagination.astro +126 -0
- package/src/components/CategoryGrid.astro +86 -17
- package/src/components/ContributorsGrid.astro +149 -0
- package/src/components/DecisionRow.astro +89 -0
- package/src/components/ExploreByCategory.astro +67 -0
- package/src/components/ExploreByStack.astro +72 -0
- package/src/components/Hero.astro +246 -0
- package/src/components/Icon.astro +223 -0
- package/src/components/ItemCard.astro +306 -30
- package/src/components/MinimalAbout.astro +82 -0
- package/src/components/OriginalCollection.astro +116 -0
- package/src/components/RecordSection.astro +88 -0
- package/src/components/RefinePanel.astro +197 -0
- package/src/components/ScoreBars.astro +92 -22
- package/src/components/SmartLensTabs.astro +71 -0
- package/src/components/StackGrid.astro +132 -0
- package/src/components/WhyThisExists.astro +116 -0
- package/src/index.ts +61 -14
- package/src/layouts/BaseLayout.astro +227 -15
- package/src/layouts/Container.astro +57 -0
- package/src/layouts/Footer.astro +235 -0
- package/src/layouts/Header.astro +159 -0
- package/src/layouts/SectionHeader.astro +58 -0
- package/{templates/default/src/components/layout → src/layouts}/Seo.astro +62 -38
- package/src/layouts/ThemeToggle.astro +110 -0
- package/src/layouts/astro-shim.d.ts +29 -0
- package/src/layouts/index.ts +24 -0
- package/src/lib/display.ts +118 -0
- package/src/lib/format.ts +65 -0
- package/src/lib/index.ts +26 -0
- package/src/lib/lenses.ts +241 -0
- package/src/lib/repo.ts +44 -0
- package/src/lib/scores.ts +94 -0
- package/src/lib/search.ts +481 -0
- package/src/lib/taxonomy-counts.ts +60 -0
- package/src/styles.css +366 -638
- package/templates/default/astro.config.mjs +8 -6
- package/templates/default/content/pages/about.md +66 -0
- package/templates/default/content/pages/methodology.md +103 -0
- package/templates/default/data/generated/records.full.json +8 -0
- package/templates/default/data/generated/records.index.json +7 -0
- package/templates/default/data/generated/records.json +8 -0
- package/templates/default/data/generated/site-config.json +31 -0
- package/templates/default/data/records/.gitkeep +0 -0
- package/templates/default/grove.config.ts +36 -0
- package/templates/default/package.json +6 -5
- package/templates/default/public/llms-full.txt +14 -0
- package/templates/default/public/llms.txt +11 -0
- package/templates/default/src/components/layout/BaseLayout.astro +10 -57
- package/templates/default/src/components/layout/Footer.astro +16 -134
- package/templates/default/src/components/layout/Header.astro +25 -126
- package/templates/default/src/data/records.ts +193 -0
- package/templates/default/src/lib/markdown.ts +171 -0
- package/templates/default/src/pages/about.astro +95 -429
- package/templates/default/src/pages/index.astro +263 -61
- package/templates/default/src/pages/projects/[slug].astro +480 -0
- package/templates/default/src/pages/projects/index.astro +193 -0
- package/templates/default/src/pages/sitemap.xml.ts +73 -61
- package/templates/default/src/pages/submit.astro +122 -411
- package/templates/default/src/styles/global.css +42 -68
- package/src/components/ActiveFilterChips.astro +0 -20
- package/src/components/DirectoryFilters.astro +0 -100
- package/src/components/DirectoryHero.astro +0 -32
- package/src/components/HealthBadge.astro +0 -12
- package/src/components/LensTabs.astro +0 -26
- package/src/components/MethodologyPanel.astro +0 -20
- package/src/components/Pagination.astro +0 -24
- package/src/components/ProjectDetail.astro +0 -166
- package/src/components/SubmitDraft.astro +0 -137
- package/templates/default/src/components/AppCard.astro +0 -248
- package/templates/default/src/components/AppSection.astro +0 -58
- package/templates/default/src/components/AppsIndexRow.astro +0 -230
- package/templates/default/src/components/AppsPagination.astro +0 -147
- package/templates/default/src/components/CategoryGrid.astro +0 -80
- package/templates/default/src/components/ContributorsGrid.astro +0 -105
- package/templates/default/src/components/DecisionRow.astro +0 -219
- package/templates/default/src/components/ExploreByCategory.astro +0 -74
- package/templates/default/src/components/ExploreByStack.astro +0 -102
- package/templates/default/src/components/Hero.astro +0 -176
- package/templates/default/src/components/MinimalAbout.astro +0 -57
- package/templates/default/src/components/OriginalCollection.astro +0 -144
- package/templates/default/src/components/RefinePanel.astro +0 -272
- package/templates/default/src/components/ScoreBars.astro +0 -72
- package/templates/default/src/components/SmartLensTabs.astro +0 -65
- package/templates/default/src/components/StackGrid.astro +0 -95
- package/templates/default/src/components/WhyThisExists.astro +0 -81
- package/templates/default/src/components/icons/Icon.astro +0 -148
- package/templates/default/src/components/layout/SectionHeader.astro +0 -42
- package/templates/default/src/components/layout/ThemeToggle.astro +0 -97
- package/templates/default/src/data/apps.ts +0 -149
- package/templates/default/src/data/categories.ts +0 -27
- package/templates/default/src/data/config.ts +0 -28
- package/templates/default/src/data/contributors.ts +0 -96
- package/templates/default/src/data/stacks.ts +0 -47
- package/templates/default/src/data/stats.ts +0 -65
- package/templates/default/src/data/types.ts +0 -248
- package/templates/default/src/lib/apps-search.ts +0 -228
- package/templates/default/src/lib/format.ts +0 -36
- package/templates/default/src/lib/lenses.ts +0 -98
- package/templates/default/src/lib/repo.ts +0 -12
- package/templates/default/src/lib/scores.ts +0 -51
- package/templates/default/src/lib/taxonomy-counts.ts +0 -31
- package/templates/default/src/pages/apps/[slug].astro +0 -709
- package/templates/default/src/pages/apps/index.astro +0 -669
- package/templates/default/src/pages/contributors.astro +0 -142
|
@@ -0,0 +1,481 @@
|
|
|
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 ─────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
/**
|
|
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.
|
|
30
|
+
*
|
|
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.
|
|
35
|
+
*/
|
|
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 = {
|
|
76
|
+
q?: string;
|
|
77
|
+
/** Stack slugs, repeated URL key `stack=`. Matches against `stack` AND `stacks[]`. */
|
|
78
|
+
stacks?: string[];
|
|
79
|
+
/** Platform slugs, repeated URL key `platform=`. Matches `platforms[]`. */
|
|
80
|
+
platforms?: string[];
|
|
81
|
+
/** Category names, repeated URL key `category=`. */
|
|
82
|
+
categories?: string[];
|
|
83
|
+
/** Curated label ids (new/hot/mature/featured), repeated URL key `label=`. */
|
|
84
|
+
labels?: string[];
|
|
85
|
+
/** SPDX license ids, repeated URL key `license=`. */
|
|
86
|
+
licenses?: string[];
|
|
87
|
+
/** Health status values, comma-separated repeated URL key `status=`. */
|
|
88
|
+
statuses?: string[];
|
|
89
|
+
/** A curated lens (single-select view). */
|
|
90
|
+
lens?: LensId;
|
|
91
|
+
/** Sort order. Single value, defaults to "recently-updated". */
|
|
92
|
+
sort?: AppsSort;
|
|
93
|
+
/** 1-based page number. */
|
|
94
|
+
page?: number;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/** Available sort orders, in UI order. */
|
|
98
|
+
export const SORT_OPTIONS = [
|
|
99
|
+
{ value: "recently-updated", label: "Recently updated" },
|
|
100
|
+
{ value: "most-starred", label: "Most starred" },
|
|
101
|
+
{ value: "recently-added", label: "Recently added" },
|
|
102
|
+
{ value: "best-overall", label: "Best overall" },
|
|
103
|
+
{ value: "alphabetical", label: "Alphabetical" },
|
|
104
|
+
] as const;
|
|
105
|
+
|
|
106
|
+
export type AppsSort = (typeof SORT_OPTIONS)[number]["value"];
|
|
107
|
+
|
|
108
|
+
const DEFAULT_SORT: AppsSort = "recently-updated";
|
|
109
|
+
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;
|
|
116
|
+
|
|
117
|
+
const KEYS = {
|
|
118
|
+
q: "q",
|
|
119
|
+
stacks: "stack",
|
|
120
|
+
platforms: "platform",
|
|
121
|
+
categories: "category",
|
|
122
|
+
labels: "label",
|
|
123
|
+
licenses: "license",
|
|
124
|
+
statuses: "status",
|
|
125
|
+
lens: "lens",
|
|
126
|
+
sort: "sort",
|
|
127
|
+
page: "page",
|
|
128
|
+
} as const;
|
|
129
|
+
|
|
130
|
+
// ── Search param round-tripping ──────────────────────────────────
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Read filters from a URLSearchParams (or anything URLSearchParams-
|
|
134
|
+
* shaped, e.g. the result of `new URL(req.url).searchParams`).
|
|
135
|
+
*/
|
|
136
|
+
export function filtersFromSearchParams(
|
|
137
|
+
sp: URLSearchParams,
|
|
138
|
+
): AppsFilters {
|
|
139
|
+
const rawSort = sp.get(KEYS.sort);
|
|
140
|
+
// Backwards-compat: old "most-mature" sort now maps to "best-overall".
|
|
141
|
+
const normalizedSort = rawSort === "most-mature" ? "best-overall" : rawSort;
|
|
142
|
+
const sort: AppsSort | undefined =
|
|
143
|
+
normalizedSort && SORT_OPTIONS.some((o) => o.value === normalizedSort)
|
|
144
|
+
? (normalizedSort as AppsSort)
|
|
145
|
+
: undefined;
|
|
146
|
+
const rawPage = Number(sp.get(KEYS.page));
|
|
147
|
+
const page =
|
|
148
|
+
Number.isFinite(rawPage) && rawPage >= 1 ? Math.floor(rawPage) : undefined;
|
|
149
|
+
return {
|
|
150
|
+
q: sp.get(KEYS.q) ?? undefined,
|
|
151
|
+
stacks: sp.getAll(KEYS.stacks).filter(Boolean),
|
|
152
|
+
platforms: sp.getAll(KEYS.platforms).filter(Boolean),
|
|
153
|
+
categories: sp.getAll(KEYS.categories).filter(Boolean),
|
|
154
|
+
labels: sp.getAll(KEYS.labels).filter(Boolean),
|
|
155
|
+
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,
|
|
161
|
+
sort,
|
|
162
|
+
page,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
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.
|
|
170
|
+
*/
|
|
171
|
+
export function searchParamsFromFilters(f: AppsFilters): URLSearchParams {
|
|
172
|
+
const sp = new URLSearchParams();
|
|
173
|
+
if (f.q) sp.set(KEYS.q, f.q);
|
|
174
|
+
for (const v of f.stacks ?? []) sp.append(KEYS.stacks, v);
|
|
175
|
+
for (const v of f.platforms ?? []) sp.append(KEYS.platforms, v);
|
|
176
|
+
for (const v of f.categories ?? []) sp.append(KEYS.categories, v);
|
|
177
|
+
for (const v of f.labels ?? []) sp.append(KEYS.labels, v);
|
|
178
|
+
for (const v of f.licenses ?? []) sp.append(KEYS.licenses, v);
|
|
179
|
+
if (f.statuses?.length) sp.set(KEYS.statuses, f.statuses.join(","));
|
|
180
|
+
if (f.lens) sp.set(KEYS.lens, f.lens);
|
|
181
|
+
if (f.sort && f.sort !== DEFAULT_SORT) sp.set(KEYS.sort, f.sort);
|
|
182
|
+
if (f.page && f.page !== DEFAULT_PAGE) sp.set(KEYS.page, String(f.page));
|
|
183
|
+
return sp;
|
|
184
|
+
}
|
|
185
|
+
|
|
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 {
|
|
193
|
+
if (f.q && f.q.trim()) return true;
|
|
194
|
+
if (f.lens) return true;
|
|
195
|
+
return Boolean(
|
|
196
|
+
(f.stacks?.length ?? 0) +
|
|
197
|
+
(f.platforms?.length ?? 0) +
|
|
198
|
+
(f.categories?.length ?? 0) +
|
|
199
|
+
(f.labels?.length ?? 0) +
|
|
200
|
+
(f.licenses?.length ?? 0) +
|
|
201
|
+
(f.statuses?.length ?? 0),
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
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.
|
|
212
|
+
*/
|
|
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
|
+
}
|
|
275
|
+
|
|
276
|
+
return true;
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ── Lenses, sort, pagination ─────────────────────────────────────
|
|
281
|
+
|
|
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));
|
|
294
|
+
}
|
|
295
|
+
|
|
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();
|
|
306
|
+
const ts = (s?: string | null): number => (s ? new Date(s).valueOf() : 0);
|
|
307
|
+
switch (sort) {
|
|
308
|
+
case "most-starred":
|
|
309
|
+
arr.sort((a, b) => (b.github?.stars ?? 0) - (a.github?.stars ?? 0));
|
|
310
|
+
break;
|
|
311
|
+
case "recently-updated":
|
|
312
|
+
arr.sort((a, b) => ts(b.github?.pushedAt) - ts(a.github?.pushedAt));
|
|
313
|
+
break;
|
|
314
|
+
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));
|
|
319
|
+
break;
|
|
320
|
+
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);
|
|
330
|
+
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);
|
|
342
|
+
});
|
|
343
|
+
break;
|
|
344
|
+
}
|
|
345
|
+
case "alphabetical":
|
|
346
|
+
arr.sort((a, b) => a.name.localeCompare(b.name));
|
|
347
|
+
break;
|
|
348
|
+
}
|
|
349
|
+
return arr;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** Slice an array for a given page (1-based). */
|
|
353
|
+
export function paginate<T>(items: T[], page: number, pageSize = PAGE_SIZE): T[] {
|
|
354
|
+
const start = (page - 1) * pageSize;
|
|
355
|
+
return items.slice(start, start + pageSize);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** Total number of pages given an item count. Always >= 1. */
|
|
359
|
+
export function totalPages(itemCount: number, pageSize = PAGE_SIZE): number {
|
|
360
|
+
return Math.max(1, Math.ceil(itemCount / pageSize));
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** Resolve the effective sort from a filters object, applying default. */
|
|
364
|
+
export function effectiveSort(f: AppsFilters): AppsSort {
|
|
365
|
+
return f.sort ?? DEFAULT_SORT;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** Resolve the effective page from a filters object, applying default. */
|
|
369
|
+
export function effectivePage(f: AppsFilters): number {
|
|
370
|
+
return f.page ?? DEFAULT_PAGE;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// ── Facets ───────────────────────────────────────────────────────
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Return a flat list of active-filter chips for display + removal.
|
|
377
|
+
* Used by the "x" button on each chip in the UI.
|
|
378
|
+
*/
|
|
379
|
+
export function activeFilterChips(
|
|
380
|
+
f: AppsFilters,
|
|
381
|
+
): { key: keyof AppsFilters; value: string; label: string }[] {
|
|
382
|
+
const out: { key: keyof AppsFilters; value: string; label: string }[] = [];
|
|
383
|
+
|
|
384
|
+
if (f.q && f.q.trim()) {
|
|
385
|
+
out.push({ key: "q", value: "", label: `“${f.q}”` });
|
|
386
|
+
}
|
|
387
|
+
for (const v of f.stacks ?? []) {
|
|
388
|
+
out.push({ key: "stacks", value: v, label: `Stack: ${v}` });
|
|
389
|
+
}
|
|
390
|
+
for (const v of f.platforms ?? []) {
|
|
391
|
+
out.push({ key: "platforms", value: v, label: `Platform: ${v}` });
|
|
392
|
+
}
|
|
393
|
+
for (const v of f.categories ?? []) {
|
|
394
|
+
out.push({ key: "categories", value: v, label: `Category: ${v}` });
|
|
395
|
+
}
|
|
396
|
+
for (const v of f.labels ?? []) {
|
|
397
|
+
out.push({ key: "labels", value: v, label: labelDisplay(v) ?? v });
|
|
398
|
+
}
|
|
399
|
+
for (const v of f.licenses ?? []) {
|
|
400
|
+
out.push({ key: "licenses", value: v, label: `License: ${v}` });
|
|
401
|
+
}
|
|
402
|
+
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 });
|
|
414
|
+
}
|
|
415
|
+
if (f.statuses && f.statuses.length) {
|
|
416
|
+
for (const v of f.statuses) {
|
|
417
|
+
out.push({ key: "statuses", value: v, label: statusDisplay(v) });
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
return out;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Build the full list of facet values from the record array,
|
|
426
|
+
* preserving a sensible order (frequency desc, then alphabetical
|
|
427
|
+
* for ties).
|
|
428
|
+
*/
|
|
429
|
+
export function buildFacets<T extends Searchable>(records: T[]) {
|
|
430
|
+
const counts = {
|
|
431
|
+
stack: new Map<string, number>(),
|
|
432
|
+
platform: new Map<string, number>(),
|
|
433
|
+
category: new Map<string, number>(),
|
|
434
|
+
label: new Map<string, number>(),
|
|
435
|
+
license: new Map<string, number>(),
|
|
436
|
+
};
|
|
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,
|
|
453
|
+
);
|
|
454
|
+
}
|
|
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
|
+
}
|
|
462
|
+
}
|
|
463
|
+
const sortByCountThenName = (m: Map<string, number>) =>
|
|
464
|
+
[...m.entries()]
|
|
465
|
+
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
|
466
|
+
.map(([value, count]) => ({ value, count }));
|
|
467
|
+
return {
|
|
468
|
+
stacks: sortByCountThenName(counts.stack),
|
|
469
|
+
platforms: sortByCountThenName(counts.platform),
|
|
470
|
+
categories: sortByCountThenName(counts.category),
|
|
471
|
+
labels: sortByCountThenName(counts.label),
|
|
472
|
+
licenses: sortByCountThenName(counts.license),
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
|
|
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 };
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build-time helpers that compute taxonomy counts from an array of
|
|
3
|
+
* records. Generic over any object that carries the index-payload
|
|
4
|
+
* fields the UI cares about (`category`, `stack` / `stacks`,
|
|
5
|
+
* `curation.labels`). No filesystem / data import — consumers pass
|
|
6
|
+
* the records explicitly so this stays testable.
|
|
7
|
+
*
|
|
8
|
+
* All functions are synchronous and cheap — they're called from
|
|
9
|
+
* Astro page frontmatter at build time, never on the client.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export interface Taxonomable {
|
|
13
|
+
category?: string;
|
|
14
|
+
stack?: string;
|
|
15
|
+
stacks?: string[];
|
|
16
|
+
curation?: { labels?: string[] };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Number of records per category name. */
|
|
20
|
+
export function countByCategory<T extends Taxonomable>(
|
|
21
|
+
records: T[],
|
|
22
|
+
): Map<string, number> {
|
|
23
|
+
const m = new Map<string, number>();
|
|
24
|
+
for (const r of records) {
|
|
25
|
+
const k = r.category || "Other";
|
|
26
|
+
m.set(k, (m.get(k) ?? 0) + 1);
|
|
27
|
+
}
|
|
28
|
+
return m;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Number of records per primary stack (combines `stack` and `stacks`). */
|
|
32
|
+
export function countByStack<T extends Taxonomable>(
|
|
33
|
+
records: T[],
|
|
34
|
+
): Map<string, number> {
|
|
35
|
+
const m = new Map<string, number>();
|
|
36
|
+
for (const r of records) {
|
|
37
|
+
const keys = new Set(
|
|
38
|
+
[r.stack, ...(r.stacks ?? [])].filter(
|
|
39
|
+
(s): s is string => Boolean(s),
|
|
40
|
+
),
|
|
41
|
+
);
|
|
42
|
+
for (const k of keys) {
|
|
43
|
+
m.set(k, (m.get(k) ?? 0) + 1);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return m;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Number of records per curated label (curation.labels). */
|
|
50
|
+
export function countByLabel<T extends Taxonomable>(
|
|
51
|
+
records: T[],
|
|
52
|
+
): Map<string, number> {
|
|
53
|
+
const m = new Map<string, number>();
|
|
54
|
+
for (const r of records) {
|
|
55
|
+
for (const l of r.curation?.labels ?? []) {
|
|
56
|
+
m.set(l, (m.get(l) ?? 0) + 1);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return m;
|
|
60
|
+
}
|