@stapel/search-react 0.25.0 → 0.26.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 +18 -0
- package/MODULE.md +21 -9
- package/README.md +71 -13
- package/dist/api/generated/schema.d.ts +18 -3
- package/dist/api/generated/schema.d.ts.map +1 -1
- package/dist/api/types.d.ts +39 -4
- package/dist/api/types.d.ts.map +1 -1
- package/dist/api/types.js.map +1 -1
- package/dist/default/FacetGroupControl.d.ts +20 -13
- package/dist/default/FacetGroupControl.d.ts.map +1 -1
- package/dist/default/FacetGroupControl.js +29 -21
- package/dist/default/FacetGroupControl.js.map +1 -1
- package/dist/default/FacetPanelPane.d.ts +15 -4
- package/dist/default/FacetPanelPane.d.ts.map +1 -1
- package/dist/default/FacetPanelPane.js +10 -5
- package/dist/default/FacetPanelPane.js.map +1 -1
- package/dist/default/SearchPage.d.ts +16 -0
- package/dist/default/SearchPage.d.ts.map +1 -1
- package/dist/default/SearchPage.js +7 -3
- package/dist/default/SearchPage.js.map +1 -1
- package/dist/headless/SearchStateProvider.d.ts +12 -2
- package/dist/headless/SearchStateProvider.d.ts.map +1 -1
- package/dist/headless/SearchStateProvider.js +58 -6
- package/dist/headless/SearchStateProvider.js.map +1 -1
- package/dist/i18n/generated/errors.es.gen.d.ts.map +1 -1
- package/dist/i18n/generated/errors.es.gen.js +1 -0
- package/dist/i18n/generated/errors.es.gen.js.map +1 -1
- package/dist/i18n/generated/errors.gen.d.ts +6 -0
- package/dist/i18n/generated/errors.gen.d.ts.map +1 -1
- package/dist/i18n/generated/errors.gen.js +3 -0
- package/dist/i18n/generated/errors.gen.js.map +1 -1
- package/dist/i18n/generated/errors.ru.gen.d.ts.map +1 -1
- package/dist/i18n/generated/errors.ru.gen.js +1 -0
- package/dist/i18n/generated/errors.ru.gen.js.map +1 -1
- package/dist/index.d.ts +4 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -3
- package/dist/index.js.map +1 -1
- package/dist/model/queries.d.ts.map +1 -1
- package/dist/model/queries.js +9 -1
- package/dist/model/queries.js.map +1 -1
- package/dist/state/facets.d.ts +62 -11
- package/dist/state/facets.d.ts.map +1 -1
- package/dist/state/facets.js +141 -29
- package/dist/state/facets.js.map +1 -1
- package/dist/state/urlState.d.ts +77 -2
- package/dist/state/urlState.d.ts.map +1 -1
- package/dist/state/urlState.js +80 -6
- package/dist/state/urlState.js.map +1 -1
- package/llms.txt +3 -2
- package/manifest.json +18 -1
- 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 +18 -3
- package/src/api/types.ts +42 -4
- package/src/default/FacetGroupControl.tsx +27 -23
- package/src/default/FacetPanelPane.tsx +30 -8
- package/src/default/SearchPage.tsx +25 -1
- package/src/headless/SearchStateProvider.tsx +94 -4
- package/src/i18n/generated/errors.es.gen.ts +1 -0
- package/src/i18n/generated/errors.gen.ts +3 -0
- package/src/i18n/generated/errors.json +9 -0
- package/src/i18n/generated/errors.ru.gen.ts +1 -0
- package/src/index.ts +13 -1
- package/src/model/queries.ts +11 -1
- package/src/state/facets.ts +173 -30
- package/src/state/urlState.ts +137 -5
package/src/state/facets.ts
CHANGED
|
@@ -55,9 +55,11 @@ import {
|
|
|
55
55
|
featureConfig,
|
|
56
56
|
featureType,
|
|
57
57
|
formatFeatureValue,
|
|
58
|
+
optionsRefOf,
|
|
58
59
|
} from "@stapel/attributes-react";
|
|
59
60
|
import type { FeatureDef } from "@stapel/attributes-react";
|
|
60
61
|
import type { FacetLabelsMap, FacetMeta, SearchQueryState } from "../api/types.js";
|
|
62
|
+
import { facetKeyMapFromLabels } from "./urlState.js";
|
|
61
63
|
|
|
62
64
|
/**
|
|
63
65
|
* Where a caption came from — the group's heading and every option carry it,
|
|
@@ -72,6 +74,21 @@ export type FacetLabelSource = "server" | "schema" | "host" | "none";
|
|
|
72
74
|
|
|
73
75
|
declare const process: { readonly env: { readonly NODE_ENV?: string } };
|
|
74
76
|
|
|
77
|
+
/**
|
|
78
|
+
* Is this a DEVELOPMENT build — i.e. may this module talk to the console?
|
|
79
|
+
*
|
|
80
|
+
* Asked as "is it dev", never as "is it not production", because the second
|
|
81
|
+
* form fails open: a browser bundle with no `process` shim leaves `NODE_ENV`
|
|
82
|
+
* undefined, `undefined !== "production"` is true, and a buyer's console gets
|
|
83
|
+
* `facet group "complectation" has no values to draw` on every page load of a
|
|
84
|
+
* live board. A warning nobody who can fix it will ever read is noise in
|
|
85
|
+
* somebody else's browser.
|
|
86
|
+
*/
|
|
87
|
+
function inDevelopment(): boolean {
|
|
88
|
+
const env = typeof process === "undefined" ? undefined : process.env;
|
|
89
|
+
return env?.NODE_ENV === "development" || env?.NODE_ENV === "test";
|
|
90
|
+
}
|
|
91
|
+
|
|
75
92
|
/** Slugs already complained about — one warning per slug per page load, not
|
|
76
93
|
* one per render. */
|
|
77
94
|
const warnedSlugs = new Set<string>();
|
|
@@ -86,8 +103,7 @@ const warnedSlugs = new Set<string>();
|
|
|
86
103
|
* at all; `FacetGroup.labelSource` is how a surface marks it up for a test.
|
|
87
104
|
*/
|
|
88
105
|
function warnUnnamedGroup(slug: string): void {
|
|
89
|
-
|
|
90
|
-
if (env?.NODE_ENV === "production") return;
|
|
106
|
+
if (!inDevelopment()) return;
|
|
91
107
|
if (warnedSlugs.has(slug)) return;
|
|
92
108
|
warnedSlugs.add(slug);
|
|
93
109
|
console.warn(
|
|
@@ -118,20 +134,20 @@ const warnedUndrawable = new Set<string>();
|
|
|
118
134
|
* category's schema), and neither of them can see it from the page.
|
|
119
135
|
*/
|
|
120
136
|
function warnUndrawableGroup(group: FacetGroup): void {
|
|
121
|
-
|
|
122
|
-
if (env?.NODE_ENV === "production") return;
|
|
137
|
+
if (!inDevelopment()) return;
|
|
123
138
|
if (warnedUndrawable.has(group.slug)) return;
|
|
124
139
|
warnedUndrawable.add(group.slug);
|
|
125
140
|
console.warn(
|
|
126
|
-
`[search-react] facet group "${group.slug}"
|
|
127
|
-
`answer
|
|
128
|
-
group.
|
|
129
|
-
? " and
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
141
|
+
`[search-react] facet group "${group.slug}" is not drawn: no candidate in ` +
|
|
142
|
+
`this answer carries any of its values${
|
|
143
|
+
group.options.length === 0
|
|
144
|
+
? " and it has no options at all (the answer did not count it and " +
|
|
145
|
+
"the schema passed to this page enumerates nothing for it)"
|
|
146
|
+
: ` (${String(group.options.length)} options, every count zero or null)`
|
|
147
|
+
}, and nothing is selected on it.` +
|
|
148
|
+
(group.feature?.mandatory === true && !facetGroupIsVocabularyBacked(group)
|
|
149
|
+
? " The schema marks this axis REQUIRED — a required axis with no" +
|
|
150
|
+
" evidence is a plan or a schema fault, not an empty shelf."
|
|
135
151
|
: "")
|
|
136
152
|
);
|
|
137
153
|
}
|
|
@@ -200,6 +216,29 @@ export interface FacetOption {
|
|
|
200
216
|
/** One facet slug, with its options. */
|
|
201
217
|
export interface FacetGroup {
|
|
202
218
|
readonly slug: string;
|
|
219
|
+
/**
|
|
220
|
+
* What this axis is called in the ADDRESS — the answer's own `url_key`, the
|
|
221
|
+
* slug when it states none.
|
|
222
|
+
*
|
|
223
|
+
* Carried on the group rather than looked up per surface: the chip row, the
|
|
224
|
+
* rail, the popular-values block and the applied chips all act by slug and
|
|
225
|
+
* all end up in the same query string, and a second place that decides how
|
|
226
|
+
* to spell a key is a second place that can disagree with the codec.
|
|
227
|
+
*
|
|
228
|
+
* OPTIONAL so a hand-built group (a demo, a fixture, a host's own list)
|
|
229
|
+
* still type-checks; `buildFacetGroups` always sets it.
|
|
230
|
+
*/
|
|
231
|
+
readonly urlKey?: string;
|
|
232
|
+
/**
|
|
233
|
+
* The vocabulary this axis draws its values from, when anything says so —
|
|
234
|
+
* the answer's `vocabulary`, else the schema's `optionsRef`.
|
|
235
|
+
*
|
|
236
|
+
* The difference between a list and a DICTIONARY, and the reason a group
|
|
237
|
+
* with three buckets on a thin stand still gets a search field: what is
|
|
238
|
+
* behind it is the catalogue's hundreds, not the three the stand happens to
|
|
239
|
+
* hold. `undefined` means nobody said, not "inline".
|
|
240
|
+
*/
|
|
241
|
+
readonly vocabulary?: string;
|
|
203
242
|
/** The group's heading: the answer's own `label`, else the feature's
|
|
204
243
|
* display name, else — with a dev warning — the raw slug. */
|
|
205
244
|
readonly label: string;
|
|
@@ -275,6 +314,46 @@ export function facetGroupHasEvidence(group: FacetGroup): boolean {
|
|
|
275
314
|
return group.options.some((option) => (option.count ?? 0) > 0);
|
|
276
315
|
}
|
|
277
316
|
|
|
317
|
+
/**
|
|
318
|
+
* Is this axis one a person can SEARCH even with no evidence behind it — an
|
|
319
|
+
* axis whose values live in a VOCABULARY?
|
|
320
|
+
*
|
|
321
|
+
* The one thing a zero-evidence group can still be. A `ref_select` whose
|
|
322
|
+
* config is a pointer into a vocabulary is a dictionary of hundreds; the
|
|
323
|
+
* control for it is a field with a search box, and that box searches the
|
|
324
|
+
* DICTIONARY, not the buckets — so it is worth opening on a leaf holding one
|
|
325
|
+
* listing exactly as it is on one holding thirty thousand. The stand being
|
|
326
|
+
* thin is a fact about the stand.
|
|
327
|
+
*
|
|
328
|
+
* `mandatory` is deliberately NOT asked. It was, for one round, and on the
|
|
329
|
+
* live laptops leaf not one of `vendor`, `model`, `screen_size` is marked
|
|
330
|
+
* required — so the rule that was meant to save the make picker deleted the
|
|
331
|
+
* vendor picker one category over. What makes the field usable is the
|
|
332
|
+
* dictionary behind it; whether the composer forces a seller to fill the
|
|
333
|
+
* field says nothing about that.
|
|
334
|
+
*/
|
|
335
|
+
function isSearchableVocabularyAxis(group: FacetGroup): boolean {
|
|
336
|
+
return facetGroupIsVocabularyBacked(group);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Does this axis draw its values from a VOCABULARY rather than an inline
|
|
341
|
+
* option table?
|
|
342
|
+
*
|
|
343
|
+
* Asked of the schema first (`ref_select`/`ref_hierarchical_select` — the
|
|
344
|
+
* types whose whole config is an `optionsRef` pointer), and of the answer
|
|
345
|
+
* when there is no schema to ask: a host that threaded no feature list still
|
|
346
|
+
* gets the right control if the server named the vocabulary. A def of any
|
|
347
|
+
* other type is a NO however it is configured — a bounded `int` carrying an
|
|
348
|
+
* `optionsRef` (the live cars `year`) is a range, and a range is not a
|
|
349
|
+
* dictionary.
|
|
350
|
+
*/
|
|
351
|
+
export function facetGroupIsVocabularyBacked(group: FacetGroup): boolean {
|
|
352
|
+
const type = group.feature === undefined ? undefined : featureType(group.feature);
|
|
353
|
+
if (type !== undefined) return VOCABULARY_BACKED_TYPES.includes(type);
|
|
354
|
+
return group.vocabulary !== undefined;
|
|
355
|
+
}
|
|
356
|
+
|
|
278
357
|
/**
|
|
279
358
|
* Is there anything for a surface to DRAW here?
|
|
280
359
|
*
|
|
@@ -282,21 +361,37 @@ export function facetGroupHasEvidence(group: FacetGroup): boolean {
|
|
|
282
361
|
* `options.length > 0` — one predicate, or the two surfaces drift into two
|
|
283
362
|
* opinions about what an empty group is.
|
|
284
363
|
*
|
|
285
|
-
*
|
|
286
|
-
*
|
|
287
|
-
*
|
|
288
|
-
*
|
|
289
|
-
*
|
|
290
|
-
*
|
|
291
|
-
*
|
|
292
|
-
*
|
|
293
|
-
*
|
|
294
|
-
*
|
|
295
|
-
*
|
|
364
|
+
* ── An axis with no evidence is not a filter, it is a heading ─────────────
|
|
365
|
+
*
|
|
366
|
+
* The predicate used to ask `options.length > 0`, and on a live laptops leaf
|
|
367
|
+
* (D249, `/c/noutbuki`) that drew six of six groups as accordions with
|
|
368
|
+
* nothing a person could narrow by: the answer counted every axis and gave
|
|
369
|
+
* every bucket a zero, and the axes the budget skipped kept their authored
|
|
370
|
+
* option tables with `count: null` on every row. Six headings, no filter, and
|
|
371
|
+
* the results below them unchanged whichever box was ticked.
|
|
372
|
+
*
|
|
373
|
+
* So the question is EVIDENCE, not option count: at least one value some
|
|
374
|
+
* candidate in this result set actually carries. Three exemptions, and they
|
|
375
|
+
* are the whole rule:
|
|
376
|
+
*
|
|
377
|
+
* - a group the reader has already FILTERED on, whatever its counts say —
|
|
378
|
+
* a constraint with no control to remove it is worse than a bare heading;
|
|
379
|
+
* - a vocabulary-backed axis (see {@link isSearchableVocabularyAxis}): its
|
|
380
|
+
* control is a FIELD, the field searches a dictionary the answer never
|
|
381
|
+
* enumerated, and it works with no buckets at all — this is the make on a
|
|
382
|
+
* cars leaf with three cars and the vendor on a laptops leaf with one;
|
|
383
|
+
* - nothing else. An authored `select` with no evidence is three checkboxes
|
|
384
|
+
* guaranteed to return nothing, and it costs a heading in a 280px rail and
|
|
385
|
+
* a chip on a 390px row to say so.
|
|
386
|
+
*
|
|
387
|
+
* A dropped axis is NAMED in development — a required axis disappearing out
|
|
388
|
+
* of a rail is a fault this pair spent a release chasing, and it must not
|
|
389
|
+
* disappear silently a second time.
|
|
296
390
|
*/
|
|
297
391
|
export function facetGroupIsDrawable(group: FacetGroup): boolean {
|
|
298
392
|
if (group.selected.length > 0) return true;
|
|
299
|
-
if (group
|
|
393
|
+
if (facetGroupHasEvidence(group)) return true;
|
|
394
|
+
if (isSearchableVocabularyAxis(group)) return true;
|
|
300
395
|
warnUndrawableGroup(group);
|
|
301
396
|
return false;
|
|
302
397
|
}
|
|
@@ -499,6 +594,30 @@ function resolveLabel(
|
|
|
499
594
|
* beats no heading, and it renders MARKED — `labelSource: "none"`, a warning
|
|
500
595
|
* in development, and a data attribute on the drawn group.
|
|
501
596
|
*/
|
|
597
|
+
/**
|
|
598
|
+
* Which vocabulary an axis draws from: the answer's word for it, then the
|
|
599
|
+
* schema's `optionsRef`. Neither invents one — `undefined` is "nobody said".
|
|
600
|
+
*/
|
|
601
|
+
/** `exactOptionalPropertyTypes` makes an absent key and an `undefined` one
|
|
602
|
+
* different types; this spreads to nothing when nobody named a vocabulary. */
|
|
603
|
+
function optionalVocabulary(
|
|
604
|
+
vocabulary: string | undefined
|
|
605
|
+
): { vocabulary?: string } {
|
|
606
|
+
return vocabulary === undefined ? {} : { vocabulary };
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function resolveVocabulary(
|
|
610
|
+
input: BuildFacetGroupsInput,
|
|
611
|
+
feature: FeatureDef | undefined,
|
|
612
|
+
slug: string
|
|
613
|
+
): string | undefined {
|
|
614
|
+
const stated = input.facetLabels?.[slug]?.vocabulary;
|
|
615
|
+
if (typeof stated === "string" && stated.length > 0) return stated;
|
|
616
|
+
if (feature === undefined) return undefined;
|
|
617
|
+
const ref = optionsRefOf(featureConfig(feature));
|
|
618
|
+
return ref === undefined || ref.vocabulary.length === 0 ? undefined : ref.vocabulary;
|
|
619
|
+
}
|
|
620
|
+
|
|
502
621
|
function resolveGroupLabel(
|
|
503
622
|
input: BuildFacetGroupsInput,
|
|
504
623
|
feature: FeatureDef | undefined,
|
|
@@ -594,13 +713,24 @@ export function buildFacetGroups(input: BuildFacetGroupsInput): readonly FacetGr
|
|
|
594
713
|
const bySlug = new Map<string, FeatureDef>();
|
|
595
714
|
for (const feature of input.categoryFeatures ?? []) bySlug.set(feature.slug, feature);
|
|
596
715
|
|
|
716
|
+
// The answer's address keys, so a URL carrying `f.make` selects the group
|
|
717
|
+
// called `make_ref_select` instead of inventing a second one beside it.
|
|
718
|
+
// Both forms are read here whatever the codec did upstream: a cold link is
|
|
719
|
+
// parsed before any answer exists, so the state can legitimately hold the
|
|
720
|
+
// short key while the groups are keyed by slug.
|
|
721
|
+
const keys = facetKeyMapFromLabels(input.facetLabels);
|
|
722
|
+
const applied = (slug: string): readonly string[] =>
|
|
723
|
+
input.state.filters[slug] ??
|
|
724
|
+
input.state.filters[keys.write[slug] ?? slug] ??
|
|
725
|
+
[];
|
|
726
|
+
|
|
597
727
|
const skipped = new Set(input.meta.skipped);
|
|
598
728
|
const slugs: string[] = [];
|
|
599
729
|
const seen = new Set<string>();
|
|
600
730
|
for (const slug of [
|
|
601
731
|
...Object.keys(input.facets),
|
|
602
732
|
...input.meta.skipped,
|
|
603
|
-
...Object.keys(input.state.filters),
|
|
733
|
+
...Object.keys(input.state.filters).map((key) => keys.read[key] ?? key),
|
|
604
734
|
]) {
|
|
605
735
|
if (seen.has(slug)) continue;
|
|
606
736
|
seen.add(slug);
|
|
@@ -619,8 +749,7 @@ export function buildFacetGroups(input: BuildFacetGroupsInput): readonly FacetGr
|
|
|
619
749
|
// for a schema that says nothing about it. That is the live case; a
|
|
620
750
|
// wrong-schema case where some other category types `make_ref_select` as
|
|
621
751
|
// free text is not one this pair can tell apart from a real `imei`.
|
|
622
|
-
|
|
623
|
-
if (!applied && !isFacetableFeature(bySlug.get(slug))) continue;
|
|
752
|
+
if (applied(slug).length === 0 && !isFacetableFeature(bySlug.get(slug))) continue;
|
|
624
753
|
slugs.push(slug);
|
|
625
754
|
}
|
|
626
755
|
|
|
@@ -628,7 +757,7 @@ export function buildFacetGroups(input: BuildFacetGroupsInput): readonly FacetGr
|
|
|
628
757
|
const feature = bySlug.get(slug);
|
|
629
758
|
const counts = input.facets[slug] ?? {};
|
|
630
759
|
const counted = !skipped.has(slug) && slug in input.facets;
|
|
631
|
-
const selected =
|
|
760
|
+
const selected = applied(slug);
|
|
632
761
|
|
|
633
762
|
// The authored option order, from whichever copy of the category config
|
|
634
763
|
// this page has. An authored list reshuffled by count is a size chart
|
|
@@ -683,6 +812,8 @@ export function buildFacetGroups(input: BuildFacetGroupsInput): readonly FacetGr
|
|
|
683
812
|
|
|
684
813
|
return {
|
|
685
814
|
slug,
|
|
815
|
+
urlKey: keys.write[slug] ?? slug,
|
|
816
|
+
...optionalVocabulary(resolveVocabulary(input, feature, slug)),
|
|
686
817
|
...resolveGroupLabel(input, feature, slug),
|
|
687
818
|
feature,
|
|
688
819
|
counted,
|
|
@@ -740,5 +871,17 @@ export function buildFacetGroups(input: BuildFacetGroupsInput): readonly FacetGr
|
|
|
740
871
|
function keepsAnAxisOpen(group: FacetGroup): boolean {
|
|
741
872
|
if (!group.counted) return true;
|
|
742
873
|
if (group.selected.length > 0) return true;
|
|
743
|
-
|
|
874
|
+
// A vocabulary axis survives its own zero: its control is a field over a
|
|
875
|
+
// dictionary the answer never enumerated, so "this stand holds no Toyotas
|
|
876
|
+
// yet" is not a reason to take the make picker off the page. Same
|
|
877
|
+
// clause as `facetGroupIsDrawable`'s, or the rail would ask a question the
|
|
878
|
+
// builder had already answered by deleting the group.
|
|
879
|
+
if (isSearchableVocabularyAxis(group)) return true;
|
|
880
|
+
if (facetCoverage(group) > 0) return true;
|
|
881
|
+
// Said here as well as in `facetGroupIsDrawable`, because a group withheld
|
|
882
|
+
// at BUILD time never reaches a surface to be named there — and a counted
|
|
883
|
+
// axis whose every bucket is zero is exactly the shape the laptops leaf
|
|
884
|
+
// arrived in. One warning per slug per page load either way.
|
|
885
|
+
warnUndrawableGroup(group);
|
|
886
|
+
return false;
|
|
744
887
|
}
|
package/src/state/urlState.ts
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
* both directions without a DOM.
|
|
21
21
|
*/
|
|
22
22
|
import type {
|
|
23
|
+
FacetLabelsMap,
|
|
23
24
|
FacetSelection,
|
|
24
25
|
SearchGeo,
|
|
25
26
|
SearchQueryState,
|
|
@@ -49,6 +50,113 @@ export const FILTER_PREFIX = "f.";
|
|
|
49
50
|
/** Prefix of a range filter parameter (`r.price=100..500`). */
|
|
50
51
|
export const RANGE_PREFIX = "r.";
|
|
51
52
|
|
|
53
|
+
/**
|
|
54
|
+
* THE SHORT KEY IN THE ADDRESS — `f.make`, not `f.make_ref_select`.
|
|
55
|
+
*
|
|
56
|
+
* A feature's identity is its slug and stays its slug: nothing is renamed,
|
|
57
|
+
* nothing is stored, and the request this pair sends carries whatever key the
|
|
58
|
+
* state holds because the server accepts both forms inside a category scope
|
|
59
|
+
* (`stapel-search` 0.14.4, `facets.resolve_url_key`). What changes is only
|
|
60
|
+
* what a PERSON reads in their own address bar: `f.make_ref_select=toyota`
|
|
61
|
+
* says "make" once and "how the importer typed it" once, and the second half
|
|
62
|
+
* is noise a reader cannot act on.
|
|
63
|
+
*
|
|
64
|
+
* The map is the ANSWER's — `facet_labels[slug].url_key`, derived per request
|
|
65
|
+
* against the queried category's own feature list. This module never derives
|
|
66
|
+
* one by chopping suffixes off slugs: the same audit that produced the rule
|
|
67
|
+
* counted 181 suffixed slugs of which every one collides with a differently
|
|
68
|
+
* typed sibling once stripped catalogue-wide, so shortening is only safe
|
|
69
|
+
* inside a scope the server knows and this codec does not.
|
|
70
|
+
*
|
|
71
|
+
* Two directions, both needed and neither symmetric:
|
|
72
|
+
*
|
|
73
|
+
* - {@link FacetKeyMap.write} is `slug → key`, applied by
|
|
74
|
+
* {@link writeSearchState} so a click produces the short address;
|
|
75
|
+
* - {@link FacetKeyMap.read} is `key → slug`, applied by
|
|
76
|
+
* {@link parseSearchState} so the short address and the full one both land
|
|
77
|
+
* on the same state, and a chip drawn from a group keyed by slug knows it
|
|
78
|
+
* is selected.
|
|
79
|
+
*/
|
|
80
|
+
export interface FacetKeyMap {
|
|
81
|
+
/** `{slug: key to write}`. A slug with no short form is absent. */
|
|
82
|
+
readonly write: Readonly<Record<string, string>>;
|
|
83
|
+
/** `{key off the address: the slug it names}`. */
|
|
84
|
+
readonly read: Readonly<Record<string, string>>;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** No short keys — a pre-0.14.4 server, or a query with no category scope. */
|
|
88
|
+
export const EMPTY_FACET_KEYS: FacetKeyMap = { write: {}, read: {} };
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Normalize `{slug: url_key}` into the two directions, refusing anything
|
|
92
|
+
* ambiguous.
|
|
93
|
+
*
|
|
94
|
+
* The server already applies these rules; they are applied again here because
|
|
95
|
+
* this codec cannot verify which server answered, and the cost of getting it
|
|
96
|
+
* wrong is a filter that reads back as a different filter. A short form is
|
|
97
|
+
* used only when it is:
|
|
98
|
+
*
|
|
99
|
+
* - not a real slug of the same answer — `f.make` in a scope declaring both
|
|
100
|
+
* `make` and `make_ref_select` is the feature CALLED `make`, and the other
|
|
101
|
+
* one keeps its full slug (the server's own rule, stated the same way);
|
|
102
|
+
* - claimed by exactly one slug. Two slugs shortening to one key means
|
|
103
|
+
* neither may use it: a collision keeps the slug on both sides.
|
|
104
|
+
*/
|
|
105
|
+
export function buildFacetKeyMap(
|
|
106
|
+
declared: Readonly<Record<string, string | null | undefined>>
|
|
107
|
+
): FacetKeyMap {
|
|
108
|
+
const slugs = Object.keys(declared);
|
|
109
|
+
const real = new Set(slugs);
|
|
110
|
+
const claims = new Map<string, number>();
|
|
111
|
+
for (const slug of slugs) {
|
|
112
|
+
const key = declared[slug];
|
|
113
|
+
if (typeof key !== "string" || key.length === 0 || key === slug) continue;
|
|
114
|
+
claims.set(key, (claims.get(key) ?? 0) + 1);
|
|
115
|
+
}
|
|
116
|
+
const write: Record<string, string> = {};
|
|
117
|
+
const read: Record<string, string> = {};
|
|
118
|
+
for (const slug of slugs) {
|
|
119
|
+
const key = declared[slug];
|
|
120
|
+
if (
|
|
121
|
+
typeof key !== "string" ||
|
|
122
|
+
key.length === 0 ||
|
|
123
|
+
key === slug ||
|
|
124
|
+
real.has(key) ||
|
|
125
|
+
claims.get(key) !== 1
|
|
126
|
+
) {
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
write[slug] = key;
|
|
130
|
+
read[key] = slug;
|
|
131
|
+
}
|
|
132
|
+
// A real slug always wins, whatever some other group shortened to. Written
|
|
133
|
+
// last so it overwrites a short form that collided with it.
|
|
134
|
+
for (const slug of slugs) read[slug] = slug;
|
|
135
|
+
return { write, read };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** {@link buildFacetKeyMap} over an answer's `facet_labels`. */
|
|
139
|
+
export function facetKeyMapFromLabels(
|
|
140
|
+
labels: FacetLabelsMap | undefined
|
|
141
|
+
): FacetKeyMap {
|
|
142
|
+
if (labels === undefined) return EMPTY_FACET_KEYS;
|
|
143
|
+
const declared: Record<string, string | null | undefined> = {};
|
|
144
|
+
for (const [slug, entry] of Object.entries(labels)) declared[slug] = entry.url_key;
|
|
145
|
+
return buildFacetKeyMap(declared);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** The slug an address key names. Unknown keys pass through unchanged — the
|
|
149
|
+
* server resolves what it recognises and 400s on what it does not, and a
|
|
150
|
+
* codec that dropped the key would lose a filter the answer can still honour. */
|
|
151
|
+
export function facetSlugForKey(key: string, keys: FacetKeyMap | undefined): string {
|
|
152
|
+
return keys?.read[key] ?? key;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** The address key a slug is written as. The slug itself when there is none. */
|
|
156
|
+
export function facetKeyForSlug(slug: string, keys: FacetKeyMap | undefined): string {
|
|
157
|
+
return keys?.write[slug] ?? slug;
|
|
158
|
+
}
|
|
159
|
+
|
|
52
160
|
/**
|
|
53
161
|
* Something in the URL this codec could not make sense of.
|
|
54
162
|
*
|
|
@@ -133,6 +241,18 @@ export interface ParseSearchStateOptions {
|
|
|
133
241
|
readonly defaultCategory?: string;
|
|
134
242
|
/** Applied when the URL carries no `lang`. */
|
|
135
243
|
readonly defaultLang?: string;
|
|
244
|
+
/**
|
|
245
|
+
* The answer's short-key map (see {@link FacetKeyMap}), so `f.make` off the
|
|
246
|
+
* address becomes the state's `make_ref_select`.
|
|
247
|
+
*
|
|
248
|
+
* Optional and late by nature: the map is a property of the ANSWER, so the
|
|
249
|
+
* first parse of a cold link runs without it. That is not a failure — the
|
|
250
|
+
* short key goes to the server, which resolves it in the category scope and
|
|
251
|
+
* answers correctly — it only means the group's own selection is matched
|
|
252
|
+
* by key rather than by slug until the answer lands (`buildFacetGroups`
|
|
253
|
+
* matches both, so nothing flickers).
|
|
254
|
+
*/
|
|
255
|
+
readonly facetKeys?: FacetKeyMap;
|
|
136
256
|
}
|
|
137
257
|
|
|
138
258
|
function num(
|
|
@@ -262,7 +382,10 @@ export function parseSearchState(
|
|
|
262
382
|
if (key.startsWith(FILTER_PREFIX) && key.length > FILTER_PREFIX.length) {
|
|
263
383
|
// `getAll` is the whole point: a repeated key is OR within the slug.
|
|
264
384
|
const values = params.getAll(key).filter((v) => v.length > 0);
|
|
265
|
-
if (values.length
|
|
385
|
+
if (values.length === 0) continue;
|
|
386
|
+
// Both forms read: `f.make` and `f.make_ref_select` land on one slug.
|
|
387
|
+
const slug = facetSlugForKey(key.slice(FILTER_PREFIX.length), options.facetKeys);
|
|
388
|
+
filters[slug] = [...(filters[slug] ?? []), ...values];
|
|
266
389
|
continue;
|
|
267
390
|
}
|
|
268
391
|
if (key.startsWith(RANGE_PREFIX) && key.length > RANGE_PREFIX.length) {
|
|
@@ -279,7 +402,7 @@ export function parseSearchState(
|
|
|
279
402
|
const from = raw.slice(0, separator);
|
|
280
403
|
const to = raw.slice(separator + 2);
|
|
281
404
|
if (from.length === 0 && to.length === 0) continue;
|
|
282
|
-
ranges[key.slice(RANGE_PREFIX.length)] = {
|
|
405
|
+
ranges[facetSlugForKey(key.slice(RANGE_PREFIX.length), options.facetKeys)] = {
|
|
283
406
|
...(from.length > 0 ? { from } : {}),
|
|
284
407
|
...(to.length > 0 ? { to } : {}),
|
|
285
408
|
};
|
|
@@ -346,10 +469,15 @@ function optional<K extends string, V>(
|
|
|
346
469
|
* is copied through untouched, so a host's own `?ref=`/`utm_*` survives a
|
|
347
470
|
* facet click. Every parameter it DOES own is rewritten from the state, so a
|
|
348
471
|
* removed filter actually leaves the URL instead of lingering as a stale key.
|
|
472
|
+
*
|
|
473
|
+
* `keys` is the answer's short-key map: a filter is written as its
|
|
474
|
+
* `url_key` when the answer states one and as its slug otherwise, so the two
|
|
475
|
+
* halves of a round trip agree without either side chopping at a string.
|
|
349
476
|
*/
|
|
350
477
|
export function writeSearchState(
|
|
351
478
|
state: SearchQueryState,
|
|
352
|
-
base?: URLSearchParams
|
|
479
|
+
base?: URLSearchParams,
|
|
480
|
+
keys?: FacetKeyMap
|
|
353
481
|
): URLSearchParams {
|
|
354
482
|
const next = new URLSearchParams();
|
|
355
483
|
|
|
@@ -367,15 +495,19 @@ export function writeSearchState(
|
|
|
367
495
|
if (state.owner !== undefined) next.set(SEARCH_PARAM.owner, state.owner);
|
|
368
496
|
|
|
369
497
|
for (const slug of Object.keys(state.filters).sort()) {
|
|
498
|
+
const key = facetKeyForSlug(slug, keys);
|
|
370
499
|
for (const value of state.filters[slug] ?? []) {
|
|
371
|
-
next.append(`${FILTER_PREFIX}${
|
|
500
|
+
next.append(`${FILTER_PREFIX}${key}`, value);
|
|
372
501
|
}
|
|
373
502
|
}
|
|
374
503
|
for (const slug of Object.keys(state.ranges).sort()) {
|
|
375
504
|
const range = state.ranges[slug];
|
|
376
505
|
if (range === undefined) continue;
|
|
377
506
|
if (range.from === undefined && range.to === undefined) continue;
|
|
378
|
-
next.set(
|
|
507
|
+
next.set(
|
|
508
|
+
`${RANGE_PREFIX}${facetKeyForSlug(slug, keys)}`,
|
|
509
|
+
`${range.from ?? ""}..${range.to ?? ""}`
|
|
510
|
+
);
|
|
379
511
|
}
|
|
380
512
|
|
|
381
513
|
if (state.geo !== undefined) {
|