@byline/search-analysis 4.9.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 +373 -0
- package/README.md +61 -0
- package/dist/analyzer.d.ts +11 -0
- package/dist/analyzer.js +310 -0
- package/dist/analyzer.test.node.d.ts +8 -0
- package/dist/analyzer.test.node.js +183 -0
- package/dist/highlight.d.ts +30 -0
- package/dist/highlight.js +161 -0
- package/dist/highlight.test.node.d.ts +8 -0
- package/dist/highlight.test.node.js +102 -0
- package/dist/identifiers.d.ts +23 -0
- package/dist/identifiers.js +102 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +12 -0
- package/dist/locale.d.ts +16 -0
- package/dist/locale.js +52 -0
- package/dist/normalize.d.ts +23 -0
- package/dist/normalize.js +50 -0
- package/dist/sql-token-codec.d.ts +22 -0
- package/dist/sql-token-codec.js +55 -0
- package/dist/sql-token-codec.test.node.d.ts +8 -0
- package/dist/sql-token-codec.test.node.js +38 -0
- package/dist/types.d.ts +125 -0
- package/dist/types.js +8 -0
- package/package.json +68 -0
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
import { encodeSqlToken } from './sql-token-codec.js';
|
|
9
|
+
const DEFAULT_MAX_FRAGMENTS = 2;
|
|
10
|
+
const DEFAULT_MAX_WORDS = 24;
|
|
11
|
+
/**
|
|
12
|
+
* Build a backend-neutral matched snippet from portable logical-token offsets.
|
|
13
|
+
*
|
|
14
|
+
* The returned string contains `<mark>…</mark>` delimiters. Consumers must
|
|
15
|
+
* parse those delimiters and render the surrounding source text as text; they
|
|
16
|
+
* must not inject the complete result as trusted HTML.
|
|
17
|
+
*/
|
|
18
|
+
export function highlightPortableText({ text, plan, analyzer, maxFragments = DEFAULT_MAX_FRAGMENTS, maxWords = DEFAULT_MAX_WORDS, }) {
|
|
19
|
+
assertPositiveInteger(maxFragments, 'maxFragments');
|
|
20
|
+
assertPositiveInteger(maxWords, 'maxWords');
|
|
21
|
+
if (text.length === 0 || plan.concepts.length === 0)
|
|
22
|
+
return undefined;
|
|
23
|
+
if (plan.analyzerFingerprint !== analyzer.fingerprint) {
|
|
24
|
+
throw new TypeError('Portable highlight plan and analyzer fingerprints do not match');
|
|
25
|
+
}
|
|
26
|
+
const queryTerms = new Set(queryTokens(plan).map((token) => encodeSqlToken(token)));
|
|
27
|
+
const analyzed = analyzer.analyzeText({ text, locale: plan.locale });
|
|
28
|
+
const matches = mergeRanges(analyzed.tokens
|
|
29
|
+
.filter((token) => queryTerms.has(encodeSqlToken(token)))
|
|
30
|
+
.map(({ start, end }) => ({ start, end })));
|
|
31
|
+
if (matches.length === 0)
|
|
32
|
+
return undefined;
|
|
33
|
+
const sourceTerms = logicalSourceRanges([...analyzed.exactTokens, ...analyzed.identifierTokens]);
|
|
34
|
+
const fragments = selectFragments(text, matches, sourceTerms, maxFragments, maxWords);
|
|
35
|
+
if (fragments.length === 0)
|
|
36
|
+
return undefined;
|
|
37
|
+
const rendered = fragments.map((fragment) => renderFragment(text, fragment, matches));
|
|
38
|
+
let snippet = rendered.join(' … ');
|
|
39
|
+
if (text.slice(0, fragments[0]?.start ?? 0).trim().length > 0)
|
|
40
|
+
snippet = `… ${snippet}`;
|
|
41
|
+
if (text.slice(fragments.at(-1)?.end ?? text.length).trim().length > 0)
|
|
42
|
+
snippet += ' …';
|
|
43
|
+
return snippet;
|
|
44
|
+
}
|
|
45
|
+
function logicalSourceRanges(tokens) {
|
|
46
|
+
const byPosition = new Map();
|
|
47
|
+
for (const token of tokens.toSorted(compareTokenRanges)) {
|
|
48
|
+
const current = byPosition.get(token.position);
|
|
49
|
+
if (current == null) {
|
|
50
|
+
byPosition.set(token.position, { start: token.start, end: token.end });
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
current.start = Math.min(current.start, token.start);
|
|
54
|
+
current.end = Math.max(current.end, token.end);
|
|
55
|
+
}
|
|
56
|
+
return [...byPosition.values()].toSorted(compareRanges);
|
|
57
|
+
}
|
|
58
|
+
function queryTokens(plan) {
|
|
59
|
+
return [...plan.concepts.flatMap(conceptTokens), ...plan.gramSequences.flat()];
|
|
60
|
+
}
|
|
61
|
+
function conceptTokens(concept) {
|
|
62
|
+
return [
|
|
63
|
+
...concept.exactTokens,
|
|
64
|
+
...concept.stemTokens,
|
|
65
|
+
...concept.lemmaTokens,
|
|
66
|
+
...concept.normalizedTokens,
|
|
67
|
+
...concept.identifierTokens,
|
|
68
|
+
...concept.gramTokens,
|
|
69
|
+
];
|
|
70
|
+
}
|
|
71
|
+
function selectFragments(text, matches, sourceTerms, maxFragments, maxWords) {
|
|
72
|
+
const candidates = uniqueRanges(matches.map((match) => fragmentAroundMatch(text, match, sourceTerms, maxWords)))
|
|
73
|
+
.map((range) => ({
|
|
74
|
+
...range,
|
|
75
|
+
matches: matches.filter((match) => overlaps(range, match)).length,
|
|
76
|
+
}))
|
|
77
|
+
.toSorted((left, right) => right.matches - left.matches || left.start - right.start);
|
|
78
|
+
const selected = [];
|
|
79
|
+
for (const candidate of candidates) {
|
|
80
|
+
if (selected.some((fragment) => overlaps(fragment, candidate)))
|
|
81
|
+
continue;
|
|
82
|
+
selected.push(candidate);
|
|
83
|
+
if (selected.length === maxFragments)
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
return selected.toSorted((left, right) => left.start - right.start);
|
|
87
|
+
}
|
|
88
|
+
function fragmentAroundMatch(text, match, sourceTerms, maxWords) {
|
|
89
|
+
const firstMatch = sourceTerms.findIndex((term) => overlaps(term, match));
|
|
90
|
+
if (firstMatch === -1)
|
|
91
|
+
return match;
|
|
92
|
+
let lastMatch = firstMatch;
|
|
93
|
+
while (lastMatch + 1 < sourceTerms.length && overlaps(sourceTerms[lastMatch + 1], match)) {
|
|
94
|
+
lastMatch += 1;
|
|
95
|
+
}
|
|
96
|
+
const matchedWords = lastMatch - firstMatch + 1;
|
|
97
|
+
const contextWords = Math.max(0, maxWords - matchedWords);
|
|
98
|
+
let first = Math.max(0, firstMatch - Math.floor(contextWords / 2));
|
|
99
|
+
let last = Math.min(sourceTerms.length - 1, lastMatch + Math.ceil(contextWords / 2));
|
|
100
|
+
const currentWords = last - first + 1;
|
|
101
|
+
if (currentWords < maxWords) {
|
|
102
|
+
const missing = maxWords - currentWords;
|
|
103
|
+
const extendLeft = Math.min(first, missing);
|
|
104
|
+
first -= extendLeft;
|
|
105
|
+
last = Math.min(sourceTerms.length - 1, last + missing - extendLeft);
|
|
106
|
+
}
|
|
107
|
+
return {
|
|
108
|
+
start: first === 0 ? 0 : (sourceTerms[first]?.start ?? match.start),
|
|
109
|
+
end: last === sourceTerms.length - 1 ? text.length : (sourceTerms[last]?.end ?? match.end),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
function renderFragment(text, fragment, matches) {
|
|
113
|
+
const included = matches
|
|
114
|
+
.filter((match) => overlaps(fragment, match))
|
|
115
|
+
.map((match) => ({
|
|
116
|
+
start: Math.max(fragment.start, match.start),
|
|
117
|
+
end: Math.min(fragment.end, match.end),
|
|
118
|
+
}));
|
|
119
|
+
let cursor = fragment.start;
|
|
120
|
+
let rendered = '';
|
|
121
|
+
for (const match of included) {
|
|
122
|
+
rendered += text.slice(cursor, match.start);
|
|
123
|
+
rendered += `<mark>${text.slice(match.start, match.end)}</mark>`;
|
|
124
|
+
cursor = match.end;
|
|
125
|
+
}
|
|
126
|
+
rendered += text.slice(cursor, fragment.end);
|
|
127
|
+
return rendered.trim();
|
|
128
|
+
}
|
|
129
|
+
function mergeRanges(ranges) {
|
|
130
|
+
const merged = [];
|
|
131
|
+
for (const range of uniqueRanges(ranges)) {
|
|
132
|
+
const previous = merged.at(-1);
|
|
133
|
+
if (previous == null || range.start > previous.end) {
|
|
134
|
+
merged.push({ ...range });
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
previous.end = Math.max(previous.end, range.end);
|
|
138
|
+
}
|
|
139
|
+
return merged;
|
|
140
|
+
}
|
|
141
|
+
function uniqueRanges(ranges) {
|
|
142
|
+
const unique = new Map();
|
|
143
|
+
for (const range of ranges.toSorted(compareRanges)) {
|
|
144
|
+
unique.set(`${range.start}:${range.end}`, range);
|
|
145
|
+
}
|
|
146
|
+
return [...unique.values()];
|
|
147
|
+
}
|
|
148
|
+
function overlaps(left, right) {
|
|
149
|
+
return left.start < right.end && right.start < left.end;
|
|
150
|
+
}
|
|
151
|
+
function compareTokenRanges(left, right) {
|
|
152
|
+
return left.start - right.start || left.end - right.end;
|
|
153
|
+
}
|
|
154
|
+
function compareRanges(left, right) {
|
|
155
|
+
return left.start - right.start || left.end - right.end;
|
|
156
|
+
}
|
|
157
|
+
function assertPositiveInteger(value, name) {
|
|
158
|
+
if (!Number.isSafeInteger(value) || value < 1) {
|
|
159
|
+
throw new RangeError(`Portable highlight ${name} must be a positive safe integer`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
export {};
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
import { describe, expect, it } from 'vitest';
|
|
9
|
+
import { createPortableSearchAnalyzer } from './analyzer.js';
|
|
10
|
+
import { highlightPortableText } from './highlight.js';
|
|
11
|
+
describe('portable highlighting', () => {
|
|
12
|
+
it('preserves original text while marking normalized terms and protected identifiers', () => {
|
|
13
|
+
const analyzer = createPortableSearchAnalyzer();
|
|
14
|
+
const plan = analyzer.analyzeQuery({
|
|
15
|
+
query: 'alpha ffi Node.js',
|
|
16
|
+
locale: 'en',
|
|
17
|
+
});
|
|
18
|
+
expect(highlightPortableText({
|
|
19
|
+
text: 'Prelude words before Alpha compatibility ffi and Node.js finish.',
|
|
20
|
+
plan,
|
|
21
|
+
analyzer,
|
|
22
|
+
})).toBe('Prelude words before <mark>Alpha</mark> compatibility <mark>ffi</mark> and <mark>Node.js</mark> finish.');
|
|
23
|
+
});
|
|
24
|
+
it('marks source terms that matched through a language expansion', () => {
|
|
25
|
+
const expander = {
|
|
26
|
+
fingerprint: 'english-highlight-test1',
|
|
27
|
+
supports: (locale) => locale.startsWith('en'),
|
|
28
|
+
expand: (token) => token.value === 'running' || token.value === 'runs' ? [{ kind: 'stem', value: 'run' }] : [],
|
|
29
|
+
};
|
|
30
|
+
const analyzer = createPortableSearchAnalyzer({ expanders: [expander] });
|
|
31
|
+
const plan = analyzer.analyzeQuery({ query: 'runs', locale: 'en' });
|
|
32
|
+
expect(highlightPortableText({
|
|
33
|
+
text: 'Running through the forest.',
|
|
34
|
+
plan,
|
|
35
|
+
analyzer,
|
|
36
|
+
})).toBe('<mark>Running</mark> through the forest.');
|
|
37
|
+
});
|
|
38
|
+
it('marks only concepts present in a partial any-term match', () => {
|
|
39
|
+
const analyzer = createPortableSearchAnalyzer();
|
|
40
|
+
const plan = analyzer.analyzeQuery({
|
|
41
|
+
query: 'forest missing',
|
|
42
|
+
matching: { operator: 'any' },
|
|
43
|
+
});
|
|
44
|
+
expect(highlightPortableText({
|
|
45
|
+
text: 'Forest restoration field notes.',
|
|
46
|
+
plan,
|
|
47
|
+
analyzer,
|
|
48
|
+
})).toBe('<mark>Forest</mark> restoration field notes.');
|
|
49
|
+
});
|
|
50
|
+
it('merges overlapping Han token and gram ranges', () => {
|
|
51
|
+
const analyzer = createPortableSearchAnalyzer();
|
|
52
|
+
const plan = analyzer.analyzeQuery({ query: '数据库' });
|
|
53
|
+
expect(highlightPortableText({ text: '研究数据库系统', plan, analyzer })).toContain('<mark>数据库</mark>');
|
|
54
|
+
});
|
|
55
|
+
it('selects bounded fragments around distant matches', () => {
|
|
56
|
+
const analyzer = createPortableSearchAnalyzer();
|
|
57
|
+
const words = Array.from({ length: 60 }, (_, index) => `word${index}`);
|
|
58
|
+
words[2] = 'alpha';
|
|
59
|
+
words[55] = 'beta';
|
|
60
|
+
const plan = analyzer.analyzeQuery({
|
|
61
|
+
query: 'alpha beta',
|
|
62
|
+
matching: { operator: 'any' },
|
|
63
|
+
});
|
|
64
|
+
const highlighted = highlightPortableText({
|
|
65
|
+
text: words.join(' '),
|
|
66
|
+
plan,
|
|
67
|
+
analyzer,
|
|
68
|
+
maxFragments: 2,
|
|
69
|
+
maxWords: 8,
|
|
70
|
+
});
|
|
71
|
+
expect(highlighted).toContain('<mark>alpha</mark>');
|
|
72
|
+
expect(highlighted).toContain('<mark>beta</mark>');
|
|
73
|
+
expect(highlighted).toContain(' … ');
|
|
74
|
+
expect(highlighted?.split(/\s+/)).toHaveLength(17);
|
|
75
|
+
});
|
|
76
|
+
it('counts overlapping identifier constituents as one snippet term', () => {
|
|
77
|
+
const analyzer = createPortableSearchAnalyzer();
|
|
78
|
+
const plan = analyzer.analyzeQuery({ query: 'covid' });
|
|
79
|
+
expect(highlightPortableText({
|
|
80
|
+
text: 'COVID-19 one two three',
|
|
81
|
+
plan,
|
|
82
|
+
analyzer,
|
|
83
|
+
maxFragments: 1,
|
|
84
|
+
maxWords: 2,
|
|
85
|
+
})).toContain('one');
|
|
86
|
+
});
|
|
87
|
+
it('returns undefined when the stored text contains no query term', () => {
|
|
88
|
+
const analyzer = createPortableSearchAnalyzer();
|
|
89
|
+
const plan = analyzer.analyzeQuery({ query: 'forest' });
|
|
90
|
+
expect(highlightPortableText({ text: 'ocean', plan, analyzer })).toBeUndefined();
|
|
91
|
+
});
|
|
92
|
+
it('rejects incompatible analyzers and invalid limits', () => {
|
|
93
|
+
const analyzer = createPortableSearchAnalyzer({ defaultLocale: 'en' });
|
|
94
|
+
const plan = analyzer.analyzeQuery({ query: 'forest' });
|
|
95
|
+
expect(() => highlightPortableText({
|
|
96
|
+
text: 'forest',
|
|
97
|
+
plan,
|
|
98
|
+
analyzer: createPortableSearchAnalyzer({ defaultLocale: 'th' }),
|
|
99
|
+
})).toThrow(/fingerprints/);
|
|
100
|
+
expect(() => highlightPortableText({ text: 'forest', plan, analyzer, maxFragments: 0 })).toThrow(RangeError);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
import type { SearchIdentifierKind } from './types.js';
|
|
9
|
+
export interface IdentifierSpan {
|
|
10
|
+
kind: SearchIdentifierKind;
|
|
11
|
+
value: string;
|
|
12
|
+
start: number;
|
|
13
|
+
end: number;
|
|
14
|
+
/** Whether word segmentation should retain exact constituent terms. */
|
|
15
|
+
preserveConstituents: boolean;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Extract non-overlapping domain identifiers in rule-priority order. The input
|
|
19
|
+
* is already search-normalized, so every emitted value is canonical.
|
|
20
|
+
*/
|
|
21
|
+
export declare function extractIdentifierSpans(text: string): IdentifierSpan[];
|
|
22
|
+
/** Replace identifier spans with equal-length spaces so word offsets survive. */
|
|
23
|
+
export declare function maskIdentifierSpans(text: string, spans: readonly IdentifierSpan[]): string;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
const RULES = [
|
|
9
|
+
{
|
|
10
|
+
kind: 'url',
|
|
11
|
+
pattern: /https?:\/\/[^\s<>"']+/giu,
|
|
12
|
+
value: trimUrlPunctuation,
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
kind: 'email',
|
|
16
|
+
pattern: /[\p{L}\p{N}._%+-]{1,64}@[\p{L}\p{N}.-]{1,255}\.[\p{L}]{2,63}/giu,
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
kind: 'technical',
|
|
20
|
+
pattern: /(?:^|(?<=[^\p{L}\p{N}_]))(?:c\+\+|c#|node\.js)(?=$|[^\p{L}\p{N}_])/giu,
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
kind: 'sku',
|
|
24
|
+
pattern: /\b[\p{L}]{2,}[-_]\d+\b/giu,
|
|
25
|
+
preserveConstituents: true,
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
kind: 'version',
|
|
29
|
+
pattern: /\bv?\d+(?:\.\d+){1,3}(?:-[\p{L}\p{N}.-]+)?\b/giu,
|
|
30
|
+
preserveConstituents: true,
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
kind: 'mention',
|
|
34
|
+
pattern: /@[\p{L}\p{M}\p{N}_]+/gu,
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
kind: 'hashtag',
|
|
38
|
+
pattern: /#[\p{L}\p{M}\p{N}_]+/gu,
|
|
39
|
+
},
|
|
40
|
+
];
|
|
41
|
+
const TRAILING_URL_PUNCTUATION = /[.,;:!?]+$/u;
|
|
42
|
+
const TRAILING_URL_BRACKETS = /[)\]}]+$/u;
|
|
43
|
+
function trimUrlPunctuation(value) {
|
|
44
|
+
let trimmed = value.replace(TRAILING_URL_PUNCTUATION, '');
|
|
45
|
+
while (TRAILING_URL_BRACKETS.test(trimmed)) {
|
|
46
|
+
const final = trimmed.at(-1);
|
|
47
|
+
const opening = final === ')' ? '(' : final === ']' ? '[' : '{';
|
|
48
|
+
const closing = final;
|
|
49
|
+
const openingCount = [...trimmed].filter((char) => char === opening).length;
|
|
50
|
+
const closingCount = [...trimmed].filter((char) => char === closing).length;
|
|
51
|
+
if (closingCount <= openingCount)
|
|
52
|
+
break;
|
|
53
|
+
trimmed = trimmed.slice(0, -1);
|
|
54
|
+
}
|
|
55
|
+
return trimmed;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Extract non-overlapping domain identifiers in rule-priority order. The input
|
|
59
|
+
* is already search-normalized, so every emitted value is canonical.
|
|
60
|
+
*/
|
|
61
|
+
export function extractIdentifierSpans(text) {
|
|
62
|
+
const spans = [];
|
|
63
|
+
for (const rule of RULES) {
|
|
64
|
+
rule.pattern.lastIndex = 0;
|
|
65
|
+
for (const match of text.matchAll(rule.pattern)) {
|
|
66
|
+
const matched = match[0];
|
|
67
|
+
const matchStart = match.index;
|
|
68
|
+
if (matched == null || matchStart == null)
|
|
69
|
+
continue;
|
|
70
|
+
const value = rule.value?.(matched) ?? matched;
|
|
71
|
+
if (value.length === 0)
|
|
72
|
+
continue;
|
|
73
|
+
const start = matchStart;
|
|
74
|
+
const end = start + value.length;
|
|
75
|
+
if (spans.some((span) => start < span.end && end > span.start))
|
|
76
|
+
continue;
|
|
77
|
+
spans.push({
|
|
78
|
+
kind: rule.kind,
|
|
79
|
+
value,
|
|
80
|
+
start,
|
|
81
|
+
end,
|
|
82
|
+
preserveConstituents: rule.preserveConstituents ?? false,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return spans.sort((a, b) => a.start - b.start || b.end - a.end);
|
|
87
|
+
}
|
|
88
|
+
/** Replace identifier spans with equal-length spaces so word offsets survive. */
|
|
89
|
+
export function maskIdentifierSpans(text, spans) {
|
|
90
|
+
if (spans.length === 0)
|
|
91
|
+
return text;
|
|
92
|
+
const characters = text.split('');
|
|
93
|
+
for (const span of spans) {
|
|
94
|
+
if (span.preserveConstituents)
|
|
95
|
+
continue;
|
|
96
|
+
for (let index = span.start; index < span.end; index++) {
|
|
97
|
+
if (characters[index] !== '\n')
|
|
98
|
+
characters[index] = ' ';
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return characters.join('');
|
|
102
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
export { createPortableSearchAnalyzer, resolveMatching } from './analyzer.js';
|
|
9
|
+
export { type HighlightPortableTextInput, highlightPortableText, type PortableHighlightOptions, } from './highlight.js';
|
|
10
|
+
export { canonicalSegmenterLocale, detectSearchLocale, resolveSearchLocale, } from './locale.js';
|
|
11
|
+
export { normalizeForSearch, type SearchNormalization } from './normalize.js';
|
|
12
|
+
export { encodeSqlToken, encodeSqlTokens, type SqlTokenCodecOptions, } from './sql-token-codec.js';
|
|
13
|
+
export type { AnalyzedText, AnalyzeQueryInput, AnalyzeTextInput, LogicalToken, LogicalTokenKind, PortableQueryPlan, PortableSearchAnalyzer, PortableSearchAnalyzerOptions, QueryConcept, QueryPhrase, ResolvedSearchMatching, SearchIdentifierKind, SearchTokenExpander, SearchTokenExpansion, } from './types.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
export { createPortableSearchAnalyzer, resolveMatching } from './analyzer.js';
|
|
9
|
+
export { highlightPortableText, } from './highlight.js';
|
|
10
|
+
export { canonicalSegmenterLocale, detectSearchLocale, resolveSearchLocale, } from './locale.js';
|
|
11
|
+
export { normalizeForSearch } from './normalize.js';
|
|
12
|
+
export { encodeSqlToken, encodeSqlTokens, } from './sql-token-codec.js';
|
package/dist/locale.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
/** Return one canonical locale when `Intl.Segmenter` supports the candidate. */
|
|
9
|
+
export declare function canonicalSegmenterLocale(candidate: string | undefined): string | undefined;
|
|
10
|
+
/** Script-based locale fallback for content without a usable declaration. */
|
|
11
|
+
export declare function detectSearchLocale(text: string, hanLocale?: 'zh' | 'ja'): string | undefined;
|
|
12
|
+
/**
|
|
13
|
+
* Declared locale wins, followed by script detection and the configured
|
|
14
|
+
* platform fallback. Every returned locale is canonical and Segmenter-safe.
|
|
15
|
+
*/
|
|
16
|
+
export declare function resolveSearchLocale(text: string, declaredLocale: string | undefined, defaultLocale?: string, hanLocale?: 'zh' | 'ja'): string;
|
package/dist/locale.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
const SCRIPT_LOCALES = [
|
|
9
|
+
[/\p{Script=Thai}/u, 'th'],
|
|
10
|
+
[/\p{Script=Hiragana}|\p{Script=Katakana}/u, 'ja'],
|
|
11
|
+
[/\p{Script=Hangul}/u, 'ko'],
|
|
12
|
+
[/\p{Script=Lao}/u, 'lo'],
|
|
13
|
+
[/\p{Script=Khmer}/u, 'km'],
|
|
14
|
+
[/\p{Script=Myanmar}/u, 'my'],
|
|
15
|
+
];
|
|
16
|
+
/** Return one canonical locale when `Intl.Segmenter` supports the candidate. */
|
|
17
|
+
export function canonicalSegmenterLocale(candidate) {
|
|
18
|
+
if (!candidate)
|
|
19
|
+
return undefined;
|
|
20
|
+
try {
|
|
21
|
+
const canonical = Intl.getCanonicalLocales(candidate)[0];
|
|
22
|
+
if (canonical == null)
|
|
23
|
+
return undefined;
|
|
24
|
+
return Intl.Segmenter.supportedLocalesOf([canonical]).length > 0 ? canonical : undefined;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/** Script-based locale fallback for content without a usable declaration. */
|
|
31
|
+
export function detectSearchLocale(text, hanLocale = 'zh') {
|
|
32
|
+
for (const [pattern, locale] of SCRIPT_LOCALES) {
|
|
33
|
+
if (pattern.test(text))
|
|
34
|
+
return locale;
|
|
35
|
+
}
|
|
36
|
+
if (/\p{Script=Han}/u.test(text))
|
|
37
|
+
return hanLocale;
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Declared locale wins, followed by script detection and the configured
|
|
42
|
+
* platform fallback. Every returned locale is canonical and Segmenter-safe.
|
|
43
|
+
*/
|
|
44
|
+
export function resolveSearchLocale(text, declaredLocale, defaultLocale = 'en', hanLocale = 'zh') {
|
|
45
|
+
const declared = canonicalSegmenterLocale(declaredLocale);
|
|
46
|
+
if (declared != null)
|
|
47
|
+
return declared;
|
|
48
|
+
const detected = canonicalSegmenterLocale(detectSearchLocale(text, hanLocale));
|
|
49
|
+
if (detected != null)
|
|
50
|
+
return detected;
|
|
51
|
+
return canonicalSegmenterLocale(defaultLocale) ?? 'en';
|
|
52
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
interface OriginalRange {
|
|
9
|
+
start: number;
|
|
10
|
+
end: number;
|
|
11
|
+
}
|
|
12
|
+
export interface SearchNormalization {
|
|
13
|
+
value: string;
|
|
14
|
+
originalRange(normalizedStart: number, normalizedEnd: number): OriginalRange;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Search-only NFKC + locale-insensitive lowercase normalization with a compact
|
|
18
|
+
* normalized-to-original offset map. Grapheme-grain processing keeps combining
|
|
19
|
+
* sequences together while mapping compatibility expansions (for example `ffi`
|
|
20
|
+
* → `ffi`) back to their original span.
|
|
21
|
+
*/
|
|
22
|
+
export declare function normalizeForSearch(original: string): SearchNormalization;
|
|
23
|
+
export {};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
|
|
9
|
+
/**
|
|
10
|
+
* Search-only NFKC + locale-insensitive lowercase normalization with a compact
|
|
11
|
+
* normalized-to-original offset map. Grapheme-grain processing keeps combining
|
|
12
|
+
* sequences together while mapping compatibility expansions (for example `ffi`
|
|
13
|
+
* → `ffi`) back to their original span.
|
|
14
|
+
*/
|
|
15
|
+
export function normalizeForSearch(original) {
|
|
16
|
+
const normalizedParts = [];
|
|
17
|
+
const originalStarts = [];
|
|
18
|
+
const originalEnds = [];
|
|
19
|
+
const segments = [...graphemeSegmenter.segment(original)];
|
|
20
|
+
for (let index = 0; index < segments.length; index++) {
|
|
21
|
+
const segment = segments[index];
|
|
22
|
+
if (segment == null)
|
|
23
|
+
continue;
|
|
24
|
+
const originalStart = segment.index;
|
|
25
|
+
const originalEnd = segments[index + 1]?.index ?? original.length;
|
|
26
|
+
const normalized = segment.segment.normalize('NFKC').toLowerCase();
|
|
27
|
+
normalizedParts.push(normalized);
|
|
28
|
+
for (let offset = 0; offset < normalized.length; offset++) {
|
|
29
|
+
originalStarts.push(originalStart);
|
|
30
|
+
originalEnds.push(originalEnd);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
const value = normalizedParts.join('');
|
|
34
|
+
return {
|
|
35
|
+
value,
|
|
36
|
+
originalRange(normalizedStart, normalizedEnd) {
|
|
37
|
+
if (normalizedStart < 0 || normalizedEnd < normalizedStart || normalizedEnd > value.length) {
|
|
38
|
+
throw new RangeError(`Normalized range ${normalizedStart}..${normalizedEnd} is outside 0..${value.length}`);
|
|
39
|
+
}
|
|
40
|
+
if (normalizedStart === normalizedEnd) {
|
|
41
|
+
const boundary = originalStarts[normalizedStart] ?? originalEnds[normalizedStart - 1] ?? original.length;
|
|
42
|
+
return { start: boundary, end: boundary };
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
start: originalStarts[normalizedStart] ?? original.length,
|
|
46
|
+
end: originalEnds[normalizedEnd - 1] ?? original.length,
|
|
47
|
+
};
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
import type { LogicalToken } from './types.js';
|
|
9
|
+
export interface SqlTokenCodecOptions {
|
|
10
|
+
/**
|
|
11
|
+
* Maximum physical term length before a SHA-256 representation is used.
|
|
12
|
+
* The default stays well below common SQL full-text parser limits.
|
|
13
|
+
*/
|
|
14
|
+
maxLength?: number;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Encode a logical token as lowercase ASCII alphanumeric text. Kind prefixes
|
|
18
|
+
* prevent exact/stem/gram collisions and ensure even one-character source
|
|
19
|
+
* terms exceed MySQL's default minimum token length.
|
|
20
|
+
*/
|
|
21
|
+
export declare function encodeSqlToken(token: Pick<LogicalToken, 'kind' | 'value'>, options?: SqlTokenCodecOptions): string;
|
|
22
|
+
export declare function encodeSqlTokens(tokens: readonly Pick<LogicalToken, 'kind' | 'value'>[], options?: SqlTokenCodecOptions): string[];
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
import { createHash } from 'node:crypto';
|
|
9
|
+
const BASE32_ALPHABET = 'abcdefghijklmnopqrstuvwxyz234567';
|
|
10
|
+
const KIND_PREFIX = {
|
|
11
|
+
exact: 'ex',
|
|
12
|
+
stem: 'st',
|
|
13
|
+
lemma: 'le',
|
|
14
|
+
normalized: 'no',
|
|
15
|
+
identifier: 'id',
|
|
16
|
+
gram: 'gr',
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* Encode a logical token as lowercase ASCII alphanumeric text. Kind prefixes
|
|
20
|
+
* prevent exact/stem/gram collisions and ensure even one-character source
|
|
21
|
+
* terms exceed MySQL's default minimum token length.
|
|
22
|
+
*/
|
|
23
|
+
export function encodeSqlToken(token, options = {}) {
|
|
24
|
+
if (token.value.length === 0)
|
|
25
|
+
throw new TypeError('Cannot encode an empty search token');
|
|
26
|
+
const prefix = KIND_PREFIX[token.kind];
|
|
27
|
+
const encoded = `${prefix}${base32(new TextEncoder().encode(token.value))}`;
|
|
28
|
+
const maxLength = options.maxLength ?? 80;
|
|
29
|
+
if (!Number.isSafeInteger(maxLength) || maxLength < 16) {
|
|
30
|
+
throw new RangeError('SQL token maxLength must be a safe integer of at least 16');
|
|
31
|
+
}
|
|
32
|
+
if (encoded.length <= maxLength)
|
|
33
|
+
return encoded;
|
|
34
|
+
const digest = createHash('sha256').update(token.value, 'utf8').digest();
|
|
35
|
+
return `${prefix}h${base32(digest)}`.slice(0, maxLength);
|
|
36
|
+
}
|
|
37
|
+
export function encodeSqlTokens(tokens, options = {}) {
|
|
38
|
+
return tokens.map((token) => encodeSqlToken(token, options));
|
|
39
|
+
}
|
|
40
|
+
function base32(bytes) {
|
|
41
|
+
let value = 0;
|
|
42
|
+
let bits = 0;
|
|
43
|
+
let output = '';
|
|
44
|
+
for (const byte of bytes) {
|
|
45
|
+
value = (value << 8) | byte;
|
|
46
|
+
bits += 8;
|
|
47
|
+
while (bits >= 5) {
|
|
48
|
+
output += BASE32_ALPHABET[(value >>> (bits - 5)) & 31];
|
|
49
|
+
bits -= 5;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (bits > 0)
|
|
53
|
+
output += BASE32_ALPHABET[(value << (5 - bits)) & 31];
|
|
54
|
+
return output;
|
|
55
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
export {};
|