@lp-labs/transliteration-search 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +154 -2
- package/dist/cjs/index.d.ts +33 -0
- package/dist/cjs/index.js +147 -0
- package/dist/cjs/package.json +1 -0
- package/dist/esm/index.d.ts +33 -0
- package/dist/esm/index.js +144 -0
- package/package.json +61 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Libin Prasanth
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,155 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @lp-labs/transliteration-search
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Original caller-table transliteration and literal search with stable original UTF-16 mapping.
|
|
4
|
+
Independent, zero runtime/peer dependencies, no bundled script tables.
|
|
5
|
+
**There is no global script coverage or linguistic equivalence claim.**
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install @lp-labs/transliteration-search
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Node >=18, ES2022 ESM/CommonJS and strict TypeScript declarations.
|
|
14
|
+
CommonJS: `const { createTransliterator } = require('@lp-labs/transliteration-search')`.
|
|
15
|
+
|
|
16
|
+
## Quickstart
|
|
17
|
+
|
|
18
|
+
```js
|
|
19
|
+
import { createTransliterator } from '@lp-labs/transliteration-search';
|
|
20
|
+
|
|
21
|
+
const engine = createTransliterator([
|
|
22
|
+
{ source: 'æ', target: 'ae' },
|
|
23
|
+
{ source: 'ж', target: 'zh' },
|
|
24
|
+
{ source: '\u0301', target: '' },
|
|
25
|
+
]);
|
|
26
|
+
const result = engine.transliterate('😀æ e\u0301');
|
|
27
|
+
if (result.text !== '😀ae e') throw new Error('Transliteration failed');
|
|
28
|
+
const range = result.originalRange(3, 4);
|
|
29
|
+
if (range.start !== 2 || range.end !== 3) throw new Error('Expansion mapping failed');
|
|
30
|
+
const hits = engine.find('😀ж', 'zh');
|
|
31
|
+
if (hits[0].start !== 2 || hits[0].end !== 3) throw new Error('Search mapping failed');
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Full API and types
|
|
35
|
+
|
|
36
|
+
The only runtime export is
|
|
37
|
+
`createTransliterator(entries: readonly TransliterationEntry[], options?: TransliterationOptions): Transliterator`.
|
|
38
|
+
Types below are all exported:
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
interface TransliterationEntry {
|
|
42
|
+
source: string;
|
|
43
|
+
target: string;
|
|
44
|
+
}
|
|
45
|
+
interface TransliterationOptions {
|
|
46
|
+
maxInput?: number;
|
|
47
|
+
maxOutput?: number;
|
|
48
|
+
}
|
|
49
|
+
interface SourceSpan {
|
|
50
|
+
originalStart: number;
|
|
51
|
+
originalEnd: number;
|
|
52
|
+
transformedStart: number;
|
|
53
|
+
transformedEnd: number;
|
|
54
|
+
}
|
|
55
|
+
interface OriginalRange {
|
|
56
|
+
start: number;
|
|
57
|
+
end: number;
|
|
58
|
+
}
|
|
59
|
+
interface TransliteratedText {
|
|
60
|
+
original: string;
|
|
61
|
+
text: string;
|
|
62
|
+
spans: SourceSpan[];
|
|
63
|
+
originalRange(start: number, end: number): OriginalRange;
|
|
64
|
+
}
|
|
65
|
+
interface TransliterationMatch extends OriginalRange {
|
|
66
|
+
transformedStart: number;
|
|
67
|
+
transformedEnd: number;
|
|
68
|
+
}
|
|
69
|
+
interface Transliterator {
|
|
70
|
+
transliterate(text: string): TransliteratedText;
|
|
71
|
+
find(text: string, query: string, maxMatches?: number): TransliterationMatch[];
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### Table customization and transformation
|
|
76
|
+
|
|
77
|
+
Supply 0..1024 entries with unique, nonempty `source` (<=32 UTF-16 units) and `target`
|
|
78
|
+
(0..64 UTF-16 units). Empty targets mean deletion. Tables are copied into a code-point trie.
|
|
79
|
+
Entries are read by bounded array indexes, not through caller-overridden iterators.
|
|
80
|
+
The **longest matching source** at each original code-point boundary wins, independent of
|
|
81
|
+
table order; replacements are not recursively processed. Unmatched code points pass through.
|
|
82
|
+
Empty tables perform identity transformation.
|
|
83
|
+
|
|
84
|
+
`maxInput` is an integer 1..65,536, default 65,536 UTF-16 units.
|
|
85
|
+
`maxOutput` is an integer 1..262,144, default 262,144 UTF-16 units.
|
|
86
|
+
These options may lower limits but cannot remove the hard bounds. Output is checked incrementally.
|
|
87
|
+
All source, target, input and query strings must have paired UTF-16 surrogates.
|
|
88
|
+
|
|
89
|
+
There is no automatic case folding, normalization, mark stripping, whitespace rewriting,
|
|
90
|
+
locale detection or recursive application. Supply exactly the mappings your application needs;
|
|
91
|
+
canonically equivalent forms need explicit entries if you want equivalent search behavior.
|
|
92
|
+
|
|
93
|
+
### Stable interval mapping
|
|
94
|
+
|
|
95
|
+
All coordinates are half-open UTF-16 ranges. Each consumed source entry/code point records one
|
|
96
|
+
span, including zero-width transformed spans for deletions. Expansion/contraction substrings
|
|
97
|
+
map back to the **entire consumed original source**, not an invented per-letter position.
|
|
98
|
+
|
|
99
|
+
`originalRange(start, end)` accepts transformed offsets that do not split surrogate pairs:
|
|
100
|
+
|
|
101
|
+
- Nonempty ranges return the smallest original interval covering overlapping nonempty spans.
|
|
102
|
+
Deleted interior text is included in that interval; deletions at the edges are excluded.
|
|
103
|
+
- A collapsed range uses right bias: the next nonempty span's original start, or the containing
|
|
104
|
+
expansion's original start when inside it. At transformed end it maps to original end,
|
|
105
|
+
including trailing deletions; fully deleted input maps to original end.
|
|
106
|
+
- Exposed spans are detached copies. Mutating them or returned text properties does not alter
|
|
107
|
+
the closure's captured mapping.
|
|
108
|
+
|
|
109
|
+
```js
|
|
110
|
+
import { createTransliterator } from '@lp-labs/transliteration-search';
|
|
111
|
+
|
|
112
|
+
const engine = createTransliterator([
|
|
113
|
+
{ source: '-', target: '' },
|
|
114
|
+
{ source: 'ß', target: 'ss' },
|
|
115
|
+
]);
|
|
116
|
+
const result = engine.transliterate('-a-b-');
|
|
117
|
+
const full = result.originalRange(0, 2);
|
|
118
|
+
if (full.start !== 1 || full.end !== 4) throw new Error('Deletion interval failed');
|
|
119
|
+
if (result.originalRange(2, 2).start !== 5) throw new Error('Trailing deletion bias failed');
|
|
120
|
+
if (engine.find('ß', 's').length !== 2) throw new Error('Expansion hits must remain distinct');
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
### Search
|
|
124
|
+
|
|
125
|
+
`find` transforms both text and query with the same table/limits, then uses case-sensitive
|
|
126
|
+
literal substring matching. It returns stable nonoverlapping **transformed** hits, mapped to
|
|
127
|
+
original ranges. Two expansion hits can map to the same original range; they are not deduplicated.
|
|
128
|
+
`maxMatches` is an integer 1..256, default 100; reaching it truncates results without error.
|
|
129
|
+
An empty or fully deleted query is rejected. No fuzzy scoring or regular-expression query syntax.
|
|
130
|
+
|
|
131
|
+
## Errors, work bounds and limits
|
|
132
|
+
|
|
133
|
+
Wrong types, duplicate/empty sources, unpaired surrogates and empty transformed queries throw
|
|
134
|
+
`TypeError`. Table/string/options/output bounds, invalid range ordering/offsets, split-surrogate
|
|
135
|
+
offsets and invalid hit limits throw `RangeError`. No coercion or partial output is returned.
|
|
136
|
+
|
|
137
|
+
Trie construction uses at most 32,768 source UTF-16 units. Transformation examines at most 32
|
|
138
|
+
source units per input position and stores <=65,536 spans, with <=262,144 output units.
|
|
139
|
+
Mapping scans spans in O(S). Search delegates substring scanning to JavaScript and performs
|
|
140
|
+
at most 256 O(S) mappings. Both transformed text and query have independent output bounds.
|
|
141
|
+
No assumptions about browser selection, grapheme alignment or global transliteration accuracy.
|
|
142
|
+
|
|
143
|
+
## Development
|
|
144
|
+
|
|
145
|
+
`npm ci --offline`, `npm run build`, `npm run typecheck`, `npm test`,
|
|
146
|
+
`npm run format`, `npm run format:check`. Cached dev packages are needed for offline installs.
|
|
147
|
+
TypeScript ^5.9.3 and Prettier ^3.6.2 are development-only dependencies.
|
|
148
|
+
|
|
149
|
+
## Support
|
|
150
|
+
|
|
151
|
+
If this library is useful to you, consider supporting its maintenance with a [donation via PayPal](https://www.paypal.me/LibinPrasanth).
|
|
152
|
+
|
|
153
|
+
## Author
|
|
154
|
+
|
|
155
|
+
Created by [Libin Prasanth](https://www.linkedin.com/in/libinprasanth/). Connect on LinkedIn for updates and discussion.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export interface TransliterationEntry {
|
|
2
|
+
source: string;
|
|
3
|
+
target: string;
|
|
4
|
+
}
|
|
5
|
+
export interface TransliterationOptions {
|
|
6
|
+
maxInput?: number;
|
|
7
|
+
maxOutput?: number;
|
|
8
|
+
}
|
|
9
|
+
export interface SourceSpan {
|
|
10
|
+
originalStart: number;
|
|
11
|
+
originalEnd: number;
|
|
12
|
+
transformedStart: number;
|
|
13
|
+
transformedEnd: number;
|
|
14
|
+
}
|
|
15
|
+
export interface OriginalRange {
|
|
16
|
+
start: number;
|
|
17
|
+
end: number;
|
|
18
|
+
}
|
|
19
|
+
export interface TransliteratedText {
|
|
20
|
+
original: string;
|
|
21
|
+
text: string;
|
|
22
|
+
spans: SourceSpan[];
|
|
23
|
+
originalRange(start: number, end: number): OriginalRange;
|
|
24
|
+
}
|
|
25
|
+
export interface TransliterationMatch extends OriginalRange {
|
|
26
|
+
transformedStart: number;
|
|
27
|
+
transformedEnd: number;
|
|
28
|
+
}
|
|
29
|
+
export interface Transliterator {
|
|
30
|
+
transliterate(text: string): TransliteratedText;
|
|
31
|
+
find(text: string, query: string, maxMatches?: number): TransliterationMatch[];
|
|
32
|
+
}
|
|
33
|
+
export declare function createTransliterator(entries: readonly TransliterationEntry[], options?: TransliterationOptions): Transliterator;
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createTransliterator = createTransliterator;
|
|
4
|
+
function bound(value, maximum) {
|
|
5
|
+
if (!Number.isSafeInteger(value) || value < 1 || value > maximum)
|
|
6
|
+
throw new RangeError(`Expected integer in 1..${maximum}.`);
|
|
7
|
+
return value;
|
|
8
|
+
}
|
|
9
|
+
function validText(value, maximum) {
|
|
10
|
+
if (typeof value !== 'string')
|
|
11
|
+
throw new TypeError('Expected text.');
|
|
12
|
+
if (value.length > maximum)
|
|
13
|
+
throw new RangeError('Text exceeds UTF-16 length bound.');
|
|
14
|
+
for (let i = 0; i < value.length; i++) {
|
|
15
|
+
const code = value.charCodeAt(i);
|
|
16
|
+
if (code >= 0xd800 && code <= 0xdbff) {
|
|
17
|
+
const next = value.charCodeAt(++i);
|
|
18
|
+
if (!(next >= 0xdc00 && next <= 0xdfff))
|
|
19
|
+
throw new TypeError('Unpaired high surrogate.');
|
|
20
|
+
}
|
|
21
|
+
else if (code >= 0xdc00 && code <= 0xdfff)
|
|
22
|
+
throw new TypeError('Unpaired low surrogate.');
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function offset(value, text) {
|
|
26
|
+
if (!Number.isSafeInteger(value) || value < 0 || value > text.length)
|
|
27
|
+
throw new RangeError('Offset outside transformed text.');
|
|
28
|
+
if (value > 0 &&
|
|
29
|
+
value < text.length &&
|
|
30
|
+
/[\uD800-\uDBFF]/.test(text[value - 1]) &&
|
|
31
|
+
/[\uDC00-\uDFFF]/.test(text[value]))
|
|
32
|
+
throw new RangeError('Offset splits a surrogate pair.');
|
|
33
|
+
}
|
|
34
|
+
function createTransliterator(entries, options = {}) {
|
|
35
|
+
if (!options || typeof options !== 'object' || Array.isArray(options))
|
|
36
|
+
throw new TypeError('Expected options object.');
|
|
37
|
+
const maxInput = bound(options.maxInput === undefined ? 65_536 : options.maxInput, 65_536);
|
|
38
|
+
const maxOutput = bound(options.maxOutput === undefined ? 262_144 : options.maxOutput, 262_144);
|
|
39
|
+
if (!Array.isArray(entries))
|
|
40
|
+
throw new TypeError('Expected an array of entries.');
|
|
41
|
+
if (entries.length > 1024)
|
|
42
|
+
throw new RangeError('Expected up to 1024 entries.');
|
|
43
|
+
const root = { children: new Map() };
|
|
44
|
+
const count = entries.length;
|
|
45
|
+
for (let index = 0; index < count; index++) {
|
|
46
|
+
const entry = entries[index];
|
|
47
|
+
if (!entry || typeof entry !== 'object')
|
|
48
|
+
throw new TypeError('Expected table entry.');
|
|
49
|
+
validText(entry.source, 32);
|
|
50
|
+
validText(entry.target, 64);
|
|
51
|
+
if (!entry.source.length)
|
|
52
|
+
throw new TypeError('Source cannot be empty.');
|
|
53
|
+
let node = root;
|
|
54
|
+
for (const char of entry.source) {
|
|
55
|
+
let child = node.children.get(char);
|
|
56
|
+
if (!child) {
|
|
57
|
+
child = { children: new Map() };
|
|
58
|
+
node.children.set(char, child);
|
|
59
|
+
}
|
|
60
|
+
node = child;
|
|
61
|
+
}
|
|
62
|
+
if (node.target !== undefined)
|
|
63
|
+
throw new TypeError('Duplicate source.');
|
|
64
|
+
node.target = entry.target;
|
|
65
|
+
}
|
|
66
|
+
const transliterate = (original) => {
|
|
67
|
+
validText(original, maxInput);
|
|
68
|
+
const spans = [];
|
|
69
|
+
const parts = [];
|
|
70
|
+
let length = 0;
|
|
71
|
+
for (let start = 0; start < original.length;) {
|
|
72
|
+
const first = String.fromCodePoint(original.codePointAt(start));
|
|
73
|
+
let end = start + first.length;
|
|
74
|
+
let replacement = first;
|
|
75
|
+
let node = root;
|
|
76
|
+
for (let cursor = start; cursor < original.length;) {
|
|
77
|
+
const char = String.fromCodePoint(original.codePointAt(cursor));
|
|
78
|
+
const next = node.children.get(char);
|
|
79
|
+
if (!next)
|
|
80
|
+
break;
|
|
81
|
+
node = next;
|
|
82
|
+
cursor += char.length;
|
|
83
|
+
if (node.target !== undefined) {
|
|
84
|
+
replacement = node.target;
|
|
85
|
+
end = cursor;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (length + replacement.length > maxOutput)
|
|
89
|
+
throw new RangeError('Output exceeds maxOutput.');
|
|
90
|
+
spans.push({
|
|
91
|
+
originalStart: start,
|
|
92
|
+
originalEnd: end,
|
|
93
|
+
transformedStart: length,
|
|
94
|
+
transformedEnd: length + replacement.length,
|
|
95
|
+
});
|
|
96
|
+
parts.push(replacement);
|
|
97
|
+
length += replacement.length;
|
|
98
|
+
start = end;
|
|
99
|
+
}
|
|
100
|
+
const text = parts.join('');
|
|
101
|
+
return {
|
|
102
|
+
original,
|
|
103
|
+
text,
|
|
104
|
+
spans: spans.map((span) => ({ ...span })),
|
|
105
|
+
originalRange(start, end) {
|
|
106
|
+
offset(start, text);
|
|
107
|
+
offset(end, text);
|
|
108
|
+
if (start > end)
|
|
109
|
+
throw new RangeError('Reversed transformed range.');
|
|
110
|
+
if (start === end) {
|
|
111
|
+
const span = spans.find((part) => part.transformedEnd > start);
|
|
112
|
+
const position = span?.originalStart ?? original.length;
|
|
113
|
+
return { start: position, end: position };
|
|
114
|
+
}
|
|
115
|
+
const overlapping = spans.filter((part) => part.transformedEnd > start &&
|
|
116
|
+
part.transformedStart < end &&
|
|
117
|
+
part.transformedEnd > part.transformedStart);
|
|
118
|
+
return {
|
|
119
|
+
start: overlapping[0].originalStart,
|
|
120
|
+
end: overlapping[overlapping.length - 1].originalEnd,
|
|
121
|
+
};
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
};
|
|
125
|
+
return {
|
|
126
|
+
transliterate,
|
|
127
|
+
find(text, query, maxMatches = 100) {
|
|
128
|
+
bound(maxMatches, 256);
|
|
129
|
+
const source = transliterate(text);
|
|
130
|
+
const needle = transliterate(query).text;
|
|
131
|
+
if (!needle.length)
|
|
132
|
+
throw new TypeError('Query cannot transliterate to empty text.');
|
|
133
|
+
const matches = [];
|
|
134
|
+
let cursor = 0;
|
|
135
|
+
while (matches.length < maxMatches) {
|
|
136
|
+
const start = source.text.indexOf(needle, cursor);
|
|
137
|
+
if (start < 0)
|
|
138
|
+
break;
|
|
139
|
+
const end = start + needle.length;
|
|
140
|
+
const range = source.originalRange(start, end);
|
|
141
|
+
matches.push({ ...range, transformedStart: start, transformedEnd: end });
|
|
142
|
+
cursor = end;
|
|
143
|
+
}
|
|
144
|
+
return matches;
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"type":"commonjs"}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export interface TransliterationEntry {
|
|
2
|
+
source: string;
|
|
3
|
+
target: string;
|
|
4
|
+
}
|
|
5
|
+
export interface TransliterationOptions {
|
|
6
|
+
maxInput?: number;
|
|
7
|
+
maxOutput?: number;
|
|
8
|
+
}
|
|
9
|
+
export interface SourceSpan {
|
|
10
|
+
originalStart: number;
|
|
11
|
+
originalEnd: number;
|
|
12
|
+
transformedStart: number;
|
|
13
|
+
transformedEnd: number;
|
|
14
|
+
}
|
|
15
|
+
export interface OriginalRange {
|
|
16
|
+
start: number;
|
|
17
|
+
end: number;
|
|
18
|
+
}
|
|
19
|
+
export interface TransliteratedText {
|
|
20
|
+
original: string;
|
|
21
|
+
text: string;
|
|
22
|
+
spans: SourceSpan[];
|
|
23
|
+
originalRange(start: number, end: number): OriginalRange;
|
|
24
|
+
}
|
|
25
|
+
export interface TransliterationMatch extends OriginalRange {
|
|
26
|
+
transformedStart: number;
|
|
27
|
+
transformedEnd: number;
|
|
28
|
+
}
|
|
29
|
+
export interface Transliterator {
|
|
30
|
+
transliterate(text: string): TransliteratedText;
|
|
31
|
+
find(text: string, query: string, maxMatches?: number): TransliterationMatch[];
|
|
32
|
+
}
|
|
33
|
+
export declare function createTransliterator(entries: readonly TransliterationEntry[], options?: TransliterationOptions): Transliterator;
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
function bound(value, maximum) {
|
|
2
|
+
if (!Number.isSafeInteger(value) || value < 1 || value > maximum)
|
|
3
|
+
throw new RangeError(`Expected integer in 1..${maximum}.`);
|
|
4
|
+
return value;
|
|
5
|
+
}
|
|
6
|
+
function validText(value, maximum) {
|
|
7
|
+
if (typeof value !== 'string')
|
|
8
|
+
throw new TypeError('Expected text.');
|
|
9
|
+
if (value.length > maximum)
|
|
10
|
+
throw new RangeError('Text exceeds UTF-16 length bound.');
|
|
11
|
+
for (let i = 0; i < value.length; i++) {
|
|
12
|
+
const code = value.charCodeAt(i);
|
|
13
|
+
if (code >= 0xd800 && code <= 0xdbff) {
|
|
14
|
+
const next = value.charCodeAt(++i);
|
|
15
|
+
if (!(next >= 0xdc00 && next <= 0xdfff))
|
|
16
|
+
throw new TypeError('Unpaired high surrogate.');
|
|
17
|
+
}
|
|
18
|
+
else if (code >= 0xdc00 && code <= 0xdfff)
|
|
19
|
+
throw new TypeError('Unpaired low surrogate.');
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function offset(value, text) {
|
|
23
|
+
if (!Number.isSafeInteger(value) || value < 0 || value > text.length)
|
|
24
|
+
throw new RangeError('Offset outside transformed text.');
|
|
25
|
+
if (value > 0 &&
|
|
26
|
+
value < text.length &&
|
|
27
|
+
/[\uD800-\uDBFF]/.test(text[value - 1]) &&
|
|
28
|
+
/[\uDC00-\uDFFF]/.test(text[value]))
|
|
29
|
+
throw new RangeError('Offset splits a surrogate pair.');
|
|
30
|
+
}
|
|
31
|
+
export function createTransliterator(entries, options = {}) {
|
|
32
|
+
if (!options || typeof options !== 'object' || Array.isArray(options))
|
|
33
|
+
throw new TypeError('Expected options object.');
|
|
34
|
+
const maxInput = bound(options.maxInput === undefined ? 65_536 : options.maxInput, 65_536);
|
|
35
|
+
const maxOutput = bound(options.maxOutput === undefined ? 262_144 : options.maxOutput, 262_144);
|
|
36
|
+
if (!Array.isArray(entries))
|
|
37
|
+
throw new TypeError('Expected an array of entries.');
|
|
38
|
+
if (entries.length > 1024)
|
|
39
|
+
throw new RangeError('Expected up to 1024 entries.');
|
|
40
|
+
const root = { children: new Map() };
|
|
41
|
+
const count = entries.length;
|
|
42
|
+
for (let index = 0; index < count; index++) {
|
|
43
|
+
const entry = entries[index];
|
|
44
|
+
if (!entry || typeof entry !== 'object')
|
|
45
|
+
throw new TypeError('Expected table entry.');
|
|
46
|
+
validText(entry.source, 32);
|
|
47
|
+
validText(entry.target, 64);
|
|
48
|
+
if (!entry.source.length)
|
|
49
|
+
throw new TypeError('Source cannot be empty.');
|
|
50
|
+
let node = root;
|
|
51
|
+
for (const char of entry.source) {
|
|
52
|
+
let child = node.children.get(char);
|
|
53
|
+
if (!child) {
|
|
54
|
+
child = { children: new Map() };
|
|
55
|
+
node.children.set(char, child);
|
|
56
|
+
}
|
|
57
|
+
node = child;
|
|
58
|
+
}
|
|
59
|
+
if (node.target !== undefined)
|
|
60
|
+
throw new TypeError('Duplicate source.');
|
|
61
|
+
node.target = entry.target;
|
|
62
|
+
}
|
|
63
|
+
const transliterate = (original) => {
|
|
64
|
+
validText(original, maxInput);
|
|
65
|
+
const spans = [];
|
|
66
|
+
const parts = [];
|
|
67
|
+
let length = 0;
|
|
68
|
+
for (let start = 0; start < original.length;) {
|
|
69
|
+
const first = String.fromCodePoint(original.codePointAt(start));
|
|
70
|
+
let end = start + first.length;
|
|
71
|
+
let replacement = first;
|
|
72
|
+
let node = root;
|
|
73
|
+
for (let cursor = start; cursor < original.length;) {
|
|
74
|
+
const char = String.fromCodePoint(original.codePointAt(cursor));
|
|
75
|
+
const next = node.children.get(char);
|
|
76
|
+
if (!next)
|
|
77
|
+
break;
|
|
78
|
+
node = next;
|
|
79
|
+
cursor += char.length;
|
|
80
|
+
if (node.target !== undefined) {
|
|
81
|
+
replacement = node.target;
|
|
82
|
+
end = cursor;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (length + replacement.length > maxOutput)
|
|
86
|
+
throw new RangeError('Output exceeds maxOutput.');
|
|
87
|
+
spans.push({
|
|
88
|
+
originalStart: start,
|
|
89
|
+
originalEnd: end,
|
|
90
|
+
transformedStart: length,
|
|
91
|
+
transformedEnd: length + replacement.length,
|
|
92
|
+
});
|
|
93
|
+
parts.push(replacement);
|
|
94
|
+
length += replacement.length;
|
|
95
|
+
start = end;
|
|
96
|
+
}
|
|
97
|
+
const text = parts.join('');
|
|
98
|
+
return {
|
|
99
|
+
original,
|
|
100
|
+
text,
|
|
101
|
+
spans: spans.map((span) => ({ ...span })),
|
|
102
|
+
originalRange(start, end) {
|
|
103
|
+
offset(start, text);
|
|
104
|
+
offset(end, text);
|
|
105
|
+
if (start > end)
|
|
106
|
+
throw new RangeError('Reversed transformed range.');
|
|
107
|
+
if (start === end) {
|
|
108
|
+
const span = spans.find((part) => part.transformedEnd > start);
|
|
109
|
+
const position = span?.originalStart ?? original.length;
|
|
110
|
+
return { start: position, end: position };
|
|
111
|
+
}
|
|
112
|
+
const overlapping = spans.filter((part) => part.transformedEnd > start &&
|
|
113
|
+
part.transformedStart < end &&
|
|
114
|
+
part.transformedEnd > part.transformedStart);
|
|
115
|
+
return {
|
|
116
|
+
start: overlapping[0].originalStart,
|
|
117
|
+
end: overlapping[overlapping.length - 1].originalEnd,
|
|
118
|
+
};
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
};
|
|
122
|
+
return {
|
|
123
|
+
transliterate,
|
|
124
|
+
find(text, query, maxMatches = 100) {
|
|
125
|
+
bound(maxMatches, 256);
|
|
126
|
+
const source = transliterate(text);
|
|
127
|
+
const needle = transliterate(query).text;
|
|
128
|
+
if (!needle.length)
|
|
129
|
+
throw new TypeError('Query cannot transliterate to empty text.');
|
|
130
|
+
const matches = [];
|
|
131
|
+
let cursor = 0;
|
|
132
|
+
while (matches.length < maxMatches) {
|
|
133
|
+
const start = source.text.indexOf(needle, cursor);
|
|
134
|
+
if (start < 0)
|
|
135
|
+
break;
|
|
136
|
+
const end = start + needle.length;
|
|
137
|
+
const range = source.originalRange(start, end);
|
|
138
|
+
matches.push({ ...range, transformedStart: start, transformedEnd: end });
|
|
139
|
+
cursor = end;
|
|
140
|
+
}
|
|
141
|
+
return matches;
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,63 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lp-labs/transliteration-search",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Caller-table transliteration and bounded literal search with original UTF-16 interval mapping.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./dist/cjs/index.js",
|
|
8
|
+
"module": "./dist/esm/index.js",
|
|
9
|
+
"types": "./dist/esm/index.d.ts",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"import": {
|
|
13
|
+
"types": "./dist/esm/index.d.ts",
|
|
14
|
+
"default": "./dist/esm/index.js"
|
|
15
|
+
},
|
|
16
|
+
"require": {
|
|
17
|
+
"types": "./dist/cjs/index.d.ts",
|
|
18
|
+
"default": "./dist/cjs/index.js"
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"./package.json": "./package.json"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"dist",
|
|
25
|
+
"README.md",
|
|
26
|
+
"LICENSE"
|
|
27
|
+
],
|
|
28
|
+
"sideEffects": false,
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=18"
|
|
31
|
+
},
|
|
32
|
+
"scripts": {
|
|
33
|
+
"build": "node scripts/build.mjs",
|
|
34
|
+
"typecheck": "npm run build && node scripts/check-types.mjs",
|
|
35
|
+
"test": "npm run build && node --test tests/*.test.mjs",
|
|
36
|
+
"format": "prettier --write src scripts tests *.json README.md",
|
|
37
|
+
"format:check": "prettier --check src scripts tests *.json README.md",
|
|
38
|
+
"prepack": "npm run build"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"typescript": "^5.9.3",
|
|
42
|
+
"prettier": "^3.6.2"
|
|
43
|
+
},
|
|
44
|
+
"prettier": {
|
|
45
|
+
"singleQuote": true,
|
|
46
|
+
"semi": true,
|
|
47
|
+
"trailingComma": "all",
|
|
48
|
+
"printWidth": 100,
|
|
49
|
+
"endOfLine": "lf"
|
|
50
|
+
},
|
|
51
|
+
"publishConfig": {
|
|
52
|
+
"access": "public",
|
|
53
|
+
"registry": "https://registry.npmjs.org/"
|
|
54
|
+
},
|
|
55
|
+
"author": {
|
|
56
|
+
"name": "Libin Prasanth",
|
|
57
|
+
"url": "https://www.linkedin.com/in/libinprasanth/"
|
|
58
|
+
},
|
|
59
|
+
"funding": {
|
|
60
|
+
"type": "paypal",
|
|
61
|
+
"url": "https://www.paypal.me/LibinPrasanth"
|
|
62
|
+
}
|
|
63
|
+
}
|