@stll/text-normalize 0.3.0 → 0.5.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/README.md CHANGED
@@ -53,6 +53,15 @@ one); a plain mark strip leaves `ł` standing and under-matches the
53
53
  lexemes the index actually stored. Non-Latin scripts are out of contract
54
54
  and pass through with their marks stripped.
55
55
 
56
+ `foldLikePostgresSearch` (`@stll/text-normalize/postgres-search-fold`) is PostgreSQL's `lower(unaccent(...))` itself,
57
+ code point for code point and every script included, from the
58
+ extension's full rule set (`POSTGRES_UNACCENT_RULES`). Use it where a
59
+ client must agree with a server-side `unaccent` match exactly, such as a
60
+ highlighter marking what the server found:
61
+ `findPostgresSearchFoldRanges` returns those marks as ranges into the
62
+ original text. It is narrower than the client search fold below: it keeps
63
+ marks and letters the extension keeps.
64
+
56
65
  `stripDiacriticsForSlug` is the same strip but decomposes with NFKD, so
57
66
  compatibility characters (ligatures, full-width forms, superscripts) fold
58
67
  to their base before a `[a-z0-9]` slug filter runs. Slugs are persisted,
@@ -61,8 +70,18 @@ generated with.
61
70
 
62
71
  ## Substring matching with highlight offsets
63
72
 
64
- `foldSearchMatchText` is the client-side match key for interactive
65
- filtering: NFKD, the `unaccent()`-parity ASCII folds, mark strip, Arabic
73
+ `createSearchQueryMatcher(query)` (from `@stll/text-normalize/search-query-matcher`) is the client-side answer to "does this
74
+ text match what the user typed": it trims and folds the query once and returns
75
+ a predicate over candidate texts (a blank query matches everything, a missing
76
+ candidate nothing). `filterBySearchQuery({ items, query, fields })` applies it
77
+ to a list, keeping the items any field of which matches. Use these for every
78
+ picker, list filter and typeahead, so a query typed without accents (`dvorak`,
79
+ `strasse`, `lodz`) finds the same items everywhere and in every UI locale. An
80
+ `Intl.Collator` with `sensitivity: "base"` is not an alternative: it compares
81
+ equal-length windows (`strasse` misses `Straße`), its verdict depends on the
82
+ runtime locale, and it keeps Arabic tashkeel significant.
83
+
84
+ `foldSearchMatchText` is the match key both sides are folded with: NFKD, the `unaccent()`-parity ASCII folds, mark strip, Arabic
66
85
  folds, lowercase — `capek` matches `Čapek` and `wroclaw` matches
67
86
  `Wrocław`, both ways. `findSearchMatchRanges` finds a folded query in
68
87
  folded content and returns ranges into the **original** string, so
