@stll/text-normalize 0.0.1-placeholder.0 → 0.1.1
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 +81 -1
- package/dist/arabic.d.ts +67 -0
- package/dist/arabic.js +156 -0
- package/dist/arabic.js.map +1 -0
- package/dist/ascii-fold-table.d.ts +29 -0
- package/dist/ascii-fold-table.js +513 -0
- package/dist/ascii-fold-table.js.map +1 -0
- package/dist/ascii-fold.d.ts +29 -0
- package/dist/ascii-fold.js +37 -0
- package/dist/ascii-fold.js.map +1 -0
- package/dist/diacritics.d.ts +32 -0
- package/dist/diacritics.js +34 -0
- package/dist/diacritics.js.map +1 -0
- package/dist/escape-regexp.d.ts +10 -0
- package/dist/escape-regexp.js +11 -0
- package/dist/escape-regexp.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -0
- package/dist/normalize.d.ts +16 -0
- package/dist/normalize.js +32 -0
- package/dist/normalize.js.map +1 -0
- package/dist/search-match.d.ts +38 -0
- package/dist/search-match.js +118 -0
- package/dist/search-match.js.map +1 -0
- package/dist/slug.d.ts +45 -0
- package/dist/slug.js +53 -0
- package/dist/slug.js.map +1 -0
- package/dist/spaced-letters.d.ts +57 -0
- package/dist/spaced-letters.js +101 -0
- package/dist/spaced-letters.js.map +1 -0
- package/package.json +54 -5
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Latin/combining-mark diacritic stripping for search keys and slugs.
|
|
3
|
+
*
|
|
4
|
+
* Both variants decompose the input and then remove every character
|
|
5
|
+
* Unicode classifies as a diacritic (`\p{Diacritic}`). `\p{Diacritic}`
|
|
6
|
+
* is deliberate: it covers combining marks in every block, not only the
|
|
7
|
+
* Combining Diacritical Marks block (U+0300–U+036F). A range like
|
|
8
|
+
* `[̀-ͯ]` misses marks in the Extended (U+1AB0–U+1AFF) and
|
|
9
|
+
* Supplement (U+1DC0–U+1DFF) blocks, so two callers using different
|
|
10
|
+
* classes would disagree on the same text.
|
|
11
|
+
*
|
|
12
|
+
* The variants differ only in the decomposition form:
|
|
13
|
+
*
|
|
14
|
+
* - `stripDiacritics` uses NFD (canonical decomposition). Use it for
|
|
15
|
+
* search keys, where compatibility characters must survive unchanged.
|
|
16
|
+
* - `stripDiacriticsForSlug` uses NFKD (compatibility decomposition), so
|
|
17
|
+
* ligatures, full-width forms, superscripts, and similar fold to their
|
|
18
|
+
* ASCII base before the `[a-z0-9]` slug filter runs. Slugs are
|
|
19
|
+
* persisted, public URL segments, so this variant pins the exact form
|
|
20
|
+
* the existing slugs were generated with; do not switch it to NFD.
|
|
21
|
+
*/
|
|
22
|
+
const DIACRITIC_RE = /\p{Diacritic}/gu;
|
|
23
|
+
/**
|
|
24
|
+
* Strip combining diacritics from search text (NFD). Non-diacritic
|
|
25
|
+
* characters, including compatibility characters, pass through unchanged.
|
|
26
|
+
*/
|
|
27
|
+
export const stripDiacritics = (text) => text.normalize("NFD").replace(DIACRITIC_RE, "");
|
|
28
|
+
/**
|
|
29
|
+
* Strip combining diacritics for slug generation (NFKD). Callers apply
|
|
30
|
+
* their own case folding and `[a-z0-9]` filtering around this; the NFKD
|
|
31
|
+
* form is load-bearing for byte-stable slugs and must not change.
|
|
32
|
+
*/
|
|
33
|
+
export const stripDiacriticsForSlug = (text) => text.normalize("NFKD").replace(DIACRITIC_RE, "");
|
|
34
|
+
//# sourceMappingURL=diacritics.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"diacritics.js","sourceRoot":"","sources":["../src/diacritics.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,MAAM,YAAY,GAAG,iBAAiB,CAAC;AAEvC;;;GAGG;AACH,MAAM,CAAC,MAAM,eAAe,GAAG,CAAC,IAAY,EAAU,EAAE,CACtD,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,CAAC,OAAO,CAAC,YAAY,EAAE,EAAE,CAAC,CAAC;AAElD;;;;GAIG;AACH,MAAM,CAAC,MAAM,sBAAsB,GAAG,CAAC,IAAY,EAAU,EAAE,CAC7D,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC,CAAC,OAAO,CAAC,YAAY,EAAE,EAAE,CAAC,CAAC"}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one escaper for text that becomes part of a pattern.
|
|
3
|
+
*
|
|
4
|
+
* Every call site builds a regular expression around something a document or
|
|
5
|
+
* a user wrote, so each needed the same escape and each had written it out.
|
|
6
|
+
* Two spellings of this is one spelling too many: the character class is the
|
|
7
|
+
* contract, and a call site that escapes one character fewer matches things
|
|
8
|
+
* its author never meant it to.
|
|
9
|
+
*/
|
|
10
|
+
export declare const escapeRegExp: (value: string) => string;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one escaper for text that becomes part of a pattern.
|
|
3
|
+
*
|
|
4
|
+
* Every call site builds a regular expression around something a document or
|
|
5
|
+
* a user wrote, so each needed the same escape and each had written it out.
|
|
6
|
+
* Two spellings of this is one spelling too many: the character class is the
|
|
7
|
+
* contract, and a call site that escapes one character fewer matches things
|
|
8
|
+
* its author never meant it to.
|
|
9
|
+
*/
|
|
10
|
+
export const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
|
11
|
+
//# sourceMappingURL=escape-regexp.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"escape-regexp.js","sourceRoot":"","sources":["../src/escape-regexp.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,MAAM,CAAC,MAAM,YAAY,GAAG,CAAC,KAAa,EAAU,EAAE,CACpD,KAAK,CAAC,OAAO,CAAC,sBAAsB,EAAE,MAAM,CAAC,CAAC"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export { applyArabicFolds, applyArabicFoldsWithOffsets, ARABIC_DIGIT_FOLDS, ARABIC_LETTER_FOLDS, ARABIC_REMOVED, } from "./arabic.js";
|
|
2
|
+
export type { FoldedText } from "./arabic.js";
|
|
3
|
+
export { foldToAscii } from "./ascii-fold.js";
|
|
4
|
+
export { ASCII_FOLD_TABLE } from "./ascii-fold-table.js";
|
|
5
|
+
export { stripDiacritics, stripDiacriticsForSlug } from "./diacritics.js";
|
|
6
|
+
export { escapeRegExp } from "./escape-regexp.js";
|
|
7
|
+
export { arabicNormalize } from "./normalize.js";
|
|
8
|
+
export { findSearchMatchRanges, foldSearchMatchText, foldSearchMatchTextWithOffsets, } from "./search-match.js";
|
|
9
|
+
export type { FoldedSearchText, SearchMatchRange } from "./search-match.js";
|
|
10
|
+
export { collapseSpacedLetters, spacedLetterRunRegex, } from "./spaced-letters.js";
|
|
11
|
+
export { slugify } from "./slug.js";
|
|
12
|
+
export type { SlugCharset, SlugifyOptions, SlugSeparator } from "./slug.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export { applyArabicFolds, applyArabicFoldsWithOffsets, ARABIC_DIGIT_FOLDS, ARABIC_LETTER_FOLDS, ARABIC_REMOVED, } from "./arabic.js";
|
|
2
|
+
export { foldToAscii } from "./ascii-fold.js";
|
|
3
|
+
export { ASCII_FOLD_TABLE } from "./ascii-fold-table.js";
|
|
4
|
+
export { stripDiacritics, stripDiacriticsForSlug } from "./diacritics.js";
|
|
5
|
+
export { escapeRegExp } from "./escape-regexp.js";
|
|
6
|
+
export { arabicNormalize } from "./normalize.js";
|
|
7
|
+
export { findSearchMatchRanges, foldSearchMatchText, foldSearchMatchTextWithOffsets, } from "./search-match.js";
|
|
8
|
+
export { collapseSpacedLetters, spacedLetterRunRegex, } from "./spaced-letters.js";
|
|
9
|
+
export { slugify } from "./slug.js";
|
|
10
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,gBAAgB,EAChB,2BAA2B,EAC3B,kBAAkB,EAClB,mBAAmB,EACnB,cAAc,GACf,MAAM,aAAa,CAAC;AAErB,OAAO,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAC;AAC9C,OAAO,EAAE,gBAAgB,EAAE,MAAM,uBAAuB,CAAC;AACzD,OAAO,EAAE,eAAe,EAAE,sBAAsB,EAAE,MAAM,iBAAiB,CAAC;AAC1E,OAAO,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAClD,OAAO,EAAE,eAAe,EAAE,MAAM,gBAAgB,CAAC;AACjD,OAAO,EACL,qBAAqB,EACrB,mBAAmB,EACnB,8BAA8B,GAC/B,MAAM,mBAAmB,CAAC;AAE3B,OAAO,EACL,qBAAqB,EACrB,oBAAoB,GACrB,MAAM,qBAAqB,CAAC;AAC7B,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC"}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TypeScript mirror of the SQL `arabic_normalize()` function. Folds Arabic
|
|
3
|
+
* orthographic variants so a query matches regardless of how the text was
|
|
4
|
+
* typed (alef/hamza/teh-marbuta/yeh variants, tashkeel, tatweel,
|
|
5
|
+
* Arabic-Indic digits), then applies locale-stable ASCII case folding for
|
|
6
|
+
* mixed-script names. It must NOT strip Latin diacritics — the stored match
|
|
7
|
+
* keys don't; client-side diacritic-insensitive matching is
|
|
8
|
+
* `foldSearchMatchText` (search-match.ts).
|
|
9
|
+
*
|
|
10
|
+
* NFKC runs first so presentation forms (U+FB50–FDFF, U+FE70–FEFF) fold
|
|
11
|
+
* to their canonical letters before the explicit folds apply.
|
|
12
|
+
*
|
|
13
|
+
* This MUST stay consistent with the SQL `arabic_normalize()` function;
|
|
14
|
+
* the golden vectors in normalize.test.ts pin the contract for both.
|
|
15
|
+
*/
|
|
16
|
+
export declare const arabicNormalize: (text: string) => string;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { applyArabicFolds } from "./arabic.js";
|
|
2
|
+
const ASCII_UPPERCASE_RE = /[A-Zİ]/gu;
|
|
3
|
+
const SEARCH_WHITESPACE_RE = /[ \t\n\v\f\r\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff]+/gu;
|
|
4
|
+
const foldSearchCase = (text) => text.replace(ASCII_UPPERCASE_RE, (char) => {
|
|
5
|
+
if (char === "İ") {
|
|
6
|
+
return "i";
|
|
7
|
+
}
|
|
8
|
+
const codePoint = char.codePointAt(0);
|
|
9
|
+
if (codePoint === undefined) {
|
|
10
|
+
return char;
|
|
11
|
+
}
|
|
12
|
+
return String.fromCodePoint(codePoint + 32);
|
|
13
|
+
});
|
|
14
|
+
/**
|
|
15
|
+
* TypeScript mirror of the SQL `arabic_normalize()` function. Folds Arabic
|
|
16
|
+
* orthographic variants so a query matches regardless of how the text was
|
|
17
|
+
* typed (alef/hamza/teh-marbuta/yeh variants, tashkeel, tatweel,
|
|
18
|
+
* Arabic-Indic digits), then applies locale-stable ASCII case folding for
|
|
19
|
+
* mixed-script names. It must NOT strip Latin diacritics — the stored match
|
|
20
|
+
* keys don't; client-side diacritic-insensitive matching is
|
|
21
|
+
* `foldSearchMatchText` (search-match.ts).
|
|
22
|
+
*
|
|
23
|
+
* NFKC runs first so presentation forms (U+FB50–FDFF, U+FE70–FEFF) fold
|
|
24
|
+
* to their canonical letters before the explicit folds apply.
|
|
25
|
+
*
|
|
26
|
+
* This MUST stay consistent with the SQL `arabic_normalize()` function;
|
|
27
|
+
* the golden vectors in normalize.test.ts pin the contract for both.
|
|
28
|
+
*/
|
|
29
|
+
export const arabicNormalize = (text) => foldSearchCase(applyArabicFolds(text.normalize("NFKC")))
|
|
30
|
+
.replace(SEARCH_WHITESPACE_RE, " ")
|
|
31
|
+
.trim();
|
|
32
|
+
//# sourceMappingURL=normalize.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"normalize.js","sourceRoot":"","sources":["../src/normalize.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAE/C,MAAM,kBAAkB,GAAG,UAAU,CAAC;AACtC,MAAM,oBAAoB,GACxB,+EAA+E,CAAC;AAElF,MAAM,cAAc,GAAG,CAAC,IAAY,EAAU,EAAE,CAC9C,IAAI,CAAC,OAAO,CAAC,kBAAkB,EAAE,CAAC,IAAI,EAAE,EAAE;IACxC,IAAI,IAAI,KAAK,GAAG,EAAE,CAAC;QACjB,OAAO,GAAG,CAAC;IACb,CAAC;IACD,MAAM,SAAS,GAAG,IAAI,CAAC,WAAW,CAAC,CAAC,CAAC,CAAC;IACtC,IAAI,SAAS,KAAK,SAAS,EAAE,CAAC;QAC5B,OAAO,IAAI,CAAC;IACd,CAAC;IACD,OAAO,MAAM,CAAC,aAAa,CAAC,SAAS,GAAG,EAAE,CAAC,CAAC;AAC9C,CAAC,CAAC,CAAC;AAEL;;;;;;;;;;;;;;GAcG;AACH,MAAM,CAAC,MAAM,eAAe,GAAG,CAAC,IAAY,EAAU,EAAE,CACtD,cAAc,CAAC,gBAAgB,CAAC,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC,CAAC,CAAC;KACrD,OAAO,CAAC,oBAAoB,EAAE,GAAG,CAAC;KAClC,IAAI,EAAE,CAAC"}
|
|
@@ -0,0 +1,38 @@
|
|
|
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
|
+
export type SearchMatchRange = {
|
|
19
|
+
end: number;
|
|
20
|
+
start: number;
|
|
21
|
+
};
|
|
22
|
+
export type FoldedSearchText = {
|
|
23
|
+
/** Original-string character range for each folded UTF-16 code unit. */
|
|
24
|
+
originalRanges: SearchMatchRange[];
|
|
25
|
+
text: string;
|
|
26
|
+
};
|
|
27
|
+
export declare const foldSearchMatchText: (value: string) => string;
|
|
28
|
+
export declare const foldSearchMatchTextWithOffsets: (content: string) => FoldedSearchText;
|
|
29
|
+
type FindSearchMatchRangesOptions = {
|
|
30
|
+
maxMatches?: number;
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* Non-overlapping occurrences of the folded query in the content, as ranges
|
|
34
|
+
* into the original string. Accepts pre-folded content so callers matching
|
|
35
|
+
* many queries against the same text fold it once.
|
|
36
|
+
*/
|
|
37
|
+
export declare const findSearchMatchRanges: (content: string | FoldedSearchText, query: string, options?: FindSearchMatchRangesOptions) => SearchMatchRange[];
|
|
38
|
+
export {};
|
|
@@ -0,0 +1,118 @@
|
|
|
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
|
+
import { applyArabicFolds } from "./arabic.js";
|
|
19
|
+
import { applyAsciiFolds } from "./ascii-fold.js";
|
|
20
|
+
const COMBINING_MARKS = /\p{M}+/gu;
|
|
21
|
+
// `toLowerCase` keeps the positional final sigma that Unicode case folding
|
|
22
|
+
// maps to σ; a lone `σ` query would otherwise miss the end of `ΟΣ`. Same
|
|
23
|
+
// length, so offsets are unaffected; a full `toUpperCase().toLowerCase()`
|
|
24
|
+
// fold is not used because it changes lengths (ß → SS) and the ASCII table
|
|
25
|
+
// already covers those letters.
|
|
26
|
+
const FINAL_SIGMA = /ς/gu;
|
|
27
|
+
const foldSearchMatchTextBeforeCase = (value) => applyArabicFolds(applyAsciiFolds(value.normalize("NFKD")).replace(COMBINING_MARKS, ""));
|
|
28
|
+
const caseFold = (value) => value.toLowerCase().replace(FINAL_SIGMA, "σ");
|
|
29
|
+
export const foldSearchMatchText = (value) => caseFold(foldSearchMatchTextBeforeCase(value));
|
|
30
|
+
export const foldSearchMatchTextWithOffsets = (content) => {
|
|
31
|
+
const units = [];
|
|
32
|
+
let originalOffset = 0;
|
|
33
|
+
for (const character of content) {
|
|
34
|
+
const start = originalOffset;
|
|
35
|
+
originalOffset += character.length;
|
|
36
|
+
units.push({
|
|
37
|
+
range: { start, end: originalOffset },
|
|
38
|
+
text: foldSearchMatchTextBeforeCase(character),
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
const beforeCase = units.map(({ text }) => text).join("");
|
|
42
|
+
const contextualLowercase = caseFold(beforeCase);
|
|
43
|
+
if (contextualLowercase.length === beforeCase.length) {
|
|
44
|
+
const originalRanges = [];
|
|
45
|
+
for (const unit of units) {
|
|
46
|
+
let codeUnit = 0;
|
|
47
|
+
while (codeUnit < unit.text.length) {
|
|
48
|
+
originalRanges.push(unit.range);
|
|
49
|
+
codeUnit += 1;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
text: contextualLowercase,
|
|
54
|
+
originalRanges,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
// Lowercasing changed the length (e.g. İ gains a combining dot), so the
|
|
58
|
+
// whole-string result cannot be aligned with the per-character units;
|
|
59
|
+
// lowercase each unit separately instead.
|
|
60
|
+
const loweredParts = [];
|
|
61
|
+
const originalRanges = [];
|
|
62
|
+
for (const unit of units) {
|
|
63
|
+
const lowered = caseFold(unit.text);
|
|
64
|
+
loweredParts.push(lowered);
|
|
65
|
+
let codeUnit = 0;
|
|
66
|
+
while (codeUnit < lowered.length) {
|
|
67
|
+
originalRanges.push(unit.range);
|
|
68
|
+
codeUnit += 1;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return { text: loweredParts.join(""), originalRanges };
|
|
72
|
+
};
|
|
73
|
+
/**
|
|
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.
|
|
77
|
+
*/
|
|
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) {
|
|
88
|
+
return [];
|
|
89
|
+
}
|
|
90
|
+
const matches = [];
|
|
91
|
+
let searchFrom = 0;
|
|
92
|
+
while (searchFrom <= foldedContent.text.length - foldedQuery.length) {
|
|
93
|
+
const foldedStart = foldedContent.text.indexOf(foldedQuery, searchFrom);
|
|
94
|
+
if (foldedStart === -1) {
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
const foldedEnd = foldedStart + foldedQuery.length;
|
|
98
|
+
const firstRange = foldedContent.originalRanges.at(foldedStart);
|
|
99
|
+
const lastRange = foldedContent.originalRanges.at(foldedEnd - 1);
|
|
100
|
+
if (!firstRange || !lastRange) {
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
matches.push({ start: firstRange.start, end: lastRange.end });
|
|
104
|
+
if (matches.length >= maxMatches) {
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
// An expanding fold (ß → ss) maps several folded units to one original
|
|
108
|
+
// character; resume after the last unit inside the matched characters so
|
|
109
|
+
// that character is reported once and the budget reaches later hits.
|
|
110
|
+
searchFrom = foldedEnd;
|
|
111
|
+
while ((foldedContent.originalRanges.at(searchFrom)?.start ?? Infinity) <
|
|
112
|
+
lastRange.end) {
|
|
113
|
+
searchFrom += 1;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return matches;
|
|
117
|
+
};
|
|
118
|
+
//# sourceMappingURL=search-match.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"search-match.js","sourceRoot":"","sources":["../src/search-match.ts"],"names":[],"mappings":"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"}
|
package/dist/slug.d.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One slug generator for the key-shaped strings the apps derive from a label.
|
|
3
|
+
*
|
|
4
|
+
* Four copies of this loop existed, differing only in their separator, their
|
|
5
|
+
* budget, their fallback, and whether a non-ASCII letter survives. Those four
|
|
6
|
+
* are the parameters; everything else — lowercase, collapse a run of rejected
|
|
7
|
+
* characters into one separator, clip, trim a trailing separator, fall back
|
|
8
|
+
* when nothing is left — was identical in all four and is fixed here.
|
|
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.
|
|
14
|
+
*
|
|
15
|
+
* Single-pass by construction: a regex pipeline over untrusted label text is
|
|
16
|
+
* what the slow-regex guard exists to prevent.
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* Which characters survive.
|
|
20
|
+
*
|
|
21
|
+
* - `ascii` keeps `[a-z0-9]`, for keys that end up in URLs and columns
|
|
22
|
+
* validated against that charset.
|
|
23
|
+
* - `unicode` keeps any Unicode letter or number, for paths shown back to the
|
|
24
|
+
* person who typed them.
|
|
25
|
+
*/
|
|
26
|
+
export type SlugCharset = "ascii" | "unicode";
|
|
27
|
+
/**
|
|
28
|
+
* The delimiters callers use: a hyphen for URL and column keys, an underscore
|
|
29
|
+
* for field paths.
|
|
30
|
+
*
|
|
31
|
+
* A closed union rather than a `string`, because an empty separator would make
|
|
32
|
+
* the trim loop below spin forever — `"".endsWith("")` is true and
|
|
33
|
+
* `slice(0, -0)` is the whole string. A caller cannot reach that state now.
|
|
34
|
+
*/
|
|
35
|
+
export type SlugSeparator = "-" | "_";
|
|
36
|
+
export type SlugifyOptions = {
|
|
37
|
+
readonly charset: SlugCharset;
|
|
38
|
+
/** Joins the surviving runs. */
|
|
39
|
+
readonly separator: SlugSeparator;
|
|
40
|
+
/** Budget the result is clipped to, before the trailing separator is cut. */
|
|
41
|
+
readonly maxLength: number;
|
|
42
|
+
/** Returned when nothing survives, so a caller always gets a usable key. */
|
|
43
|
+
readonly fallback: string;
|
|
44
|
+
};
|
|
45
|
+
export declare const slugify: (value: string, { charset, separator, maxLength, fallback }: SlugifyOptions) => string;
|
package/dist/slug.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One slug generator for the key-shaped strings the apps derive from a label.
|
|
3
|
+
*
|
|
4
|
+
* Four copies of this loop existed, differing only in their separator, their
|
|
5
|
+
* budget, their fallback, and whether a non-ASCII letter survives. Those four
|
|
6
|
+
* are the parameters; everything else — lowercase, collapse a run of rejected
|
|
7
|
+
* characters into one separator, clip, trim a trailing separator, fall back
|
|
8
|
+
* when nothing is left — was identical in all four and is fixed here.
|
|
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.
|
|
14
|
+
*
|
|
15
|
+
* Single-pass by construction: a regex pipeline over untrusted label text is
|
|
16
|
+
* what the slow-regex guard exists to prevent.
|
|
17
|
+
*/
|
|
18
|
+
const UNICODE_SLUG_CHARACTER = /[\p{L}\p{N}]/u;
|
|
19
|
+
const isSlugCharacter = (character, charset) => {
|
|
20
|
+
if (charset === "unicode") {
|
|
21
|
+
return UNICODE_SLUG_CHARACTER.test(character);
|
|
22
|
+
}
|
|
23
|
+
return ((character >= "a" && character <= "z") ||
|
|
24
|
+
(character >= "0" && character <= "9"));
|
|
25
|
+
};
|
|
26
|
+
export const slugify = (value, { charset, separator, maxLength, fallback }) => {
|
|
27
|
+
let buffer = "";
|
|
28
|
+
let lastWasSeparator = true;
|
|
29
|
+
for (const character of value.toLowerCase()) {
|
|
30
|
+
if (isSlugCharacter(character, charset)) {
|
|
31
|
+
buffer += character;
|
|
32
|
+
lastWasSeparator = false;
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
if (!lastWasSeparator) {
|
|
36
|
+
buffer += separator;
|
|
37
|
+
lastWasSeparator = true;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
let clipped = "";
|
|
41
|
+
for (const character of buffer) {
|
|
42
|
+
if (clipped.length + character.length > maxLength) {
|
|
43
|
+
break;
|
|
44
|
+
}
|
|
45
|
+
clipped += character;
|
|
46
|
+
}
|
|
47
|
+
// Every separator is one character, so the trim shortens by one per pass.
|
|
48
|
+
while (clipped.endsWith(separator)) {
|
|
49
|
+
clipped = clipped.slice(0, -1);
|
|
50
|
+
}
|
|
51
|
+
return clipped || fallback;
|
|
52
|
+
};
|
|
53
|
+
//# sourceMappingURL=slug.js.map
|
package/dist/slug.js.map
ADDED
|
@@ -0,0 +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"}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Collapse spaced-out letters used for emphasis in court PDFs.
|
|
3
|
+
*
|
|
4
|
+
* Slovak and Czech courts format words with letter-spacing:
|
|
5
|
+
* `r o z h o d o l :` -> `rozhodol:`
|
|
6
|
+
* `o d ô v o d n e n i e :` -> `odôvodnenie:`
|
|
7
|
+
* `z a m i e t a` -> `zamieta`
|
|
8
|
+
*
|
|
9
|
+
* These break full-text search ("rozhodol" won't match
|
|
10
|
+
* "r o z h o d o l"). We collapse runs of single Unicode letters
|
|
11
|
+
* separated by single spaces, optionally followed by punctuation.
|
|
12
|
+
*
|
|
13
|
+
* Requires at least FOUR letters in the run. Czech/Slovak have many
|
|
14
|
+
* single-letter words (prepositions a, i, k, o, s, u, v, z), so a 2–3
|
|
15
|
+
* letter run like `u a v` ("u", "a", "v") is far more likely to be real
|
|
16
|
+
* words than letter-spaced emphasis; collapsing it to `uav` would corrupt
|
|
17
|
+
* the text. Genuine spaced words ("z a m i e t a", "r o z h o d o l") are
|
|
18
|
+
* always longer, so the floor loses nothing in practice.
|
|
19
|
+
*
|
|
20
|
+
* This is the single source of the threshold: the ingestion pipeline
|
|
21
|
+
* (index-time) and the case-viewer find-in-page normalizer (query-time)
|
|
22
|
+
* both consume it, so a spaced heading collapses identically on both
|
|
23
|
+
* sides and highlight offsets stay aligned.
|
|
24
|
+
*/
|
|
25
|
+
/**
|
|
26
|
+
* A fresh global RegExp matching one spaced-letter run. Returned from a
|
|
27
|
+
* factory (not a shared constant) because callers use it with both
|
|
28
|
+
* `String.replace` and `String.matchAll`; a fresh instance avoids any
|
|
29
|
+
* shared `lastIndex` surprises.
|
|
30
|
+
*/
|
|
31
|
+
export declare const spacedLetterRunRegex: () => RegExp;
|
|
32
|
+
/**
|
|
33
|
+
* Collapse every spaced-letter run in `text` to its concatenated letters,
|
|
34
|
+
* normalizing multi-spaces to a single space.
|
|
35
|
+
*
|
|
36
|
+
* Applied to a fixpoint rather than once, so `f(f(x)) === f(x)` holds for
|
|
37
|
+
* every input. A single pass did not settle: the pattern matches letters
|
|
38
|
+
* separated by exactly one space, while the multi-space squeeze runs
|
|
39
|
+
* afterwards, so a run written with two spaces survived the pass and came
|
|
40
|
+
* out in the very shape the pattern was looking for. The next call then
|
|
41
|
+
* collapsed what the previous one had not.
|
|
42
|
+
*
|
|
43
|
+
* That mattered because the two sides of a search comparison are folded
|
|
44
|
+
* at different times — index time during ingestion, query time in the
|
|
45
|
+
* case-viewer's find-in-page — and the query side is handed text the
|
|
46
|
+
* index side already collapsed. A function that collapsed more on its
|
|
47
|
+
* second application made those two sides disagree, which is exactly the
|
|
48
|
+
* alignment this module exists to guarantee.
|
|
49
|
+
*
|
|
50
|
+
* Widening the pattern's separator to `+` would also settle it in one
|
|
51
|
+
* pass, and is wrong: a single space separates the letters of one
|
|
52
|
+
* emphasized word, and a wider gap separates two of them. Accepting both
|
|
53
|
+
* as letter separators collapses `Ž a l o b a s e z a m í t á` to
|
|
54
|
+
* `Žalobasezamítá` — the whole verdict as one word. The loop keeps that
|
|
55
|
+
* distinction inside each pass.
|
|
56
|
+
*/
|
|
57
|
+
export declare const collapseSpacedLetters: (text: string) => string;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Collapse spaced-out letters used for emphasis in court PDFs.
|
|
3
|
+
*
|
|
4
|
+
* Slovak and Czech courts format words with letter-spacing:
|
|
5
|
+
* `r o z h o d o l :` -> `rozhodol:`
|
|
6
|
+
* `o d ô v o d n e n i e :` -> `odôvodnenie:`
|
|
7
|
+
* `z a m i e t a` -> `zamieta`
|
|
8
|
+
*
|
|
9
|
+
* These break full-text search ("rozhodol" won't match
|
|
10
|
+
* "r o z h o d o l"). We collapse runs of single Unicode letters
|
|
11
|
+
* separated by single spaces, optionally followed by punctuation.
|
|
12
|
+
*
|
|
13
|
+
* Requires at least FOUR letters in the run. Czech/Slovak have many
|
|
14
|
+
* single-letter words (prepositions a, i, k, o, s, u, v, z), so a 2–3
|
|
15
|
+
* letter run like `u a v` ("u", "a", "v") is far more likely to be real
|
|
16
|
+
* words than letter-spaced emphasis; collapsing it to `uav` would corrupt
|
|
17
|
+
* the text. Genuine spaced words ("z a m i e t a", "r o z h o d o l") are
|
|
18
|
+
* always longer, so the floor loses nothing in practice.
|
|
19
|
+
*
|
|
20
|
+
* This is the single source of the threshold: the ingestion pipeline
|
|
21
|
+
* (index-time) and the case-viewer find-in-page normalizer (query-time)
|
|
22
|
+
* both consume it, so a spaced heading collapses identically on both
|
|
23
|
+
* sides and highlight offsets stay aligned.
|
|
24
|
+
*/
|
|
25
|
+
// `\p{L} (?:\p{L} ){2,}\p{L}` is four or more spaced letters: one leading
|
|
26
|
+
// letter, at least two interior letters, one trailing letter. Anchored by
|
|
27
|
+
// whitespace/string boundaries so it never touches normal words, digits,
|
|
28
|
+
// or case references.
|
|
29
|
+
const buildSpacedLetterRunSource = () => "(?<=\\s|^)(?:\\p{L} (?:\\p{L} ){2,}\\p{L})(?: ?[,:;.!?])?(?=\\s|$)";
|
|
30
|
+
/**
|
|
31
|
+
* A fresh global RegExp matching one spaced-letter run. Returned from a
|
|
32
|
+
* factory (not a shared constant) because callers use it with both
|
|
33
|
+
* `String.replace` and `String.matchAll`; a fresh instance avoids any
|
|
34
|
+
* shared `lastIndex` surprises.
|
|
35
|
+
*/
|
|
36
|
+
export const spacedLetterRunRegex = () => new RegExp(buildSpacedLetterRunSource(), "gu");
|
|
37
|
+
const MULTI_SPACE_RE = / {2,}/gu;
|
|
38
|
+
const SPACE_RE = / /gu;
|
|
39
|
+
// Single-entry cache: sk-courts.ts's classifiers (isHoldingMarker,
|
|
40
|
+
// isReasoningMarker, isInstructionMarker) each re-normalize the same line
|
|
41
|
+
// text in sequence while classifying a heading, so consecutive calls on an
|
|
42
|
+
// identical string are common during ingestion. Deterministic pure
|
|
43
|
+
// function, so caching only the most recent call is always correct — a
|
|
44
|
+
// cache miss just falls through to the normal computation.
|
|
45
|
+
let lastInput;
|
|
46
|
+
let lastResult;
|
|
47
|
+
/**
|
|
48
|
+
* One collapse pass: join every spaced-letter run, then normalize any
|
|
49
|
+
* resulting multi-spaces to a single space.
|
|
50
|
+
*
|
|
51
|
+
* A pass only ever deletes space characters, so it either leaves the
|
|
52
|
+
* text alone or makes it strictly shorter. That is what bounds the loop
|
|
53
|
+
* below.
|
|
54
|
+
*/
|
|
55
|
+
const collapsePass = (text) => text
|
|
56
|
+
.replace(spacedLetterRunRegex(), (match) => match.replace(SPACE_RE, ""))
|
|
57
|
+
.replace(MULTI_SPACE_RE, " ");
|
|
58
|
+
/**
|
|
59
|
+
* Collapse every spaced-letter run in `text` to its concatenated letters,
|
|
60
|
+
* normalizing multi-spaces to a single space.
|
|
61
|
+
*
|
|
62
|
+
* Applied to a fixpoint rather than once, so `f(f(x)) === f(x)` holds for
|
|
63
|
+
* every input. A single pass did not settle: the pattern matches letters
|
|
64
|
+
* separated by exactly one space, while the multi-space squeeze runs
|
|
65
|
+
* afterwards, so a run written with two spaces survived the pass and came
|
|
66
|
+
* out in the very shape the pattern was looking for. The next call then
|
|
67
|
+
* collapsed what the previous one had not.
|
|
68
|
+
*
|
|
69
|
+
* That mattered because the two sides of a search comparison are folded
|
|
70
|
+
* at different times — index time during ingestion, query time in the
|
|
71
|
+
* case-viewer's find-in-page — and the query side is handed text the
|
|
72
|
+
* index side already collapsed. A function that collapsed more on its
|
|
73
|
+
* second application made those two sides disagree, which is exactly the
|
|
74
|
+
* alignment this module exists to guarantee.
|
|
75
|
+
*
|
|
76
|
+
* Widening the pattern's separator to `+` would also settle it in one
|
|
77
|
+
* pass, and is wrong: a single space separates the letters of one
|
|
78
|
+
* emphasized word, and a wider gap separates two of them. Accepting both
|
|
79
|
+
* as letter separators collapses `Ž a l o b a s e z a m í t á` to
|
|
80
|
+
* `Žalobasezamítá` — the whole verdict as one word. The loop keeps that
|
|
81
|
+
* distinction inside each pass.
|
|
82
|
+
*/
|
|
83
|
+
export const collapseSpacedLetters = (text) => {
|
|
84
|
+
if (text === lastInput && lastResult !== undefined) {
|
|
85
|
+
return lastResult;
|
|
86
|
+
}
|
|
87
|
+
let result = text;
|
|
88
|
+
for (;;) {
|
|
89
|
+
const next = collapsePass(result);
|
|
90
|
+
// Terminates: a pass that changes anything strictly shortens the
|
|
91
|
+
// text, and a pass that changes nothing ends the loop.
|
|
92
|
+
if (next === result) {
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
result = next;
|
|
96
|
+
}
|
|
97
|
+
lastInput = text;
|
|
98
|
+
lastResult = result;
|
|
99
|
+
return result;
|
|
100
|
+
};
|
|
101
|
+
//# sourceMappingURL=spaced-letters.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"spaced-letters.js","sourceRoot":"","sources":["../src/spaced-letters.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAEH,0EAA0E;AAC1E,0EAA0E;AAC1E,yEAAyE;AACzE,sBAAsB;AACtB,MAAM,0BAA0B,GAAG,GAAG,EAAE,CACtC,oEAAoE,CAAC;AAEvE;;;;;GAKG;AACH,MAAM,CAAC,MAAM,oBAAoB,GAAG,GAAW,EAAE,CAC/C,IAAI,MAAM,CAAC,0BAA0B,EAAE,EAAE,IAAI,CAAC,CAAC;AAEjD,MAAM,cAAc,GAAG,SAAS,CAAC;AACjC,MAAM,QAAQ,GAAG,KAAK,CAAC;AAEvB,mEAAmE;AACnE,0EAA0E;AAC1E,2EAA2E;AAC3E,mEAAmE;AACnE,uEAAuE;AACvE,2DAA2D;AAC3D,IAAI,SAA6B,CAAC;AAClC,IAAI,UAA8B,CAAC;AAEnC;;;;;;;GAOG;AACH,MAAM,YAAY,GAAG,CAAC,IAAY,EAAU,EAAE,CAC5C,IAAI;KACD,OAAO,CAAC,oBAAoB,EAAE,EAAE,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,OAAO,CAAC,QAAQ,EAAE,EAAE,CAAC,CAAC;KACvE,OAAO,CAAC,cAAc,EAAE,GAAG,CAAC,CAAC;AAElC;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,MAAM,CAAC,MAAM,qBAAqB,GAAG,CAAC,IAAY,EAAU,EAAE;IAC5D,IAAI,IAAI,KAAK,SAAS,IAAI,UAAU,KAAK,SAAS,EAAE,CAAC;QACnD,OAAO,UAAU,CAAC;IACpB,CAAC;IAED,IAAI,MAAM,GAAG,IAAI,CAAC;IAClB,SAAS,CAAC;QACR,MAAM,IAAI,GAAG,YAAY,CAAC,MAAM,CAAC,CAAC;QAClC,iEAAiE;QACjE,uDAAuD;QACvD,IAAI,IAAI,KAAK,MAAM,EAAE,CAAC;YACpB,MAAM;QACR,CAAC;QACD,MAAM,GAAG,IAAI,CAAC;IAChB,CAAC;IAED,SAAS,GAAG,IAAI,CAAC;IACjB,UAAU,GAAG,MAAM,CAAC;IACpB,OAAO,MAAM,CAAC;AAChB,CAAC,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,9 +1,58 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stll/text-normalize",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Search match-key text normalization — Arabic orthographic variant folding (alef/hamza/teh-marbuta/yeh, tashkeel, tatweel, digits) with per-language fold tables",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"arabic",
|
|
7
|
+
"i18n",
|
|
8
|
+
"normalization",
|
|
9
|
+
"search",
|
|
10
|
+
"text-search",
|
|
11
|
+
"unicode"
|
|
12
|
+
],
|
|
6
13
|
"homepage": "https://github.com/stella/stella/tree/main/packages/text-normalize",
|
|
7
|
-
"bugs": {
|
|
8
|
-
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/stella/stella/issues"
|
|
16
|
+
},
|
|
17
|
+
"license": "Apache-2.0",
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "https://github.com/stella/stella.git",
|
|
21
|
+
"directory": "packages/text-normalize"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"dist",
|
|
25
|
+
"README.md"
|
|
26
|
+
],
|
|
27
|
+
"type": "module",
|
|
28
|
+
"sideEffects": false,
|
|
29
|
+
"main": "./dist/index.js",
|
|
30
|
+
"types": "./dist/index.d.ts",
|
|
31
|
+
"exports": {
|
|
32
|
+
".": {
|
|
33
|
+
"types": "./dist/index.d.ts",
|
|
34
|
+
"import": "./dist/index.js"
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
"publishConfig": {
|
|
38
|
+
"access": "public"
|
|
39
|
+
},
|
|
40
|
+
"scripts": {
|
|
41
|
+
"clean": "git clean -xdf dist .cache .turbo node_modules",
|
|
42
|
+
"build": "rm -rf dist && bun ../../packages/scripts/src/tsc-native.ts -p tsconfig.build.json",
|
|
43
|
+
"pack:dry-run": "bun pm pack --dry-run",
|
|
44
|
+
"test": "bun test src",
|
|
45
|
+
"test:property": "bun test --preload @stll/property-testing/preload $(grep -rlF 'fc.assert' src --include='*.test.ts')",
|
|
46
|
+
"typecheck": "bun ../../packages/scripts/src/tsc-native.ts --noEmit",
|
|
47
|
+
"lint": "cd ../.. && bun --bun oxlint -c oxlint.config.ts --report-unused-disable-directives-severity=error --type-aware --type-check packages/text-normalize",
|
|
48
|
+
"lint:fix": "cd ../.. && bun --bun oxlint -c oxlint.config.ts --type-aware --fix packages/text-normalize",
|
|
49
|
+
"format": "bun ../../scripts/run-oxfmt.ts .",
|
|
50
|
+
"prepack": "bun run build"
|
|
51
|
+
},
|
|
52
|
+
"devDependencies": {
|
|
53
|
+
"@stll/property-testing": "0.0.0",
|
|
54
|
+
"@stll/typescript-config": "0.0.0",
|
|
55
|
+
"bun-types": "1.4.2",
|
|
56
|
+
"fast-check": "^4.10.1"
|
|
57
|
+
}
|
|
9
58
|
}
|