@stapel/search-react 0.21.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 +50 -0
- package/README.md +65 -2
- package/dist/api/types.d.ts +24 -3
- 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/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/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/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 +6 -0
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/keys.d.ts +30 -0
- package/dist/i18n/keys.d.ts.map +1 -1
- package/dist/i18n/keys.js +37 -0
- package/dist/i18n/keys.js.map +1 -1
- package/dist/i18n/ru.d.ts.map +1 -1
- package/dist/i18n/ru.js +6 -0
- package/dist/i18n/ru.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/state/facets.d.ts +26 -6
- package/dist/state/facets.d.ts.map +1 -1
- package/dist/state/facets.js +72 -15
- 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/llms.txt +3 -1
- package/manifest.json +38 -1
- package/nav-manifest.json +1 -1
- package/package.json +7 -7
- package/src/analytics/generated/events.json +1 -1
- package/src/api/types.ts +29 -3
- package/src/default/FacetGroupControl.tsx +213 -8
- package/src/default/PartitionChips.tsx +161 -0
- package/src/default/PopularValues.tsx +151 -0
- package/src/default/index.ts +15 -0
- package/src/headless/useFacetLabels.ts +7 -7
- package/src/i18n/es.ts +6 -0
- package/src/i18n/keys.ts +38 -0
- package/src/i18n/ru.ts +6 -0
- package/src/index.ts +9 -0
- package/src/state/facets.ts +107 -27
- package/src/state/translit.ts +113 -0
package/src/i18n/keys.ts
CHANGED
|
@@ -157,6 +157,24 @@ export const SEARCH_I18N_KEYS = {
|
|
|
157
157
|
* Distinct from `facetsEmpty`, which says the SEARCH offers no filters —
|
|
158
158
|
* here the filters exist and the query missed. */
|
|
159
159
|
facetsSearchEmpty: "search.facets.search_empty",
|
|
160
|
+
/**
|
|
161
|
+
* The box inside ONE dictionary group — 418 car makes are not a list a
|
|
162
|
+
* person scrolls. Distinct from `facetsSearch`, which finds a GROUP among
|
|
163
|
+
* forty headings: one narrows the panel, the other narrows one axis of it,
|
|
164
|
+
* and a translator handed one string for both has to pick which surface to
|
|
165
|
+
* be wrong on.
|
|
166
|
+
*/
|
|
167
|
+
facetsDictionarySearch: "search.facets.dictionary_search",
|
|
168
|
+
/** The dictionary box matched no value of this group. Not `facetsEmpty`,
|
|
169
|
+
* and not `facetsSearchEmpty`: the group exists and its values exist. */
|
|
170
|
+
facetsDictionaryEmpty: "search.facets.dictionary_empty",
|
|
171
|
+
/** Over the values already chosen, which stay visible whatever the box
|
|
172
|
+
* says — a filter a person cannot see is a filter they cannot remove. */
|
|
173
|
+
facetsDictionaryChosen: "search.facets.dictionary_chosen",
|
|
174
|
+
/** The way from the "popular values" block into the whole control. The
|
|
175
|
+
* block is a shortcut over the busiest values, so the link has to say that
|
|
176
|
+
* the rest exist. */
|
|
177
|
+
facetsPopularAll: "search.facets.popular_all",
|
|
160
178
|
/**
|
|
161
179
|
* The rail's sticky footer: what the applied filters LEFT — "N listings
|
|
162
180
|
* match". A PLURAL FAMILY: it counts a noun in words. Desktop filters
|
|
@@ -192,6 +210,20 @@ export const SEARCH_I18N_KEYS = {
|
|
|
192
210
|
* `{count}` is how many chips it hides (D16). */
|
|
193
211
|
filtersChipOverflow: "search.filters.chips_overflow",
|
|
194
212
|
|
|
213
|
+
// ── a partition: one template split by a value its children name ─────────
|
|
214
|
+
/**
|
|
215
|
+
* The first chip of a partition row — the parent itself, unnarrowed.
|
|
216
|
+
*
|
|
217
|
+
* Not `facetsPopularAll` reused: that one OPENS a control, this one is a
|
|
218
|
+
* CHOICE among the children (and the one that is selected by default). The
|
|
219
|
+
* two are the same word in English and stop being one the moment a
|
|
220
|
+
* translator reads either as an imperative.
|
|
221
|
+
*/
|
|
222
|
+
partitionAll: "search.partition.all",
|
|
223
|
+
/** The accessible name of the chip row — a `radiogroup` with no name is
|
|
224
|
+
* announced as nothing at all. */
|
|
225
|
+
partitionLabel: "search.partition.label",
|
|
226
|
+
|
|
195
227
|
// ── the way OUT of a search that found nothing ───────────────────────────
|
|
196
228
|
/** Caption above the exit buttons. Not a second "nothing found" — that
|
|
197
229
|
* sentence has already been said; this one says what can be done next. */
|
|
@@ -462,6 +494,12 @@ export const searchI18nBundleEn: Record<string, string> = {
|
|
|
462
494
|
"search.facets.show_less": "Show fewer",
|
|
463
495
|
"search.facets.search": "Find a filter",
|
|
464
496
|
"search.facets.search_empty": "No filter matches this",
|
|
497
|
+
"search.facets.dictionary_search": "Find a value",
|
|
498
|
+
"search.facets.dictionary_empty": "No value matches this",
|
|
499
|
+
"search.facets.dictionary_chosen": "Chosen",
|
|
500
|
+
"search.facets.popular_all": "All",
|
|
501
|
+
"search.partition.all": "All",
|
|
502
|
+
"search.partition.label": "Section",
|
|
465
503
|
"search.facets.match_count.one": "{count} listing matches",
|
|
466
504
|
"search.facets.match_count.other": "{count} listings match",
|
|
467
505
|
"search.facets.range_invalid":
|
package/src/i18n/ru.ts
CHANGED
|
@@ -122,6 +122,12 @@ export const searchI18nBundleRu: I18nDictionary = {
|
|
|
122
122
|
"search.facets.show_less": "Свернуть",
|
|
123
123
|
"search.facets.search": "Найти фильтр",
|
|
124
124
|
"search.facets.search_empty": "Среди фильтров такого нет",
|
|
125
|
+
"search.facets.dictionary_search": "Найти значение",
|
|
126
|
+
"search.facets.dictionary_empty": "Такого значения здесь нет",
|
|
127
|
+
"search.facets.dictionary_chosen": "Выбрано",
|
|
128
|
+
"search.facets.popular_all": "Все",
|
|
129
|
+
"search.partition.all": "Все",
|
|
130
|
+
"search.partition.label": "Раздел",
|
|
125
131
|
"search.facets.match_count.one": "Найдено {count} объявление",
|
|
126
132
|
"search.facets.match_count.few": "Найдено {count} объявления",
|
|
127
133
|
"search.facets.match_count.many": "Найдено {count} объявлений",
|
package/src/index.ts
CHANGED
|
@@ -52,6 +52,8 @@ export {
|
|
|
52
52
|
} from "./api/types.js";
|
|
53
53
|
export type {
|
|
54
54
|
FacetCategoryCount,
|
|
55
|
+
FacetLabels,
|
|
56
|
+
FacetLabelsMap,
|
|
55
57
|
FacetMeta,
|
|
56
58
|
FacetSelection,
|
|
57
59
|
FacetWithheldGroup,
|
|
@@ -120,9 +122,16 @@ export {
|
|
|
120
122
|
export type {
|
|
121
123
|
BuildFacetGroupsInput,
|
|
122
124
|
FacetGroup,
|
|
125
|
+
FacetLabelSource,
|
|
123
126
|
FacetOption,
|
|
124
127
|
} from "./state/facets.js";
|
|
125
128
|
|
|
129
|
+
export {
|
|
130
|
+
consonantKey,
|
|
131
|
+
translitKey,
|
|
132
|
+
translitPrefixMatch,
|
|
133
|
+
} from "./state/translit.js";
|
|
134
|
+
|
|
126
135
|
export {
|
|
127
136
|
RANGE_FEATURE_TYPES,
|
|
128
137
|
buildRangeGroups,
|
package/src/state/facets.ts
CHANGED
|
@@ -8,9 +8,12 @@
|
|
|
8
8
|
* they would have if you swapped to them. A panel that greys the siblings
|
|
9
9
|
* out has silently converted a drill-down facet into a naive one, and the
|
|
10
10
|
* e2e leg in the spec (§7.2) exists to catch exactly that.
|
|
11
|
-
* 2. **
|
|
12
|
-
*
|
|
13
|
-
*
|
|
11
|
+
* 2. **LABELS have one stated order: ANSWER, then schema, then the raw
|
|
12
|
+
* term** — for the group's heading and for every option alike, with a host
|
|
13
|
+
* resolver between the last two for options, applied by
|
|
14
|
+
* `useHostFacetLabels` on what this module could not name. The bottom of
|
|
15
|
+
* that order is not a label and is marked as such: `labelSource: "none"`,
|
|
16
|
+
* a warning in development, and a data attribute on the drawn group.
|
|
14
17
|
*
|
|
15
18
|
* The answer leads because it is the only source that always exists and
|
|
16
19
|
* the only one that has RESOLVED anything: `categoryFeatures` is an
|
|
@@ -50,12 +53,48 @@
|
|
|
50
53
|
import {
|
|
51
54
|
VOCABULARY_BACKED_TYPES,
|
|
52
55
|
featureConfig,
|
|
53
|
-
featureName,
|
|
54
56
|
featureType,
|
|
55
57
|
formatFeatureValue,
|
|
56
58
|
} from "@stapel/attributes-react";
|
|
57
59
|
import type { FeatureDef } from "@stapel/attributes-react";
|
|
58
|
-
import type { FacetMeta, SearchQueryState } from "../api/types.js";
|
|
60
|
+
import type { FacetLabelsMap, FacetMeta, SearchQueryState } from "../api/types.js";
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Where a caption came from — the group's heading and every option carry it,
|
|
64
|
+
* because "did anybody actually name this?" is a question two surfaces have
|
|
65
|
+
* to answer and neither can answer by looking at the string.
|
|
66
|
+
*
|
|
67
|
+
* `"none"` is the honest bottom: the raw slug for a heading, the raw index
|
|
68
|
+
* term for an option. It is not a label, it is what is printed when there is
|
|
69
|
+
* none, and it is the one value a storefront's own test asserts against.
|
|
70
|
+
*/
|
|
71
|
+
export type FacetLabelSource = "server" | "schema" | "host" | "none";
|
|
72
|
+
|
|
73
|
+
declare const process: { readonly env: { readonly NODE_ENV?: string } };
|
|
74
|
+
|
|
75
|
+
/** Slugs already complained about — one warning per slug per page load, not
|
|
76
|
+
* one per render. */
|
|
77
|
+
const warnedSlugs = new Set<string>();
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* A heading nobody named, said once, in development only.
|
|
81
|
+
*
|
|
82
|
+
* A raw slug in a filter panel is a wiring fault — the category has no
|
|
83
|
+
* feature definition for the slug and the server sent no label — and it is
|
|
84
|
+
* invisible to everyone but the buyer who meets it. The group still renders,
|
|
85
|
+
* because a heading a person cannot read still beats options with no heading
|
|
86
|
+
* at all; `FacetGroup.labelSource` is how a surface marks it up for a test.
|
|
87
|
+
*/
|
|
88
|
+
function warnUnnamedGroup(slug: string): void {
|
|
89
|
+
const env = typeof process === "undefined" ? undefined : process.env;
|
|
90
|
+
if (env?.NODE_ENV === "production") return;
|
|
91
|
+
if (warnedSlugs.has(slug)) return;
|
|
92
|
+
warnedSlugs.add(slug);
|
|
93
|
+
console.warn(
|
|
94
|
+
`[search-react] facet group "${slug}" has no label: the answer sent none ` +
|
|
95
|
+
`and no category feature defines it, so the heading is the raw slug.`
|
|
96
|
+
);
|
|
97
|
+
}
|
|
59
98
|
|
|
60
99
|
/**
|
|
61
100
|
* Value types whose values are a BOUNDED OPTION SET — the only kind of
|
|
@@ -111,14 +150,22 @@ export interface FacetOption {
|
|
|
111
150
|
readonly count: number | null;
|
|
112
151
|
/** Resolved through the category schema; the raw value when there is none. */
|
|
113
152
|
readonly label: string;
|
|
153
|
+
/** Which of the three sources named it — `"none"` means {@link label} IS
|
|
154
|
+
* the raw index term. The host seam reads this rather than comparing the
|
|
155
|
+
* two strings. */
|
|
156
|
+
readonly labelSource: FacetLabelSource;
|
|
114
157
|
readonly selected: boolean;
|
|
115
158
|
}
|
|
116
159
|
|
|
117
160
|
/** One facet slug, with its options. */
|
|
118
161
|
export interface FacetGroup {
|
|
119
162
|
readonly slug: string;
|
|
120
|
-
/** The
|
|
163
|
+
/** The group's heading: the answer's own `label`, else the feature's
|
|
164
|
+
* display name, else — with a dev warning — the raw slug. */
|
|
121
165
|
readonly label: string;
|
|
166
|
+
/** Which source named the heading. `"none"` is the slug standing in for a
|
|
167
|
+
* name nobody has; a surface marks it so a storefront test can fail on it. */
|
|
168
|
+
readonly labelSource: FacetLabelSource;
|
|
122
169
|
/** The category-schema entry behind the slug, when the host supplied one. */
|
|
123
170
|
readonly feature: FeatureDef | undefined;
|
|
124
171
|
/** `false` when the server skipped this slug — counts are `null`. */
|
|
@@ -199,17 +246,17 @@ export interface BuildFacetGroupsInput {
|
|
|
199
246
|
* `apple` or `chernyy`. The server read that vocabulary against the
|
|
200
247
|
* snapshot the documents were written with.
|
|
201
248
|
*
|
|
249
|
+
* Each entry also carries the GROUP's own `label` — the heading, resolved
|
|
250
|
+
* from the feature definition server-side and localized like everything
|
|
251
|
+
* else. It leads for the same two reasons, and it is what a category whose
|
|
252
|
+
* host passed no feature list has instead of a rail full of index slugs.
|
|
253
|
+
*
|
|
202
254
|
* ABSENT on a server older than 0.4.0 — absent, not empty — which is why
|
|
203
255
|
* every read of it here is optional-chained and every option falls through
|
|
204
256
|
* to the schema and then to its raw self. No labels are invented at any
|
|
205
257
|
* step.
|
|
206
258
|
*/
|
|
207
|
-
readonly facetLabels?:
|
|
208
|
-
Record<
|
|
209
|
-
string,
|
|
210
|
-
{ readonly translatable: boolean; readonly values: Readonly<Record<string, string>> }
|
|
211
|
-
>
|
|
212
|
-
>;
|
|
259
|
+
readonly facetLabels?: FacetLabelsMap;
|
|
213
260
|
/** Translator for label keys. */
|
|
214
261
|
readonly t?: (key: string) => string;
|
|
215
262
|
/** BCP-47 tag, forwarded to `formatFeatureValue` for `date` options. */
|
|
@@ -287,11 +334,49 @@ function resolveLabel(
|
|
|
287
334
|
slug: string,
|
|
288
335
|
value: string,
|
|
289
336
|
labelOptions: { t?: (key: string) => string; locale?: string }
|
|
290
|
-
): string {
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
337
|
+
): { readonly label: string; readonly labelSource: FacetLabelSource } {
|
|
338
|
+
const answer = serverLabel(input.facetLabels, slug, value, input.t);
|
|
339
|
+
if (answer !== undefined) return { label: answer, labelSource: "server" };
|
|
340
|
+
const schema = facetOptionLabel(feature, value, labelOptions);
|
|
341
|
+
// `facetOptionLabel` hands back the value unchanged for an option it
|
|
342
|
+
// cannot name, and that identity is the whole test — a formatter that
|
|
343
|
+
// returned the term is a formatter that named nothing.
|
|
344
|
+
return schema === value
|
|
345
|
+
? { label: value, labelSource: "none" }
|
|
346
|
+
: { label: schema, labelSource: "schema" };
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* The group's HEADING, in the one order the fleet states: the answer, then
|
|
351
|
+
* the category schema, then the slug under a dev warning.
|
|
352
|
+
*
|
|
353
|
+
* The answer leads because it is the only source that always exists. The
|
|
354
|
+
* schema slot is optional, and at a live classified's cars branch the
|
|
355
|
+
* storefront passed an empty feature list, so every heading in the rail was
|
|
356
|
+
* a raw index slug — the make group was on screen, unlabelled, and the
|
|
357
|
+
* complaint that came back was "I cannot pick a make".
|
|
358
|
+
*
|
|
359
|
+
* The slug is not a fallback anyone may ship: it renders, because a heading
|
|
360
|
+
* beats no heading, and it renders MARKED — `labelSource: "none"`, a warning
|
|
361
|
+
* in development, and a data attribute on the drawn group.
|
|
362
|
+
*/
|
|
363
|
+
function resolveGroupLabel(
|
|
364
|
+
input: BuildFacetGroupsInput,
|
|
365
|
+
feature: FeatureDef | undefined,
|
|
366
|
+
slug: string
|
|
367
|
+
): { readonly label: string; readonly labelSource: FacetLabelSource } {
|
|
368
|
+
const answer = input.facetLabels?.[slug]?.label;
|
|
369
|
+
if (typeof answer === "string" && answer.length > 0) {
|
|
370
|
+
return { label: translate(input.t, answer), labelSource: "server" };
|
|
371
|
+
}
|
|
372
|
+
// `featureName` falls back to the slug itself, so the def has to be asked
|
|
373
|
+
// for a NAME rather than for a name-or-slug: a def with none names nothing.
|
|
374
|
+
const declared = feature?.name;
|
|
375
|
+
if (typeof declared === "string" && declared.length > 0) {
|
|
376
|
+
return { label: translate(input.t, declared), labelSource: "schema" };
|
|
377
|
+
}
|
|
378
|
+
warnUnnamedGroup(slug);
|
|
379
|
+
return { label: slug, labelSource: "none" };
|
|
295
380
|
}
|
|
296
381
|
|
|
297
382
|
/**
|
|
@@ -446,22 +531,17 @@ export function buildFacetGroups(input: BuildFacetGroupsInput): readonly FacetGr
|
|
|
446
531
|
|
|
447
532
|
return {
|
|
448
533
|
slug,
|
|
449
|
-
|
|
450
|
-
feature === undefined
|
|
451
|
-
? slug
|
|
452
|
-
: translate(input.t, featureName(feature)),
|
|
534
|
+
...resolveGroupLabel(input, feature, slug),
|
|
453
535
|
feature,
|
|
454
536
|
counted,
|
|
455
537
|
selected,
|
|
456
538
|
options: values.map((value) => ({
|
|
457
539
|
value,
|
|
458
540
|
count: counted ? (counts[value] ?? 0) : null,
|
|
459
|
-
// The
|
|
460
|
-
//
|
|
461
|
-
//
|
|
462
|
-
|
|
463
|
-
// makes "did it resolve?" answerable without a second lookup.
|
|
464
|
-
label: resolveLabel(input, feature, slug, value, labelOptions),
|
|
541
|
+
// The answer's caption first, then the schema's own option table,
|
|
542
|
+
// then the raw value — and the source is carried rather than
|
|
543
|
+
// re-derived, so "did it resolve?" needs no second lookup.
|
|
544
|
+
...resolveLabel(input, feature, slug, value, labelOptions),
|
|
465
545
|
selected: selected.includes(value),
|
|
466
546
|
})),
|
|
467
547
|
};
|
|
@@ -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
|
+
}
|