@flighthq/textsegment 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/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -0
- package/dist/textSegment.d.ts +5 -0
- package/dist/textSegment.d.ts.map +1 -0
- package/dist/textSegment.js +20 -0
- package/dist/textSegment.js.map +1 -0
- package/dist/textSegmentBoundary.d.ts +7 -0
- package/dist/textSegmentBoundary.d.ts.map +1 -0
- package/dist/textSegmentBoundary.js +76 -0
- package/dist/textSegmentBoundary.js.map +1 -0
- package/dist/textSegmenterBackend.d.ts +5 -0
- package/dist/textSegmenterBackend.d.ts.map +1 -0
- package/dist/textSegmenterBackend.js +62 -0
- package/dist/textSegmenterBackend.js.map +1 -0
- package/package.json +37 -0
- package/src/textSegment.test.ts +53 -0
- package/src/textSegmentBoundary.test.ts +98 -0
- package/src/textSegmenterBackend.test.ts +78 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,eAAe,CAAC;AAC9B,cAAc,uBAAuB,CAAC;AACtC,cAAc,wBAAwB,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,eAAe,CAAC;AAC9B,cAAc,uBAAuB,CAAC;AACtC,cAAc,wBAAwB,CAAC"}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { TextSegment } from '@flighthq/types';
|
|
2
|
+
export declare function segmentGraphemes(text: string, locale?: string): readonly TextSegment[];
|
|
3
|
+
export declare function segmentSentences(text: string, locale?: string): readonly TextSegment[];
|
|
4
|
+
export declare function segmentWords(text: string, locale?: string): readonly TextSegment[];
|
|
5
|
+
//# sourceMappingURL=textSegment.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"textSegment.d.ts","sourceRoot":"","sources":["../src/textSegment.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAC;AAQnD,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,SAAS,WAAW,EAAE,CAEtF;AAID,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,SAAS,WAAW,EAAE,CAEtF;AAKD,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,SAAS,WAAW,EAAE,CAElF"}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { getTextSegmenterBackend } from './textSegmenterBackend';
|
|
2
|
+
// Enumerates the grapheme clusters of `text` (UAX #29) via the active backend. Each segment is one
|
|
3
|
+
// user-perceived character: an emoji ZWJ sequence or a base-plus-combining-mark cluster is a single
|
|
4
|
+
// segment, not its constituent code points. `locale` is threaded to the backend for the rare
|
|
5
|
+
// locale-tailored cases. isWordLike is absent on grapheme segments.
|
|
6
|
+
export function segmentGraphemes(text, locale) {
|
|
7
|
+
return getTextSegmenterBackend().segment(text, 'grapheme', locale);
|
|
8
|
+
}
|
|
9
|
+
// Enumerates the sentences of `text` (UAX #29) via the active backend, in order and covering the
|
|
10
|
+
// whole string. isWordLike is absent on sentence segments.
|
|
11
|
+
export function segmentSentences(text, locale) {
|
|
12
|
+
return getTextSegmenterBackend().segment(text, 'sentence', locale);
|
|
13
|
+
}
|
|
14
|
+
// Enumerates the words of `text` (UAX #29) via the active backend. Word segments carry isWordLike:
|
|
15
|
+
// true for letters/numbers (a real word), false for the punctuation and whitespace runs between
|
|
16
|
+
// them. Filter on isWordLike to keep only words. `locale` is threaded to the backend.
|
|
17
|
+
export function segmentWords(text, locale) {
|
|
18
|
+
return getTextSegmenterBackend().segment(text, 'word', locale);
|
|
19
|
+
}
|
|
20
|
+
//# sourceMappingURL=textSegment.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"textSegment.js","sourceRoot":"","sources":["../src/textSegment.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,uBAAuB,EAAE,MAAM,wBAAwB,CAAC;AAEjE,mGAAmG;AACnG,oGAAoG;AACpG,6FAA6F;AAC7F,oEAAoE;AACpE,MAAM,UAAU,gBAAgB,CAAC,IAAY,EAAE,MAAe;IAC5D,OAAO,uBAAuB,EAAE,CAAC,OAAO,CAAC,IAAI,EAAE,UAAU,EAAE,MAAM,CAAC,CAAC;AACrE,CAAC;AAED,iGAAiG;AACjG,2DAA2D;AAC3D,MAAM,UAAU,gBAAgB,CAAC,IAAY,EAAE,MAAe;IAC5D,OAAO,uBAAuB,EAAE,CAAC,OAAO,CAAC,IAAI,EAAE,UAAU,EAAE,MAAM,CAAC,CAAC;AACrE,CAAC;AAED,mGAAmG;AACnG,gGAAgG;AAChG,sFAAsF;AACtF,MAAM,UAAU,YAAY,CAAC,IAAY,EAAE,MAAe;IACxD,OAAO,uBAAuB,EAAE,CAAC,OAAO,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,CAAC,CAAC;AACjE,CAAC"}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { TextSegmentRange } from '@flighthq/types';
|
|
2
|
+
export declare function getNextGraphemeBoundary(text: string, index: number, locale?: string): number;
|
|
3
|
+
export declare function getNextWordBoundary(text: string, index: number, locale?: string): number;
|
|
4
|
+
export declare function getPreviousGraphemeBoundary(text: string, index: number, locale?: string): number;
|
|
5
|
+
export declare function getPreviousWordBoundary(text: string, index: number, locale?: string): number;
|
|
6
|
+
export declare function getWordRangeAt(text: string, index: number, locale?: string): TextSegmentRange | null;
|
|
7
|
+
//# sourceMappingURL=textSegmentBoundary.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"textSegmentBoundary.d.ts","sourceRoot":"","sources":["../src/textSegmentBoundary.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAe,gBAAgB,EAAE,MAAM,iBAAiB,CAAC;AAOrE,wBAAgB,uBAAuB,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,MAAM,CAE5F;AAKD,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,MAAM,CAExF;AAKD,wBAAgB,2BAA2B,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,MAAM,CAEhG;AAID,wBAAgB,uBAAuB,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,MAAM,CAE5F;AAMD,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,gBAAgB,GAAG,IAAI,CAapG"}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { segmentGraphemes, segmentWords } from './textSegment';
|
|
2
|
+
// Returns the next grapheme-cluster boundary at or after `index` — the offset a caret lands on when
|
|
3
|
+
// stepping right by one user-perceived character, so an emoji or combining sequence is crossed in a
|
|
4
|
+
// single step. `index` is clamped into [0, text.length]; at or past the end it returns text.length.
|
|
5
|
+
export function getNextGraphemeBoundary(text, index, locale) {
|
|
6
|
+
return nextSegmentBoundary(segmentGraphemes(text, locale), index, text.length);
|
|
7
|
+
}
|
|
8
|
+
// Returns the next word boundary at or after `index` — the offset a caret lands on when jumping right
|
|
9
|
+
// by one word (Ctrl/Alt-Right). `index` is clamped into [0, text.length]; at or past the end it
|
|
10
|
+
// returns text.length.
|
|
11
|
+
export function getNextWordBoundary(text, index, locale) {
|
|
12
|
+
return nextSegmentBoundary(segmentWords(text, locale), index, text.length);
|
|
13
|
+
}
|
|
14
|
+
// Returns the previous grapheme-cluster boundary at or before `index` — the offset a caret lands on
|
|
15
|
+
// when stepping left by one user-perceived character. `index` is clamped into [0, text.length]; at or
|
|
16
|
+
// before the start it returns 0.
|
|
17
|
+
export function getPreviousGraphemeBoundary(text, index, locale) {
|
|
18
|
+
return previousSegmentBoundary(segmentGraphemes(text, locale), index);
|
|
19
|
+
}
|
|
20
|
+
// Returns the previous word boundary at or before `index` — the offset a caret lands on when jumping
|
|
21
|
+
// left by one word. `index` is clamped into [0, text.length]; at or before the start it returns 0.
|
|
22
|
+
export function getPreviousWordBoundary(text, index, locale) {
|
|
23
|
+
return previousSegmentBoundary(segmentWords(text, locale), index);
|
|
24
|
+
}
|
|
25
|
+
// Returns the word-like segment's range covering `index`, for double-click / word-select. `index` is
|
|
26
|
+
// clamped into [0, text.length]; at the very end it resolves against the last character. Returns null
|
|
27
|
+
// when the covered segment is not word-like (whitespace or punctuation) or the text is empty — the
|
|
28
|
+
// caller should then select nothing, mirroring how a double-click on a space selects no word.
|
|
29
|
+
export function getWordRangeAt(text, index, locale) {
|
|
30
|
+
if (text.length === 0)
|
|
31
|
+
return null;
|
|
32
|
+
const clamped = clampIndex(index, text.length);
|
|
33
|
+
// At the trailing boundary there is no segment starting at text.length; resolve against the last
|
|
34
|
+
// character so a double-click at the end still selects the final word.
|
|
35
|
+
const lookup = clamped === text.length ? text.length - 1 : clamped;
|
|
36
|
+
const segments = segmentWords(text, locale);
|
|
37
|
+
for (const segment of segments) {
|
|
38
|
+
if (lookup >= segment.start && lookup < segment.end) {
|
|
39
|
+
return segment.isWordLike === true ? { start: segment.start, end: segment.end } : null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
// The boundary offsets are every segment start plus text.length (segments cover the string in order
|
|
45
|
+
// with strictly increasing starts). Clamping keeps out-of-range indices from throwing per house rules.
|
|
46
|
+
function clampIndex(index, length) {
|
|
47
|
+
if (index < 0)
|
|
48
|
+
return 0;
|
|
49
|
+
if (index > length)
|
|
50
|
+
return length;
|
|
51
|
+
return index;
|
|
52
|
+
}
|
|
53
|
+
function nextSegmentBoundary(segments, index, length) {
|
|
54
|
+
const from = clampIndex(index, length);
|
|
55
|
+
if (from >= length)
|
|
56
|
+
return length;
|
|
57
|
+
for (const segment of segments) {
|
|
58
|
+
if (segment.start > from)
|
|
59
|
+
return segment.start;
|
|
60
|
+
}
|
|
61
|
+
return length;
|
|
62
|
+
}
|
|
63
|
+
function previousSegmentBoundary(segments, index) {
|
|
64
|
+
const length = segments.length === 0 ? 0 : segments[segments.length - 1].end;
|
|
65
|
+
const from = clampIndex(index, length);
|
|
66
|
+
if (from <= 0)
|
|
67
|
+
return 0;
|
|
68
|
+
let previous = 0;
|
|
69
|
+
for (const segment of segments) {
|
|
70
|
+
if (segment.start >= from)
|
|
71
|
+
break;
|
|
72
|
+
previous = segment.start;
|
|
73
|
+
}
|
|
74
|
+
return previous;
|
|
75
|
+
}
|
|
76
|
+
//# sourceMappingURL=textSegmentBoundary.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"textSegmentBoundary.js","sourceRoot":"","sources":["../src/textSegmentBoundary.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { TextSegmenterBackend } from '@flighthq/types';
|
|
2
|
+
export declare function createWebTextSegmenterBackend(): TextSegmenterBackend;
|
|
3
|
+
export declare function getTextSegmenterBackend(): TextSegmenterBackend;
|
|
4
|
+
export declare function setTextSegmenterBackend(backend: TextSegmenterBackend | null): void;
|
|
5
|
+
//# sourceMappingURL=textSegmenterBackend.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"textSegmenterBackend.d.ts","sourceRoot":"","sources":["../src/textSegmenterBackend.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAuC,oBAAoB,EAAE,MAAM,iBAAiB,CAAC;AAQjG,wBAAgB,6BAA6B,IAAI,oBAAoB,CAEpE;AAKD,wBAAgB,uBAAuB,IAAI,oBAAoB,CAG9D;AAKD,wBAAgB,uBAAuB,CAAC,OAAO,EAAE,oBAAoB,GAAG,IAAI,GAAG,IAAI,CAElF"}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Builds the default web backend: a wrapper over the browser-native Intl.Segmenter. It ships no
|
|
2
|
+
// Unicode tables — the engine already carries them — so the common path costs nothing in bundle
|
|
3
|
+
// weight. Intl.Segmenter instances are cached by (locale, granularity) because constructing one is
|
|
4
|
+
// expensive relative to a single segment() call. Where Intl.Segmenter is absent (an old or headless
|
|
5
|
+
// engine), segment() returns [] rather than throwing; install a from-scratch UAX #29 backend via
|
|
6
|
+
// setTextSegmenterBackend for those hosts.
|
|
7
|
+
export function createWebTextSegmenterBackend() {
|
|
8
|
+
return { segment: segmentWithIntlSegmenter };
|
|
9
|
+
}
|
|
10
|
+
// Returns the active segmenter backend, lazily creating the web default the first time so there is
|
|
11
|
+
// always an answer. A native host or a from-scratch UAX #29 backend replaces it via
|
|
12
|
+
// setTextSegmenterBackend; passing null there restores this lazy web default.
|
|
13
|
+
export function getTextSegmenterBackend() {
|
|
14
|
+
if (_backend === null)
|
|
15
|
+
_backend = createWebTextSegmenterBackend();
|
|
16
|
+
return _backend;
|
|
17
|
+
}
|
|
18
|
+
// Installs a segmenter backend; pass null to fall back to the lazily-created web default. Last write
|
|
19
|
+
// wins — registering over an existing backend replaces it. Opt-in and side-effect-free at import:
|
|
20
|
+
// nothing installs until a host calls this (or a segment*/boundary query lazily builds the web one).
|
|
21
|
+
export function setTextSegmenterBackend(backend) {
|
|
22
|
+
_backend = backend;
|
|
23
|
+
}
|
|
24
|
+
let _backend = null;
|
|
25
|
+
// Cached Intl.Segmenter instances keyed by `locale|granularity`. A Map preserves insertion order, so
|
|
26
|
+
// the first key is the oldest and drives simple FIFO eviction once the cache is full. Instances are
|
|
27
|
+
// immutable, so sharing them across calls is safe.
|
|
28
|
+
const _segmenterCache = new Map();
|
|
29
|
+
const _segmenterCacheCapacity = 64;
|
|
30
|
+
function getCachedSegmenter(locale, granularity) {
|
|
31
|
+
if (typeof Intl === 'undefined' || typeof Intl.Segmenter === 'undefined')
|
|
32
|
+
return null;
|
|
33
|
+
const key = `${locale ?? ''}|${granularity}`;
|
|
34
|
+
const existing = _segmenterCache.get(key);
|
|
35
|
+
if (existing !== undefined)
|
|
36
|
+
return existing;
|
|
37
|
+
const built = new Intl.Segmenter(locale, { granularity });
|
|
38
|
+
if (_segmenterCache.size >= _segmenterCacheCapacity) {
|
|
39
|
+
const oldest = _segmenterCache.keys().next().value;
|
|
40
|
+
if (oldest !== undefined)
|
|
41
|
+
_segmenterCache.delete(oldest);
|
|
42
|
+
}
|
|
43
|
+
_segmenterCache.set(key, built);
|
|
44
|
+
return built;
|
|
45
|
+
}
|
|
46
|
+
function segmentWithIntlSegmenter(text, granularity, locale) {
|
|
47
|
+
const segmenter = getCachedSegmenter(locale, granularity);
|
|
48
|
+
if (segmenter === null)
|
|
49
|
+
return [];
|
|
50
|
+
const out = [];
|
|
51
|
+
const isWordGranularity = granularity === 'word';
|
|
52
|
+
for (const data of segmenter.segment(text)) {
|
|
53
|
+
const start = data.index;
|
|
54
|
+
const record = { start, end: start + data.segment.length, text: data.segment };
|
|
55
|
+
// isWordLike is only meaningful — and only reported — for word granularity.
|
|
56
|
+
if (isWordGranularity)
|
|
57
|
+
record.isWordLike = data.isWordLike ?? false;
|
|
58
|
+
out.push(record);
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
//# sourceMappingURL=textSegmenterBackend.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"textSegmenterBackend.js","sourceRoot":"","sources":["../src/textSegmenterBackend.ts"],"names":[],"mappings":"AAEA,gGAAgG;AAChG,gGAAgG;AAChG,mGAAmG;AACnG,oGAAoG;AACpG,iGAAiG;AACjG,2CAA2C;AAC3C,MAAM,UAAU,6BAA6B;IAC3C,OAAO,EAAE,OAAO,EAAE,wBAAwB,EAAE,CAAC;AAC/C,CAAC;AAED,mGAAmG;AACnG,oFAAoF;AACpF,8EAA8E;AAC9E,MAAM,UAAU,uBAAuB;IACrC,IAAI,QAAQ,KAAK,IAAI;QAAE,QAAQ,GAAG,6BAA6B,EAAE,CAAC;IAClE,OAAO,QAAQ,CAAC;AAClB,CAAC;AAED,qGAAqG;AACrG,kGAAkG;AAClG,qGAAqG;AACrG,MAAM,UAAU,uBAAuB,CAAC,OAAoC;IAC1E,QAAQ,GAAG,OAAO,CAAC;AACrB,CAAC;AAED,IAAI,QAAQ,GAAgC,IAAI,CAAC;AAEjD,qGAAqG;AACrG,oGAAoG;AACpG,mDAAmD;AACnD,MAAM,eAAe,GAAG,IAAI,GAAG,EAA0B,CAAC;AAC1D,MAAM,uBAAuB,GAAG,EAAE,CAAC;AAEnC,SAAS,kBAAkB,CAAC,MAA0B,EAAE,WAAmC;IACzF,IAAI,OAAO,IAAI,KAAK,WAAW,IAAI,OAAO,IAAI,CAAC,SAAS,KAAK,WAAW;QAAE,OAAO,IAAI,CAAC;IACtF,MAAM,GAAG,GAAG,GAAG,MAAM,IAAI,EAAE,IAAI,WAAW,EAAE,CAAC;IAC7C,MAAM,QAAQ,GAAG,eAAe,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;IAC1C,IAAI,QAAQ,KAAK,SAAS;QAAE,OAAO,QAAQ,CAAC;IAE5C,MAAM,KAAK,GAAG,IAAI,IAAI,CAAC,SAAS,CAAC,MAAM,EAAE,EAAE,WAAW,EAAE,CAAC,CAAC;IAC1D,IAAI,eAAe,CAAC,IAAI,IAAI,uBAAuB,EAAE,CAAC;QACpD,MAAM,MAAM,GAAG,eAAe,CAAC,IAAI,EAAE,CAAC,IAAI,EAAE,CAAC,KAAK,CAAC;QACnD,IAAI,MAAM,KAAK,SAAS;YAAE,eAAe,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;IAC3D,CAAC;IACD,eAAe,CAAC,GAAG,CAAC,GAAG,EAAE,KAAK,CAAC,CAAC;IAChC,OAAO,KAAK,CAAC;AACf,CAAC;AAED,SAAS,wBAAwB,CAC/B,IAAY,EACZ,WAAmC,EACnC,MAAe;IAEf,MAAM,SAAS,GAAG,kBAAkB,CAAC,MAAM,EAAE,WAAW,CAAC,CAAC;IAC1D,IAAI,SAAS,KAAK,IAAI;QAAE,OAAO,EAAE,CAAC;IAElC,MAAM,GAAG,GAAkB,EAAE,CAAC;IAC9B,MAAM,iBAAiB,GAAG,WAAW,KAAK,MAAM,CAAC;IACjD,KAAK,MAAM,IAAI,IAAI,SAAS,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,CAAC;QAC3C,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC;QACzB,MAAM,MAAM,GAAgB,EAAE,KAAK,EAAE,GAAG,EAAE,KAAK,GAAG,IAAI,CAAC,OAAO,CAAC,MAAM,EAAE,IAAI,EAAE,IAAI,CAAC,OAAO,EAAE,CAAC;QAC5F,4EAA4E;QAC5E,IAAI,iBAAiB;YAAE,MAAM,CAAC,UAAU,GAAG,IAAI,CAAC,UAAU,IAAI,KAAK,CAAC;QACpE,GAAG,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;IACnB,CAAC;IACD,OAAO,GAAG,CAAC;AACb,CAAC"}
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@flighthq/textsegment",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"default": "./dist/index.js"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"dist",
|
|
15
|
+
"src/**/*.test.ts",
|
|
16
|
+
"!dist/**/*.test.js",
|
|
17
|
+
"!dist/**/*.test.d.ts",
|
|
18
|
+
"!dist/**/*.test.js.map",
|
|
19
|
+
"!dist/**/*.test.d.ts.map"
|
|
20
|
+
],
|
|
21
|
+
"scripts": {
|
|
22
|
+
"build": "tsc -b",
|
|
23
|
+
"clean": "tsc -b --clean",
|
|
24
|
+
"test": "vitest run --config vitest.config.ts",
|
|
25
|
+
"test:watch": "vitest --watch --config vitest.config.ts",
|
|
26
|
+
"prepack": "npm run clean && npm run clean:dist && npm run build",
|
|
27
|
+
"clean:dist": "tsx ../../scripts/clean-package-dist.ts"
|
|
28
|
+
},
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"@flighthq/types": "0.1.0"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"typescript": "^5.3.0"
|
|
34
|
+
},
|
|
35
|
+
"description": "Unicode text-segmentation seam (UAX #29 grapheme/word/sentence) over a swappable backend defaulting to the browser-native Intl.Segmenter",
|
|
36
|
+
"sideEffects": false
|
|
37
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { TextSegment, TextSegmentGranularity, TextSegmenterBackend } from '@flighthq/types';
|
|
2
|
+
|
|
3
|
+
import { segmentGraphemes, segmentSentences, segmentWords } from './textSegment';
|
|
4
|
+
import { setTextSegmenterBackend } from './textSegmenterBackend';
|
|
5
|
+
|
|
6
|
+
afterEach(() => setTextSegmenterBackend(null));
|
|
7
|
+
|
|
8
|
+
describe('segmentGraphemes', () => {
|
|
9
|
+
it('treats a ZWJ family emoji as one grapheme', () => {
|
|
10
|
+
const segments = segmentGraphemes('a👨👩👧b');
|
|
11
|
+
expect(segments.map((s) => s.text)).toEqual(['a', '👨👩👧', 'b']);
|
|
12
|
+
expect(segments[1].start).toBe(1);
|
|
13
|
+
expect(segments[1].end).toBe('a👨👩👧'.length);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('treats a base plus combining mark as one grapheme', () => {
|
|
17
|
+
// 'e' + combining acute accent (U+0301) is one user-perceived character.
|
|
18
|
+
const segments = segmentGraphemes('éx');
|
|
19
|
+
expect(segments.map((s) => s.text)).toEqual(['é', 'x']);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('threads the locale argument to the active backend', () => {
|
|
23
|
+
let seenLocale: string | undefined = 'unset';
|
|
24
|
+
const fake: TextSegmenterBackend = {
|
|
25
|
+
segment(text: string, _granularity: TextSegmentGranularity, locale?: string): readonly TextSegment[] {
|
|
26
|
+
seenLocale = locale;
|
|
27
|
+
return [{ start: 0, end: text.length, text }];
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
setTextSegmenterBackend(fake);
|
|
31
|
+
segmentGraphemes('hi', 'de-DE');
|
|
32
|
+
expect(seenLocale).toBe('de-DE');
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe('segmentSentences', () => {
|
|
37
|
+
it('splits into sentences', () => {
|
|
38
|
+
const segments = segmentSentences('Hi. Bye.');
|
|
39
|
+
expect(segments.map((s) => s.text.trim())).toEqual(['Hi.', 'Bye.']);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe('segmentWords', () => {
|
|
44
|
+
it('marks words as word-like and punctuation/whitespace as not', () => {
|
|
45
|
+
const segments = segmentWords('Hello, world.');
|
|
46
|
+
const words = segments.filter((s) => s.isWordLike === true).map((s) => s.text);
|
|
47
|
+
expect(words).toEqual(['Hello', 'world']);
|
|
48
|
+
const comma = segments.find((s) => s.text === ',');
|
|
49
|
+
expect(comma?.isWordLike).toBe(false);
|
|
50
|
+
const space = segments.find((s) => s.text === ' ');
|
|
51
|
+
expect(space?.isWordLike).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getNextGraphemeBoundary,
|
|
3
|
+
getNextWordBoundary,
|
|
4
|
+
getPreviousGraphemeBoundary,
|
|
5
|
+
getPreviousWordBoundary,
|
|
6
|
+
getWordRangeAt,
|
|
7
|
+
} from './textSegmentBoundary';
|
|
8
|
+
import { setTextSegmenterBackend } from './textSegmenterBackend';
|
|
9
|
+
|
|
10
|
+
afterEach(() => setTextSegmenterBackend(null));
|
|
11
|
+
|
|
12
|
+
// A ZWJ family emoji is a single grapheme cluster spanning several UTF-16 code units.
|
|
13
|
+
const FAMILY = '👨👩👧';
|
|
14
|
+
const EMOJI_TEXT = `a${FAMILY}b`;
|
|
15
|
+
const FAMILY_START = 1;
|
|
16
|
+
const B_START = FAMILY_START + FAMILY.length;
|
|
17
|
+
const EMOJI_LEN = EMOJI_TEXT.length;
|
|
18
|
+
|
|
19
|
+
describe('getNextGraphemeBoundary', () => {
|
|
20
|
+
it('steps over an emoji cluster in a single move', () => {
|
|
21
|
+
expect(getNextGraphemeBoundary(EMOJI_TEXT, FAMILY_START)).toBe(B_START);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('advances one plain grapheme from the start', () => {
|
|
25
|
+
expect(getNextGraphemeBoundary(EMOJI_TEXT, 0)).toBe(FAMILY_START);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('clamps at text.length', () => {
|
|
29
|
+
expect(getNextGraphemeBoundary(EMOJI_TEXT, EMOJI_LEN)).toBe(EMOJI_LEN);
|
|
30
|
+
expect(getNextGraphemeBoundary(EMOJI_TEXT, 999)).toBe(EMOJI_LEN);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe('getNextWordBoundary', () => {
|
|
35
|
+
it('jumps to the end of the current word', () => {
|
|
36
|
+
expect(getNextWordBoundary('foo bar', 0)).toBe(3);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('clamps at text.length', () => {
|
|
40
|
+
expect(getNextWordBoundary('foo bar', 7)).toBe(7);
|
|
41
|
+
expect(getNextWordBoundary('foo bar', 42)).toBe(7);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe('getPreviousGraphemeBoundary', () => {
|
|
46
|
+
it('inverts a step over an emoji cluster', () => {
|
|
47
|
+
expect(getPreviousGraphemeBoundary(EMOJI_TEXT, B_START)).toBe(FAMILY_START);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('clamps at 0', () => {
|
|
51
|
+
expect(getPreviousGraphemeBoundary(EMOJI_TEXT, 0)).toBe(0);
|
|
52
|
+
expect(getPreviousGraphemeBoundary(EMOJI_TEXT, -5)).toBe(0);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('steps back from the end onto the last cluster start', () => {
|
|
56
|
+
expect(getPreviousGraphemeBoundary(EMOJI_TEXT, EMOJI_LEN)).toBe(B_START);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe('getPreviousWordBoundary', () => {
|
|
61
|
+
it('jumps to the start of the current word', () => {
|
|
62
|
+
expect(getPreviousWordBoundary('foo bar', 7)).toBe(4);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('clamps at 0', () => {
|
|
66
|
+
expect(getPreviousWordBoundary('foo bar', 0)).toBe(0);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe('getWordRangeAt', () => {
|
|
71
|
+
it('returns the word range under an index', () => {
|
|
72
|
+
expect(getWordRangeAt('foo bar', 1)).toEqual({ start: 0, end: 3 });
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('returns null in whitespace (double-click on a space selects no word)', () => {
|
|
76
|
+
expect(getWordRangeAt('foo bar', 3)).toBeNull();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('resolves the final word from the trailing boundary', () => {
|
|
80
|
+
expect(getWordRangeAt('foo bar', 7)).toEqual({ start: 4, end: 7 });
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('returns null for empty text', () => {
|
|
84
|
+
expect(getWordRangeAt('', 0)).toBeNull();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('threads the locale to the active backend', () => {
|
|
88
|
+
let seenLocale: string | undefined = 'unset';
|
|
89
|
+
setTextSegmenterBackend({
|
|
90
|
+
segment(text, _granularity, locale) {
|
|
91
|
+
seenLocale = locale;
|
|
92
|
+
return [{ start: 0, end: text.length, text, isWordLike: true }];
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
getWordRangeAt('word', 1, 'ja-JP');
|
|
96
|
+
expect(seenLocale).toBe('ja-JP');
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { TextSegment, TextSegmentGranularity, TextSegmenterBackend } from '@flighthq/types';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
createWebTextSegmenterBackend,
|
|
5
|
+
getTextSegmenterBackend,
|
|
6
|
+
setTextSegmenterBackend,
|
|
7
|
+
} from './textSegmenterBackend';
|
|
8
|
+
|
|
9
|
+
interface RecordingBackend extends TextSegmenterBackend {
|
|
10
|
+
calls: Array<{ text: string; granularity: TextSegmentGranularity; locale: string | undefined }>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function recordingBackend(): RecordingBackend {
|
|
14
|
+
const calls: RecordingBackend['calls'] = [];
|
|
15
|
+
return {
|
|
16
|
+
calls,
|
|
17
|
+
segment(text: string, granularity: TextSegmentGranularity, locale?: string): readonly TextSegment[] {
|
|
18
|
+
calls.push({ text, granularity, locale });
|
|
19
|
+
return [{ start: 0, end: text.length, text }];
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
afterEach(() => setTextSegmenterBackend(null));
|
|
25
|
+
|
|
26
|
+
describe('createWebTextSegmenterBackend', () => {
|
|
27
|
+
it('segments a ZWJ family emoji as a single grapheme', () => {
|
|
28
|
+
const segments = createWebTextSegmenterBackend().segment('a👨👩👧b', 'grapheme');
|
|
29
|
+
expect(segments.map((s) => s.text)).toEqual(['a', '👨👩👧', 'b']);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('reports isWordLike for word granularity and omits it otherwise', () => {
|
|
33
|
+
const backend = createWebTextSegmenterBackend();
|
|
34
|
+
const words = backend.segment('Hi there', 'word');
|
|
35
|
+
expect(words.every((s) => typeof s.isWordLike === 'boolean')).toBe(true);
|
|
36
|
+
const graphemes = backend.segment('Hi', 'grapheme');
|
|
37
|
+
expect(graphemes.every((s) => s.isWordLike === undefined)).toBe(true);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('produces gap-free start/end offsets covering the whole string', () => {
|
|
41
|
+
const segments = createWebTextSegmenterBackend().segment('abc', 'grapheme');
|
|
42
|
+
expect(segments).toEqual([
|
|
43
|
+
{ start: 0, end: 1, text: 'a' },
|
|
44
|
+
{ start: 1, end: 2, text: 'b' },
|
|
45
|
+
{ start: 2, end: 3, text: 'c' },
|
|
46
|
+
]);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe('getTextSegmenterBackend', () => {
|
|
51
|
+
it('lazily falls back to a web backend when none is registered', () => {
|
|
52
|
+
const backend = getTextSegmenterBackend();
|
|
53
|
+
expect(backend).not.toBeNull();
|
|
54
|
+
expect(backend.segment('ab', 'grapheme').length).toBe(2);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('returns the registered backend', () => {
|
|
58
|
+
const backend = recordingBackend();
|
|
59
|
+
setTextSegmenterBackend(backend);
|
|
60
|
+
expect(getTextSegmenterBackend()).toBe(backend);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe('setTextSegmenterBackend', () => {
|
|
65
|
+
it('routes segmentation through the installed backend', () => {
|
|
66
|
+
const backend = recordingBackend();
|
|
67
|
+
setTextSegmenterBackend(backend);
|
|
68
|
+
getTextSegmenterBackend().segment('hello', 'sentence', 'fr');
|
|
69
|
+
expect(backend.calls).toEqual([{ text: 'hello', granularity: 'sentence', locale: 'fr' }]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('clears back to the lazy web default when passed null', () => {
|
|
73
|
+
setTextSegmenterBackend(recordingBackend());
|
|
74
|
+
setTextSegmenterBackend(null);
|
|
75
|
+
// The web default segments a ZWJ cluster as one grapheme; a recording backend would not.
|
|
76
|
+
expect(getTextSegmenterBackend().segment('a👨👩👧b', 'grapheme').length).toBe(3);
|
|
77
|
+
});
|
|
78
|
+
});
|