@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.
- package/README.md +9 -75
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/display.d.ts +1 -26
- package/dist/lib/display.d.ts.map +1 -1
- package/dist/lib/display.js +1 -103
- package/dist/lib/display.js.map +1 -1
- package/dist/lib/format.d.ts +1 -28
- package/dist/lib/format.d.ts.map +1 -1
- package/dist/lib/format.js +1 -71
- package/dist/lib/format.js.map +1 -1
- package/dist/lib/lenses.d.ts +1 -57
- package/dist/lib/lenses.d.ts.map +1 -1
- package/dist/lib/lenses.js +1 -198
- package/dist/lib/lenses.js.map +1 -1
- package/dist/lib/repo.d.ts +1 -19
- package/dist/lib/repo.d.ts.map +1 -1
- package/dist/lib/repo.js +1 -30
- package/dist/lib/repo.js.map +1 -1
- package/dist/lib/scores.d.ts +1 -36
- package/dist/lib/scores.d.ts.map +1 -1
- package/dist/lib/scores.js +1 -75
- package/dist/lib/scores.js.map +1 -1
- package/dist/lib/search.d.ts +1 -113
- package/dist/lib/search.d.ts.map +1 -1
- package/dist/lib/search.js +1 -379
- package/dist/lib/search.js.map +1 -1
- package/dist/lib/taxonomy-counts.d.ts +1 -10
- package/dist/lib/taxonomy-counts.d.ts.map +1 -1
- package/dist/lib/taxonomy-counts.js +1 -21
- package/dist/lib/taxonomy-counts.js.map +1 -1
- package/package.json +23 -11
- package/src/components/ContributorsGrid.astro +3 -3
- package/src/components/CurationGrid.astro +2 -2
- package/src/components/DirectoryIndexClient.astro +240 -0
- package/src/components/FilterGroupMenu.astro +8 -4
- package/src/components/FinalCta.astro +41 -0
- package/src/components/Hero.astro +15 -18
- package/src/components/Icon.astro +35 -7
- package/src/components/IndexRow.astro +2 -2
- package/src/components/ItemCard.astro +2 -7
- package/src/components/RefinePanel.astro +7 -7
- package/src/components/SmartLensTabs.astro +9 -5
- package/src/components/StackGrid.astro +1 -1
- package/src/components/SubmissionClient.astro +133 -0
- package/src/index.ts +11 -1
- package/src/layouts/BaseLayout.astro +30 -8
- package/src/layouts/Footer.astro +25 -56
- package/src/layouts/Header.astro +33 -15
- package/src/layouts/ThemeToggle.astro +7 -3
- package/src/lib/display.ts +1 -111
- package/src/lib/format.ts +1 -64
- package/src/lib/lenses.ts +1 -220
- package/src/lib/repo.ts +1 -32
- package/src/lib/scores.ts +1 -90
- package/src/lib/search.ts +1 -418
- package/src/lib/taxonomy-counts.ts +1 -26
- package/{templates/default/src/data/records.ts → src/server/directory.ts} +12 -8
- package/src/server/index.ts +2 -0
- package/src/server/models.ts +436 -0
- package/dist/template-routes.test.d.ts +0 -2
- package/dist/template-routes.test.d.ts.map +0 -1
- package/dist/template-routes.test.js +0 -63
- package/dist/template-routes.test.js.map +0 -1
- package/dist/theme.test.d.ts +0 -2
- package/dist/theme.test.d.ts.map +0 -1
- package/dist/theme.test.js +0 -60
- package/dist/theme.test.js.map +0 -1
- package/src/template-routes.test.ts +0 -103
- package/src/theme.test.ts +0 -76
- package/templates/default/.github/ISSUE_TEMPLATE/bug_report.md +0 -33
- package/templates/default/.github/ISSUE_TEMPLATE/feature_request.md +0 -20
- package/templates/default/.github/ISSUE_TEMPLATE/record_submission.md +0 -48
- package/templates/default/.github/pull_request_template.md +0 -27
- package/templates/default/.github/workflows/build.yml +0 -53
- package/templates/default/.github/workflows/cleanup-stale-records.yml +0 -46
- package/templates/default/.github/workflows/daily-refresh.yml +0 -51
- package/templates/default/.github/workflows/sync-contributors.yml +0 -51
- package/templates/default/.github/workflows/sync-github-metadata.yml +0 -56
- package/templates/default/.github/workflows/validate-data.yml +0 -33
- package/templates/default/astro.config.mjs +0 -25
- package/templates/default/content/pages/about.md +0 -66
- package/templates/default/content/pages/methodology.md +0 -103
- package/templates/default/data/records/.gitkeep +0 -0
- package/templates/default/data/records/coolify.yml +0 -46
- package/templates/default/data/records/gitea.yml +0 -46
- package/templates/default/data/records/hoppscotch.yml +0 -47
- package/templates/default/data/records/inventree.yml +0 -47
- package/templates/default/data/records/label-studio.yml +0 -46
- package/templates/default/data/records/logseq.yml +0 -48
- package/templates/default/data/records/private-gpt.yml +0 -47
- package/templates/default/data/records/wakapi.yml +0 -44
- package/templates/default/data/taxonomy/categories.yml +0 -47
- package/templates/default/data/taxonomy/distribution-channels.yml +0 -43
- package/templates/default/data/taxonomy/platforms.yml +0 -27
- package/templates/default/data/taxonomy/stacks.yml +0 -41
- package/templates/default/grove.config.ts +0 -36
- package/templates/default/package.json +0 -43
- package/templates/default/public/icons/platforms/android.svg +0 -1
- package/templates/default/public/icons/platforms/apple-dark.svg +0 -1
- package/templates/default/public/icons/platforms/apple-light.svg +0 -1
- package/templates/default/public/icons/platforms/chrome.svg +0 -1
- package/templates/default/public/icons/platforms/chromeos.svg +0 -5
- package/templates/default/public/icons/platforms/desktop.svg +0 -4
- package/templates/default/public/icons/platforms/embedded.svg +0 -5
- package/templates/default/public/icons/platforms/ios.svg +0 -1
- package/templates/default/public/icons/platforms/linux.svg +0 -1
- package/templates/default/public/icons/platforms/macos.svg +0 -1
- package/templates/default/public/icons/platforms/ubuntu.svg +0 -1
- package/templates/default/public/icons/platforms/web.svg +0 -5
- package/templates/default/public/icons/platforms/windows.svg +0 -6
- package/templates/default/public/icons/stacks/android.svg +0 -1
- package/templates/default/public/icons/stacks/apple-dark.svg +0 -1
- package/templates/default/public/icons/stacks/apple-light.svg +0 -1
- package/templates/default/public/icons/stacks/bun.svg +0 -1
- package/templates/default/public/icons/stacks/capacitor.svg +0 -14
- package/templates/default/public/icons/stacks/clojure.svg +0 -1
- package/templates/default/public/icons/stacks/clojurescript.svg +0 -1
- package/templates/default/public/icons/stacks/dart.svg +0 -1
- package/templates/default/public/icons/stacks/deno.svg +0 -1
- package/templates/default/public/icons/stacks/django.svg +0 -1
- package/templates/default/public/icons/stacks/docker.svg +0 -1
- package/templates/default/public/icons/stacks/firebase.svg +0 -1
- package/templates/default/public/icons/stacks/flutter.svg +0 -1
- package/templates/default/public/icons/stacks/go.svg +0 -1
- package/templates/default/public/icons/stacks/graphql.svg +0 -1
- package/templates/default/public/icons/stacks/ionic.svg +0 -4
- package/templates/default/public/icons/stacks/java.svg +0 -1
- package/templates/default/public/icons/stacks/javascript.svg +0 -1
- package/templates/default/public/icons/stacks/kotlin.svg +0 -1
- package/templates/default/public/icons/stacks/llm.svg +0 -1
- package/templates/default/public/icons/stacks/mongodb.svg +0 -1
- package/templates/default/public/icons/stacks/node.js.svg +0 -1
- package/templates/default/public/icons/stacks/nodejs.svg +0 -1
- package/templates/default/public/icons/stacks/python.svg +0 -1
- package/templates/default/public/icons/stacks/rag.svg +0 -7
- package/templates/default/public/icons/stacks/react-native.svg +0 -1
- package/templates/default/public/icons/stacks/react.svg +0 -1
- package/templates/default/public/icons/stacks/rust.svg +0 -1
- package/templates/default/public/icons/stacks/solidity.svg +0 -1
- package/templates/default/public/icons/stacks/sveltekit.svg +0 -1
- package/templates/default/public/icons/stacks/swift.svg +0 -1
- package/templates/default/public/icons/stacks/tensorflow.svg +0 -1
- package/templates/default/public/icons/stacks/typescript.svg +0 -1
- package/templates/default/public/icons/stacks/vue.svg +0 -1
- package/templates/default/public/llms-full.txt +0 -83
- package/templates/default/public/llms.txt +0 -11
- package/templates/default/public/og-image.svg +0 -48
- package/templates/default/public/robots.txt +0 -53
- package/templates/default/public/sitemap.xml +0 -63
- package/templates/default/src/pages/404.astro +0 -64
- package/templates/default/src/pages/[slug]/[recordSlug].astro +0 -691
- package/templates/default/src/pages/[slug]/index.astro +0 -482
- package/templates/default/src/pages/about.astro +0 -189
- package/templates/default/src/pages/apps/[recordSlug].astro +0 -34
- package/templates/default/src/pages/contributors.astro +0 -175
- package/templates/default/src/pages/index.astro +0 -233
- package/templates/default/src/pages/sitemap.xml.ts +0 -89
- package/templates/default/src/pages/submit.astro +0 -281
- package/templates/default/src/styles/global.css +0 -33
- package/templates/default/tsconfig.json +0 -9
- package/templates/default/wrangler.jsonc +0 -28
package/src/lib/search.ts
CHANGED
|
@@ -1,418 +1 @@
|
|
|
1
|
-
|
|
2
|
-
import { labelDisplay, lensDisplay, statusDisplay } from "./display.js";
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* URL-driven filter state for the /items discovery page.
|
|
6
|
-
*
|
|
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".
|
|
9
|
-
*/
|
|
10
|
-
export type IndexFilters = {
|
|
11
|
-
q?: string;
|
|
12
|
-
stacks?: string[];
|
|
13
|
-
platforms?: string[];
|
|
14
|
-
categories?: string[];
|
|
15
|
-
labels?: string[];
|
|
16
|
-
licenses?: string[];
|
|
17
|
-
statuses?: string[];
|
|
18
|
-
/** A curated lens (e.g. "good-to-learn", "production-like"). Single value. */
|
|
19
|
-
lens?: string;
|
|
20
|
-
/** Sort order. Single value, defaults to "recently-updated". */
|
|
21
|
-
sort?: IndexSort | undefined;
|
|
22
|
-
/** 1-based page number. */
|
|
23
|
-
page?: number | undefined;
|
|
24
|
-
};
|
|
25
|
-
|
|
26
|
-
/** Available sort orders, in UI order. */
|
|
27
|
-
export const SORT_OPTIONS = [
|
|
28
|
-
{ value: "recently-updated", label: "Recently updated" },
|
|
29
|
-
{ value: "most-starred", label: "Most starred" },
|
|
30
|
-
{ value: "recently-added", label: "Recently added" },
|
|
31
|
-
{ value: "best-overall", label: "Best overall" },
|
|
32
|
-
{ value: "alphabetical", label: "Alphabetical" },
|
|
33
|
-
] as const;
|
|
34
|
-
|
|
35
|
-
export type IndexSort = (typeof SORT_OPTIONS)[number]["value"];
|
|
36
|
-
|
|
37
|
-
const DEFAULT_SORT: IndexSort = "recently-updated";
|
|
38
|
-
const DEFAULT_PAGE = 1;
|
|
39
|
-
const DEFAULT_PAGE_SIZE = 20;
|
|
40
|
-
|
|
41
|
-
const KEYS = {
|
|
42
|
-
q: "q",
|
|
43
|
-
stacks: "stack",
|
|
44
|
-
platforms: "platform",
|
|
45
|
-
categories: "category",
|
|
46
|
-
labels: "label",
|
|
47
|
-
licenses: "license",
|
|
48
|
-
statuses: "status",
|
|
49
|
-
lens: "lens",
|
|
50
|
-
sort: "sort",
|
|
51
|
-
page: "page",
|
|
52
|
-
} as const;
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* Read filters from a URLSearchParams (or anything URLSearchParams-shaped,
|
|
56
|
-
* e.g. the result of `new URL(req.url).searchParams`).
|
|
57
|
-
*/
|
|
58
|
-
export function filtersFromSearchParams(sp: URLSearchParams): IndexFilters {
|
|
59
|
-
const rawSort = sp.get(KEYS.sort);
|
|
60
|
-
// Backwards-compat: old "most-mature" sort now maps to "best-overall".
|
|
61
|
-
const normalizedSort = rawSort === "most-mature" ? "best-overall" : rawSort;
|
|
62
|
-
const sort: IndexSort | undefined =
|
|
63
|
-
normalizedSort && (SORT_OPTIONS as readonly { value: string }[]).some((o) => o.value === normalizedSort)
|
|
64
|
-
? (normalizedSort as IndexSort)
|
|
65
|
-
: undefined;
|
|
66
|
-
const rawPage = Number(sp.get(KEYS.page));
|
|
67
|
-
const page = Number.isFinite(rawPage) && rawPage >= 1 ? Math.floor(rawPage) : undefined;
|
|
68
|
-
const q = sp.get(KEYS.q);
|
|
69
|
-
const lens = sp.get(KEYS.lens);
|
|
70
|
-
return {
|
|
71
|
-
...(q !== null ? { q } : {}),
|
|
72
|
-
stacks: sp.getAll(KEYS.stacks).filter(Boolean),
|
|
73
|
-
platforms: sp.getAll(KEYS.platforms).filter(Boolean),
|
|
74
|
-
categories: sp.getAll(KEYS.categories).filter(Boolean),
|
|
75
|
-
labels: sp.getAll(KEYS.labels).filter(Boolean),
|
|
76
|
-
licenses: sp.getAll(KEYS.licenses).filter(Boolean),
|
|
77
|
-
statuses: sp.getAll(KEYS.statuses).flatMap((s) => s.split(",")).filter(Boolean),
|
|
78
|
-
...(lens !== null ? { lens } : {}),
|
|
79
|
-
...(sort !== undefined ? { sort } : {}),
|
|
80
|
-
...(page !== undefined ? { page } : {}),
|
|
81
|
-
};
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* Serialize a filters object back to URLSearchParams.
|
|
86
|
-
* Undefined / empty values are dropped. Defaults (sort, page 1, comfortable
|
|
87
|
-
* density) are also dropped to keep URLs short and canonical.
|
|
88
|
-
*/
|
|
89
|
-
export function searchParamsFromFilters(f: IndexFilters): URLSearchParams {
|
|
90
|
-
const sp = new URLSearchParams();
|
|
91
|
-
if (f.q) sp.set(KEYS.q, f.q);
|
|
92
|
-
for (const v of f.stacks ?? []) sp.append(KEYS.stacks, v);
|
|
93
|
-
for (const v of f.platforms ?? []) sp.append(KEYS.platforms, v);
|
|
94
|
-
for (const v of f.categories ?? []) sp.append(KEYS.categories, v);
|
|
95
|
-
for (const v of f.labels ?? []) sp.append(KEYS.labels, v);
|
|
96
|
-
for (const v of f.licenses ?? []) sp.append(KEYS.licenses, v);
|
|
97
|
-
if (f.statuses?.length) sp.set(KEYS.statuses, f.statuses.join(","));
|
|
98
|
-
if (f.lens) sp.set(KEYS.lens, f.lens);
|
|
99
|
-
if (f.sort && f.sort !== DEFAULT_SORT) sp.set(KEYS.sort, f.sort);
|
|
100
|
-
if (f.page && f.page !== DEFAULT_PAGE) sp.set(KEYS.page, String(f.page));
|
|
101
|
-
return sp;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
/** True if any filter is active (i.e. the result list isn't "all items"). */
|
|
105
|
-
export function hasAnyFilter(f: IndexFilters): boolean {
|
|
106
|
-
if (f.q && f.q.trim()) return true;
|
|
107
|
-
if (f.lens) return true;
|
|
108
|
-
return Boolean(
|
|
109
|
-
(f.stacks?.length ?? 0) +
|
|
110
|
-
(f.platforms?.length ?? 0) +
|
|
111
|
-
(f.categories?.length ?? 0) +
|
|
112
|
-
(f.labels?.length ?? 0) +
|
|
113
|
-
(f.licenses?.length ?? 0) +
|
|
114
|
-
(f.statuses?.length ?? 0),
|
|
115
|
-
);
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/** How many items per page. Constant for now; future versions may vary. */
|
|
119
|
-
export const PAGE_SIZE = DEFAULT_PAGE_SIZE;
|
|
120
|
-
|
|
121
|
-
/**
|
|
122
|
-
* Sort items in-place-free: returns a new array. Stable for ties.
|
|
123
|
-
* Apps missing the sort key (e.g. no stars) sink to the bottom rather
|
|
124
|
-
* than vanishing.
|
|
125
|
-
*/
|
|
126
|
-
function recordName(record: IndexRecord): string {
|
|
127
|
-
return record.kind === "resource" ? record.title : record.name;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
function projectStars(record: IndexRecord): number {
|
|
131
|
-
return record.kind === "project" ? record.github?.stars ?? 0 : 0;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
function recordUpdatedAt(record: IndexRecord): string | null {
|
|
135
|
-
if (record.kind === "project") return record.github?.pushedAt ?? null;
|
|
136
|
-
if (record.kind === "resource") return record.publishedAt ?? null;
|
|
137
|
-
return null;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
function recordAddedAt(record: IndexRecord): string | null {
|
|
141
|
-
return record.curation?.reviewedAt ?? null;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
export function applySort(items: IndexRecord[], sort: IndexSort): IndexRecord[] {
|
|
145
|
-
const arr = items.slice();
|
|
146
|
-
const ts = (s?: string | null): number => (s ? new Date(s).valueOf() : 0);
|
|
147
|
-
switch (sort) {
|
|
148
|
-
case "most-starred":
|
|
149
|
-
arr.sort((a, b) => projectStars(b) - projectStars(a));
|
|
150
|
-
break;
|
|
151
|
-
case "recently-updated":
|
|
152
|
-
arr.sort((a, b) => ts(recordUpdatedAt(b)) - ts(recordUpdatedAt(a)));
|
|
153
|
-
break;
|
|
154
|
-
case "recently-added":
|
|
155
|
-
arr.sort((a, b) => ts(recordAddedAt(b)) - ts(recordAddedAt(a)));
|
|
156
|
-
break;
|
|
157
|
-
case "best-overall": {
|
|
158
|
-
const reviewed = (a: IndexRecord) => (a.curation?.reviewed ? 1 : 0);
|
|
159
|
-
const visible = (a: IndexRecord) => (a.visibility === "keep" ? 1 : 0);
|
|
160
|
-
arr.sort((a, b) => {
|
|
161
|
-
const reviewedDelta = reviewed(b) - reviewed(a);
|
|
162
|
-
if (reviewedDelta !== 0) return reviewedDelta;
|
|
163
|
-
const visibleDelta = visible(b) - visible(a);
|
|
164
|
-
if (visibleDelta !== 0) return visibleDelta;
|
|
165
|
-
return projectStars(b) - projectStars(a);
|
|
166
|
-
});
|
|
167
|
-
break;
|
|
168
|
-
}
|
|
169
|
-
case "alphabetical":
|
|
170
|
-
arr.sort((a, b) => recordName(a).localeCompare(recordName(b)));
|
|
171
|
-
break;
|
|
172
|
-
}
|
|
173
|
-
return arr;
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
/** Slice an array for a given page (1-based). */
|
|
177
|
-
export function paginate<T>(items: T[], page: number, pageSize = PAGE_SIZE): T[] {
|
|
178
|
-
const start = (page - 1) * pageSize;
|
|
179
|
-
return items.slice(start, start + pageSize);
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
/** Total number of pages given an item count. Always >= 1. */
|
|
183
|
-
export function totalPages(itemCount: number, pageSize = PAGE_SIZE): number {
|
|
184
|
-
return Math.max(1, Math.ceil(itemCount / pageSize));
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
/** Resolve the effective sort from a filters object, applying default. */
|
|
188
|
-
export function effectiveSort(f: IndexFilters): IndexSort {
|
|
189
|
-
return f.sort ?? DEFAULT_SORT;
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
/** Resolve the effective page from a filters object, applying default. */
|
|
193
|
-
export function effectivePage(f: IndexFilters): number {
|
|
194
|
-
return f.page ?? DEFAULT_PAGE;
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
/**
|
|
198
|
-
* Return the subset of items that match all active filters (AND across
|
|
199
|
-
* dimensions, OR within a dimension).
|
|
200
|
-
*
|
|
201
|
-
* `q` is a case-insensitive substring search across name, owner,
|
|
202
|
-
* description, and category. Other dimensions are exact match.
|
|
203
|
-
*/
|
|
204
|
-
export function filterRecords(items: IndexRecord[], f: IndexFilters): IndexRecord[] {
|
|
205
|
-
const q = f.q?.trim().toLowerCase();
|
|
206
|
-
const stacks = f.stacks?.length ? new Set(f.stacks) : null;
|
|
207
|
-
const platforms = f.platforms?.length ? new Set(f.platforms) : null;
|
|
208
|
-
const categories = f.categories?.length ? new Set(f.categories) : null;
|
|
209
|
-
const labels = f.labels?.length ? new Set(f.labels) : null;
|
|
210
|
-
const licenses = f.licenses?.length ? new Set(f.licenses) : null;
|
|
211
|
-
const statuses = f.statuses?.length ? new Set(f.statuses) : null;
|
|
212
|
-
|
|
213
|
-
return items.filter((a) => {
|
|
214
|
-
// Text search — match name, owner, description, category, stack,
|
|
215
|
-
// stacks, platforms, tags, backend, architecture, stateManagement,
|
|
216
|
-
// bestFor, whyListed, license, status.
|
|
217
|
-
if (q) {
|
|
218
|
-
const repoUrl = a.kind === "project" ? a.repoUrl : a.links.github ?? "";
|
|
219
|
-
const ownerMatch =
|
|
220
|
-
/github\.com\/([^/]+)\//.exec(repoUrl ?? "")?.[1]?.toLowerCase() ?? "";
|
|
221
|
-
const projectFields =
|
|
222
|
-
a.kind === "project"
|
|
223
|
-
? [
|
|
224
|
-
a.stack,
|
|
225
|
-
...a.stacks,
|
|
226
|
-
...a.platforms,
|
|
227
|
-
a.projectType,
|
|
228
|
-
a.difficulty ?? "",
|
|
229
|
-
a.codebaseSize ?? "",
|
|
230
|
-
...a.bestFor,
|
|
231
|
-
...a.whyListed,
|
|
232
|
-
a.github?.license ?? "",
|
|
233
|
-
a.health?.status ? statusDisplay(a.health.status) : "",
|
|
234
|
-
]
|
|
235
|
-
: [];
|
|
236
|
-
const resourceFields =
|
|
237
|
-
a.kind === "resource"
|
|
238
|
-
? [a.type, a.topic, a.author ?? ""]
|
|
239
|
-
: [];
|
|
240
|
-
const entityFields =
|
|
241
|
-
a.kind === "entity"
|
|
242
|
-
? [a.type, a.location ?? "", a.parent ?? ""]
|
|
243
|
-
: [];
|
|
244
|
-
const haystack = [
|
|
245
|
-
recordName(a),
|
|
246
|
-
ownerMatch,
|
|
247
|
-
a.description,
|
|
248
|
-
a.category,
|
|
249
|
-
...a.tags,
|
|
250
|
-
...projectFields,
|
|
251
|
-
...resourceFields,
|
|
252
|
-
...entityFields,
|
|
253
|
-
]
|
|
254
|
-
.join(" ")
|
|
255
|
-
.toLowerCase();
|
|
256
|
-
if (!haystack.includes(q)) return false;
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
if (stacks) {
|
|
260
|
-
if (a.kind !== "project") return false;
|
|
261
|
-
const allStacks = [a.stack, ...a.stacks].filter(
|
|
262
|
-
(stack): stack is string => Boolean(stack),
|
|
263
|
-
);
|
|
264
|
-
if (!allStacks.some((s) => stacks.has(s))) return false;
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
if (platforms) {
|
|
268
|
-
if (a.kind !== "project") return false;
|
|
269
|
-
if (!a.platforms.some((p) => platforms.has(p))) return false;
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
if (categories) {
|
|
273
|
-
if (!categories.has(a.category)) return false;
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
if (labels) {
|
|
277
|
-
if (!a.curation?.labels?.some((l) => labels.has(l))) return false;
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
if (licenses) {
|
|
281
|
-
if (a.kind !== "project") return false;
|
|
282
|
-
const license = a.github?.license;
|
|
283
|
-
if (!license || !licenses.has(license)) return false;
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
if (statuses) {
|
|
287
|
-
if (a.kind !== "project") return false;
|
|
288
|
-
const status = a.health?.status;
|
|
289
|
-
if (!status || !statuses.has(status)) return false;
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
if (f.lens) {
|
|
293
|
-
if (!a.curation?.lenses?.includes(f.lens)) return false;
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
return true;
|
|
297
|
-
});
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
/** Build a flat list of "active filter" chips for display + removal. */
|
|
301
|
-
export function activeFilterChips(
|
|
302
|
-
f: IndexFilters,
|
|
303
|
-
): { key: keyof IndexFilters; value: string; label: string }[] {
|
|
304
|
-
const out: { key: keyof IndexFilters; value: string; label: string }[] = [];
|
|
305
|
-
|
|
306
|
-
if (f.q && f.q.trim()) {
|
|
307
|
-
out.push({ key: "q", value: "", label: `“${f.q}”` });
|
|
308
|
-
}
|
|
309
|
-
for (const v of f.stacks ?? []) {
|
|
310
|
-
out.push({ key: "stacks", value: v, label: `Stack: ${v}` });
|
|
311
|
-
}
|
|
312
|
-
for (const v of f.platforms ?? []) {
|
|
313
|
-
out.push({ key: "platforms", value: v, label: `Platform: ${v}` });
|
|
314
|
-
}
|
|
315
|
-
for (const v of f.categories ?? []) {
|
|
316
|
-
out.push({ key: "categories", value: v, label: `Category: ${v}` });
|
|
317
|
-
}
|
|
318
|
-
for (const v of f.labels ?? []) {
|
|
319
|
-
out.push({ key: "labels", value: v, label: labelDisplay(v) ?? v });
|
|
320
|
-
}
|
|
321
|
-
for (const v of f.licenses ?? []) {
|
|
322
|
-
out.push({ key: "licenses", value: v, label: `License: ${v}` });
|
|
323
|
-
}
|
|
324
|
-
if (f.lens) {
|
|
325
|
-
out.push({ key: "lens", value: f.lens, label: lensDisplay(f.lens) });
|
|
326
|
-
}
|
|
327
|
-
// Status: collapse the "stale,quiet" composite into a single chip.
|
|
328
|
-
if (f.statuses && f.statuses.length) {
|
|
329
|
-
const joined = f.statuses.join(",");
|
|
330
|
-
if (joined === "stale,quiet") {
|
|
331
|
-
out.push({ key: "statuses", value: "stale,quiet", label: statusDisplay("needs-maintainer") });
|
|
332
|
-
} else {
|
|
333
|
-
for (const v of f.statuses) {
|
|
334
|
-
out.push({ key: "statuses", value: v, label: statusDisplay(v) });
|
|
335
|
-
}
|
|
336
|
-
}
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
return out;
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
/**
|
|
343
|
-
* Return a new filters object with one (key, value) removed.
|
|
344
|
-
* - key="q" → clears the search string
|
|
345
|
-
* - multi-value keys → removes the single matching value
|
|
346
|
-
* Used by the "x" button on each active filter chip.
|
|
347
|
-
* Also resets page to 1 so the user doesn't get stranded on a high page
|
|
348
|
-
* that no longer has results.
|
|
349
|
-
*/
|
|
350
|
-
export function removeFilter(
|
|
351
|
-
f: IndexFilters,
|
|
352
|
-
key: keyof IndexFilters,
|
|
353
|
-
value: string,
|
|
354
|
-
): IndexFilters {
|
|
355
|
-
let next: IndexFilters;
|
|
356
|
-
if (key === "q") {
|
|
357
|
-
// `q` is `string | undefined` under exactOptionalPropertyTypes; the
|
|
358
|
-
// cleanest way to drop it is to omit the key entirely (delete-style).
|
|
359
|
-
const { q: _drop, ...rest } = f;
|
|
360
|
-
next = rest;
|
|
361
|
-
} else {
|
|
362
|
-
const current = f[key] as string[] | undefined;
|
|
363
|
-
if (!current) return f;
|
|
364
|
-
const filtered = current.filter((v) => v !== value);
|
|
365
|
-
if (filtered.length === 0) {
|
|
366
|
-
const { [key]: _drop, ...rest } = f;
|
|
367
|
-
next = rest;
|
|
368
|
-
} else {
|
|
369
|
-
next = { ...f, [key]: filtered };
|
|
370
|
-
}
|
|
371
|
-
}
|
|
372
|
-
next.page = 1;
|
|
373
|
-
return next;
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
/**
|
|
377
|
-
* Build the full list of facet values from the items array, preserving
|
|
378
|
-
* a sensible order (frequency desc, then alphabetical for ties).
|
|
379
|
-
*/
|
|
380
|
-
export function buildFacets(items: IndexRecord[]) {
|
|
381
|
-
const counts = {
|
|
382
|
-
stack: new Map<string, number>(),
|
|
383
|
-
platform: new Map<string, number>(),
|
|
384
|
-
category: new Map<string, number>(),
|
|
385
|
-
label: new Map<string, number>(),
|
|
386
|
-
license: new Map<string, number>(),
|
|
387
|
-
};
|
|
388
|
-
for (const a of items) {
|
|
389
|
-
if (a.kind === "project") {
|
|
390
|
-
const allStacks = new Set(
|
|
391
|
-
[a.stack, ...a.stacks].filter((stack): stack is string => Boolean(stack)),
|
|
392
|
-
);
|
|
393
|
-
for (const s of allStacks) counts.stack.set(s, (counts.stack.get(s) ?? 0) + 1);
|
|
394
|
-
for (const p of a.platforms) counts.platform.set(p, (counts.platform.get(p) ?? 0) + 1);
|
|
395
|
-
const license = a.github?.license;
|
|
396
|
-
if (license) counts.license.set(license, (counts.license.get(license) ?? 0) + 1);
|
|
397
|
-
}
|
|
398
|
-
counts.category.set(a.category, (counts.category.get(a.category) ?? 0) + 1);
|
|
399
|
-
for (const l of a.curation?.labels ?? []) counts.label.set(l, (counts.label.get(l) ?? 0) + 1);
|
|
400
|
-
}
|
|
401
|
-
const sortByCountThenName = (m: Map<string, number>) =>
|
|
402
|
-
[...m.entries()]
|
|
403
|
-
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
|
404
|
-
.map(([value, count]) => ({ value, count }));
|
|
405
|
-
return {
|
|
406
|
-
stacks: sortByCountThenName(counts.stack),
|
|
407
|
-
platforms: sortByCountThenName(counts.platform),
|
|
408
|
-
categories: sortByCountThenName(counts.category),
|
|
409
|
-
labels: sortByCountThenName(counts.label),
|
|
410
|
-
licenses: sortByCountThenName(counts.license),
|
|
411
|
-
};
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
// ── V1-published API ─────────────────────────────────────────────────
|
|
415
|
-
// `IndexFilters` / `IndexSort` are the canonical V1 names for the
|
|
416
|
-
// filter-state type. The internal functions (filterRecords, applySort,
|
|
417
|
-
// paginate, buildFacets, etc.) operate on the generic `IndexRecord`
|
|
418
|
-
// shape and are intentionally framework-agnostic.
|
|
1
|
+
export * from "@grove-dev/core";
|
|
@@ -1,26 +1 @@
|
|
|
1
|
-
|
|
2
|
-
category?: string;
|
|
3
|
-
stack?: string;
|
|
4
|
-
stacks?: string[];
|
|
5
|
-
};
|
|
6
|
-
|
|
7
|
-
/** Number of items in the directory, per category name. */
|
|
8
|
-
export function countByCategory(items: TaxonomyRecord[] = []): Map<string, number> {
|
|
9
|
-
const m = new Map<string, number>();
|
|
10
|
-
for (const a of items) {
|
|
11
|
-
const k = a.category || "Other";
|
|
12
|
-
m.set(k, (m.get(k) ?? 0) + 1);
|
|
13
|
-
}
|
|
14
|
-
return m;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
/** Number of items in the directory, per primary stack. */
|
|
18
|
-
export function countByStack(items: TaxonomyRecord[] = []): Map<string, number> {
|
|
19
|
-
const m = new Map<string, number>();
|
|
20
|
-
for (const a of items) {
|
|
21
|
-
const stacks = new Set([a.stack, ...(a.stacks ?? [])].filter(Boolean) as string[]);
|
|
22
|
-
if (stacks.size === 0) stacks.add("Other");
|
|
23
|
-
for (const stack of stacks) m.set(stack, (m.get(stack) ?? 0) + 1);
|
|
24
|
-
}
|
|
25
|
-
return m;
|
|
26
|
-
}
|
|
1
|
+
export * from "@grove-dev/core";
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Source of truth: `data/generated/records.{full,index,json}` and
|
|
3
3
|
* `data/generated/site-config.json`, produced at build time by
|
|
4
|
-
*
|
|
4
|
+
* automatically from `data/records/*.yml` and `grove.config.ts`.
|
|
5
5
|
*
|
|
6
6
|
* Three flavors of records are written by the generator:
|
|
7
7
|
*
|
|
@@ -26,19 +26,19 @@
|
|
|
26
26
|
*
|
|
27
27
|
* JSON imports are static at the top of this module so Vite
|
|
28
28
|
* resolves them at build time. A fresh scaffold that has not yet
|
|
29
|
-
*
|
|
29
|
+
* complete preparation will fail the build with a clear Vite
|
|
30
30
|
* resolution error, which is the correct signal — a build that
|
|
31
31
|
* silently renders an empty directory hides real config mistakes.
|
|
32
32
|
*/
|
|
33
|
-
import fullPayload from "
|
|
34
|
-
import indexPayload from "
|
|
35
|
-
import siteConfigPayload from "
|
|
33
|
+
import fullPayload from "@grove/generated/records.full.json";
|
|
34
|
+
import indexPayload from "@grove/generated/records.index.json";
|
|
35
|
+
import siteConfigPayload from "@grove/generated/site-config.json";
|
|
36
36
|
import { existsSync, readFileSync } from "node:fs";
|
|
37
37
|
import { fileURLToPath } from "node:url";
|
|
38
38
|
import { dirname, resolve } from "node:path";
|
|
39
39
|
import { marked } from "marked";
|
|
40
40
|
import sanitizeHtml from "sanitize-html";
|
|
41
|
-
import { prettySlug } from "
|
|
41
|
+
import { prettySlug } from "../lib/display.js";
|
|
42
42
|
import type {
|
|
43
43
|
ProjectRecord,
|
|
44
44
|
ResourceRecord,
|
|
@@ -176,7 +176,7 @@ export function entityBySlug(slug: string): EntityRecord | undefined {
|
|
|
176
176
|
// route slug, the kind filter, and the human label. Rather than
|
|
177
177
|
// hardcode "project" / "projects" / "kind: project" at every
|
|
178
178
|
// call-site, we derive them once from `site-config.json`'s
|
|
179
|
-
// `blueprintConfig` block (populated by
|
|
179
|
+
// `blueprintConfig` block (populated by Grove preparation).
|
|
180
180
|
//
|
|
181
181
|
// `indexSlug()` and `itemLabel()` retain their old signatures
|
|
182
182
|
// (zero-arg) so existing pages keep working — they now read from
|
|
@@ -364,7 +364,11 @@ export function getPageContentHtml(page: string): string | null {
|
|
|
364
364
|
if (!path) return null;
|
|
365
365
|
|
|
366
366
|
try {
|
|
367
|
-
const
|
|
367
|
+
const markdown = readFileSync(path, "utf8").replace(
|
|
368
|
+
/^---\r?\n[\s\S]*?\r?\n---\r?\n?/,
|
|
369
|
+
"",
|
|
370
|
+
);
|
|
371
|
+
const rawHtml = marked.parse(markdown, { async: false }) as string;
|
|
368
372
|
return sanitizeHtml(rawHtml, {
|
|
369
373
|
allowedTags: [
|
|
370
374
|
"h1", "h2", "h3", "h4",
|