@stapel/search-react 0.20.0 → 0.22.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 +138 -0
- package/README.md +65 -2
- package/dist/api/generated/schema.d.ts +96 -4
- package/dist/api/generated/schema.d.ts.map +1 -1
- package/dist/api/types.d.ts +68 -6
- package/dist/api/types.d.ts.map +1 -1
- package/dist/api/types.js.map +1 -1
- package/dist/default/FacetGroupControl.d.ts +34 -4
- package/dist/default/FacetGroupControl.d.ts.map +1 -1
- package/dist/default/FacetGroupControl.js +112 -7
- package/dist/default/FacetGroupControl.js.map +1 -1
- package/dist/default/FacetPanelPane.d.ts.map +1 -1
- package/dist/default/FacetPanelPane.js +27 -1
- package/dist/default/FacetPanelPane.js.map +1 -1
- package/dist/default/LocationSummaryLine.d.ts +3 -0
- package/dist/default/LocationSummaryLine.d.ts.map +1 -1
- package/dist/default/LocationSummaryLine.js +55 -8
- package/dist/default/LocationSummaryLine.js.map +1 -1
- package/dist/default/PartitionChips.d.ts +23 -0
- package/dist/default/PartitionChips.d.ts.map +1 -0
- package/dist/default/PartitionChips.js +94 -0
- package/dist/default/PartitionChips.js.map +1 -0
- package/dist/default/PopularValues.d.ts +58 -0
- package/dist/default/PopularValues.d.ts.map +1 -0
- package/dist/default/PopularValues.js +46 -0
- package/dist/default/PopularValues.js.map +1 -0
- package/dist/default/SearchPage.d.ts.map +1 -1
- package/dist/default/SearchPage.js +5 -0
- package/dist/default/SearchPage.js.map +1 -1
- package/dist/default/index.d.ts +5 -1
- package/dist/default/index.d.ts.map +1 -1
- package/dist/default/index.js +6 -1
- package/dist/default/index.js.map +1 -1
- package/dist/headless/FacetPanel.d.ts +29 -1
- package/dist/headless/FacetPanel.d.ts.map +1 -1
- package/dist/headless/FacetPanel.js +14 -0
- package/dist/headless/FacetPanel.js.map +1 -1
- package/dist/headless/SearchStateProvider.d.ts +15 -0
- package/dist/headless/SearchStateProvider.d.ts.map +1 -1
- package/dist/headless/SearchStateProvider.js +38 -3
- package/dist/headless/SearchStateProvider.js.map +1 -1
- package/dist/headless/useFacetLabels.js +7 -7
- package/dist/headless/useFacetLabels.js.map +1 -1
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +12 -0
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/keys.d.ts +72 -0
- package/dist/i18n/keys.d.ts.map +1 -1
- package/dist/i18n/keys.js +86 -0
- package/dist/i18n/keys.js.map +1 -1
- package/dist/i18n/ru.d.ts.map +1 -1
- package/dist/i18n/ru.js +14 -0
- package/dist/i18n/ru.js.map +1 -1
- package/dist/index.d.ts +4 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/state/degradations.d.ts +9 -0
- package/dist/state/degradations.d.ts.map +1 -1
- package/dist/state/degradations.js +13 -0
- package/dist/state/degradations.js.map +1 -1
- package/dist/state/facets.d.ts +29 -6
- package/dist/state/facets.d.ts.map +1 -1
- package/dist/state/facets.js +120 -16
- package/dist/state/facets.js.map +1 -1
- package/dist/state/translit.d.ts +58 -0
- package/dist/state/translit.d.ts.map +1 -0
- package/dist/state/translit.js +115 -0
- package/dist/state/translit.js.map +1 -0
- package/dist/state/urlState.d.ts +26 -1
- package/dist/state/urlState.d.ts.map +1 -1
- package/dist/state/urlState.js +43 -17
- package/dist/state/urlState.js.map +1 -1
- package/llms.txt +4 -2
- package/manifest.json +49 -2
- package/nav-manifest.json +1 -1
- package/package.json +8 -8
- package/src/analytics/generated/events.json +1 -1
- package/src/api/generated/schema.ts +96 -4
- package/src/api/types.ts +77 -5
- package/src/default/FacetGroupControl.tsx +213 -8
- package/src/default/FacetPanelPane.tsx +36 -1
- package/src/default/LocationSummaryLine.tsx +114 -53
- package/src/default/PartitionChips.tsx +161 -0
- package/src/default/PopularValues.tsx +151 -0
- package/src/default/SearchPage.tsx +5 -0
- package/src/default/index.ts +15 -0
- package/src/headless/FacetPanel.tsx +49 -1
- package/src/headless/SearchStateProvider.tsx +59 -3
- package/src/headless/useFacetLabels.ts +7 -7
- package/src/i18n/es.ts +16 -0
- package/src/i18n/keys.ts +91 -0
- package/src/i18n/ru.ts +20 -0
- package/src/index.ts +12 -0
- package/src/state/degradations.ts +14 -0
- package/src/state/facets.ts +154 -28
- package/src/state/translit.ts +113 -0
- package/src/state/urlState.ts +72 -13
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Matching a typed prefix against a dictionary value ACROSS ALPHABETS.
|
|
3
|
+
*
|
|
4
|
+
* A vocabulary of car makes holds `Toyota`, `Land Rover`, `Timberland` — Latin
|
|
5
|
+
* words, in a catalogue read by people typing Cyrillic. "toyota" spelled in
|
|
6
|
+
* Cyrillic and `Toyota` are the same word said in two scripts, and a
|
|
7
|
+
* `String.includes` over the caption answers "no" to every one of them. The
|
|
8
|
+
* autocatalog has 418 makes: without this, the search box in a dictionary
|
|
9
|
+
* facet is a box that works only for the half of the buyers whose keyboard
|
|
10
|
+
* matches the catalogue's.
|
|
11
|
+
*
|
|
12
|
+
* ── Two keys per word, and why the second one exists ──────────────────────
|
|
13
|
+
*
|
|
14
|
+
* The first key is a plain transliteration: Cyrillic → Latin through the table
|
|
15
|
+
* below, lowercased, everything but letters and digits dropped. That alone
|
|
16
|
+
* gets the Cyrillic spelling of "toyota" to `toiota` and `Toyota` to `toyota`
|
|
17
|
+
* — still not equal, and this is the general case rather than a bad table.
|
|
18
|
+
* Vowels are exactly where two scripts disagree: `i` against `y`, `e` against
|
|
19
|
+
* `a` in a borrowed word, `timberlend` against `timberland`. A vowel-perfect
|
|
20
|
+
* table does not exist, because the disagreement is in the borrowing, not in
|
|
21
|
+
* the letters.
|
|
22
|
+
*
|
|
23
|
+
* So the second key is the CONSONANT SKELETON: the first key with its vowels
|
|
24
|
+
* removed. `timberlend` and `timberland` are both `tmbrlnd`; `toiota` and
|
|
25
|
+
* `toyota` are both `tt`. Digits stay — a skeleton is about letters people
|
|
26
|
+
* disagree on, and `5` is not one.
|
|
27
|
+
*
|
|
28
|
+
* A candidate matches when either key of any of its WORDS starts with the
|
|
29
|
+
* corresponding key of the needle: a typed "rover" in either script finds
|
|
30
|
+
* `Land Rover`, and typing into the box narrows as you go, which is the whole
|
|
31
|
+
* point of a prefix rule. The skeleton arm is skipped for a needle that has no
|
|
32
|
+
* consonants at all — an empty skeleton is a prefix of everything, and a box
|
|
33
|
+
* that matches all 418 makes after one vowel is a box that has stopped
|
|
34
|
+
* answering.
|
|
35
|
+
*
|
|
36
|
+
* Table-driven and dependency-free on purpose: this is 40 pairs of characters,
|
|
37
|
+
* and a transliteration library is a page-weight decision made for a lookup
|
|
38
|
+
* table.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Cyrillic → Latin, chosen for how the two scripts spell the SAME borrowed
|
|
43
|
+
* name rather than for a standard's fidelity: `kha` is `h` (Honda, not
|
|
44
|
+
* Khonda), `tse` is `c`, the two signs are nothing. Any letter absent here is
|
|
45
|
+
* kept as it is, so a Latin caption passes through untouched. Keys are
|
|
46
|
+
* quoted — the fleet's source is English, and a Cyrillic identifier is not.
|
|
47
|
+
*/
|
|
48
|
+
const CYRILLIC_TO_LATIN: Readonly<Record<string, string>> = {
|
|
49
|
+
"а": "a", "б": "b", "в": "v", "г": "g", "д": "d", "е": "e", "ё": "e",
|
|
50
|
+
"ж": "zh", "з": "z", "и": "i", "й": "i", "к": "k", "л": "l", "м": "m",
|
|
51
|
+
"н": "n", "о": "o", "п": "p", "р": "r", "с": "s", "т": "t", "у": "u",
|
|
52
|
+
"ф": "f", "х": "h", "ц": "c", "ч": "ch", "ш": "sh", "щ": "sh", "ъ": "",
|
|
53
|
+
"ы": "y", "ь": "", "э": "e", "ю": "u", "я": "a",
|
|
54
|
+
// Ukrainian/Belarusian letters a shared vocabulary can carry.
|
|
55
|
+
"і": "i", "ї": "i", "є": "e", "ґ": "g", "ў": "u",
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/** Vowels of the transliterated form — `y` included, because it stands for a
|
|
59
|
+
* Cyrillic semi-vowel as often as it stands for a consonant. */
|
|
60
|
+
const VOWELS = new Set(["a", "e", "i", "o", "u", "y"]);
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* One word, transliterated and folded to `[a-z0-9]`.
|
|
64
|
+
*
|
|
65
|
+
* `NFKD` + combining-mark strip first, so `Škoda` and `Skoda` are one word
|
|
66
|
+
* before anything else looks at them.
|
|
67
|
+
*/
|
|
68
|
+
export function translitKey(text: string): string {
|
|
69
|
+
const folded = text
|
|
70
|
+
.normalize("NFKD")
|
|
71
|
+
.replace(/[\u0300-\u036f]/g, "")
|
|
72
|
+
.toLowerCase();
|
|
73
|
+
let out = "";
|
|
74
|
+
for (const char of folded) {
|
|
75
|
+
const mapped = CYRILLIC_TO_LATIN[char];
|
|
76
|
+
if (mapped !== undefined) out += mapped;
|
|
77
|
+
else if (/[a-z0-9]/.test(char)) out += char;
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** {@link translitKey} without its vowels — the key two spellings of one
|
|
83
|
+
* borrowed name agree on. */
|
|
84
|
+
export function consonantKey(text: string): string {
|
|
85
|
+
let out = "";
|
|
86
|
+
for (const char of translitKey(text)) if (!VOWELS.has(char)) out += char;
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The words of a caption, split on everything that is not a letter or a
|
|
91
|
+
* digit — so `Land Rover` is reachable by "rover" in either script. */
|
|
92
|
+
function words(text: string): readonly string[] {
|
|
93
|
+
return text.split(/[^\p{L}\p{N}]+/u).filter((word) => word.length > 0);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Does `candidate` answer the typed `needle`?
|
|
98
|
+
*
|
|
99
|
+
* Prefix, not substring: a dictionary of makes searched by substring puts
|
|
100
|
+
* `Great Wall` under `all`. Every word of the candidate is a starting point,
|
|
101
|
+
* and both keys are tried — see the module note.
|
|
102
|
+
*/
|
|
103
|
+
export function translitPrefixMatch(needle: string, candidate: string): boolean {
|
|
104
|
+
const key = translitKey(needle);
|
|
105
|
+
if (key.length === 0) return true;
|
|
106
|
+
const skeleton = consonantKey(needle);
|
|
107
|
+
for (const word of [candidate, ...words(candidate)]) {
|
|
108
|
+
const wordKey = translitKey(word);
|
|
109
|
+
if (wordKey.startsWith(key)) return true;
|
|
110
|
+
if (skeleton.length > 0 && consonantKey(word).startsWith(skeleton)) return true;
|
|
111
|
+
}
|
|
112
|
+
return false;
|
|
113
|
+
}
|
package/src/state/urlState.ts
CHANGED
|
@@ -74,19 +74,45 @@ export type SearchStateIssueCode =
|
|
|
74
74
|
/** `bbox` was not four numbers. */
|
|
75
75
|
| "bbox_malformed"
|
|
76
76
|
/** `r.<slug>` was not `from..to`. */
|
|
77
|
-
| "range_malformed"
|
|
77
|
+
| "range_malformed"
|
|
78
|
+
/**
|
|
79
|
+
* `radius_km` with nothing to measure it FROM — no `lat`/`lon`, no `bbox`.
|
|
80
|
+
*
|
|
81
|
+
* Not malformed: `radius_km=300` is a perfectly good number, and the search
|
|
82
|
+
* that ran is the honest one (a radius with no centre narrows nothing, so
|
|
83
|
+
* the count is the full count). What was wrong was the SILENCE. The URL said
|
|
84
|
+
* 300km, the results were unnarrowed, and the location control went on
|
|
85
|
+
* advertising its own default — measured on a live leaf as "300 in the
|
|
86
|
+
* address bar, 25 on the screen" with no way for a person to tell which one
|
|
87
|
+
* the page had used. Neither number is dropped and neither is rewritten;
|
|
88
|
+
* the disagreement is simply stated.
|
|
89
|
+
*/
|
|
90
|
+
| "radius_without_place";
|
|
78
91
|
|
|
79
92
|
const ISSUE_KEY: Readonly<Record<SearchStateIssueCode, string>> = {
|
|
80
93
|
not_a_number: "search.url.issue.not_a_number",
|
|
81
94
|
geo_incomplete: "search.url.issue.geo_incomplete",
|
|
82
95
|
bbox_malformed: "search.url.issue.bbox_malformed",
|
|
83
96
|
range_malformed: "search.url.issue.range_malformed",
|
|
97
|
+
radius_without_place: "search.url.issue.radius_without_place",
|
|
84
98
|
};
|
|
85
99
|
|
|
86
100
|
/** What {@link parseSearchState} answers: the state, plus what it could not read. */
|
|
87
101
|
export interface ParsedSearchState {
|
|
88
102
|
readonly state: SearchQueryState;
|
|
89
103
|
readonly issues: readonly SearchStateIssue[];
|
|
104
|
+
/**
|
|
105
|
+
* A `radius_km` the URL carries with no place to measure it from.
|
|
106
|
+
*
|
|
107
|
+
* NOT part of the state — a radius with no centre is not a filter and
|
|
108
|
+
* nothing is sent to the server — but it is a number the person typed, and
|
|
109
|
+
* dropping it on the floor is how the control and the address ended up
|
|
110
|
+
* disagreeing: `?radius_km=300`, a button offering "within 25 km", and a
|
|
111
|
+
* press that silently rewrote 300 to 25. Whoever draws the offer reads this
|
|
112
|
+
* so that what the button SAYS, what pressing it DOES, and what the link
|
|
113
|
+
* ASKED FOR are one number.
|
|
114
|
+
*/
|
|
115
|
+
readonly orphanRadiusKm?: number;
|
|
90
116
|
}
|
|
91
117
|
|
|
92
118
|
export interface ParseSearchStateOptions {
|
|
@@ -122,10 +148,16 @@ function num(
|
|
|
122
148
|
return value;
|
|
123
149
|
}
|
|
124
150
|
|
|
151
|
+
/** Where `parseGeo` leaves a radius it could not attach to anything. */
|
|
152
|
+
interface GeoParse {
|
|
153
|
+
readonly geo: SearchGeo | undefined;
|
|
154
|
+
readonly orphanRadiusKm?: number;
|
|
155
|
+
}
|
|
156
|
+
|
|
125
157
|
function parseGeo(
|
|
126
158
|
params: URLSearchParams,
|
|
127
159
|
issues: SearchStateIssue[]
|
|
128
|
-
):
|
|
160
|
+
): GeoParse {
|
|
129
161
|
const bbox = params.get(SEARCH_PARAM.bbox);
|
|
130
162
|
if (bbox !== null) {
|
|
131
163
|
const parts = bbox.split(",");
|
|
@@ -135,7 +167,7 @@ function parseGeo(
|
|
|
135
167
|
code: "bbox_malformed",
|
|
136
168
|
messageKey: ISSUE_KEY.bbox_malformed,
|
|
137
169
|
});
|
|
138
|
-
return undefined;
|
|
170
|
+
return { geo: undefined };
|
|
139
171
|
}
|
|
140
172
|
const values = parts.map((p) => Number(p));
|
|
141
173
|
if (values.some((v) => !Number.isFinite(v))) {
|
|
@@ -144,43 +176,64 @@ function parseGeo(
|
|
|
144
176
|
code: "bbox_malformed",
|
|
145
177
|
messageKey: ISSUE_KEY.bbox_malformed,
|
|
146
178
|
});
|
|
147
|
-
return undefined;
|
|
179
|
+
return { geo: undefined };
|
|
148
180
|
}
|
|
149
181
|
// `minLon > maxLon` is LEGAL — it means the box crosses the antimeridian
|
|
150
182
|
// (`stapel-search/query.py`). Normalizing it here would silently turn a
|
|
151
183
|
// Pacific search into the rest of the world.
|
|
152
184
|
return {
|
|
185
|
+
geo: {
|
|
153
186
|
kind: "bbox",
|
|
154
187
|
minLat: values[0] as number,
|
|
155
188
|
minLon: values[1] as number,
|
|
156
189
|
maxLat: values[2] as number,
|
|
157
190
|
maxLon: values[3] as number,
|
|
191
|
+
},
|
|
158
192
|
};
|
|
159
193
|
}
|
|
160
194
|
|
|
161
195
|
const rawLat = params.get(SEARCH_PARAM.lat);
|
|
162
196
|
const rawLon = params.get(SEARCH_PARAM.lon);
|
|
163
|
-
if (rawLat === null && rawLon === null)
|
|
197
|
+
if (rawLat === null && rawLon === null) {
|
|
198
|
+
// A radius parked in the URL with no point to measure it from. See
|
|
199
|
+
// `radius_without_place`: the search below is correct, the screen was the
|
|
200
|
+
// liar.
|
|
201
|
+
const orphan = params.get(SEARCH_PARAM.radiusKm);
|
|
202
|
+
if (orphan !== null) {
|
|
203
|
+
issues.push({
|
|
204
|
+
param: SEARCH_PARAM.radiusKm,
|
|
205
|
+
code: "radius_without_place",
|
|
206
|
+
messageKey: ISSUE_KEY.radius_without_place,
|
|
207
|
+
});
|
|
208
|
+
const km = Number(orphan);
|
|
209
|
+
if (Number.isFinite(km) && km > 0) {
|
|
210
|
+
return { geo: undefined, orphanRadiusKm: km };
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return { geo: undefined };
|
|
214
|
+
}
|
|
164
215
|
if (rawLat === null || rawLon === null) {
|
|
165
216
|
issues.push({
|
|
166
217
|
param: rawLat === null ? SEARCH_PARAM.lat : SEARCH_PARAM.lon,
|
|
167
218
|
code: "geo_incomplete",
|
|
168
219
|
messageKey: ISSUE_KEY.geo_incomplete,
|
|
169
220
|
});
|
|
170
|
-
return undefined;
|
|
221
|
+
return { geo: undefined };
|
|
171
222
|
}
|
|
172
223
|
const lat = num(rawLat, SEARCH_PARAM.lat, issues);
|
|
173
224
|
const lon = num(rawLon, SEARCH_PARAM.lon, issues);
|
|
174
|
-
if (lat === undefined || lon === undefined) return undefined;
|
|
225
|
+
if (lat === undefined || lon === undefined) return { geo: undefined };
|
|
175
226
|
|
|
176
227
|
const rawRadius = params.get(SEARCH_PARAM.radiusKm);
|
|
177
228
|
const radiusKm =
|
|
178
229
|
rawRadius === null ? undefined : num(rawRadius, SEARCH_PARAM.radiusKm, issues);
|
|
179
230
|
return {
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
231
|
+
geo: {
|
|
232
|
+
kind: "center",
|
|
233
|
+
lat,
|
|
234
|
+
lon,
|
|
235
|
+
...(radiusKm !== undefined ? { radiusKm } : {}),
|
|
236
|
+
},
|
|
184
237
|
};
|
|
185
238
|
}
|
|
186
239
|
|
|
@@ -246,6 +299,8 @@ export function parseSearchState(
|
|
|
246
299
|
const direction: "next" | "prev" | undefined =
|
|
247
300
|
rawDirection === null ? undefined : rawDirection === "prev" ? "prev" : "next";
|
|
248
301
|
|
|
302
|
+
const geoParse = parseGeo(params, issues);
|
|
303
|
+
|
|
249
304
|
const state: SearchQueryState = {
|
|
250
305
|
type: params.get(SEARCH_PARAM.type) ?? options.defaultType,
|
|
251
306
|
q: params.get(SEARCH_PARAM.q) ?? options.defaultQ ?? "",
|
|
@@ -257,7 +312,7 @@ export function parseSearchState(
|
|
|
257
312
|
params.get(SEARCH_PARAM.category) ?? options.defaultCategory
|
|
258
313
|
),
|
|
259
314
|
...optional(SEARCH_PARAM.owner, params.get(SEARCH_PARAM.owner) ?? undefined),
|
|
260
|
-
...optional("geo",
|
|
315
|
+
...optional("geo", geoParse.geo),
|
|
261
316
|
...optional(
|
|
262
317
|
SEARCH_PARAM.sort,
|
|
263
318
|
params.get(SEARCH_PARAM.sort) ?? options.defaultSort
|
|
@@ -268,7 +323,11 @@ export function parseSearchState(
|
|
|
268
323
|
...optional("limit", limit),
|
|
269
324
|
};
|
|
270
325
|
|
|
271
|
-
return {
|
|
326
|
+
return {
|
|
327
|
+
state,
|
|
328
|
+
issues,
|
|
329
|
+
...optional("orphanRadiusKm", geoParse.orphanRadiusKm),
|
|
330
|
+
};
|
|
272
331
|
}
|
|
273
332
|
|
|
274
333
|
/** `exactOptionalPropertyTypes` makes `{k: undefined}` and "absent" different
|