@stapel/search-react 0.1.0
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/CHANGELOG.md +1 -0
- package/MODULE.md +203 -0
- package/README.md +175 -0
- package/dist/api/generated/schema.d.ts +375 -0
- package/dist/api/generated/schema.d.ts.map +1 -0
- package/dist/api/generated/schema.js +2 -0
- package/dist/api/generated/schema.js.map +1 -0
- package/dist/api/searchApi.d.ts +77 -0
- package/dist/api/searchApi.d.ts.map +1 -0
- package/dist/api/searchApi.js +79 -0
- package/dist/api/searchApi.js.map +1 -0
- package/dist/api/types.d.ts +131 -0
- package/dist/api/types.d.ts.map +1 -0
- package/dist/api/types.js +20 -0
- package/dist/api/types.js.map +1 -0
- package/dist/default/DegradationNotice.d.ts +19 -0
- package/dist/default/DegradationNotice.d.ts.map +1 -0
- package/dist/default/DegradationNotice.js +14 -0
- package/dist/default/DegradationNotice.js.map +1 -0
- package/dist/default/ErrorAlert.d.ts +9 -0
- package/dist/default/ErrorAlert.d.ts.map +1 -0
- package/dist/default/ErrorAlert.js +27 -0
- package/dist/default/ErrorAlert.js.map +1 -0
- package/dist/default/FacetPanelPane.d.ts +26 -0
- package/dist/default/FacetPanelPane.d.ts.map +1 -0
- package/dist/default/FacetPanelPane.js +32 -0
- package/dist/default/FacetPanelPane.js.map +1 -0
- package/dist/default/RankingDisclosurePane.d.ts +17 -0
- package/dist/default/RankingDisclosurePane.d.ts.map +1 -0
- package/dist/default/RankingDisclosurePane.js +23 -0
- package/dist/default/RankingDisclosurePane.js.map +1 -0
- package/dist/default/SearchPage.d.ts +34 -0
- package/dist/default/SearchPage.d.ts.map +1 -0
- package/dist/default/SearchPage.js +16 -0
- package/dist/default/SearchPage.js.map +1 -0
- package/dist/default/SearchResultCard.d.ts +34 -0
- package/dist/default/SearchResultCard.d.ts.map +1 -0
- package/dist/default/SearchResultCard.js +38 -0
- package/dist/default/SearchResultCard.js.map +1 -0
- package/dist/default/SearchResultsPane.d.ts +32 -0
- package/dist/default/SearchResultsPane.d.ts.map +1 -0
- package/dist/default/SearchResultsPane.js +46 -0
- package/dist/default/SearchResultsPane.js.map +1 -0
- package/dist/default/SortSelect.d.ts +22 -0
- package/dist/default/SortSelect.d.ts.map +1 -0
- package/dist/default/SortSelect.js +39 -0
- package/dist/default/SortSelect.js.map +1 -0
- package/dist/default/UrlIssueNotice.d.ts +12 -0
- package/dist/default/UrlIssueNotice.d.ts.map +1 -0
- package/dist/default/UrlIssueNotice.js +13 -0
- package/dist/default/UrlIssueNotice.js.map +1 -0
- package/dist/default/index.d.ts +40 -0
- package/dist/default/index.d.ts.map +1 -0
- package/dist/default/index.js +34 -0
- package/dist/default/index.js.map +1 -0
- package/dist/default/theme.d.ts +24 -0
- package/dist/default/theme.d.ts.map +1 -0
- package/dist/default/theme.js +39 -0
- package/dist/default/theme.js.map +1 -0
- package/dist/default/types.d.ts +12 -0
- package/dist/default/types.d.ts.map +1 -0
- package/dist/default/types.js +2 -0
- package/dist/default/types.js.map +1 -0
- package/dist/headless/FacetPanel.d.ts +61 -0
- package/dist/headless/FacetPanel.d.ts.map +1 -0
- package/dist/headless/FacetPanel.js +57 -0
- package/dist/headless/FacetPanel.js.map +1 -0
- package/dist/headless/RankingDisclosure.d.ts +39 -0
- package/dist/headless/RankingDisclosure.d.ts.map +1 -0
- package/dist/headless/RankingDisclosure.js +28 -0
- package/dist/headless/RankingDisclosure.js.map +1 -0
- package/dist/headless/SearchProvider.d.ts +19 -0
- package/dist/headless/SearchProvider.d.ts.map +1 -0
- package/dist/headless/SearchProvider.js +15 -0
- package/dist/headless/SearchProvider.js.map +1 -0
- package/dist/headless/SearchResults.d.ts +68 -0
- package/dist/headless/SearchResults.d.ts.map +1 -0
- package/dist/headless/SearchResults.js +70 -0
- package/dist/headless/SearchResults.js.map +1 -0
- package/dist/headless/SearchStateProvider.d.ts +69 -0
- package/dist/headless/SearchStateProvider.d.ts.map +1 -0
- package/dist/headless/SearchStateProvider.js +71 -0
- package/dist/headless/SearchStateProvider.js.map +1 -0
- package/dist/i18n/errorsMap.d.ts +27 -0
- package/dist/i18n/errorsMap.d.ts.map +1 -0
- package/dist/i18n/errorsMap.js +37 -0
- package/dist/i18n/errorsMap.js.map +1 -0
- package/dist/i18n/es.d.ts +13 -0
- package/dist/i18n/es.d.ts.map +1 -0
- package/dist/i18n/es.js +85 -0
- package/dist/i18n/es.js.map +1 -0
- package/dist/i18n/generated/errors.es.gen.d.ts +9 -0
- package/dist/i18n/generated/errors.es.gen.d.ts.map +1 -0
- package/dist/i18n/generated/errors.es.gen.js +63 -0
- package/dist/i18n/generated/errors.es.gen.js.map +1 -0
- package/dist/i18n/generated/errors.gen.d.ts +359 -0
- package/dist/i18n/generated/errors.gen.d.ts.map +1 -0
- package/dist/i18n/generated/errors.gen.js +183 -0
- package/dist/i18n/generated/errors.gen.js.map +1 -0
- package/dist/i18n/generated/errors.ru.gen.d.ts +9 -0
- package/dist/i18n/generated/errors.ru.gen.d.ts.map +1 -0
- package/dist/i18n/generated/errors.ru.gen.js +63 -0
- package/dist/i18n/generated/errors.ru.gen.js.map +1 -0
- package/dist/i18n/keys.d.ts +107 -0
- package/dist/i18n/keys.d.ts.map +1 -0
- package/dist/i18n/keys.js +185 -0
- package/dist/i18n/keys.js.map +1 -0
- package/dist/i18n/ru.d.ts +29 -0
- package/dist/i18n/ru.d.ts.map +1 -0
- package/dist/i18n/ru.js +101 -0
- package/dist/i18n/ru.js.map +1 -0
- package/dist/index.d.ts +72 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +66 -0
- package/dist/index.js.map +1 -0
- package/dist/model/context.d.ts +11 -0
- package/dist/model/context.d.ts.map +1 -0
- package/dist/model/context.js +16 -0
- package/dist/model/context.js.map +1 -0
- package/dist/model/queries.d.ts +48 -0
- package/dist/model/queries.d.ts.map +1 -0
- package/dist/model/queries.js +65 -0
- package/dist/model/queries.js.map +1 -0
- package/dist/model/queryKeys.d.ts +25 -0
- package/dist/model/queryKeys.d.ts.map +1 -0
- package/dist/model/queryKeys.js +22 -0
- package/dist/model/queryKeys.js.map +1 -0
- package/dist/model/runtime.d.ts +25 -0
- package/dist/model/runtime.d.ts.map +1 -0
- package/dist/model/runtime.js +6 -0
- package/dist/model/runtime.js.map +1 -0
- package/dist/nav/manifest.d.ts +21 -0
- package/dist/nav/manifest.d.ts.map +1 -0
- package/dist/nav/manifest.js +34 -0
- package/dist/nav/manifest.js.map +1 -0
- package/dist/router/index.d.ts +10 -0
- package/dist/router/index.d.ts.map +1 -0
- package/dist/router/index.js +34 -0
- package/dist/router/index.js.map +1 -0
- package/dist/state/degradations.d.ts +30 -0
- package/dist/state/degradations.d.ts.map +1 -0
- package/dist/state/degradations.js +52 -0
- package/dist/state/degradations.js.map +1 -0
- package/dist/state/facets.d.ts +68 -0
- package/dist/state/facets.d.ts.map +1 -0
- package/dist/state/facets.js +171 -0
- package/dist/state/facets.js.map +1 -0
- package/dist/state/urlState.d.ts +143 -0
- package/dist/state/urlState.d.ts.map +1 -0
- package/dist/state/urlState.js +319 -0
- package/dist/state/urlState.js.map +1 -0
- package/llms.txt +87 -0
- package/manifest.json +652 -0
- package/nav-manifest.json +44 -0
- package/package.json +133 -0
- package/src/analytics/generated/events.json +7 -0
- package/src/api/generated/schema.ts +377 -0
- package/src/api/searchApi.ts +169 -0
- package/src/api/types.ts +160 -0
- package/src/default/DegradationNotice.tsx +50 -0
- package/src/default/ErrorAlert.tsx +46 -0
- package/src/default/FacetPanelPane.tsx +174 -0
- package/src/default/RankingDisclosurePane.tsx +116 -0
- package/src/default/SearchPage.tsx +87 -0
- package/src/default/SearchResultCard.tsx +96 -0
- package/src/default/SearchResultsPane.tsx +173 -0
- package/src/default/SortSelect.tsx +77 -0
- package/src/default/UrlIssueNotice.tsx +37 -0
- package/src/default/index.ts +48 -0
- package/src/default/theme.tsx +66 -0
- package/src/default/types.ts +12 -0
- package/src/headless/FacetPanel.tsx +113 -0
- package/src/headless/RankingDisclosure.tsx +58 -0
- package/src/headless/SearchProvider.tsx +20 -0
- package/src/headless/SearchResults.tsx +149 -0
- package/src/headless/SearchStateProvider.tsx +175 -0
- package/src/i18n/errorsMap.ts +50 -0
- package/src/i18n/es.ts +110 -0
- package/src/i18n/generated/errors.es.gen.ts +69 -0
- package/src/i18n/generated/errors.gen.ts +214 -0
- package/src/i18n/generated/errors.json +423 -0
- package/src/i18n/generated/errors.ru.gen.ts +69 -0
- package/src/i18n/keys.ts +220 -0
- package/src/i18n/ru.ts +126 -0
- package/src/index.ts +154 -0
- package/src/model/context.tsx +28 -0
- package/src/model/queries.ts +79 -0
- package/src/model/queryKeys.ts +48 -0
- package/src/model/runtime.ts +33 -0
- package/src/nav/manifest.ts +53 -0
- package/src/router/index.ts +38 -0
- package/src/state/degradations.ts +75 -0
- package/src/state/facets.ts +219 -0
- package/src/state/urlState.ts +458 -0
- package/tsconfig.json +26 -0
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE URL IS THE STATE.
|
|
3
|
+
*
|
|
4
|
+
* Every parameter of a search — text, category, facet filters, ranges, geo,
|
|
5
|
+
* sort, page size and the keyset cursor — lives in the query string, and no
|
|
6
|
+
* component keeps a second copy. The acceptance test the spec sets (§4.2) is
|
|
7
|
+
* behavioural, not structural: copy the address into another tab and get the
|
|
8
|
+
* same results; press Back and lose exactly the last filter; reload and lose
|
|
9
|
+
* nothing. All three follow from there being one source.
|
|
10
|
+
*
|
|
11
|
+
* The parameter NAMES here are the backend's own (`type`, `q`, `f.<slug>`,
|
|
12
|
+
* `r.<slug>`, `lat`/`lon`/`radius_km`, `bbox`, `sort`, `facets`, `anchor`,
|
|
13
|
+
* `direction`, `limit`). That is deliberate: a browser URL is then the API
|
|
14
|
+
* query string, so a support ticket that pastes a link is a request anyone can
|
|
15
|
+
* replay with curl, and there is no translation table to drift.
|
|
16
|
+
*
|
|
17
|
+
* This module is PURE — no React, no router, no fetch. The React binding is
|
|
18
|
+
* `headless/SearchStateProvider`; the react-router binding is the `./router`
|
|
19
|
+
* subpath. Keeping the codec here is what makes the round trip testable in
|
|
20
|
+
* both directions without a DOM.
|
|
21
|
+
*/
|
|
22
|
+
import type {
|
|
23
|
+
FacetSelection,
|
|
24
|
+
SearchGeo,
|
|
25
|
+
SearchQueryState,
|
|
26
|
+
SearchRange,
|
|
27
|
+
} from "../api/types.js";
|
|
28
|
+
|
|
29
|
+
/** Wire parameter names, in one place so a rename is one edit. */
|
|
30
|
+
export const SEARCH_PARAM = {
|
|
31
|
+
type: "type",
|
|
32
|
+
q: "q",
|
|
33
|
+
lang: "lang",
|
|
34
|
+
category: "category",
|
|
35
|
+
owner: "owner",
|
|
36
|
+
sort: "sort",
|
|
37
|
+
facets: "facets",
|
|
38
|
+
anchor: "anchor",
|
|
39
|
+
direction: "direction",
|
|
40
|
+
limit: "limit",
|
|
41
|
+
lat: "lat",
|
|
42
|
+
lon: "lon",
|
|
43
|
+
radiusKm: "radius_km",
|
|
44
|
+
bbox: "bbox",
|
|
45
|
+
} as const;
|
|
46
|
+
|
|
47
|
+
/** Prefix of a facet filter parameter (`f.brand=bosch`). */
|
|
48
|
+
export const FILTER_PREFIX = "f.";
|
|
49
|
+
/** Prefix of a range filter parameter (`r.price=100..500`). */
|
|
50
|
+
export const RANGE_PREFIX = "r.";
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Something in the URL this codec could not make sense of.
|
|
54
|
+
*
|
|
55
|
+
* Reported rather than silently dropped, for the same reason a facet's
|
|
56
|
+
* `skipped` list is reported: a link whose location filter lost half of
|
|
57
|
+
* itself should say so, not quietly widen the search and let the person
|
|
58
|
+
* believe they are looking at what they shared. Each issue carries an i18n
|
|
59
|
+
* key so a skin can render a sentence.
|
|
60
|
+
*/
|
|
61
|
+
export interface SearchStateIssue {
|
|
62
|
+
/** The offending parameter name, as it appeared in the URL. */
|
|
63
|
+
readonly param: string;
|
|
64
|
+
readonly code: SearchStateIssueCode;
|
|
65
|
+
/** i18n key for the sentence (`search.url.issue.*`). */
|
|
66
|
+
readonly messageKey: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export type SearchStateIssueCode =
|
|
70
|
+
/** A numeric parameter was not a number. */
|
|
71
|
+
| "not_a_number"
|
|
72
|
+
/** `lat` without `lon`, or the other way round. */
|
|
73
|
+
| "geo_incomplete"
|
|
74
|
+
/** `bbox` was not four numbers. */
|
|
75
|
+
| "bbox_malformed"
|
|
76
|
+
/** `r.<slug>` was not `from..to`. */
|
|
77
|
+
| "range_malformed";
|
|
78
|
+
|
|
79
|
+
const ISSUE_KEY: Readonly<Record<SearchStateIssueCode, string>> = {
|
|
80
|
+
not_a_number: "search.url.issue.not_a_number",
|
|
81
|
+
geo_incomplete: "search.url.issue.geo_incomplete",
|
|
82
|
+
bbox_malformed: "search.url.issue.bbox_malformed",
|
|
83
|
+
range_malformed: "search.url.issue.range_malformed",
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/** What {@link parseSearchState} answers: the state, plus what it could not read. */
|
|
87
|
+
export interface ParsedSearchState {
|
|
88
|
+
readonly state: SearchQueryState;
|
|
89
|
+
readonly issues: readonly SearchStateIssue[];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface ParseSearchStateOptions {
|
|
93
|
+
/**
|
|
94
|
+
* The doc type to search when the URL carries none. Required in practice —
|
|
95
|
+
* `type` is the endpoint's only mandatory parameter — and supplied by the
|
|
96
|
+
* host, because which types exist is a deployment fact
|
|
97
|
+
* (`stapel-search`'s registry), not something this package can know.
|
|
98
|
+
*/
|
|
99
|
+
readonly defaultType: string;
|
|
100
|
+
/** Applied when the URL carries no `q`. Defaults to `""`. */
|
|
101
|
+
readonly defaultQ?: string;
|
|
102
|
+
/** Applied when the URL carries no `sort`; omitted lets the server choose. */
|
|
103
|
+
readonly defaultSort?: string;
|
|
104
|
+
/** Applied when the URL carries no `limit`. */
|
|
105
|
+
readonly defaultLimit?: number;
|
|
106
|
+
/** Applied when the URL carries no `category` (e.g. a category page). */
|
|
107
|
+
readonly defaultCategory?: string;
|
|
108
|
+
/** Applied when the URL carries no `lang`. */
|
|
109
|
+
readonly defaultLang?: string;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function num(
|
|
113
|
+
raw: string,
|
|
114
|
+
param: string,
|
|
115
|
+
issues: SearchStateIssue[]
|
|
116
|
+
): number | undefined {
|
|
117
|
+
const value = Number(raw);
|
|
118
|
+
if (raw.trim().length === 0 || !Number.isFinite(value)) {
|
|
119
|
+
issues.push({ param, code: "not_a_number", messageKey: ISSUE_KEY.not_a_number });
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
return value;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function parseGeo(
|
|
126
|
+
params: URLSearchParams,
|
|
127
|
+
issues: SearchStateIssue[]
|
|
128
|
+
): SearchGeo | undefined {
|
|
129
|
+
const bbox = params.get(SEARCH_PARAM.bbox);
|
|
130
|
+
if (bbox !== null) {
|
|
131
|
+
const parts = bbox.split(",");
|
|
132
|
+
if (parts.length !== 4) {
|
|
133
|
+
issues.push({
|
|
134
|
+
param: SEARCH_PARAM.bbox,
|
|
135
|
+
code: "bbox_malformed",
|
|
136
|
+
messageKey: ISSUE_KEY.bbox_malformed,
|
|
137
|
+
});
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
const values = parts.map((p) => Number(p));
|
|
141
|
+
if (values.some((v) => !Number.isFinite(v))) {
|
|
142
|
+
issues.push({
|
|
143
|
+
param: SEARCH_PARAM.bbox,
|
|
144
|
+
code: "bbox_malformed",
|
|
145
|
+
messageKey: ISSUE_KEY.bbox_malformed,
|
|
146
|
+
});
|
|
147
|
+
return undefined;
|
|
148
|
+
}
|
|
149
|
+
// `minLon > maxLon` is LEGAL — it means the box crosses the antimeridian
|
|
150
|
+
// (`stapel-search/query.py`). Normalizing it here would silently turn a
|
|
151
|
+
// Pacific search into the rest of the world.
|
|
152
|
+
return {
|
|
153
|
+
kind: "bbox",
|
|
154
|
+
minLat: values[0] as number,
|
|
155
|
+
minLon: values[1] as number,
|
|
156
|
+
maxLat: values[2] as number,
|
|
157
|
+
maxLon: values[3] as number,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const rawLat = params.get(SEARCH_PARAM.lat);
|
|
162
|
+
const rawLon = params.get(SEARCH_PARAM.lon);
|
|
163
|
+
if (rawLat === null && rawLon === null) return undefined;
|
|
164
|
+
if (rawLat === null || rawLon === null) {
|
|
165
|
+
issues.push({
|
|
166
|
+
param: rawLat === null ? SEARCH_PARAM.lat : SEARCH_PARAM.lon,
|
|
167
|
+
code: "geo_incomplete",
|
|
168
|
+
messageKey: ISSUE_KEY.geo_incomplete,
|
|
169
|
+
});
|
|
170
|
+
return undefined;
|
|
171
|
+
}
|
|
172
|
+
const lat = num(rawLat, SEARCH_PARAM.lat, issues);
|
|
173
|
+
const lon = num(rawLon, SEARCH_PARAM.lon, issues);
|
|
174
|
+
if (lat === undefined || lon === undefined) return undefined;
|
|
175
|
+
|
|
176
|
+
const rawRadius = params.get(SEARCH_PARAM.radiusKm);
|
|
177
|
+
const radiusKm =
|
|
178
|
+
rawRadius === null ? undefined : num(rawRadius, SEARCH_PARAM.radiusKm, issues);
|
|
179
|
+
return {
|
|
180
|
+
kind: "center",
|
|
181
|
+
lat,
|
|
182
|
+
lon,
|
|
183
|
+
...(radiusKm !== undefined ? { radiusKm } : {}),
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function parseFacetSelection(raw: string | null): FacetSelection | undefined {
|
|
188
|
+
if (raw === null) return undefined;
|
|
189
|
+
const lowered = raw.trim().toLowerCase();
|
|
190
|
+
if (lowered === "on" || lowered === "off") return lowered;
|
|
191
|
+
const slugs = raw
|
|
192
|
+
.split(",")
|
|
193
|
+
.map((s) => s.trim())
|
|
194
|
+
.filter((s) => s.length > 0);
|
|
195
|
+
return slugs.length > 0 ? slugs : undefined;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** URL → state. Never throws; anything unreadable lands in `issues`. */
|
|
199
|
+
export function parseSearchState(
|
|
200
|
+
params: URLSearchParams,
|
|
201
|
+
options: ParseSearchStateOptions
|
|
202
|
+
): ParsedSearchState {
|
|
203
|
+
const issues: SearchStateIssue[] = [];
|
|
204
|
+
|
|
205
|
+
const filters: Record<string, readonly string[]> = {};
|
|
206
|
+
const ranges: Record<string, SearchRange> = {};
|
|
207
|
+
|
|
208
|
+
for (const key of new Set(params.keys())) {
|
|
209
|
+
if (key.startsWith(FILTER_PREFIX) && key.length > FILTER_PREFIX.length) {
|
|
210
|
+
// `getAll` is the whole point: a repeated key is OR within the slug.
|
|
211
|
+
const values = params.getAll(key).filter((v) => v.length > 0);
|
|
212
|
+
if (values.length > 0) filters[key.slice(FILTER_PREFIX.length)] = values;
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
if (key.startsWith(RANGE_PREFIX) && key.length > RANGE_PREFIX.length) {
|
|
216
|
+
const raw = params.get(key) ?? "";
|
|
217
|
+
const separator = raw.indexOf("..");
|
|
218
|
+
if (separator < 0) {
|
|
219
|
+
issues.push({
|
|
220
|
+
param: key,
|
|
221
|
+
code: "range_malformed",
|
|
222
|
+
messageKey: ISSUE_KEY.range_malformed,
|
|
223
|
+
});
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
const from = raw.slice(0, separator);
|
|
227
|
+
const to = raw.slice(separator + 2);
|
|
228
|
+
if (from.length === 0 && to.length === 0) continue;
|
|
229
|
+
ranges[key.slice(RANGE_PREFIX.length)] = {
|
|
230
|
+
...(from.length > 0 ? { from } : {}),
|
|
231
|
+
...(to.length > 0 ? { to } : {}),
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const rawLimit = params.get(SEARCH_PARAM.limit);
|
|
237
|
+
const limit =
|
|
238
|
+
rawLimit === null
|
|
239
|
+
? options.defaultLimit
|
|
240
|
+
: num(rawLimit, SEARCH_PARAM.limit, issues);
|
|
241
|
+
|
|
242
|
+
const rawDirection = params.get(SEARCH_PARAM.direction);
|
|
243
|
+
// The server coerces anything unrecognised to "next" without complaining
|
|
244
|
+
// (`query.py`), so the codec mirrors that instead of inventing a refusal
|
|
245
|
+
// the backend does not have.
|
|
246
|
+
const direction: "next" | "prev" | undefined =
|
|
247
|
+
rawDirection === null ? undefined : rawDirection === "prev" ? "prev" : "next";
|
|
248
|
+
|
|
249
|
+
const state: SearchQueryState = {
|
|
250
|
+
type: params.get(SEARCH_PARAM.type) ?? options.defaultType,
|
|
251
|
+
q: params.get(SEARCH_PARAM.q) ?? options.defaultQ ?? "",
|
|
252
|
+
filters,
|
|
253
|
+
ranges,
|
|
254
|
+
...optional(SEARCH_PARAM.lang, params.get(SEARCH_PARAM.lang) ?? options.defaultLang),
|
|
255
|
+
...optional(
|
|
256
|
+
SEARCH_PARAM.category,
|
|
257
|
+
params.get(SEARCH_PARAM.category) ?? options.defaultCategory
|
|
258
|
+
),
|
|
259
|
+
...optional(SEARCH_PARAM.owner, params.get(SEARCH_PARAM.owner) ?? undefined),
|
|
260
|
+
...optional("geo", parseGeo(params, issues)),
|
|
261
|
+
...optional(
|
|
262
|
+
SEARCH_PARAM.sort,
|
|
263
|
+
params.get(SEARCH_PARAM.sort) ?? options.defaultSort
|
|
264
|
+
),
|
|
265
|
+
...optional("facets", parseFacetSelection(params.get(SEARCH_PARAM.facets))),
|
|
266
|
+
...optional(SEARCH_PARAM.anchor, params.get(SEARCH_PARAM.anchor) ?? undefined),
|
|
267
|
+
...optional("direction", direction),
|
|
268
|
+
...optional("limit", limit),
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
return { state, issues };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** `exactOptionalPropertyTypes` makes `{k: undefined}` and "absent" different
|
|
275
|
+
* types; this spreads to nothing when the value is absent. */
|
|
276
|
+
function optional<K extends string, V>(
|
|
277
|
+
key: K,
|
|
278
|
+
value: V | undefined
|
|
279
|
+
): Record<K, V> | Record<string, never> {
|
|
280
|
+
return value === undefined ? {} : ({ [key]: value } as Record<K, V>);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* State → URL.
|
|
285
|
+
*
|
|
286
|
+
* `base` is the CURRENT query string: every parameter this codec does not own
|
|
287
|
+
* is copied through untouched, so a host's own `?ref=`/`utm_*` survives a
|
|
288
|
+
* facet click. Every parameter it DOES own is rewritten from the state, so a
|
|
289
|
+
* removed filter actually leaves the URL instead of lingering as a stale key.
|
|
290
|
+
*/
|
|
291
|
+
export function writeSearchState(
|
|
292
|
+
state: SearchQueryState,
|
|
293
|
+
base?: URLSearchParams
|
|
294
|
+
): URLSearchParams {
|
|
295
|
+
const next = new URLSearchParams();
|
|
296
|
+
|
|
297
|
+
if (base !== undefined) {
|
|
298
|
+
for (const key of new Set(base.keys())) {
|
|
299
|
+
if (ownsParam(key)) continue;
|
|
300
|
+
for (const value of base.getAll(key)) next.append(key, value);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
next.set(SEARCH_PARAM.type, state.type);
|
|
305
|
+
if (state.q.length > 0) next.set(SEARCH_PARAM.q, state.q);
|
|
306
|
+
if (state.lang !== undefined) next.set(SEARCH_PARAM.lang, state.lang);
|
|
307
|
+
if (state.category !== undefined) next.set(SEARCH_PARAM.category, state.category);
|
|
308
|
+
if (state.owner !== undefined) next.set(SEARCH_PARAM.owner, state.owner);
|
|
309
|
+
|
|
310
|
+
for (const slug of Object.keys(state.filters).sort()) {
|
|
311
|
+
for (const value of state.filters[slug] ?? []) {
|
|
312
|
+
next.append(`${FILTER_PREFIX}${slug}`, value);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
for (const slug of Object.keys(state.ranges).sort()) {
|
|
316
|
+
const range = state.ranges[slug];
|
|
317
|
+
if (range === undefined) continue;
|
|
318
|
+
if (range.from === undefined && range.to === undefined) continue;
|
|
319
|
+
next.set(`${RANGE_PREFIX}${slug}`, `${range.from ?? ""}..${range.to ?? ""}`);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (state.geo !== undefined) {
|
|
323
|
+
if (state.geo.kind === "bbox") {
|
|
324
|
+
const { minLat, minLon, maxLat, maxLon } = state.geo;
|
|
325
|
+
next.set(SEARCH_PARAM.bbox, `${minLat},${minLon},${maxLat},${maxLon}`);
|
|
326
|
+
} else {
|
|
327
|
+
next.set(SEARCH_PARAM.lat, String(state.geo.lat));
|
|
328
|
+
next.set(SEARCH_PARAM.lon, String(state.geo.lon));
|
|
329
|
+
if (state.geo.radiusKm !== undefined) {
|
|
330
|
+
next.set(SEARCH_PARAM.radiusKm, String(state.geo.radiusKm));
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (state.sort !== undefined) next.set(SEARCH_PARAM.sort, state.sort);
|
|
336
|
+
if (state.facets !== undefined) {
|
|
337
|
+
next.set(
|
|
338
|
+
SEARCH_PARAM.facets,
|
|
339
|
+
Array.isArray(state.facets) ? state.facets.join(",") : (state.facets as string)
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
if (state.anchor !== undefined) next.set(SEARCH_PARAM.anchor, state.anchor);
|
|
343
|
+
if (state.direction !== undefined) next.set(SEARCH_PARAM.direction, state.direction);
|
|
344
|
+
if (state.limit !== undefined) next.set(SEARCH_PARAM.limit, String(state.limit));
|
|
345
|
+
|
|
346
|
+
return next;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** Is this URL parameter owned by the search codec? */
|
|
350
|
+
export function ownsParam(key: string): boolean {
|
|
351
|
+
if (key.startsWith(FILTER_PREFIX) || key.startsWith(RANGE_PREFIX)) return true;
|
|
352
|
+
return (Object.values(SEARCH_PARAM) as readonly string[]).includes(key);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** A patch over {@link SearchQueryState}; `null` clears an optional member. */
|
|
356
|
+
export type SearchStatePatch = {
|
|
357
|
+
readonly [K in keyof SearchQueryState]?: SearchQueryState[K] | null;
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Apply a patch, and — unless the patch itself moves the page — DROP the
|
|
362
|
+
* keyset cursor.
|
|
363
|
+
*
|
|
364
|
+
* This is the single most load-bearing line in the module. `anchor` encodes a
|
|
365
|
+
* position inside ONE ordered candidate set (`{v, k, o}` over the active sort);
|
|
366
|
+
* carry it across a filter or sort change and the server either refuses it or,
|
|
367
|
+
* worse, honours it against a different set and answers page 4 of something
|
|
368
|
+
* the person never asked for. Every mutator in the provider goes through here,
|
|
369
|
+
* so "changing a filter returns you to the first page" is a property of the
|
|
370
|
+
* state machine rather than a rule each call site must remember.
|
|
371
|
+
*/
|
|
372
|
+
export function patchSearchState(
|
|
373
|
+
state: SearchQueryState,
|
|
374
|
+
patch: SearchStatePatch
|
|
375
|
+
): SearchQueryState {
|
|
376
|
+
const movesPage = "anchor" in patch || "direction" in patch;
|
|
377
|
+
const merged: Record<string, unknown> = { ...state, ...patch };
|
|
378
|
+
if (!movesPage) {
|
|
379
|
+
merged["anchor"] = undefined;
|
|
380
|
+
merged["direction"] = undefined;
|
|
381
|
+
}
|
|
382
|
+
// `type` and `q` are never absent: `q` has the empty string for "no text",
|
|
383
|
+
// and a state without `type` could not be requested at all.
|
|
384
|
+
if (typeof merged["q"] !== "string") merged["q"] = "";
|
|
385
|
+
if (typeof merged["type"] !== "string") merged["type"] = state.type;
|
|
386
|
+
|
|
387
|
+
// `null` in a patch CLEARS a member, and `exactOptionalPropertyTypes` makes
|
|
388
|
+
// "absent" and "present but undefined" different types — so the cleared
|
|
389
|
+
// members are filtered out rather than left as `undefined` holes.
|
|
390
|
+
return Object.fromEntries(
|
|
391
|
+
Object.entries(merged).filter(([, value]) => value !== null && value !== undefined)
|
|
392
|
+
) as unknown as SearchQueryState;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** Toggle one value of one facet slug, OR-style. Returns a new state with the
|
|
396
|
+
* cursor dropped (see {@link patchSearchState}). */
|
|
397
|
+
export function toggleFilterValue(
|
|
398
|
+
state: SearchQueryState,
|
|
399
|
+
slug: string,
|
|
400
|
+
value: string
|
|
401
|
+
): SearchQueryState {
|
|
402
|
+
const current = state.filters[slug] ?? [];
|
|
403
|
+
const next = current.includes(value)
|
|
404
|
+
? current.filter((v) => v !== value)
|
|
405
|
+
: [...current, value];
|
|
406
|
+
return setFilterValues(state, slug, next);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** Replace one slug's chosen values; an empty list removes the filter. */
|
|
410
|
+
export function setFilterValues(
|
|
411
|
+
state: SearchQueryState,
|
|
412
|
+
slug: string,
|
|
413
|
+
values: readonly string[]
|
|
414
|
+
): SearchQueryState {
|
|
415
|
+
const filters: Record<string, readonly string[]> = Object.fromEntries(
|
|
416
|
+
Object.entries(state.filters).filter(([key]) => key !== slug)
|
|
417
|
+
);
|
|
418
|
+
if (values.length > 0) filters[slug] = values;
|
|
419
|
+
return patchSearchState(state, { filters });
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** Set or clear one `r.<slug>` range. */
|
|
423
|
+
export function setRangeValue(
|
|
424
|
+
state: SearchQueryState,
|
|
425
|
+
slug: string,
|
|
426
|
+
range: SearchRange | null
|
|
427
|
+
): SearchQueryState {
|
|
428
|
+
const ranges: Record<string, SearchRange> = Object.fromEntries(
|
|
429
|
+
Object.entries(state.ranges).filter(([key]) => key !== slug)
|
|
430
|
+
);
|
|
431
|
+
if (range !== null && (range.from !== undefined || range.to !== undefined)) {
|
|
432
|
+
ranges[slug] = range;
|
|
433
|
+
}
|
|
434
|
+
return patchSearchState(state, { ranges });
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Drop every filter, range and geo constraint, keeping what identifies the
|
|
439
|
+
* search itself: the doc type, the text, the language, the category the
|
|
440
|
+
* person navigated into, and the page size.
|
|
441
|
+
*
|
|
442
|
+
* Category is KEPT on purpose: on `/c/:slug` it is the page, not a filter, and
|
|
443
|
+
* a "clear filters" button that teleported the visitor to the root catalogue
|
|
444
|
+
* would be removing something they never set.
|
|
445
|
+
*/
|
|
446
|
+
export function clearFilters(state: SearchQueryState): SearchQueryState {
|
|
447
|
+
return patchSearchState(state, { filters: {}, ranges: {}, geo: null });
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/** How many constraints the person has actually applied (facet values +
|
|
451
|
+
* ranges + geo) — what a "clear all (N)" control counts. */
|
|
452
|
+
export function activeFilterCount(state: SearchQueryState): number {
|
|
453
|
+
let count = 0;
|
|
454
|
+
for (const values of Object.values(state.filters)) count += values.length;
|
|
455
|
+
count += Object.keys(state.ranges).length;
|
|
456
|
+
if (state.geo !== undefined) count += 1;
|
|
457
|
+
return count;
|
|
458
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json.schemastore.org/tsconfig",
|
|
3
|
+
"_comment": "Self-contained on purpose: standalone-buildable per frontend-standard §7. Mirrors the root tsconfig.base.json settings.",
|
|
4
|
+
"compilerOptions": {
|
|
5
|
+
"target": "ES2022",
|
|
6
|
+
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
|
7
|
+
"module": "ESNext",
|
|
8
|
+
"moduleResolution": "bundler",
|
|
9
|
+
"jsx": "react-jsx",
|
|
10
|
+
"strict": true,
|
|
11
|
+
"noUncheckedIndexedAccess": true,
|
|
12
|
+
"noImplicitOverride": true,
|
|
13
|
+
"exactOptionalPropertyTypes": true,
|
|
14
|
+
"isolatedModules": true,
|
|
15
|
+
"isolatedDeclarations": true,
|
|
16
|
+
"verbatimModuleSyntax": true,
|
|
17
|
+
"declaration": true,
|
|
18
|
+
"declarationMap": true,
|
|
19
|
+
"sourceMap": true,
|
|
20
|
+
"skipLibCheck": true,
|
|
21
|
+
"forceConsistentCasingInFileNames": true,
|
|
22
|
+
"outDir": "dist",
|
|
23
|
+
"rootDir": "src"
|
|
24
|
+
},
|
|
25
|
+
"include": ["src"]
|
|
26
|
+
}
|