@grove-dev/astro 0.2.10 → 0.2.14
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 +26 -18
- package/dist/lib/display.d.ts +11 -17
- package/dist/lib/display.d.ts.map +1 -1
- package/dist/lib/display.js +43 -43
- package/dist/lib/display.js.map +1 -1
- package/dist/lib/format.d.ts +6 -7
- package/dist/lib/format.d.ts.map +1 -1
- package/dist/lib/format.js +11 -12
- package/dist/lib/format.js.map +1 -1
- package/dist/lib/lenses.d.ts +35 -73
- package/dist/lib/lenses.d.ts.map +1 -1
- package/dist/lib/lenses.js +144 -126
- package/dist/lib/lenses.js.map +1 -1
- package/dist/lib/repo.d.ts +12 -22
- package/dist/lib/repo.d.ts.map +1 -1
- package/dist/lib/repo.js +13 -25
- package/dist/lib/repo.js.map +1 -1
- package/dist/lib/scores.d.ts +15 -16
- package/dist/lib/scores.d.ts.map +1 -1
- package/dist/lib/scores.js +4 -11
- package/dist/lib/scores.js.map +1 -1
- package/dist/lib/search.d.ts +44 -129
- package/dist/lib/search.d.ts.map +1 -1
- package/dist/lib/search.js +192 -180
- package/dist/lib/search.js.map +1 -1
- package/dist/lib/taxonomy-counts.d.ts +7 -21
- package/dist/lib/taxonomy-counts.d.ts.map +1 -1
- package/dist/lib/taxonomy-counts.js +12 -31
- package/dist/lib/taxonomy-counts.js.map +1 -1
- package/package.json +4 -4
- package/src/components/CategoryGrid.astro +43 -31
- package/src/components/ContributorsGrid.astro +3 -3
- package/src/components/CurationGrid.astro +41 -0
- package/src/components/ExploreByStack.astro +6 -0
- package/src/components/FilterGroupMenu.astro +49 -0
- package/src/components/FilterOptions.astro +44 -0
- package/src/components/Hero.astro +198 -147
- package/src/components/Icon.astro +10 -3
- package/src/components/IndexRow.astro +75 -0
- package/src/components/ItemCard.astro +266 -248
- package/src/components/Pagination.astro +63 -0
- package/src/components/RecordSection.astro +70 -34
- package/src/components/RefinePanel.astro +115 -141
- package/src/components/StackGrid.astro +32 -24
- package/src/components/WhyThisExists.astro +32 -32
- package/src/layouts/BaseLayout.astro +104 -49
- package/src/layouts/Container.astro +5 -44
- package/src/layouts/Footer.astro +31 -20
- package/src/layouts/Header.astro +40 -19
- package/src/layouts/SectionHeader.astro +27 -43
- package/src/layouts/Seo.astro +38 -56
- package/src/layouts/ThemeToggle.astro +35 -48
- package/src/lib/display.ts +46 -53
- package/src/lib/format.ts +11 -12
- package/src/lib/lenses.ts +151 -172
- package/src/lib/repo.ts +17 -28
- package/src/lib/scores.ts +15 -19
- package/src/lib/search.ts +218 -291
- package/src/lib/taxonomy-counts.ts +12 -46
- package/src/styles.css +209 -1
- package/templates/default/.github/ISSUE_TEMPLATE/bug_report.md +33 -0
- package/templates/default/.github/ISSUE_TEMPLATE/feature_request.md +20 -0
- package/templates/default/.github/ISSUE_TEMPLATE/record_submission.md +48 -0
- package/templates/default/.github/pull_request_template.md +27 -0
- package/templates/default/.github/workflows/build.yml +55 -0
- package/templates/default/.github/workflows/cleanup-stale-records.yml +48 -0
- package/templates/default/.github/workflows/daily-refresh.yml +53 -0
- package/templates/default/.github/workflows/sync-contributors.yml +53 -0
- package/templates/default/.github/workflows/sync-github-metadata.yml +58 -0
- package/templates/default/.github/workflows/validate-data.yml +35 -0
- package/templates/default/data/records/coolify.yml +46 -0
- package/templates/default/data/records/gitea.yml +46 -0
- package/templates/default/data/records/hoppscotch.yml +47 -0
- package/templates/default/data/records/inventree.yml +47 -0
- package/templates/default/data/records/label-studio.yml +46 -0
- package/templates/default/data/records/logseq.yml +48 -0
- package/templates/default/data/records/private-gpt.yml +47 -0
- package/templates/default/data/records/wakapi.yml +44 -0
- package/templates/default/data/taxonomy/categories.yml +47 -0
- package/templates/default/data/taxonomy/distribution-channels.yml +43 -0
- package/templates/default/data/taxonomy/platforms.yml +27 -0
- package/templates/default/data/taxonomy/stacks.yml +41 -0
- package/templates/default/grove.config.ts +1 -1
- package/templates/default/package.json +5 -4
- package/templates/default/public/icons/stacks/bun.svg +1 -0
- package/templates/default/public/icons/stacks/clojure.svg +1 -0
- package/templates/default/public/icons/stacks/clojurescript.svg +1 -0
- package/templates/default/public/icons/stacks/deno.svg +1 -0
- package/templates/default/public/icons/stacks/django.svg +1 -0
- package/templates/default/public/icons/stacks/docker.svg +1 -0
- package/templates/default/public/icons/stacks/go.svg +1 -0
- package/templates/default/public/icons/stacks/llm.svg +1 -0
- package/templates/default/public/icons/stacks/node.js.svg +1 -0
- package/templates/default/public/icons/stacks/sveltekit.svg +1 -0
- package/templates/default/public/icons/stacks/vue.svg +1 -0
- package/templates/default/public/llms-full.txt +197 -5
- package/templates/default/public/llms.txt +107 -6
- package/templates/default/public/sitemap.xml +63 -0
- package/templates/default/scripts/build-llms.mjs +123 -0
- package/templates/default/scripts/build-records-json.mjs +27 -0
- package/templates/default/scripts/build-sitemap.mjs +20 -0
- package/templates/default/scripts/cleanup-stale-records.mjs +20 -0
- package/templates/default/scripts/enrich-github-metadata.mjs +99 -0
- package/templates/default/scripts/fetch-icons.mjs +137 -0
- package/templates/default/scripts/migrate-legacy-to-schema-v1.mjs +86 -0
- package/templates/default/scripts/parse-legacy-readme.mjs +59 -0
- package/templates/default/scripts/refresh-records-activity.mjs +24 -0
- package/templates/default/scripts/repair-license-format.mjs +72 -0
- package/templates/default/scripts/report-cleanup-candidates.mjs +21 -0
- package/templates/default/scripts/seed-from-github.mjs +62 -0
- package/templates/default/scripts/sync-contributors.mjs +145 -0
- package/templates/default/scripts/sync-github-metadata.mjs +28 -0
- package/templates/default/scripts/validate-records.mjs +27 -0
- package/templates/default/src/data/records.ts +138 -51
- package/templates/default/src/pages/404.astro +64 -0
- package/templates/default/src/pages/{projects/[slug].astro → [slug]/[recordSlug].astro} +236 -66
- package/templates/default/src/pages/[slug]/index.astro +477 -0
- package/templates/default/src/pages/about.astro +165 -82
- package/templates/default/src/pages/apps/[recordSlug].astro +33 -0
- package/templates/default/src/pages/contributors.astro +184 -0
- package/templates/default/src/pages/index.astro +119 -162
- package/templates/default/src/pages/submit.astro +276 -114
- package/templates/default/wrangler.jsonc +28 -0
- package/LICENSE +0 -21
- package/src/components/AppsIndexRow.astro +0 -204
- package/src/components/AppsPagination.astro +0 -126
- package/templates/default/.astro/content.d.ts +0 -155
- package/templates/default/.astro/settings.json +0 -5
- package/templates/default/.astro/types.d.ts +0 -1
- package/templates/default/data/generated/apps.json +0 -1
- package/templates/default/data/generated/records.full.json +0 -8
- package/templates/default/data/generated/records.index.json +0 -7
- package/templates/default/data/generated/records.json +0 -8
- package/templates/default/data/generated/repo-stats.json +0 -1
- package/templates/default/data/generated/site-config.json +0 -31
- package/templates/default/dist/_astro/BaseLayout.Dz_HR-68.css +0 -1
- package/templates/default/dist/about/index.html +0 -165
- package/templates/default/dist/icons/platforms/android.svg +0 -1
- package/templates/default/dist/icons/platforms/apple-dark.svg +0 -1
- package/templates/default/dist/icons/platforms/apple-light.svg +0 -1
- package/templates/default/dist/icons/platforms/chrome.svg +0 -1
- package/templates/default/dist/icons/platforms/chromeos.svg +0 -5
- package/templates/default/dist/icons/platforms/desktop.svg +0 -4
- package/templates/default/dist/icons/platforms/embedded.svg +0 -5
- package/templates/default/dist/icons/platforms/ios.svg +0 -1
- package/templates/default/dist/icons/platforms/linux.svg +0 -1
- package/templates/default/dist/icons/platforms/macos.svg +0 -1
- package/templates/default/dist/icons/platforms/ubuntu.svg +0 -1
- package/templates/default/dist/icons/platforms/web.svg +0 -5
- package/templates/default/dist/icons/platforms/windows.svg +0 -6
- package/templates/default/dist/icons/stacks/android.svg +0 -1
- package/templates/default/dist/icons/stacks/apple-dark.svg +0 -1
- package/templates/default/dist/icons/stacks/apple-light.svg +0 -1
- package/templates/default/dist/icons/stacks/capacitor.svg +0 -14
- package/templates/default/dist/icons/stacks/dart.svg +0 -1
- package/templates/default/dist/icons/stacks/firebase.svg +0 -1
- package/templates/default/dist/icons/stacks/flutter.svg +0 -1
- package/templates/default/dist/icons/stacks/graphql.svg +0 -1
- package/templates/default/dist/icons/stacks/ionic.svg +0 -4
- package/templates/default/dist/icons/stacks/java.svg +0 -1
- package/templates/default/dist/icons/stacks/javascript.svg +0 -1
- package/templates/default/dist/icons/stacks/kotlin.svg +0 -1
- package/templates/default/dist/icons/stacks/mongodb.svg +0 -1
- package/templates/default/dist/icons/stacks/nodejs.svg +0 -1
- package/templates/default/dist/icons/stacks/python.svg +0 -1
- package/templates/default/dist/icons/stacks/react-native.svg +0 -1
- package/templates/default/dist/icons/stacks/react.svg +0 -1
- package/templates/default/dist/icons/stacks/rust.svg +0 -1
- package/templates/default/dist/icons/stacks/solidity.svg +0 -1
- package/templates/default/dist/icons/stacks/swift.svg +0 -1
- package/templates/default/dist/icons/stacks/tensorflow.svg +0 -1
- package/templates/default/dist/icons/stacks/typescript.svg +0 -1
- package/templates/default/dist/index.html +0 -55
- package/templates/default/dist/llms-full.txt +0 -14
- package/templates/default/dist/llms.txt +0 -11
- package/templates/default/dist/og-image.svg +0 -48
- package/templates/default/dist/projects/index.html +0 -61
- package/templates/default/dist/robots.txt +0 -53
- package/templates/default/dist/sitemap.xml +0 -21
- package/templates/default/dist/submit/index.html +0 -69
- package/templates/default/node_modules/.bin/astro +0 -21
- package/templates/default/node_modules/.bin/astro-check +0 -21
- package/templates/default/node_modules/.bin/grove +0 -21
- package/templates/default/node_modules/.bin/jiti +0 -21
- package/templates/default/node_modules/.bin/prettier +0 -21
- package/templates/default/node_modules/.bin/rollup +0 -21
- package/templates/default/node_modules/.bin/tsc +0 -21
- package/templates/default/node_modules/.bin/tsserver +0 -21
- package/templates/default/node_modules/.bin/tsx +0 -21
- package/templates/default/node_modules/.bin/vite +0 -21
- package/templates/default/node_modules/.bin/yaml +0 -21
- package/templates/default/node_modules/.vite/deps/_metadata.json +0 -79
- package/templates/default/node_modules/.vite/deps/astro-KRVZHBC5.js +0 -433
- package/templates/default/node_modules/.vite/deps/astro-KRVZHBC5.js.map +0 -7
- package/templates/default/node_modules/.vite/deps/astro___aria-query.js +0 -5
- package/templates/default/node_modules/.vite/deps/astro___aria-query.js.map +0 -7
- package/templates/default/node_modules/.vite/deps/astro___axobject-query.js +0 -5
- package/templates/default/node_modules/.vite/deps/astro___axobject-query.js.map +0 -7
- package/templates/default/node_modules/.vite/deps/astro___html-escaper.js +0 -9
- package/templates/default/node_modules/.vite/deps/astro___html-escaper.js.map +0 -7
- package/templates/default/node_modules/.vite/deps/astro_runtime_client_dev-toolbar_entrypoint__js.js +0 -241
- package/templates/default/node_modules/.vite/deps/astro_runtime_client_dev-toolbar_entrypoint__js.js.map +0 -7
- package/templates/default/node_modules/.vite/deps/audit-GYHQ3ITH.js +0 -1592
- package/templates/default/node_modules/.vite/deps/audit-GYHQ3ITH.js.map +0 -7
- package/templates/default/node_modules/.vite/deps/chunk-5WRI5ZAA.js +0 -30
- package/templates/default/node_modules/.vite/deps/chunk-5WRI5ZAA.js.map +0 -7
- package/templates/default/node_modules/.vite/deps/chunk-7P4KIK53.js +0 -186
- package/templates/default/node_modules/.vite/deps/chunk-7P4KIK53.js.map +0 -7
- package/templates/default/node_modules/.vite/deps/chunk-BQGHFQJH.js +0 -48
- package/templates/default/node_modules/.vite/deps/chunk-BQGHFQJH.js.map +0 -7
- package/templates/default/node_modules/.vite/deps/chunk-CJCDKWP2.js +0 -6779
- package/templates/default/node_modules/.vite/deps/chunk-CJCDKWP2.js.map +0 -7
- package/templates/default/node_modules/.vite/deps/chunk-FHJ3XBDE.js +0 -33
- package/templates/default/node_modules/.vite/deps/chunk-FHJ3XBDE.js.map +0 -7
- package/templates/default/node_modules/.vite/deps/chunk-FQEMNIUQ.js +0 -88
- package/templates/default/node_modules/.vite/deps/chunk-FQEMNIUQ.js.map +0 -7
- package/templates/default/node_modules/.vite/deps/chunk-HBSVDAQD.js +0 -72
- package/templates/default/node_modules/.vite/deps/chunk-HBSVDAQD.js.map +0 -7
- package/templates/default/node_modules/.vite/deps/chunk-HDOQ2U3U.js +0 -3757
- package/templates/default/node_modules/.vite/deps/chunk-HDOQ2U3U.js.map +0 -7
- package/templates/default/node_modules/.vite/deps/chunk-XEBKRCNE.js +0 -54
- package/templates/default/node_modules/.vite/deps/chunk-XEBKRCNE.js.map +0 -7
- package/templates/default/node_modules/.vite/deps/package.json +0 -3
- package/templates/default/node_modules/.vite/deps/settings-C5H4VHXU.js +0 -213
- package/templates/default/node_modules/.vite/deps/settings-C5H4VHXU.js.map +0 -7
- package/templates/default/node_modules/.vite/deps/toolbar-H5DB6HVP.js +0 -525
- package/templates/default/node_modules/.vite/deps/toolbar-H5DB6HVP.js.map +0 -7
- package/templates/default/node_modules/.vite/deps/ui-library-JGHU6JE2.js +0 -1020
- package/templates/default/node_modules/.vite/deps/ui-library-JGHU6JE2.js.map +0 -7
- package/templates/default/node_modules/.vite/deps/xray-EFCQOW4E.js +0 -147
- package/templates/default/node_modules/.vite/deps/xray-EFCQOW4E.js.map +0 -7
- package/templates/default/src/components/layout/BaseLayout.astro +0 -47
- package/templates/default/src/components/layout/Container.astro +0 -18
- package/templates/default/src/components/layout/Footer.astro +0 -27
- package/templates/default/src/components/layout/Header.astro +0 -43
- package/templates/default/src/lib/markdown.ts +0 -171
- package/templates/default/src/pages/projects/index.astro +0 -193
package/src/lib/search.ts
CHANGED
|
@@ -1,95 +1,24 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
32
|
-
*
|
|
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
|
|
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 IndexFilters = {
|
|
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 (
|
|
90
|
-
lens?:
|
|
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?:
|
|
21
|
+
sort?: IndexSort;
|
|
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
|
|
35
|
+
export type IndexSort = (typeof SORT_OPTIONS)[number]["value"];
|
|
107
36
|
|
|
108
|
-
const DEFAULT_SORT:
|
|
37
|
+
const DEFAULT_SORT: IndexSort = "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
|
-
*
|
|
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): IndexFilters {
|
|
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:
|
|
143
|
-
normalizedSort && SORT_OPTIONS.some((o) => o.value === normalizedSort)
|
|
144
|
-
? (normalizedSort as
|
|
62
|
+
const sort: IndexSort | undefined =
|
|
63
|
+
normalizedSort && (SORT_OPTIONS as readonly { value: string }[]).some((o) => o.value === normalizedSort)
|
|
64
|
+
? (normalizedSort as IndexSort)
|
|
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
|
-
|
|
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.
|
|
168
|
-
* empty values are dropped. Defaults (sort, page 1
|
|
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:
|
|
87
|
+
export function searchParamsFromFilters(f: IndexFilters): 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
|
-
|
|
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: IndexFilters): 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
|
-
*
|
|
207
|
-
*
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
|
|
277
|
-
|
|
128
|
+
function projectStars(record: IndexRecord): number {
|
|
129
|
+
return record.kind === "project" ? record.github?.stars ?? 0 : 0;
|
|
278
130
|
}
|
|
279
131
|
|
|
280
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: IndexSort): 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
|
|
147
|
+
arr.sort((a, b) => projectStars(b) - projectStars(a));
|
|
310
148
|
break;
|
|
311
149
|
case "recently-updated":
|
|
312
|
-
arr.sort((a, b) => ts(b
|
|
150
|
+
arr.sort((a, b) => ts(recordUpdatedAt(b)) - ts(recordUpdatedAt(a)));
|
|
313
151
|
break;
|
|
314
152
|
case "recently-added":
|
|
315
|
-
|
|
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
|
-
|
|
322
|
-
|
|
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
|
|
332
|
-
if (
|
|
333
|
-
const
|
|
334
|
-
if (
|
|
335
|
-
|
|
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.
|
|
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:
|
|
186
|
+
export function effectiveSort(f: IndexFilters): IndexSort {
|
|
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:
|
|
191
|
+
export function effectivePage(f: IndexFilters): number {
|
|
370
192
|
return f.page ?? DEFAULT_PAGE;
|
|
371
193
|
}
|
|
372
194
|
|
|
373
|
-
// ── Facets ───────────────────────────────────────────────────────
|
|
374
|
-
|
|
375
195
|
/**
|
|
376
|
-
* Return
|
|
377
|
-
*
|
|
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 filterRecords(items: IndexRecord[], f: IndexFilters): 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:
|
|
381
|
-
): { key: keyof
|
|
382
|
-
const out: { key: keyof
|
|
300
|
+
f: IndexFilters,
|
|
301
|
+
): { key: keyof IndexFilters; value: string; label: string }[] {
|
|
302
|
+
const out: { key: keyof IndexFilters; 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
|
-
|
|
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
|
-
|
|
417
|
-
|
|
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
|
-
*
|
|
426
|
-
*
|
|
427
|
-
*
|
|
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: IndexFilters,
|
|
350
|
+
key: keyof IndexFilters,
|
|
351
|
+
value: string,
|
|
352
|
+
): IndexFilters {
|
|
353
|
+
let next: IndexFilters;
|
|
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
|
|
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
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
(
|
|
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
|
-
|
|
456
|
-
|
|
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,8 @@ export function buildFacets<T extends Searchable>(records: T[]) {
|
|
|
473
401
|
};
|
|
474
402
|
}
|
|
475
403
|
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
export type { ProjectRecord };
|
|
404
|
+
// ── V1-published API ─────────────────────────────────────────────────
|
|
405
|
+
// `IndexFilters` / `IndexSort` are the canonical V1 names for the
|
|
406
|
+
// filter-state type. The internal functions (filterRecords, applySort,
|
|
407
|
+
// paginate, buildFacets, etc.) operate on the generic `IndexRecord`
|
|
408
|
+
// shape and are intentionally framework-agnostic.
|