@mintlify/common 1.0.1138 → 1.0.1139
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 +1 -0
- package/dist/index.js +1 -0
- package/dist/search/highlight.d.ts +7 -0
- package/dist/search/highlight.js +400 -0
- package/dist/search/highlight.test.d.ts +1 -0
- package/dist/search/highlight.test.js +176 -0
- package/dist/search/index.d.ts +1 -0
- package/dist/search/index.js +1 -0
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/package.json +2 -2
package/dist/index.d.ts
CHANGED
|
@@ -21,6 +21,7 @@ export * from './asyncapi/index.js';
|
|
|
21
21
|
export * from './isAbsoluteUrl.js';
|
|
22
22
|
export * from './isValidEmail.js';
|
|
23
23
|
export * from './rss/index.js';
|
|
24
|
+
export * from './search/index.js';
|
|
24
25
|
export * from './services/aws.js';
|
|
25
26
|
export * from './slugify.js';
|
|
26
27
|
export * from './services/aws.js';
|
package/dist/index.js
CHANGED
|
@@ -21,6 +21,7 @@ export * from './asyncapi/index.js';
|
|
|
21
21
|
export * from './isAbsoluteUrl.js';
|
|
22
22
|
export * from './isValidEmail.js';
|
|
23
23
|
export * from './rss/index.js';
|
|
24
|
+
export * from './search/index.js';
|
|
24
25
|
export * from './services/aws.js';
|
|
25
26
|
export * from './slugify.js';
|
|
26
27
|
export * from './services/aws.js';
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export declare function getHighlightedText(text: string, query: string): string;
|
|
2
|
+
export type HighlightSnippetOptions = {
|
|
3
|
+
leadingContext?: number;
|
|
4
|
+
maxLength?: number;
|
|
5
|
+
};
|
|
6
|
+
export declare function stripDuplicatedTitleLine(content: string, header: string): string;
|
|
7
|
+
export declare function getHighlightedSnippet(text: string, query: string, options?: HighlightSnippetOptions): string;
|
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
const STOP_WORDS = new Set([
|
|
2
|
+
'i',
|
|
3
|
+
'me',
|
|
4
|
+
'my',
|
|
5
|
+
'myself',
|
|
6
|
+
'we',
|
|
7
|
+
'our',
|
|
8
|
+
'ours',
|
|
9
|
+
'ourselves',
|
|
10
|
+
'you',
|
|
11
|
+
'your',
|
|
12
|
+
'yours',
|
|
13
|
+
'yourself',
|
|
14
|
+
'yourselves',
|
|
15
|
+
'he',
|
|
16
|
+
'him',
|
|
17
|
+
'his',
|
|
18
|
+
'himself',
|
|
19
|
+
'she',
|
|
20
|
+
'her',
|
|
21
|
+
'hers',
|
|
22
|
+
'herself',
|
|
23
|
+
'it',
|
|
24
|
+
'its',
|
|
25
|
+
'itself',
|
|
26
|
+
'they',
|
|
27
|
+
'them',
|
|
28
|
+
'their',
|
|
29
|
+
'theirs',
|
|
30
|
+
'themselves',
|
|
31
|
+
'what',
|
|
32
|
+
'which',
|
|
33
|
+
'who',
|
|
34
|
+
'whom',
|
|
35
|
+
'this',
|
|
36
|
+
'that',
|
|
37
|
+
'these',
|
|
38
|
+
'those',
|
|
39
|
+
'am',
|
|
40
|
+
'is',
|
|
41
|
+
'are',
|
|
42
|
+
'was',
|
|
43
|
+
'were',
|
|
44
|
+
'be',
|
|
45
|
+
'been',
|
|
46
|
+
'being',
|
|
47
|
+
'have',
|
|
48
|
+
'has',
|
|
49
|
+
'had',
|
|
50
|
+
'having',
|
|
51
|
+
'do',
|
|
52
|
+
'does',
|
|
53
|
+
'did',
|
|
54
|
+
'doing',
|
|
55
|
+
'a',
|
|
56
|
+
'an',
|
|
57
|
+
'the',
|
|
58
|
+
'and',
|
|
59
|
+
'but',
|
|
60
|
+
'if',
|
|
61
|
+
'or',
|
|
62
|
+
'because',
|
|
63
|
+
'as',
|
|
64
|
+
'until',
|
|
65
|
+
'while',
|
|
66
|
+
'of',
|
|
67
|
+
'at',
|
|
68
|
+
'by',
|
|
69
|
+
'for',
|
|
70
|
+
'with',
|
|
71
|
+
'about',
|
|
72
|
+
'against',
|
|
73
|
+
'between',
|
|
74
|
+
'into',
|
|
75
|
+
'through',
|
|
76
|
+
'during',
|
|
77
|
+
'before',
|
|
78
|
+
'after',
|
|
79
|
+
'above',
|
|
80
|
+
'below',
|
|
81
|
+
'to',
|
|
82
|
+
'from',
|
|
83
|
+
'up',
|
|
84
|
+
'down',
|
|
85
|
+
'in',
|
|
86
|
+
'out',
|
|
87
|
+
'on',
|
|
88
|
+
'off',
|
|
89
|
+
'over',
|
|
90
|
+
'under',
|
|
91
|
+
'again',
|
|
92
|
+
'further',
|
|
93
|
+
'then',
|
|
94
|
+
'once',
|
|
95
|
+
'here',
|
|
96
|
+
'there',
|
|
97
|
+
'when',
|
|
98
|
+
'where',
|
|
99
|
+
'why',
|
|
100
|
+
'how',
|
|
101
|
+
'all',
|
|
102
|
+
'any',
|
|
103
|
+
'both',
|
|
104
|
+
'each',
|
|
105
|
+
'few',
|
|
106
|
+
'more',
|
|
107
|
+
'most',
|
|
108
|
+
'other',
|
|
109
|
+
'some',
|
|
110
|
+
'such',
|
|
111
|
+
'no',
|
|
112
|
+
'nor',
|
|
113
|
+
'not',
|
|
114
|
+
'only',
|
|
115
|
+
'own',
|
|
116
|
+
'same',
|
|
117
|
+
'so',
|
|
118
|
+
'than',
|
|
119
|
+
'too',
|
|
120
|
+
'very',
|
|
121
|
+
's',
|
|
122
|
+
't',
|
|
123
|
+
'can',
|
|
124
|
+
'will',
|
|
125
|
+
'just',
|
|
126
|
+
'don',
|
|
127
|
+
'should',
|
|
128
|
+
'now',
|
|
129
|
+
"aren't",
|
|
130
|
+
"it's",
|
|
131
|
+
"don't",
|
|
132
|
+
'get',
|
|
133
|
+
]);
|
|
134
|
+
const MAX_QUERY_WORDS = 20;
|
|
135
|
+
const HIGHLIGHT_TIMEOUT_MS = 100;
|
|
136
|
+
const PRE_TAG = '<mark><b>';
|
|
137
|
+
const POST_TAG = '</b></mark>';
|
|
138
|
+
function escapeHtml(str) {
|
|
139
|
+
return str
|
|
140
|
+
.replace(/&/g, '&')
|
|
141
|
+
.replace(/</g, '<')
|
|
142
|
+
.replace(/>/g, '>')
|
|
143
|
+
.replace(/"/g, '"');
|
|
144
|
+
}
|
|
145
|
+
// Server-side search results arrive pre-wrapped with <mark><b>…</b></mark>
|
|
146
|
+
// highlight tags. We re-highlight on the client now, so strip them here —
|
|
147
|
+
// otherwise escapeHtml below would turn them into visible <mark> text.
|
|
148
|
+
const SERVER_HIGHLIGHT_TAGS = /<\/?(?:mark|b)\b[^>]*>/gi;
|
|
149
|
+
function stripServerHighlightTags(text) {
|
|
150
|
+
return text.replace(SERVER_HIGHLIGHT_TAGS, '');
|
|
151
|
+
}
|
|
152
|
+
function isStopWord(word) {
|
|
153
|
+
return STOP_WORDS.has(word.toLowerCase());
|
|
154
|
+
}
|
|
155
|
+
function chunkByNonStopWords(words) {
|
|
156
|
+
const groups = [];
|
|
157
|
+
let current = [];
|
|
158
|
+
for (const w of words) {
|
|
159
|
+
if (!w)
|
|
160
|
+
continue;
|
|
161
|
+
if (isStopWord(w)) {
|
|
162
|
+
if (current.length > 0) {
|
|
163
|
+
groups.push(current);
|
|
164
|
+
current = [];
|
|
165
|
+
}
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
current.push(w);
|
|
169
|
+
}
|
|
170
|
+
if (current.length > 0)
|
|
171
|
+
groups.push(current);
|
|
172
|
+
return groups;
|
|
173
|
+
}
|
|
174
|
+
function findAllCaseInsensitive(text, search) {
|
|
175
|
+
const lower = text.toLowerCase();
|
|
176
|
+
const searchLower = search.toLowerCase();
|
|
177
|
+
const indices = [];
|
|
178
|
+
let start = 0;
|
|
179
|
+
while (start <= lower.length - searchLower.length) {
|
|
180
|
+
const idx = lower.indexOf(searchLower, start);
|
|
181
|
+
if (idx === -1)
|
|
182
|
+
break;
|
|
183
|
+
indices.push(idx);
|
|
184
|
+
start = idx + 1;
|
|
185
|
+
}
|
|
186
|
+
return indices;
|
|
187
|
+
}
|
|
188
|
+
function generateQueryVariations(query) {
|
|
189
|
+
const cleanedQuery = query.replace(/[?,.!\n"]/g, '');
|
|
190
|
+
const queryWords = cleanedQuery.split(/\s+/).filter(Boolean).slice(0, MAX_QUERY_WORDS);
|
|
191
|
+
const groups = chunkByNonStopWords(queryWords);
|
|
192
|
+
const multiToken = [];
|
|
193
|
+
for (const group of groups) {
|
|
194
|
+
if (group.length > 1)
|
|
195
|
+
multiToken.push(group.join(' '));
|
|
196
|
+
}
|
|
197
|
+
const groupStarts = groups
|
|
198
|
+
.map((g) => cleanedQuery.indexOf(g.join(' ')))
|
|
199
|
+
.filter((i) => i >= 0)
|
|
200
|
+
.sort((a, b) => a - b);
|
|
201
|
+
for (let i = 0; i < groupStarts.length - 1; i++) {
|
|
202
|
+
const span = cleanedQuery.slice(groupStarts[i], groupStarts[i + 1]).trim();
|
|
203
|
+
const nonStopCount = span.split(/\s+/).filter((w) => !isStopWord(w)).length;
|
|
204
|
+
if (nonStopCount > 1) {
|
|
205
|
+
if (span.split(/\s+/).length > 1)
|
|
206
|
+
multiToken.push(span);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
if (groupStarts.length > 0) {
|
|
210
|
+
const tail = cleanedQuery.slice(groupStarts[groupStarts.length - 1]).trim();
|
|
211
|
+
if (tail)
|
|
212
|
+
multiToken.push(tail);
|
|
213
|
+
}
|
|
214
|
+
for (let len = queryWords.length - 1; len > 0; len--) {
|
|
215
|
+
for (let skip = 0; skip <= queryWords.length - len; skip++) {
|
|
216
|
+
const sub = queryWords
|
|
217
|
+
.slice(skip, skip + len)
|
|
218
|
+
.map((w) => w.trim())
|
|
219
|
+
.join(' ');
|
|
220
|
+
const nonStopCount = sub.split(/\s+/).filter((w) => !isStopWord(w)).length;
|
|
221
|
+
if (len > 2 || nonStopCount >= 1) {
|
|
222
|
+
multiToken.push(sub);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
const variations = [query, cleanedQuery, ...multiToken].filter((x) => x.trim().length > 0);
|
|
227
|
+
const seen = new Set();
|
|
228
|
+
const unique = [];
|
|
229
|
+
for (const v of variations) {
|
|
230
|
+
if (!seen.has(v)) {
|
|
231
|
+
seen.add(v);
|
|
232
|
+
unique.push(v);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
const numWords = queryWords.length;
|
|
236
|
+
unique.sort((a, b) => {
|
|
237
|
+
if (numWords >= MAX_QUERY_WORDS)
|
|
238
|
+
return b.trim().length - a.trim().length;
|
|
239
|
+
const aWords = a.split(/\s+/).filter(Boolean).length;
|
|
240
|
+
const bWords = b.split(/\s+/).filter(Boolean).length;
|
|
241
|
+
if (bWords !== aWords)
|
|
242
|
+
return bWords - aWords;
|
|
243
|
+
return b.trim().length - a.trim().length;
|
|
244
|
+
});
|
|
245
|
+
return unique;
|
|
246
|
+
}
|
|
247
|
+
function computeMergedSpans(text, matchedQueries) {
|
|
248
|
+
var _a;
|
|
249
|
+
const spans = [];
|
|
250
|
+
const isWordChar = /\w/;
|
|
251
|
+
for (const q of matchedQueries) {
|
|
252
|
+
const trimmed = q.trim();
|
|
253
|
+
if (!trimmed)
|
|
254
|
+
continue;
|
|
255
|
+
const isSingleWord = !/\s/.test(trimmed);
|
|
256
|
+
for (const idx of findAllCaseInsensitive(text, trimmed)) {
|
|
257
|
+
const end = idx + trimmed.length;
|
|
258
|
+
if (isSingleWord) {
|
|
259
|
+
const beforeOk = idx === 0 || !isWordChar.test((_a = text[idx - 1]) !== null && _a !== void 0 ? _a : '');
|
|
260
|
+
if (!beforeOk)
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
spans.push({ start: idx, end });
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
spans.sort((a, b) => a.start - b.start || b.end - a.end);
|
|
267
|
+
const mergedSpans = [];
|
|
268
|
+
for (const span of spans) {
|
|
269
|
+
const last = mergedSpans[mergedSpans.length - 1];
|
|
270
|
+
if (last && span.start <= last.end) {
|
|
271
|
+
last.end = Math.max(last.end, span.end);
|
|
272
|
+
}
|
|
273
|
+
else {
|
|
274
|
+
mergedSpans.push({ start: span.start, end: span.end });
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
return mergedSpans;
|
|
278
|
+
}
|
|
279
|
+
function renderSpans(text, mergedSpans) {
|
|
280
|
+
let result = '';
|
|
281
|
+
let cursor = 0;
|
|
282
|
+
for (const span of mergedSpans) {
|
|
283
|
+
result += escapeHtml(text.slice(cursor, span.start));
|
|
284
|
+
const original = text.slice(span.start, span.end);
|
|
285
|
+
if (original)
|
|
286
|
+
result += `${PRE_TAG}${escapeHtml(original)}${POST_TAG}`;
|
|
287
|
+
cursor = span.end;
|
|
288
|
+
}
|
|
289
|
+
result += escapeHtml(text.slice(cursor));
|
|
290
|
+
return result;
|
|
291
|
+
}
|
|
292
|
+
function renderHighlightedSpans(text, matchedQueries) {
|
|
293
|
+
return renderSpans(text, computeMergedSpans(text, matchedQueries));
|
|
294
|
+
}
|
|
295
|
+
const VARIATIONS_CACHE_LIMIT = 64;
|
|
296
|
+
const variationsCache = new Map();
|
|
297
|
+
function getCachedVariations(query) {
|
|
298
|
+
const cached = variationsCache.get(query);
|
|
299
|
+
if (cached)
|
|
300
|
+
return cached;
|
|
301
|
+
const variations = generateQueryVariations(query);
|
|
302
|
+
variationsCache.set(query, variations);
|
|
303
|
+
if (variationsCache.size > VARIATIONS_CACHE_LIMIT) {
|
|
304
|
+
const oldestKey = variationsCache.keys().next().value;
|
|
305
|
+
if (oldestKey !== undefined)
|
|
306
|
+
variationsCache.delete(oldestKey);
|
|
307
|
+
}
|
|
308
|
+
return variations;
|
|
309
|
+
}
|
|
310
|
+
function getMatchedQueries(text, query) {
|
|
311
|
+
const deadline = Date.now() + HIGHLIGHT_TIMEOUT_MS;
|
|
312
|
+
const variations = getCachedVariations(query);
|
|
313
|
+
const matchedQueries = [];
|
|
314
|
+
for (const variation of variations) {
|
|
315
|
+
if (Date.now() > deadline)
|
|
316
|
+
return [];
|
|
317
|
+
const trimmed = variation.trim();
|
|
318
|
+
if (matchedQueries.some((q) => q.trim() === trimmed))
|
|
319
|
+
continue;
|
|
320
|
+
if (findAllCaseInsensitive(text, variation).length > 0) {
|
|
321
|
+
matchedQueries.push(variation);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
return matchedQueries;
|
|
325
|
+
}
|
|
326
|
+
export function getHighlightedText(text, query) {
|
|
327
|
+
const stripped = stripServerHighlightTags(text);
|
|
328
|
+
if (!stripped || !query)
|
|
329
|
+
return escapeHtml(stripped);
|
|
330
|
+
const matchedQueries = getMatchedQueries(stripped, query);
|
|
331
|
+
if (matchedQueries.length === 0)
|
|
332
|
+
return escapeHtml(stripped);
|
|
333
|
+
return renderHighlightedSpans(stripped, matchedQueries);
|
|
334
|
+
}
|
|
335
|
+
const ELLIPSIS = '…';
|
|
336
|
+
const SNIPPET_LEADING_CONTEXT = 24;
|
|
337
|
+
const SNIPPET_MAX_LENGTH = 240;
|
|
338
|
+
const SNIPPET_BOUNDARY_LOOKAROUND = 12;
|
|
339
|
+
function snapStartToWordBoundary(text, index, lookaround) {
|
|
340
|
+
if (index <= 0)
|
|
341
|
+
return 0;
|
|
342
|
+
const earliest = Math.max(0, index - lookaround);
|
|
343
|
+
const previousSpace = text.lastIndexOf(' ', index - 1);
|
|
344
|
+
return previousSpace >= earliest ? previousSpace + 1 : index;
|
|
345
|
+
}
|
|
346
|
+
function snapEndToWordBoundary(text, index, lookaround) {
|
|
347
|
+
if (index >= text.length)
|
|
348
|
+
return text.length;
|
|
349
|
+
const latest = Math.min(text.length, index + lookaround);
|
|
350
|
+
const nextSpace = text.indexOf(' ', index);
|
|
351
|
+
return nextSpace !== -1 && nextSpace < latest ? nextSpace : index;
|
|
352
|
+
}
|
|
353
|
+
function leadingWindow(text, maxLength) {
|
|
354
|
+
if (text.length <= maxLength)
|
|
355
|
+
return escapeHtml(text);
|
|
356
|
+
const end = snapEndToWordBoundary(text, maxLength, SNIPPET_BOUNDARY_LOOKAROUND);
|
|
357
|
+
return `${escapeHtml(text.slice(0, end))} ${ELLIPSIS}`;
|
|
358
|
+
}
|
|
359
|
+
export function stripDuplicatedTitleLine(content, header) {
|
|
360
|
+
const newlineIdx = content.indexOf('\n');
|
|
361
|
+
if (newlineIdx < 0 || !header)
|
|
362
|
+
return content;
|
|
363
|
+
const normalize = (value) => stripServerHighlightTags(value).replace(/\s+/g, ' ').trim();
|
|
364
|
+
return normalize(content.slice(0, newlineIdx)) === normalize(header)
|
|
365
|
+
? content.slice(newlineIdx + 1)
|
|
366
|
+
: content;
|
|
367
|
+
}
|
|
368
|
+
export function getHighlightedSnippet(text, query, options) {
|
|
369
|
+
var _a, _b;
|
|
370
|
+
const stripped = stripServerHighlightTags(text);
|
|
371
|
+
const leadingContext = (_a = options === null || options === void 0 ? void 0 : options.leadingContext) !== null && _a !== void 0 ? _a : SNIPPET_LEADING_CONTEXT;
|
|
372
|
+
const maxLength = (_b = options === null || options === void 0 ? void 0 : options.maxLength) !== null && _b !== void 0 ? _b : SNIPPET_MAX_LENGTH;
|
|
373
|
+
if (!stripped || !query)
|
|
374
|
+
return leadingWindow(stripped, maxLength);
|
|
375
|
+
const matchedQueries = getMatchedQueries(stripped, query);
|
|
376
|
+
if (matchedQueries.length === 0)
|
|
377
|
+
return leadingWindow(stripped, maxLength);
|
|
378
|
+
const mergedSpans = computeMergedSpans(stripped, matchedQueries);
|
|
379
|
+
const firstMatch = mergedSpans[0];
|
|
380
|
+
if (!firstMatch)
|
|
381
|
+
return leadingWindow(stripped, maxLength);
|
|
382
|
+
let windowStart = firstMatch.start <= leadingContext ? 0 : firstMatch.start - leadingContext;
|
|
383
|
+
let windowEnd = Math.min(stripped.length, Math.max(firstMatch.end, windowStart + maxLength));
|
|
384
|
+
windowStart = snapStartToWordBoundary(stripped, windowStart, SNIPPET_BOUNDARY_LOOKAROUND);
|
|
385
|
+
windowEnd = snapEndToWordBoundary(stripped, windowEnd, SNIPPET_BOUNDARY_LOOKAROUND);
|
|
386
|
+
const windowed = stripped.slice(windowStart, windowEnd);
|
|
387
|
+
const windowedSpans = [];
|
|
388
|
+
for (const span of mergedSpans) {
|
|
389
|
+
if (span.end <= windowStart || span.start >= windowEnd)
|
|
390
|
+
continue;
|
|
391
|
+
windowedSpans.push({
|
|
392
|
+
start: Math.max(0, span.start - windowStart),
|
|
393
|
+
end: Math.min(windowed.length, span.end - windowStart),
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
const body = renderSpans(windowed, windowedSpans);
|
|
397
|
+
const lead = windowStart > 0 ? `${ELLIPSIS} ` : '';
|
|
398
|
+
const trail = windowEnd < stripped.length ? ` ${ELLIPSIS}` : '';
|
|
399
|
+
return `${lead}${body}${trail}`;
|
|
400
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { describe, expect, test } from 'vitest';
|
|
2
|
+
import { getHighlightedSnippet, getHighlightedText, stripDuplicatedTitleLine, } from './highlight.js';
|
|
3
|
+
const PRE = '<mark><b>';
|
|
4
|
+
const POST = '</b></mark>';
|
|
5
|
+
const ELLIPSIS = '…';
|
|
6
|
+
describe('getHighlightedText', () => {
|
|
7
|
+
test('returns escaped text unchanged when query is empty', () => {
|
|
8
|
+
expect(getHighlightedText('Quickstart', '')).toBe('Quickstart');
|
|
9
|
+
expect(getHighlightedText('', 'foo')).toBe('');
|
|
10
|
+
});
|
|
11
|
+
test('returns escaped text unchanged when no match is found', () => {
|
|
12
|
+
expect(getHighlightedText('Quickstart', 'foobar')).toBe('Quickstart');
|
|
13
|
+
});
|
|
14
|
+
test('wraps a case-insensitive match in mark/b tags and preserves original casing', () => {
|
|
15
|
+
expect(getHighlightedText('Quickstart', 'quickstart')).toBe(`${PRE}Quickstart${POST}`);
|
|
16
|
+
});
|
|
17
|
+
test('highlights every occurrence of the query', () => {
|
|
18
|
+
expect(getHighlightedText('search and search again', 'search')).toBe(`${PRE}search${POST} and ${PRE}search${POST} again`);
|
|
19
|
+
});
|
|
20
|
+
test('escapes HTML in the surrounding text', () => {
|
|
21
|
+
expect(getHighlightedText('<script>quickstart</script>', 'quickstart')).toBe(`<script>${PRE}quickstart${POST}</script>`);
|
|
22
|
+
});
|
|
23
|
+
test('highlights matches across a full chunk without truncating', () => {
|
|
24
|
+
const content = Array.from({ length: 200 }, (_, i) => (i === 100 ? 'target' : `w${i}`)).join(' ');
|
|
25
|
+
const result = getHighlightedText(content, 'target');
|
|
26
|
+
expect(result).toContain(`${PRE}target${POST}`);
|
|
27
|
+
expect(result).toContain('w0');
|
|
28
|
+
expect(result).toContain('w199');
|
|
29
|
+
});
|
|
30
|
+
test('highlights multi-word query terms', () => {
|
|
31
|
+
const content = 'Learn how to configure webhooks for your application and receive events';
|
|
32
|
+
const result = getHighlightedText(content, 'configure webhooks');
|
|
33
|
+
expect(result).toContain(`${PRE}configure webhooks${POST}`);
|
|
34
|
+
});
|
|
35
|
+
test('preserves original case in highlighted text', () => {
|
|
36
|
+
const content = 'The TypeScript SDK provides type safety';
|
|
37
|
+
const result = getHighlightedText(content, 'typescript sdk');
|
|
38
|
+
expect(result).toContain(`${PRE}TypeScript SDK${POST}`);
|
|
39
|
+
});
|
|
40
|
+
test('escapes HTML in non-highlighted content to prevent XSS', () => {
|
|
41
|
+
const content = 'Use <script>alert("xss")</script> to test the API endpoint';
|
|
42
|
+
const result = getHighlightedText(content, 'API');
|
|
43
|
+
expect(result).not.toContain('<script>');
|
|
44
|
+
expect(result).toContain('<script>');
|
|
45
|
+
expect(result).toContain(`${PRE}API${POST}`);
|
|
46
|
+
});
|
|
47
|
+
test('respects word boundaries when only padded variations match', () => {
|
|
48
|
+
const result = getHighlightedText('shellofoo and a stray foo here', 'foo bar');
|
|
49
|
+
expect(result).toContain(`stray ${PRE}foo${POST} here`);
|
|
50
|
+
expect(result).not.toContain(`shello${PRE}foo${POST}`);
|
|
51
|
+
});
|
|
52
|
+
test('highlights standalone words when a compound match also appears', () => {
|
|
53
|
+
const result = getHighlightedText('foo bar here, also foo standalone, and bar elsewhere', 'foo bar');
|
|
54
|
+
expect(result).toContain(`${PRE}foo bar${POST}`);
|
|
55
|
+
expect(result).toContain(`also ${PRE}foo${POST} standalone`);
|
|
56
|
+
expect(result).toContain(`and ${PRE}bar${POST} elsewhere`);
|
|
57
|
+
});
|
|
58
|
+
test('highlights an individual query word inside a short text segment', () => {
|
|
59
|
+
expect(getHighlightedText('Webhooks', 'configure webhooks')).toBe(`${PRE}Webhooks${POST}`);
|
|
60
|
+
});
|
|
61
|
+
test('highlights a word prefix when the query is a partial word', () => {
|
|
62
|
+
expect(getHighlightedText('Test the integration', 'tes')).toBe(`${PRE}Tes${POST}t the integration`);
|
|
63
|
+
expect(getHighlightedText('Testing your proxy setup', 'tes')).toBe(`${PRE}Tes${POST}ting your proxy setup`);
|
|
64
|
+
});
|
|
65
|
+
test('highlights the full word when the query matches it exactly', () => {
|
|
66
|
+
expect(getHighlightedText('Test the integration', 'test')).toBe(`${PRE}Test${POST} the integration`);
|
|
67
|
+
});
|
|
68
|
+
test('does not highlight a partial match in the middle of a word', () => {
|
|
69
|
+
expect(getHighlightedText('latest greatest', 'test')).toBe('latest greatest');
|
|
70
|
+
});
|
|
71
|
+
test('strips server-emitted <mark><b> highlight tags before re-highlighting', () => {
|
|
72
|
+
expect(getHighlightedText('<mark><b>Install</b></mark> the Mintlify CLI', 'install')).toBe(`${PRE}Install${POST} the Mintlify CLI`);
|
|
73
|
+
});
|
|
74
|
+
test('strips server highlight tags even when query has no match', () => {
|
|
75
|
+
expect(getHighlightedText('<mark><b>Install</b></mark> the CLI', 'foobar')).toBe('Install the CLI');
|
|
76
|
+
});
|
|
77
|
+
test('strips server highlight tags when query is empty', () => {
|
|
78
|
+
expect(getHighlightedText('<mark><b>Install</b></mark> the CLI', '')).toBe('Install the CLI');
|
|
79
|
+
});
|
|
80
|
+
test('completes within timeout for very long queries', () => {
|
|
81
|
+
const content = 'A '.repeat(1000) + 'needle ' + 'B '.repeat(1000);
|
|
82
|
+
const longQuery = Array.from({ length: 30 }, (_, i) => `word${i}`).join(' ');
|
|
83
|
+
const start = Date.now();
|
|
84
|
+
getHighlightedText(content, longQuery);
|
|
85
|
+
const elapsed = Date.now() - start;
|
|
86
|
+
expect(elapsed).toBeLessThan(500);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
describe('getHighlightedSnippet', () => {
|
|
90
|
+
test('returns escaped text unchanged when query is empty', () => {
|
|
91
|
+
expect(getHighlightedSnippet('Quickstart', '')).toBe('Quickstart');
|
|
92
|
+
expect(getHighlightedSnippet('', 'foo')).toBe('');
|
|
93
|
+
});
|
|
94
|
+
test('returns escaped text unchanged when no match is found', () => {
|
|
95
|
+
expect(getHighlightedSnippet('Quickstart guide', 'foobar')).toBe('Quickstart guide');
|
|
96
|
+
});
|
|
97
|
+
test('does not window when the match is near the start', () => {
|
|
98
|
+
expect(getHighlightedSnippet('Quickstart guide for the API', 'quickstart')).toBe(`${PRE}Quickstart${POST} guide for the API`);
|
|
99
|
+
});
|
|
100
|
+
test('windows around a match buried deep in the content', () => {
|
|
101
|
+
const boilerplate = 'REST Endpoint POST /v1/resources Summary Creates a resource Description This endpoint ';
|
|
102
|
+
const content = `${boilerplate}accepts a customerEmail field in the request body`;
|
|
103
|
+
const result = getHighlightedSnippet(content, 'customerEmail');
|
|
104
|
+
expect(result.startsWith(`${ELLIPSIS} `)).toBe(true);
|
|
105
|
+
expect(result).toContain(`${PRE}customerEmail${POST}`);
|
|
106
|
+
// Leading boilerplate is windowed out of the snippet.
|
|
107
|
+
expect(result).not.toContain('REST Endpoint POST');
|
|
108
|
+
});
|
|
109
|
+
test('keeps preceding context before the match within the leading window', () => {
|
|
110
|
+
const content = 'word '.repeat(40) + 'before target needle after';
|
|
111
|
+
const result = getHighlightedSnippet(content, 'needle');
|
|
112
|
+
expect(result).toContain(`${PRE}needle${POST}`);
|
|
113
|
+
expect(result).toContain('target');
|
|
114
|
+
});
|
|
115
|
+
test('adds a trailing ellipsis when content is windowed at both ends', () => {
|
|
116
|
+
const lead = 'lorem ipsum dolor sit amet '.repeat(5);
|
|
117
|
+
const tail = ' consectetur adipiscing elit sed do eiusmod tempor incididunt'.repeat(10);
|
|
118
|
+
const content = `${lead}MATCHWORD${tail}`;
|
|
119
|
+
const result = getHighlightedSnippet(content, 'matchword');
|
|
120
|
+
expect(result.startsWith(`${ELLIPSIS} `)).toBe(true);
|
|
121
|
+
expect(result.endsWith(` ${ELLIPSIS}`)).toBe(true);
|
|
122
|
+
expect(result).toContain(`${PRE}MATCHWORD${POST}`);
|
|
123
|
+
});
|
|
124
|
+
test('does not add a trailing ellipsis when the match is at the very end', () => {
|
|
125
|
+
const content = 'word '.repeat(40) + 'finalToken';
|
|
126
|
+
const result = getHighlightedSnippet(content, 'finalToken');
|
|
127
|
+
expect(result.startsWith(`${ELLIPSIS} `)).toBe(true);
|
|
128
|
+
expect(result.endsWith(`${PRE}finalToken${POST}`)).toBe(true);
|
|
129
|
+
});
|
|
130
|
+
test('escapes HTML in the windowed snippet', () => {
|
|
131
|
+
const content = 'intro text '.repeat(10) + '<script>danger</script> targetField value';
|
|
132
|
+
const result = getHighlightedSnippet(content, 'targetField');
|
|
133
|
+
expect(result).not.toContain('<script>');
|
|
134
|
+
expect(result).toContain('<script>');
|
|
135
|
+
expect(result).toContain(`${PRE}targetField${POST}`);
|
|
136
|
+
});
|
|
137
|
+
test('caps output length even when the match is near the start', () => {
|
|
138
|
+
const content = 'match ' + 'word '.repeat(400);
|
|
139
|
+
const result = getHighlightedSnippet(content, 'match', { maxLength: 40 });
|
|
140
|
+
expect(result.startsWith(`${PRE}match${POST}`)).toBe(true);
|
|
141
|
+
expect(result.endsWith(` ${ELLIPSIS}`)).toBe(true);
|
|
142
|
+
// No leading ellipsis since the window starts at the beginning.
|
|
143
|
+
expect(result.startsWith(`${ELLIPSIS}`)).toBe(false);
|
|
144
|
+
// Bounded output rather than the full ~2k-char chunk.
|
|
145
|
+
expect(result.length).toBeLessThan(120);
|
|
146
|
+
});
|
|
147
|
+
test('respects a custom leading context', () => {
|
|
148
|
+
const content = 'aaaa bbbb cccc dddd eeee ffff gggg needle tail';
|
|
149
|
+
const result = getHighlightedSnippet(content, 'needle', { leadingContext: 5 });
|
|
150
|
+
expect(result.startsWith(`${ELLIPSIS} `)).toBe(true);
|
|
151
|
+
expect(result).toContain(`${PRE}needle${POST}`);
|
|
152
|
+
expect(result).not.toContain('aaaa');
|
|
153
|
+
});
|
|
154
|
+
test('caps the leading window when there is no query or no match', () => {
|
|
155
|
+
const content = 'word '.repeat(100).trim();
|
|
156
|
+
const noQuery = getHighlightedSnippet(content, '', { maxLength: 40 });
|
|
157
|
+
const noMatch = getHighlightedSnippet(content, 'zzz', { maxLength: 40 });
|
|
158
|
+
for (const result of [noQuery, noMatch]) {
|
|
159
|
+
expect(result.endsWith(` ${ELLIPSIS}`)).toBe(true);
|
|
160
|
+
expect(result.length).toBeLessThan(60);
|
|
161
|
+
expect(result).not.toContain(PRE);
|
|
162
|
+
}
|
|
163
|
+
expect(getHighlightedSnippet('short text', 'zzz', { maxLength: 40 })).toBe('short text');
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
describe('stripDuplicatedTitleLine', () => {
|
|
167
|
+
test('drops a first line that repeats the header', () => {
|
|
168
|
+
expect(stripDuplicatedTitleLine('Install\nRun npm install', 'Install')).toBe('Run npm install');
|
|
169
|
+
expect(stripDuplicatedTitleLine('<mark>Install</mark>\nRun it', 'Install')).toBe('Run it');
|
|
170
|
+
});
|
|
171
|
+
test('leaves content alone otherwise', () => {
|
|
172
|
+
expect(stripDuplicatedTitleLine('Overview\nRun it', 'Install')).toBe('Overview\nRun it');
|
|
173
|
+
expect(stripDuplicatedTitleLine('Install', 'Install')).toBe('Install');
|
|
174
|
+
expect(stripDuplicatedTitleLine('Install\nRun it', '')).toBe('Install\nRun it');
|
|
175
|
+
});
|
|
176
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './highlight.js';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './highlight.js';
|