@@ -80,9 +99,9 @@ import {
80
99
  ```
81
100
 
82
101
  `containsSearchMatchKey(content, query)` checks a literal substring in keys
83
- already prepared by the caller. It does not trim or fold either input, so a
84
- picker can retain locale-specific casing or accent-sensitive matching. Empty
85
- queries match every key. For accent-insensitive highlight ranges into original
102
+ already prepared by the caller, such as fixed tokens in a message or keys a
103
+ caller folded itself. It does not trim or fold either input; a user-typed query
104
+ goes through `createSearchQueryMatcher` instead. Empty queries match every key. For accent-insensitive highlight ranges into original
86
105
  text, use `findSearchMatchRanges` from `@stll/text-normalize` instead.
87
106
 
88
107
  `findSearchMatchKeyRanges(content, query, { start?, end?, maxMatches? })`
@@ -0,0 +1,34 @@
1
+ /**
2
+ * The client half of PostgreSQL's `search_fold(text)`: `lower(unaccent(...))`
3
+ * with the extension's default dictionary, reproduced code point for code
4
+ * point so a highlighter marks exactly what a server-side fold matched.
5
+ *
6
+ * Deliberately narrower than `foldSearchMatchText`, which also decomposes,
7
+ * strips every combining mark and folds Arabic and final sigma: marking a run
8
+ * the server did not match would claim a match that never happened. Here a
9
+ * character the extension has no rule for keeps its marks, `ς` stays `ς`, and
10
+ * precomposed and decomposed spellings agree only because the extension maps
11
+ * the precomposed letter and deletes the combining mark.
12
+ *
13
+ * Both halves are per code point: `unaccent` replaces single code points (its
14
+ * rules have no multi-character sources), and `lower()` under a libc or
15
+ * builtin collation maps each code point on its own. The parity test in
16
+ * `apps/api/src/lib/search/postgres-search-fold-parity.db.test.ts` holds this
17
+ * against a live database for every assigned code point.
18
+ */
19
+ import type { FoldedSearchText, SearchMatchRange } from "./search-match.js";
20
+ /** `search_fold(value)` as PostgreSQL computes it. */
21
+ export declare const foldLikePostgresSearch: (value: string) => string;
22
+ /**
23
+ * `foldLikePostgresSearch` with each folded UTF-16 unit mapped back to the
24
+ * code point it came from. A code point the fold deletes (a combining mark)
25
+ * joins the range of the one before it, so a match that ends on `e` in
26
+ * `é` covers the accent the reader sees on it.
27
+ */
28
+ export declare const foldLikePostgresSearchWithOffsets: (content: string) => FoldedSearchText;
29
+ /**
30
+ * Non-overlapping occurrences of `term` in `content` under the PostgreSQL
31
+ * search fold, as ranges into the original `content`. The term is folded as
32
+ * given, not trimmed.
33
+ */
34
+ export declare const findPostgresSearchFoldRanges: (content: string, term: string) => SearchMatchRange[];
@@ -0,0 +1,98 @@
1
+ /**
2
+ * The client half of PostgreSQL's `search_fold(text)`: `lower(unaccent(...))`
3
+ * with the extension's default dictionary, reproduced code point for code
4
+ * point so a highlighter marks exactly what a server-side fold matched.
5
+ *
6
+ * Deliberately narrower than `foldSearchMatchText`, which also decomposes,
7
+ * strips every combining mark and folds Arabic and final sigma: marking a run
8
+ * the server did not match would claim a match that never happened. Here a
9
+ * character the extension has no rule for keeps its marks, `ς` stays `ς`, and
10
+ * precomposed and decomposed spellings agree only because the extension maps
11
+ * the precomposed letter and deletes the combining mark.
12
+ *
13
+ * Both halves are per code point: `unaccent` replaces single code points (its
14
+ * rules have no multi-character sources), and `lower()` under a libc or
15
+ * builtin collation maps each code point on its own. The parity test in
16
+ * `apps/api/src/lib/search/postgres-search-fold-parity.db.test.ts` holds this
17
+ * against a live database for every assigned code point.
18
+ */
19
+ import { findFoldedRanges } from "./search-match.js";
20
+ import { POSTGRES_UNACCENT_RULES } from "./unaccent-rules.js";
21
+ /**
22
+ * `toLowerCase` applies Unicode's full mapping, while `lower()` maps one code
23
+ * point to one (the simple mapping). The two differ only where the full
24
+ * mapping expands, and the one unconditional expansion (U+0130 to `i` plus
25
+ * U+0307) keeps the simple mapping as its first code point. Lowering one code
26
+ * point at a time also keeps JavaScript's contextual final sigma out, which
27
+ * `lower()` never applies.
28
+ */
29
+ const lowerCodePoint = (character) => {
30
+ // Destructuring a string iterates code points, so this is the first one.
31
+ const [simple = character] = character.toLowerCase();
32
+ return simple;
33
+ };
34
+ /**
35
+ * One code point's fold. Lone surrogates cannot reach the database (a UTF-8
36
+ * database refuses them), so they fold as the replacement character the way
37
+ * `toWellFormed` spells them.
38
+ */
39
+ const foldCodePoint = (character) => {
40
+ const wellFormed = character.toWellFormed();
41
+ const unaccented = POSTGRES_UNACCENT_RULES[wellFormed] ?? wellFormed;
42
+ let lowered = "";
43
+ for (const codePoint of unaccented) {
44
+ lowered += lowerCodePoint(codePoint);
45
+ }
46
+ return lowered;
47
+ };
48
+ /** `search_fold(value)` as PostgreSQL computes it. */
49
+ export const foldLikePostgresSearch = (value) => {
50
+ let folded = "";
51
+ for (const character of value) {
52
+ folded += foldCodePoint(character);
53
+ }
54
+ return folded;
55
+ };
56
+ /**
57
+ * `foldLikePostgresSearch` with each folded UTF-16 unit mapped back to the
58
+ * code point it came from. A code point the fold deletes (a combining mark)
59
+ * joins the range of the one before it, so a match that ends on `e` in
60
+ * `é` covers the accent the reader sees on it.
61
+ */
62
+ export const foldLikePostgresSearchWithOffsets = (content) => {
63
+ const parts = [];
64
+ const originalRanges = [];
65
+ let previous;
66
+ let offset = 0;
67
+ for (const character of content) {
68
+ const range = { start: offset, end: offset + character.length };
69
+ offset = range.end;
70
+ const folded = foldCodePoint(character);
71
+ if (folded === "") {
72
+ if (previous !== undefined) {
73
+ previous.end = range.end;
74
+ }
75
+ continue;
76
+ }
77
+ parts.push(folded);
78
+ // One entry per UTF-16 unit, not per code point: matches index units.
79
+ let unit = 0;
80
+ while (unit < folded.length) {
81
+ originalRanges.push(range);
82
+ unit += 1;
83
+ }
84
+ previous = range;
85
+ }
86
+ return { originalRanges, text: parts.join("") };
87
+ };
88
+ /**
89
+ * Non-overlapping occurrences of `term` in `content` under the PostgreSQL
90
+ * search fold, as ranges into the original `content`. The term is folded as
91
+ * given, not trimmed.
92
+ */
93
+ export const findPostgresSearchFoldRanges = (content, term) => findFoldedRanges({
94
+ content: foldLikePostgresSearchWithOffsets(content),
95
+ maxMatches: Number.MAX_SAFE_INTEGER,
96
+ query: foldLikePostgresSearch(term),
97
+ });
98
+ //# sourceMappingURL=postgres-search-fold.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"postgres-search-fold.js","sourceRoot":"","sources":["../src/postgres-search-fold.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH,OAAO,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAC;AAErD,OAAO,EAAE,uBAAuB,EAAE,MAAM,qBAAqB,CAAC;AAE9D;;;;;;;GAOG;AACH,MAAM,cAAc,GAAG,CAAC,SAAiB,EAAU,EAAE;IACnD,yEAAyE;IACzE,MAAM,CAAC,MAAM,GAAG,SAAS,CAAC,GAAG,SAAS,CAAC,WAAW,EAAE,CAAC;IACrD,OAAO,MAAM,CAAC;AAChB,CAAC,CAAC;AAEF;;;;GAIG;AACH,MAAM,aAAa,GAAG,CAAC,SAAiB,EAAU,EAAE;IAClD,MAAM,UAAU,GAAG,SAAS,CAAC,YAAY,EAAE,CAAC;IAC5C,MAAM,UAAU,GAAG,uBAAuB,CAAC,UAAU,CAAC,IAAI,UAAU,CAAC;IACrE,IAAI,OAAO,GAAG,EAAE,CAAC;IACjB,KAAK,MAAM,SAAS,IAAI,UAAU,EAAE,CAAC;QACnC,OAAO,IAAI,cAAc,CAAC,SAAS,CAAC,CAAC;IACvC,CAAC;IACD,OAAO,OAAO,CAAC;AACjB,CAAC,CAAC;AAEF,sDAAsD;AACtD,MAAM,CAAC,MAAM,sBAAsB,GAAG,CAAC,KAAa,EAAU,EAAE;IAC9D,IAAI,MAAM,GAAG,EAAE,CAAC;IAChB,KAAK,MAAM,SAAS,IAAI,KAAK,EAAE,CAAC;QAC9B,MAAM,IAAI,aAAa,CAAC,SAAS,CAAC,CAAC;IACrC,CAAC;IACD,OAAO,MAAM,CAAC;AAChB,CAAC,CAAC;AAEF;;;;;GAKG;AACH,MAAM,CAAC,MAAM,iCAAiC,GAAG,CAC/C,OAAe,EACG,EAAE;IACpB,MAAM,KAAK,GAAa,EAAE,CAAC;IAC3B,MAAM,cAAc,GAAuB,EAAE,CAAC;IAC9C,IAAI,QAAsC,CAAC;IAC3C,IAAI,MAAM,GAAG,CAAC,CAAC;IACf,KAAK,MAAM,SAAS,IAAI,OAAO,EAAE,CAAC;QAChC,MAAM,KAAK,GAAG,EAAE,KAAK,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,SAAS,CAAC,MAAM,EAAE,CAAC;QAChE,MAAM,GAAG,KAAK,CAAC,GAAG,CAAC;QACnB,MAAM,MAAM,GAAG,aAAa,CAAC,SAAS,CAAC,CAAC;QACxC,IAAI,MAAM,KAAK,EAAE,EAAE,CAAC;YAClB,IAAI,QAAQ,KAAK,SAAS,EAAE,CAAC;gBAC3B,QAAQ,CAAC,GAAG,GAAG,KAAK,CAAC,GAAG,CAAC;YAC3B,CAAC;YACD,SAAS;QACX,CAAC;QACD,KAAK,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;QACnB,sEAAsE;QACtE,IAAI,IAAI,GAAG,CAAC,CAAC;QACb,OAAO,IAAI,GAAG,MAAM,CAAC,MAAM,EAAE,CAAC;YAC5B,cAAc,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;YAC3B,IAAI,IAAI,CAAC,CAAC;QACZ,CAAC;QACD,QAAQ,GAAG,KAAK,CAAC;IACnB,CAAC;IACD,OAAO,EAAE,cAAc,EAAE,IAAI,EAAE,KAAK,CAAC,IAAI,CAAC,EAAE,CAAC,EAAE,CAAC;AAClD,CAAC,CAAC;AAEF;;;;GAIG;AACH,MAAM,CAAC,MAAM,4BAA4B,GAAG,CAC1C,OAAe,EACf,IAAY,EACQ,EAAE,CACtB,gBAAgB,CAAC;IACf,OAAO,EAAE,iCAAiC,CAAC,OAAO,CAAC;IACnD,UAAU,EAAE,MAAM,CAAC,gBAAgB;IACnC,KAAK,EAAE,sBAAsB,CAAC,IAAI,CAAC;CACpC,CAAC,CAAC"}
@@ -1,20 +1,3 @@
1
- /**
2
- * Diacritic-insensitive substring matching with original-index mapping.
3
- *
4
- * `foldSearchMatchText` builds the client-side match key: NFKD
5
- * decomposition, the `unaccent()`-parity ASCII folds (letters with no
6
- * decomposition: `ł`, `ø`, `đ`, `ß`), combining-mark strip, Arabic
7
- * orthographic folds, then lowercase — so a `capek` query matches `Čapek`
8
- * and `wroclaw` matches `Wrocław`, both ways. It is deliberately distinct
9
- * from `arabicNormalize` (normalize.ts), which is pinned to the SQL
10
- * `arabic_normalize()` contract and must not strip Latin diacritics.
11
- *
12
- * `findSearchMatchRanges` locates a folded query inside folded content and
13
- * reports matches as ranges into the ORIGINAL string, so highlights wrap the
14
- * text the user actually sees. Folding changes string length (per-character
15
- * decomposition, mark removal, contextual lowercasing), so the folded form
16
- * carries a per-code-unit map back to the originating character's range.
17
- */
18
1
  export type SearchMatchRange = {
19
2
  end: number;
20
3
  start: number;
@@ -26,6 +9,18 @@ export type FoldedSearchText = {
26
9
  };
27
10
  export declare const foldSearchMatchText: (value: string) => string;
28
11
  export declare const foldSearchMatchTextWithOffsets: (content: string) => FoldedSearchText;
12
+ type FindFoldedRangesOptions = {
13
+ content: FoldedSearchText;
14
+ maxMatches: number;
15
+ /** Already folded by the same fold as `content`. */
16
+ query: string;
17
+ };
18
+ /**
19
+ * Non-overlapping occurrences of a folded query in folded content, as ranges
20
+ * into the original string. Shared by every fold that carries
21
+ * `originalRanges`, so they agree on what "non-overlapping" means.
22
+ */
23
+ export declare const findFoldedRanges: ({ content, maxMatches, query, }: FindFoldedRangesOptions) => SearchMatchRange[];
29
24
  type FindSearchMatchRangesOptions = {
30
25
  maxMatches?: number;
31
26
  };
@@ -1,3 +1,4 @@
1
+ // parser-output-unchanged: `findSearchMatchRanges` hands its scan to `findFoldedRanges` with the same folded content, query and budget; the ranges it returns are unchanged.
1
2
  /**
2
3
  * Diacritic-insensitive substring matching with original-index mapping.
3
4
  *
@@ -71,32 +72,24 @@ export const foldSearchMatchTextWithOffsets = (content) => {
71
72
  return { text: loweredParts.join(""), originalRanges };
72
73
  };
73
74
  /**
74
- * Non-overlapping occurrences of the folded query in the content, as ranges
75
- * into the original string. Accepts pre-folded content so callers matching
76
- * many queries against the same text fold it once.
75
+ * Non-overlapping occurrences of a folded query in folded content, as ranges
76
+ * into the original string. Shared by every fold that carries
77
+ * `originalRanges`, so they agree on what "non-overlapping" means.
77
78
  */
78
- export const findSearchMatchRanges = (content, query, options = {}) => {
79
- const maxMatches = Math.max(0, Math.floor(options.maxMatches ?? Number.MAX_SAFE_INTEGER));
80
- if (maxMatches === 0) {
81
- return [];
82
- }
83
- const foldedContent = typeof content === "string"
84
- ? foldSearchMatchTextWithOffsets(content)
85
- : content;
86
- const foldedQuery = foldSearchMatchText(query.trim());
87
- if (foldedQuery.length === 0) {
79
+ export const findFoldedRanges = ({ content, maxMatches, query, }) => {
80
+ if (maxMatches <= 0 || query.length === 0) {
88
81
  return [];
89
82
  }
90
83
  const matches = [];
91
84
  let searchFrom = 0;
92
- while (searchFrom <= foldedContent.text.length - foldedQuery.length) {
93
- const foldedStart = foldedContent.text.indexOf(foldedQuery, searchFrom);
85
+ while (searchFrom <= content.text.length - query.length) {
86
+ const foldedStart = content.text.indexOf(query, searchFrom);
94
87
  if (foldedStart === -1) {
95
88
  break;
96
89
  }
97
- const foldedEnd = foldedStart + foldedQuery.length;
98
- const firstRange = foldedContent.originalRanges.at(foldedStart);
99
- const lastRange = foldedContent.originalRanges.at(foldedEnd - 1);
90
+ const foldedEnd = foldedStart + query.length;
91
+ const firstRange = content.originalRanges.at(foldedStart);
92
+ const lastRange = content.originalRanges.at(foldedEnd - 1);
100
93
  if (!firstRange || !lastRange) {
101
94
  break;
102
95
  }
@@ -108,11 +101,28 @@ export const findSearchMatchRanges = (content, query, options = {}) => {
108
101
  // character; resume after the last unit inside the matched characters so
109
102
  // that character is reported once and the budget reaches later hits.
110
103
  searchFrom = foldedEnd;
111
- while ((foldedContent.originalRanges.at(searchFrom)?.start ?? Infinity) <
112
- lastRange.end) {
104
+ while ((content.originalRanges.at(searchFrom)?.start ?? Infinity) < lastRange.end) {
113
105
  searchFrom += 1;
114
106
  }
115
107
  }
116
108
  return matches;
117
109
  };
110
+ /**
111
+ * Non-overlapping occurrences of the folded query in the content, as ranges
112
+ * into the original string. Accepts pre-folded content so callers matching
113
+ * many queries against the same text fold it once.
114
+ */
115
+ export const findSearchMatchRanges = (content, query, options = {}) => {
116
+ const maxMatches = Math.max(0, Math.floor(options.maxMatches ?? Number.MAX_SAFE_INTEGER));
117
+ if (maxMatches === 0) {
118
+ return [];
119
+ }
120
+ return findFoldedRanges({
121
+ content: typeof content === "string"
122
+ ? foldSearchMatchTextWithOffsets(content)
123
+ : content,
124
+ maxMatches,
125
+ query: foldSearchMatchText(query.trim()),
126
+ });
127
+ };
118
128
  //# sourceMappingURL=search-match.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"search-match.js","sourceRoot":"","sources":["../src/search-match.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"search-match.js","sourceRoot":"","sources":["../src/search-match.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,38 @@
1
+ /**
2
+ * The client-side match for user-typed queries, kept out of the package root
3
+ * so the corpus parsers that read the root do not depend on it.
4
+ */
5
+ /** Whether one candidate text contains the query a matcher was built for. */
6
+ export type SearchQueryMatcher = (candidate: string | null | undefined) => boolean;
7
+ /**
8
+ * The client-side answer to "does this text match what the user typed".
9
+ *
10
+ * Both sides go through `foldSearchMatchText`, so case, accents, letters
11
+ * without a decomposition (`ł`, `ø`, `ß` → `ss`), compatibility ligatures and
12
+ * Arabic orthographic variants are ignored identically in every UI locale,
13
+ * and a filtered list agrees with the highlights `findSearchMatchRanges`
14
+ * draws in it. The query is trimmed and folded once and candidate keys are
15
+ * cached; a blank query matches every candidate, and a missing candidate
16
+ * matches none. Lone surrogates become U+FFFD on both sides, so a query can
17
+ * never match half of an astral character.
18
+ *
19
+ * Preferred over an `Intl.Collator` with `sensitivity: "base"`: a collator
20
+ * compares equal-length windows, so `strasse` never finds `Straße`; its
21
+ * verdict depends on the runtime locale (Turkish `ISTANBUL` misses
22
+ * `istanbul`, German `Müller` misses `muller`); it keeps Arabic tashkeel
23
+ * significant; and it compares every window of every item per keystroke,
24
+ * where a fold key is computed once per text.
25
+ */
26
+ export declare const createSearchQueryMatcher: (query: string) => SearchQueryMatcher;
27
+ type FilterBySearchQueryOptions<T> = {
28
+ /** The texts an item is found by; it matches when any of them does. */
29
+ fields: (item: T) => readonly (string | null | undefined)[];
30
+ items: readonly T[];
31
+ query: string;
32
+ };
33
+ /**
34
+ * The items any of whose fields matches the query, in their given order,
35
+ * through `createSearchQueryMatcher`. A blank query keeps every item.
36
+ */
37
+ export declare const filterBySearchQuery: <T>({ fields, items, query, }: FilterBySearchQueryOptions<T>) => T[];
38
+ export {};
@@ -0,0 +1,65 @@
1
+ /**
2
+ * The client-side match for user-typed queries, kept out of the package root
3
+ * so the corpus parsers that read the root do not depend on it.
4
+ */
5
+ import { foldSearchMatchText } from "./search-match.js";
6
+ // A list filter folds the same names on every keystroke. Remembering their
7
+ // keys turns a keystroke over 2,000 three-field rows from about 3 ms into
8
+ // about 0.2 ms, level with a bare `toLowerCase().includes()`. The fold is
9
+ // pure, so the cache is unobservable; clearing it whole when full bounds its
10
+ // memory without an eviction policy.
11
+ const CANDIDATE_KEY_CACHE_LIMIT = 10_000;
12
+ const candidateKeys = new Map();
13
+ const candidateKey = (candidate) => {
14
+ const cached = candidateKeys.get(candidate);
15
+ if (cached !== undefined) {
16
+ return cached;
17
+ }
18
+ if (candidateKeys.size >= CANDIDATE_KEY_CACHE_LIMIT) {
19
+ candidateKeys.clear();
20
+ }
21
+ const key = foldSearchMatchText(candidate.toWellFormed());
22
+ candidateKeys.set(candidate, key);
23
+ return key;
24
+ };
25
+ /**
26
+ * The client-side answer to "does this text match what the user typed".
27
+ *
28
+ * Both sides go through `foldSearchMatchText`, so case, accents, letters
29
+ * without a decomposition (`ł`, `ø`, `ß` → `ss`), compatibility ligatures and
30
+ * Arabic orthographic variants are ignored identically in every UI locale,
31
+ * and a filtered list agrees with the highlights `findSearchMatchRanges`
32
+ * draws in it. The query is trimmed and folded once and candidate keys are
33
+ * cached; a blank query matches every candidate, and a missing candidate
34
+ * matches none. Lone surrogates become U+FFFD on both sides, so a query can
35
+ * never match half of an astral character.
36
+ *
37
+ * Preferred over an `Intl.Collator` with `sensitivity: "base"`: a collator
38
+ * compares equal-length windows, so `strasse` never finds `Straße`; its
39
+ * verdict depends on the runtime locale (Turkish `ISTANBUL` misses
40
+ * `istanbul`, German `Müller` misses `muller`); it keeps Arabic tashkeel
41
+ * significant; and it compares every window of every item per keystroke,
42
+ * where a fold key is computed once per text.
43
+ */
44
+ export const createSearchQueryMatcher = (query) => {
45
+ const foldedQuery = foldSearchMatchText(query.trim().toWellFormed());
46
+ if (foldedQuery === "") {
47
+ return () => true;
48
+ }
49
+ return (candidate) => candidate !== null &&
50
+ candidate !== undefined &&
51
+ candidateKey(candidate).includes(foldedQuery);
52
+ };
53
+ /**
54
+ * The items any of whose fields matches the query, in their given order,
55
+ * through `createSearchQueryMatcher`. A blank query keeps every item.
56
+ */
57
+ export const filterBySearchQuery = ({ fields, items, query, }) => {
58
+ const matches = createSearchQueryMatcher(query);
59
+ // A query that matches empty text also keeps items with no search fields.
60
+ if (matches("")) {
61
+ return [...items];
62
+ }
63
+ return items.filter((item) => fields(item).some(matches));
64
+ };
65
+ //# sourceMappingURL=search-query-matcher.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"search-query-matcher.js","sourceRoot":"","sources":["../src/search-query-matcher.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EAAE,mBAAmB,EAAE,MAAM,mBAAmB,CAAC;AAExD,2EAA2E;AAC3E,0EAA0E;AAC1E,0EAA0E;AAC1E,6EAA6E;AAC7E,qCAAqC;AACrC,MAAM,yBAAyB,GAAG,MAAM,CAAC;AACzC,MAAM,aAAa,GAAG,IAAI,GAAG,EAAkB,CAAC;AAEhD,MAAM,YAAY,GAAG,CAAC,SAAiB,EAAU,EAAE;IACjD,MAAM,MAAM,GAAG,aAAa,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC;IAC5C,IAAI,MAAM,KAAK,SAAS,EAAE,CAAC;QACzB,OAAO,MAAM,CAAC;IAChB,CAAC;IACD,IAAI,aAAa,CAAC,IAAI,IAAI,yBAAyB,EAAE,CAAC;QACpD,aAAa,CAAC,KAAK,EAAE,CAAC;IACxB,CAAC;IACD,MAAM,GAAG,GAAG,mBAAmB,CAAC,SAAS,CAAC,YAAY,EAAE,CAAC,CAAC;IAC1D,aAAa,CAAC,GAAG,CAAC,SAAS,EAAE,GAAG,CAAC,CAAC;IAClC,OAAO,GAAG,CAAC;AACb,CAAC,CAAC;AAOF;;;;;;;;;;;;;;;;;;GAkBG;AACH,MAAM,CAAC,MAAM,wBAAwB,GAAG,CAAC,KAAa,EAAsB,EAAE;IAC5E,MAAM,WAAW,GAAG,mBAAmB,CAAC,KAAK,CAAC,IAAI,EAAE,CAAC,YAAY,EAAE,CAAC,CAAC;IACrE,IAAI,WAAW,KAAK,EAAE,EAAE,CAAC;QACvB,OAAO,GAAG,EAAE,CAAC,IAAI,CAAC;IACpB,CAAC;IACD,OAAO,CAAC,SAAS,EAAE,EAAE,CACnB,SAAS,KAAK,IAAI;QAClB,SAAS,KAAK,SAAS;QACvB,YAAY,CAAC,SAAS,CAAC,CAAC,QAAQ,CAAC,WAAW,CAAC,CAAC;AAClD,CAAC,CAAC;AASF;;;GAGG;AACH,MAAM,CAAC,MAAM,mBAAmB,GAAG,CAAI,EACrC,MAAM,EACN,KAAK,EACL,KAAK,GACyB,EAAO,EAAE;IACvC,MAAM,OAAO,GAAG,wBAAwB,CAAC,KAAK,CAAC,CAAC;IAChD,0EAA0E;IAC1E,IAAI,OAAO,CAAC,EAAE,CAAC,EAAE,CAAC;QAChB,OAAO,CAAC,GAAG,KAAK,CAAC,CAAC;IACpB,CAAC;IACD,OAAO,KAAK,CAAC,MAAM,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC,CAAC;AAC5D,CAAC,CAAC"}
package/dist/slug.d.ts CHANGED
@@ -7,10 +7,11 @@
7
7
  * characters into one separator, clip, trim a trailing separator, fall back
8
8
  * when nothing is left — was identical in all four and is fixed here.
9
9
  *
10
- * Deliberately does NOT transliterate. `slugify("café")` is `"caf"` under the
11
- * `ascii` charset, not `"cafe"`: slugs are persisted, and folding here would
12
- * quietly start minting different keys for the same labels. The folding
13
- * primitives in this package are for search text, where nothing is stored.
10
+ * The `ascii` charset transliterates Latin letters before filtering:
11
+ * `slugify("Łódź")` is `"lodz"`, not `"odz"`. A key minted before
12
+ * transliteration is not recomputed by its owner; a caller that re-derives a
13
+ * stored key at lookup (the case-law routes) recognises the earlier fold
14
+ * itself.
14
15
  *
15
16
  * Single-pass by construction: a regex pipeline over untrusted label text is
16
17
  * what the slow-regex guard exists to prevent.
@@ -19,7 +20,7 @@
19
20
  * Which characters survive.
20
21
  *
21
22
  * - `ascii` keeps `[a-z0-9]`, for keys that end up in URLs and columns
22
- * validated against that charset.
23
+ * validated against that charset. Latin letters are transliterated first.
23
24
  * - `unicode` keeps any Unicode letter or number, for paths shown back to the
24
25
  * person who typed them.
25
26
  */
package/dist/slug.js CHANGED
@@ -1,3 +1,4 @@
1
+ // parser-output-unchanged: no case-law or legislation adapter or parser calls slugify; adapters reach this module only through the package index.
1
2
  /**
2
3
  * One slug generator for the key-shaped strings the apps derive from a label.
3
4
  *
@@ -7,15 +8,28 @@
7
8
  * characters into one separator, clip, trim a trailing separator, fall back
8
9
  * when nothing is left — was identical in all four and is fixed here.
9
10
  *
10
- * Deliberately does NOT transliterate. `slugify("café")` is `"caf"` under the
11
- * `ascii` charset, not `"cafe"`: slugs are persisted, and folding here would
12
- * quietly start minting different keys for the same labels. The folding
13
- * primitives in this package are for search text, where nothing is stored.
11
+ * The `ascii` charset transliterates Latin letters before filtering:
12
+ * `slugify("Łódź")` is `"lodz"`, not `"odz"`. A key minted before
13
+ * transliteration is not recomputed by its owner; a caller that re-derives a
14
+ * stored key at lookup (the case-law routes) recognises the earlier fold
15
+ * itself.
14
16
  *
15
17
  * Single-pass by construction: a regex pipeline over untrusted label text is
16
18
  * what the slow-regex guard exists to prevent.
17
19
  */
20
+ import { applyAsciiFolds } from "./ascii-fold.js";
18
21
  const UNICODE_SLUG_CHARACTER = /[\p{L}\p{N}]/u;
22
+ /** Compatibility decomposition, the search fold's mark strip and the table. */
23
+ const spellInAscii = (text) => applyAsciiFolds(text.normalize("NFKD"));
24
+ /**
25
+ * Latin letters spelled in ASCII: compatibility decomposition (`fi`, `A`), the
26
+ * search fold's mark strip (`č`), and the `unaccent()` table for letters no
27
+ * decomposition reaches (`ł`, `ø`, `ß`, `þ`, `ı`). The table is not closed
28
+ * under case (`Ɩ` folds and its small letter `ɩ` does not; `ɡ` folds and its
29
+ * capital `Ɡ` does not), so it applies on both sides of the lowercase. Other
30
+ * scripts pass through and are filtered out.
31
+ */
32
+ const transliterateToAscii = (value) => spellInAscii(spellInAscii(value).toLowerCase()).toLowerCase();
19
33
  const isSlugCharacter = (character, charset) => {
20
34
  if (charset === "unicode") {
21
35
  return UNICODE_SLUG_CHARACTER.test(character);
@@ -24,9 +38,10 @@ const isSlugCharacter = (character, charset) => {
24
38
  (character >= "0" && character <= "9"));
25
39
  };
26
40
  export const slugify = (value, { charset, separator, maxLength, fallback }) => {
41
+ const source = charset === "ascii" ? transliterateToAscii(value) : value.toLowerCase();
27
42
  let buffer = "";
28
43
  let lastWasSeparator = true;
29
- for (const character of value.toLowerCase()) {
44
+ for (const character of source) {
30
45
  if (isSlugCharacter(character, charset)) {
31
46
  buffer += character;
32
47
  lastWasSeparator = false;
package/dist/slug.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"slug.js","sourceRoot":"","sources":["../src/slug.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAgCH,MAAM,sBAAsB,GAAG,eAAe,CAAC;AAE/C,MAAM,eAAe,GAAG,CAAC,SAAiB,EAAE,OAAoB,EAAW,EAAE;IAC3E,IAAI,OAAO,KAAK,SAAS,EAAE,CAAC;QAC1B,OAAO,sBAAsB,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC;IAChD,CAAC;IACD,OAAO,CACL,CAAC,SAAS,IAAI,GAAG,IAAI,SAAS,IAAI,GAAG,CAAC;QACtC,CAAC,SAAS,IAAI,GAAG,IAAI,SAAS,IAAI,GAAG,CAAC,CACvC,CAAC;AACJ,CAAC,CAAC;AAEF,MAAM,CAAC,MAAM,OAAO,GAAG,CACrB,KAAa,EACb,EAAE,OAAO,EAAE,SAAS,EAAE,SAAS,EAAE,QAAQ,EAAkB,EACnD,EAAE;IACV,IAAI,MAAM,GAAG,EAAE,CAAC;IAChB,IAAI,gBAAgB,GAAG,IAAI,CAAC;IAE5B,KAAK,MAAM,SAAS,IAAI,KAAK,CAAC,WAAW,EAAE,EAAE,CAAC;QAC5C,IAAI,eAAe,CAAC,SAAS,EAAE,OAAO,CAAC,EAAE,CAAC;YACxC,MAAM,IAAI,SAAS,CAAC;YACpB,gBAAgB,GAAG,KAAK,CAAC;YACzB,SAAS;QACX,CAAC;QACD,IAAI,CAAC,gBAAgB,EAAE,CAAC;YACtB,MAAM,IAAI,SAAS,CAAC;YACpB,gBAAgB,GAAG,IAAI,CAAC;QAC1B,CAAC;IACH,CAAC;IAED,IAAI,OAAO,GAAG,EAAE,CAAC;IACjB,KAAK,MAAM,SAAS,IAAI,MAAM,EAAE,CAAC;QAC/B,IAAI,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,MAAM,GAAG,SAAS,EAAE,CAAC;YAClD,MAAM;QACR,CAAC;QACD,OAAO,IAAI,SAAS,CAAC;IACvB,CAAC;IACD,0EAA0E;IAC1E,OAAO,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAC,EAAE,CAAC;QACnC,OAAO,GAAG,OAAO,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC;IACjC,CAAC;IAED,OAAO,OAAO,IAAI,QAAQ,CAAC;AAC7B,CAAC,CAAC"}
1
+ {"version":3,"file":"slug.js","sourceRoot":"","sources":["../src/slug.ts"],"names":[],"mappings":"AAAA,kJAAkJ;AAClJ;;;;;;;;;;;;;;;;;GAiBG;AAEH,OAAO,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAC;AAgClD,MAAM,sBAAsB,GAAG,eAAe,CAAC;AAE/C,+EAA+E;AAC/E,MAAM,YAAY,GAAG,CAAC,IAAY,EAAU,EAAE,CAC5C,eAAe,CAAC,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC,CAAC,CAAC;AAE1C;;;;;;;GAOG;AACH,MAAM,oBAAoB,GAAG,CAAC,KAAa,EAAU,EAAE,CACrD,YAAY,CAAC,YAAY,CAAC,KAAK,CAAC,CAAC,WAAW,EAAE,CAAC,CAAC,WAAW,EAAE,CAAC;AAEhE,MAAM,eAAe,GAAG,CAAC,SAAiB,EAAE,OAAoB,EAAW,EAAE;IAC3E,IAAI,OAAO,KAAK,SAAS,EAAE,CAAC;QAC1B,OAAO,sBAAsB,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC;IAChD,CAAC;IACD,OAAO,CACL,CAAC,SAAS,IAAI,GAAG,IAAI,SAAS,IAAI,GAAG,CAAC;QACtC,CAAC,SAAS,IAAI,GAAG,IAAI,SAAS,IAAI,GAAG,CAAC,CACvC,CAAC;AACJ,CAAC,CAAC;AAEF,MAAM,CAAC,MAAM,OAAO,GAAG,CACrB,KAAa,EACb,EAAE,OAAO,EAAE,SAAS,EAAE,SAAS,EAAE,QAAQ,EAAkB,EACnD,EAAE;IACV,MAAM,MAAM,GACV,OAAO,KAAK,OAAO,CAAC,CAAC,CAAC,oBAAoB,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,WAAW,EAAE,CAAC;IAC1E,IAAI,MAAM,GAAG,EAAE,CAAC;IAChB,IAAI,gBAAgB,GAAG,IAAI,CAAC;IAE5B,KAAK,MAAM,SAAS,IAAI,MAAM,EAAE,CAAC;QAC/B,IAAI,eAAe,CAAC,SAAS,EAAE,OAAO,CAAC,EAAE,CAAC;YACxC,MAAM,IAAI,SAAS,CAAC;YACpB,gBAAgB,GAAG,KAAK,CAAC;YACzB,SAAS;QACX,CAAC;QACD,IAAI,CAAC,gBAAgB,EAAE,CAAC;YACtB,MAAM,IAAI,SAAS,CAAC;YACpB,gBAAgB,GAAG,IAAI,CAAC;QAC1B,CAAC;IACH,CAAC;IAED,IAAI,OAAO,GAAG,EAAE,CAAC;IACjB,KAAK,MAAM,SAAS,IAAI,MAAM,EAAE,CAAC;QAC/B,IAAI,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,MAAM,GAAG,SAAS,EAAE,CAAC;YAClD,MAAM;QACR,CAAC;QACD,OAAO,IAAI,SAAS,CAAC;IACvB,CAAC;IACD,0EAA0E;IAC1E,OAAO,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAC,EAAE,CAAC;QACnC,OAAO,GAAG,OAAO,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC;IACjC,CAAC;IAED,OAAO,OAAO,IAAI,QAAQ,CAAC;AAC7B,CAAC,CAAC"}
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Every rule PostgreSQL's `unaccent` extension applies with its default
3
+ * `unaccent` dictionary: each key is one code point, each value what
4
+ * `unaccent()` replaces it with (empty for the combining and enclosing marks
5
+ * it deletes). A code point without an entry passes through unchanged.
6
+ *
7
+ * Generated from the extension itself, so the two sides cannot drift by hand:
8
+ *
9
+ * ```sql
10
+ * SELECT cp, unaccent(chr(cp))
11
+ * FROM generate_series(1, 196607) AS cp
12
+ * WHERE NOT (cp BETWEEN 55296 AND 57343)
13
+ * AND unaccent(chr(cp)) IS DISTINCT FROM chr(cp);
14
+ * ```
15
+ *
16
+ * `apps/api/src/lib/search/postgres-search-fold-parity.db.test.ts` re-runs
17
+ * the query against a live PostgreSQL and asserts both directions, so an
18
+ * extension upgrade that adds, drops or retargets a rule fails CI instead of
19
+ * splitting a client-side fold from the server's.
20
+ *
21
+ * Unlike `ASCII_FOLD_TABLE`, which holds only the Latin rules NFD cannot
22
+ * express, this is the whole rule set, every script included: a caller that
23
+ * must agree with `unaccent()` character for character (a highlighter
24
+ * marking what a server-side match found) cannot approximate any of it.
25
+ */
26
+ export declare const POSTGRES_UNACCENT_RULES: Readonly<Record<string, string>>;