@stapel/search-react 0.25.0 → 0.27.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.
Files changed (81) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/MODULE.md +21 -9
  3. package/README.md +79 -18
  4. package/dist/api/generated/schema.d.ts +18 -3
  5. package/dist/api/generated/schema.d.ts.map +1 -1
  6. package/dist/api/types.d.ts +39 -4
  7. package/dist/api/types.d.ts.map +1 -1
  8. package/dist/api/types.js.map +1 -1
  9. package/dist/default/FacetGroupControl.d.ts +20 -13
  10. package/dist/default/FacetGroupControl.d.ts.map +1 -1
  11. package/dist/default/FacetGroupControl.js +29 -21
  12. package/dist/default/FacetGroupControl.js.map +1 -1
  13. package/dist/default/FacetPanelPane.d.ts +28 -11
  14. package/dist/default/FacetPanelPane.d.ts.map +1 -1
  15. package/dist/default/FacetPanelPane.js +53 -24
  16. package/dist/default/FacetPanelPane.js.map +1 -1
  17. package/dist/default/SearchPage.d.ts +27 -0
  18. package/dist/default/SearchPage.d.ts.map +1 -1
  19. package/dist/default/SearchPage.js +21 -8
  20. package/dist/default/SearchPage.js.map +1 -1
  21. package/dist/headless/SearchStateProvider.d.ts +12 -2
  22. package/dist/headless/SearchStateProvider.d.ts.map +1 -1
  23. package/dist/headless/SearchStateProvider.js +58 -6
  24. package/dist/headless/SearchStateProvider.js.map +1 -1
  25. package/dist/i18n/es.d.ts.map +1 -1
  26. package/dist/i18n/es.js +0 -2
  27. package/dist/i18n/es.js.map +1 -1
  28. package/dist/i18n/generated/errors.es.gen.d.ts.map +1 -1
  29. package/dist/i18n/generated/errors.es.gen.js +1 -0
  30. package/dist/i18n/generated/errors.es.gen.js.map +1 -1
  31. package/dist/i18n/generated/errors.gen.d.ts +6 -0
  32. package/dist/i18n/generated/errors.gen.d.ts.map +1 -1
  33. package/dist/i18n/generated/errors.gen.js +3 -0
  34. package/dist/i18n/generated/errors.gen.js.map +1 -1
  35. package/dist/i18n/generated/errors.ru.gen.d.ts.map +1 -1
  36. package/dist/i18n/generated/errors.ru.gen.js +1 -0
  37. package/dist/i18n/generated/errors.ru.gen.js.map +1 -1
  38. package/dist/i18n/keys.d.ts +13 -11
  39. package/dist/i18n/keys.d.ts.map +1 -1
  40. package/dist/i18n/keys.js +13 -14
  41. package/dist/i18n/keys.js.map +1 -1
  42. package/dist/i18n/ru.d.ts.map +1 -1
  43. package/dist/i18n/ru.js +0 -4
  44. package/dist/i18n/ru.js.map +1 -1
  45. package/dist/index.d.ts +4 -4
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/index.js +3 -3
  48. package/dist/index.js.map +1 -1
  49. package/dist/model/queries.d.ts.map +1 -1
  50. package/dist/model/queries.js +9 -1
  51. package/dist/model/queries.js.map +1 -1
  52. package/dist/state/facets.d.ts +62 -11
  53. package/dist/state/facets.d.ts.map +1 -1
  54. package/dist/state/facets.js +141 -29
  55. package/dist/state/facets.js.map +1 -1
  56. package/dist/state/urlState.d.ts +77 -2
  57. package/dist/state/urlState.d.ts.map +1 -1
  58. package/dist/state/urlState.js +80 -6
  59. package/dist/state/urlState.js.map +1 -1
  60. package/llms.txt +3 -2
  61. package/manifest.json +18 -4
  62. package/nav-manifest.json +1 -1
  63. package/package.json +9 -9
  64. package/src/analytics/generated/events.json +1 -1
  65. package/src/api/generated/schema.ts +18 -3
  66. package/src/api/types.ts +42 -4
  67. package/src/default/FacetGroupControl.tsx +27 -23
  68. package/src/default/FacetPanelPane.tsx +81 -33
  69. package/src/default/SearchPage.tsx +55 -6
  70. package/src/headless/SearchStateProvider.tsx +94 -4
  71. package/src/i18n/es.ts +0 -4
  72. package/src/i18n/generated/errors.es.gen.ts +1 -0
  73. package/src/i18n/generated/errors.gen.ts +3 -0
  74. package/src/i18n/generated/errors.json +9 -0
  75. package/src/i18n/generated/errors.ru.gen.ts +1 -0
  76. package/src/i18n/keys.ts +13 -16
  77. package/src/i18n/ru.ts +0 -8
  78. package/src/index.ts +13 -1
  79. package/src/model/queries.ts +11 -1
  80. package/src/state/facets.ts +173 -30
  81. package/src/state/urlState.ts +137 -5
@@ -9,6 +9,7 @@ import type {
9
9
  SuggestAnswer,
10
10
  } from "../api/types.js";
11
11
  import { SUGGEST_MAX_LIMIT, SUGGEST_MIN_CHARS } from "../state/limits.js";
12
+ import { usePublishFacetKeys } from "../headless/SearchStateProvider.js";
12
13
  import { useSearchApi } from "./context.js";
13
14
  import { searchQueryKeys } from "./queryKeys.js";
14
15
 
@@ -53,13 +54,22 @@ export function useSearchQuery(
53
54
  ): UseQueryResult<SearchResponse, StapelApiError> {
54
55
  const api = useSearchApi();
55
56
  const params = searchQueryParams(state);
56
- return useQuery({
57
+ const query = useQuery<SearchResponse, StapelApiError>({
57
58
  queryKey: searchQueryKeys.query(params),
58
59
  queryFn: ({ signal }) => api.query(state, { signal }),
59
60
  enabled: (options?.enabled ?? true) && state.type.length > 0,
60
61
  placeholderData: keepPreviousData,
61
62
  retry: false,
62
63
  });
64
+ // The answer states what each axis is called IN THE ADDRESS
65
+ // (`facet_labels[slug].url_key`); the codec that writes the address sits
66
+ // above this hook, so the map is handed up. Every surface reads through
67
+ // this one hook, so there is exactly one place the address learns its own
68
+ // spelling — and outside a `<SearchStateProvider>` it is a no-op.
69
+ usePublishFacetKeys(
70
+ query.data === undefined ? undefined : query.data.facet_labels
71
+ );
72
+ return query;
63
73
  }
64
74
 
65
75
  /**
@@ -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
- const env = typeof process === "undefined" ? undefined : process.env;
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
- const env = typeof process === "undefined" ? undefined : process.env;
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}" has no values to draw: the ` +
127
- `answer did not count it${
128
- group.feature === undefined
129
- ? " and the category schema passed to this page does not define it"
130
- : " and its config carries a vocabulary pointer, not an option table"
131
- }, so the group is not rendered.` +
132
- (group.feature?.mandatory === true
133
- ? " The schema marks this axis REQUIRED — a buyer cannot narrow by a" +
134
- " field every seller had to fill."
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
- * A group with no options is a heading over nothing: after
286
- * {@link buildFacetGroups} learned to read the schema, what is left in that
287
- * state is a `ref_select` whose config is a bare `optionsRef` pointer and
288
- * which the server did not count nothing to enumerate from either side. It
289
- * is not drawn, and in development it is NAMED: a required axis (the make on
290
- * cars leaf) disappearing out of a rail is exactly the fault this pair spent
291
- * a release chasing, and it must not disappear silently a second time.
292
- *
293
- * A group the reader has already FILTERED on is drawn whatever its options
294
- * say a constraint with no control to remove it is worse than a bare
295
- * heading.
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.options.length > 0) return true;
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
- const applied = (input.state.filters[slug] ?? []).length > 0;
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 = input.state.filters[slug] ?? [];
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
- return facetCoverage(group) > 0;
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
  }
@@ -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 > 0) filters[key.slice(FILTER_PREFIX.length)] = values;
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}${slug}`, value);
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(`${RANGE_PREFIX}${slug}`, `${range.from ?? ""}..${range.to ?? ""}`);
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) {