Skip to content

Commit a2fba11

Browse files
committed
Deploy PR #1624 preview
1 parent d63a4b8 commit a2fba11

13 files changed

Lines changed: 47873 additions & 0 deletions

docs-preview-1624/assets/main.js

Lines changed: 1080 additions & 0 deletions
Large diffs are not rendered by default.

docs-preview-1624/assets/search-data.json

Lines changed: 1 addition & 0 deletions
Large diffs are not rendered by default.
Lines changed: 398 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,398 @@
1+
const isWordBoundary = (char) =>
2+
/[A-Z]/.test(char) || /[-_\/.]/.test(char) || /\s/.test(char);
3+
4+
const isCaseTransition = (prev, curr) => {
5+
const prevIsUpper = prev.toLowerCase() !== prev;
6+
const currIsUpper = curr.toLowerCase() !== curr;
7+
return (
8+
prevIsUpper && currIsUpper && prev.toLowerCase() !== curr.toLowerCase()
9+
);
10+
};
11+
12+
const findBestSubsequenceMatch = (query, target) => {
13+
const n = query.length;
14+
const m = target.length;
15+
16+
if (n === 0 || m === 0) return null;
17+
18+
const positions = [];
19+
20+
const memo = new Map();
21+
const key = (qIdx, tIdx, gap) => `${qIdx}:${tIdx}:${gap}`;
22+
23+
const findBest = (qIdx, tIdx, currentGap) => {
24+
if (qIdx === n) {
25+
return { done: true, positions: [...positions], gap: currentGap };
26+
}
27+
28+
const memoKey = key(qIdx, tIdx, currentGap);
29+
if (memo.has(memoKey)) {
30+
return memo.get(memoKey);
31+
}
32+
33+
let bestResult = null;
34+
35+
for (let i = tIdx; i < m; i++) {
36+
if (target[i] === query[qIdx]) {
37+
positions.push(i);
38+
const gap = qIdx === 0 ? 0 : i - positions[positions.length - 2] - 1;
39+
const newGap = currentGap + gap;
40+
41+
if (newGap > m) {
42+
positions.pop();
43+
continue;
44+
}
45+
46+
const result = findBest(qIdx + 1, i + 1, newGap);
47+
positions.pop();
48+
49+
if (result && (!bestResult || result.gap < bestResult.gap)) {
50+
bestResult = result;
51+
if (result.gap === 0) break;
52+
}
53+
}
54+
}
55+
56+
memo.set(memoKey, bestResult);
57+
return bestResult;
58+
};
59+
60+
const result = findBest(0, 0, 0);
61+
if (!result) return null;
62+
63+
const consecutive = (() => {
64+
let c = 1;
65+
for (let i = 1; i < result.positions.length; i++) {
66+
if (result.positions[i] === result.positions[i - 1] + 1) {
67+
c++;
68+
}
69+
}
70+
return c;
71+
})();
72+
73+
return {
74+
positions: result.positions,
75+
consecutive,
76+
score: calculateMatchScore(query, target, result.positions, consecutive),
77+
};
78+
};
79+
80+
const calculateMatchScore = (query, target, positions, consecutive) => {
81+
const n = positions.length;
82+
const m = target.length;
83+
84+
if (n === 0) return 0;
85+
86+
let score = 1.0;
87+
88+
const startBonus = (m - positions[0]) / m;
89+
score += startBonus * 0.5;
90+
91+
let gapPenalty = 0;
92+
for (let i = 1; i < n; i++) {
93+
const gap = positions[i] - positions[i - 1] - 1;
94+
if (gap > 0) {
95+
gapPenalty += Math.min(gap / m, 1.0) * 0.3;
96+
}
97+
}
98+
score -= gapPenalty;
99+
100+
const consecutiveBonus = consecutive / n;
101+
score += consecutiveBonus * 0.3;
102+
103+
let boundaryBonus = 0;
104+
for (let i = 0; i < n; i++) {
105+
const char = target[positions[i]];
106+
if (i === 0 || isWordBoundary(char)) {
107+
boundaryBonus += 0.05;
108+
}
109+
if (i > 0) {
110+
const prevChar = target[positions[i - 1]];
111+
if (isCaseTransition(prevChar, char)) {
112+
boundaryBonus += 0.03;
113+
}
114+
}
115+
}
116+
score = Math.min(1.0, score + boundaryBonus);
117+
118+
const lengthPenalty = Math.abs(query.length - n) / Math.max(query.length, m);
119+
score -= lengthPenalty * 0.2;
120+
121+
return Math.max(0, Math.min(1.0, score));
122+
};
123+
124+
const fuzzyMatch = (query, target) => {
125+
const lowerQuery = query.toLowerCase();
126+
const lowerTarget = target.toLowerCase();
127+
128+
if (lowerQuery.length === 0) return null;
129+
if (lowerTarget.length === 0) return null;
130+
131+
if (lowerTarget === lowerQuery) {
132+
return 1.0;
133+
}
134+
135+
if (lowerTarget.includes(lowerQuery)) {
136+
const ratio = lowerQuery.length / lowerTarget.length;
137+
return 0.8 + ratio * 0.2;
138+
}
139+
140+
const match = findBestSubsequenceMatch(lowerQuery, lowerTarget);
141+
if (!match) {
142+
return null;
143+
}
144+
145+
return Math.min(1.0, match.score);
146+
};
147+
148+
const isOptionDocument = (doc) =>
149+
doc?.title?.toLowerCase().startsWith("option: ") ||
150+
doc?.path?.startsWith("options.html#");
151+
152+
const normalizeSearchText = (text) =>
153+
(typeof text === "string" ? text : "")
154+
.toLowerCase()
155+
.replace(/[^a-z0-9]+/g, " ")
156+
.trim()
157+
.replace(/\s+/g, " ");
158+
159+
const anchorSearchText = (anchor) =>
160+
`${anchor?.text || ""} ${anchor?.id || ""}`;
161+
162+
const textMatchInfo = (text, rawQuery, searchTerms) => {
163+
const lowerText = typeof text === "string" ? text.toLowerCase() : "";
164+
const normalizedText = normalizeSearchText(text);
165+
const normalizedQuery = normalizeSearchText(rawQuery);
166+
const exactText = lowerText === rawQuery || normalizedText === normalizedQuery;
167+
const exactPhrase =
168+
lowerText.includes(rawQuery) || normalizedText.includes(normalizedQuery);
169+
const matchedTerms = searchTerms.filter(
170+
(term) => lowerText.includes(term) || normalizedText.includes(term),
171+
);
172+
173+
return {
174+
exactText,
175+
exactPhrase,
176+
matchedTerms,
177+
allTerms:
178+
searchTerms.length > 0 && matchedTerms.length === searchTerms.length,
179+
anyTerm: matchedTerms.length > 0,
180+
};
181+
};
182+
183+
const updateBestRank = (match, rank) => {
184+
match.bestRank = Math.min(match.bestRank, rank);
185+
};
186+
187+
self.onmessage = function (e) {
188+
const { messageId, type, data } = e.data;
189+
190+
const respond = (type, data) => {
191+
self.postMessage({ messageId, type, data });
192+
};
193+
194+
const respondError = (error) => {
195+
self.postMessage({
196+
messageId,
197+
type: "error",
198+
error: error.message || String(error),
199+
});
200+
};
201+
202+
try {
203+
if (type === "tokenize") {
204+
const text = typeof data === "string" ? data : "";
205+
const words = text.toLowerCase().match(/\b[a-zA-Z0-9_-]+\b/g) || [];
206+
const tokens = words.filter((word) => word.length > 2);
207+
const uniqueTokens = Array.from(new Set(tokens));
208+
respond("tokens", uniqueTokens);
209+
} else if (type === "search") {
210+
const { query, limit = 10 } = data;
211+
212+
if (!query || typeof query !== "string") {
213+
respond("results", []);
214+
return;
215+
}
216+
217+
const rawQuery = query.toLowerCase();
218+
const text = typeof query === "string" ? query : "";
219+
const words = text.toLowerCase().match(/\b[a-zA-Z0-9_-]+\b/g) || [];
220+
const searchTerms = words.filter((word) => word.length > 2);
221+
222+
let documents = [];
223+
if (typeof data.documents === "string") {
224+
documents = JSON.parse(data.documents);
225+
} else if (Array.isArray(data.documents)) {
226+
documents = data.documents;
227+
} else if (typeof data.transferables === "string") {
228+
documents = JSON.parse(data.transferables);
229+
}
230+
231+
if (!Array.isArray(documents) || documents.length === 0) {
232+
respond("results", []);
233+
return;
234+
}
235+
236+
const useFuzzySearch = rawQuery.length >= 3;
237+
238+
if (searchTerms.length === 0 && rawQuery.length < 3) {
239+
respond("results", []);
240+
return;
241+
}
242+
243+
const pageMatches = new Map();
244+
245+
// Pre-compute lower-case strings for each document
246+
const processedDocs = documents.map((doc, docId) => {
247+
const title = typeof doc.title === "string" ? doc.title : "";
248+
const content = typeof doc.content === "string" ? doc.content : "";
249+
250+
return {
251+
docId,
252+
doc,
253+
lowerTitle: title.toLowerCase(),
254+
lowerContent: content.toLowerCase(),
255+
lowerAnchors: Array.isArray(doc.anchors)
256+
? doc.anchors
257+
.map((anchor) => normalizeSearchText(anchorSearchText(anchor)))
258+
.join(" ")
259+
: "",
260+
};
261+
});
262+
263+
// First pass, only docs containing at least one search term
264+
processedDocs.forEach(({
265+
docId,
266+
doc,
267+
lowerTitle,
268+
lowerContent,
269+
lowerAnchors,
270+
}) => {
271+
const normalizedQuery = normalizeSearchText(rawQuery);
272+
const hasRelevantToken =
273+
lowerTitle.includes(rawQuery) ||
274+
lowerContent.includes(rawQuery) ||
275+
lowerAnchors.includes(normalizedQuery) ||
276+
searchTerms.some(
277+
(term) =>
278+
lowerTitle.includes(term) ||
279+
lowerContent.includes(term) ||
280+
lowerAnchors.includes(term),
281+
);
282+
if (!hasRelevantToken) return;
283+
284+
let match = pageMatches.get(docId);
285+
if (!match) {
286+
match = { doc, pageScore: 0, matchingAnchors: [], bestRank: 99 };
287+
pageMatches.set(docId, match);
288+
}
289+
290+
const titleMatch = textMatchInfo(lowerTitle, rawQuery, searchTerms);
291+
const contentMatch = textMatchInfo(lowerContent, rawQuery, searchTerms);
292+
293+
if (titleMatch.exactText) {
294+
match.pageScore += 300;
295+
updateBestRank(match, isOptionDocument(doc) ? 3 : 0);
296+
} else if (titleMatch.exactPhrase) {
297+
match.pageScore += 200;
298+
updateBestRank(match, isOptionDocument(doc) ? 4 : 1);
299+
} else if (titleMatch.allTerms) {
300+
match.pageScore += 100;
301+
updateBestRank(match, isOptionDocument(doc) ? 5 : 2);
302+
} else if (titleMatch.anyTerm) {
303+
match.pageScore += titleMatch.matchedTerms.length * 10;
304+
updateBestRank(match, isOptionDocument(doc) ? 8 : 6);
305+
}
306+
307+
if (contentMatch.exactPhrase) {
308+
match.pageScore += 30;
309+
updateBestRank(match, isOptionDocument(doc) ? 9 : 7);
310+
} else if (contentMatch.allTerms) {
311+
match.pageScore += 15;
312+
updateBestRank(match, isOptionDocument(doc) ? 10 : 8);
313+
} else if (contentMatch.anyTerm) {
314+
match.pageScore += contentMatch.matchedTerms.length * 3;
315+
updateBestRank(match, isOptionDocument(doc) ? 11 : 9);
316+
}
317+
318+
if (
319+
isOptionDocument(doc) &&
320+
!titleMatch.exactPhrase &&
321+
!titleMatch.anyTerm
322+
) {
323+
match.pageScore *= 0.25;
324+
}
325+
});
326+
327+
// Second pass: Find matching anchors
328+
pageMatches.forEach((match) => {
329+
const doc = match.doc;
330+
if (
331+
!doc.anchors ||
332+
!Array.isArray(doc.anchors) ||
333+
doc.anchors.length === 0
334+
) {
335+
return;
336+
}
337+
338+
doc.anchors.forEach((anchor) => {
339+
if (!anchor || !anchor.text) return;
340+
341+
const anchorText = anchorSearchText(anchor).toLowerCase();
342+
const anchorMatch = textMatchInfo(anchorText, rawQuery, searchTerms);
343+
let anchorMatches = false;
344+
345+
if (anchorMatch.exactPhrase || anchorMatch.allTerms) {
346+
anchorMatches = true;
347+
} else if (useFuzzySearch) {
348+
const fuzzyScore = fuzzyMatch(rawQuery, anchorText);
349+
if (fuzzyScore !== null && fuzzyScore >= 0.8) {
350+
anchorMatches = true;
351+
}
352+
}
353+
354+
if (!anchorMatches) {
355+
searchTerms.forEach((term) => {
356+
if (anchorText.includes(term)) {
357+
anchorMatches = true;
358+
}
359+
});
360+
}
361+
362+
if (anchorMatches) {
363+
match.matchingAnchors.push(anchor);
364+
365+
if (anchorMatch.exactText) {
366+
match.pageScore += 300;
367+
updateBestRank(match, 0);
368+
} else if (anchorMatch.exactPhrase) {
369+
match.pageScore += 200;
370+
updateBestRank(match, 1);
371+
} else if (anchorMatch.allTerms) {
372+
match.pageScore += 100;
373+
updateBestRank(match, 2);
374+
} else {
375+
match.pageScore += 10;
376+
updateBestRank(match, 6);
377+
}
378+
}
379+
});
380+
});
381+
382+
const results = Array.from(pageMatches.values())
383+
.filter((m) => m.pageScore > 5)
384+
.sort((a, b) => {
385+
if (a.bestRank !== b.bestRank) return a.bestRank - b.bestRank;
386+
if (b.pageScore !== a.pageScore) return b.pageScore - a.pageScore;
387+
return (
388+
Number(isOptionDocument(a.doc)) - Number(isOptionDocument(b.doc))
389+
);
390+
})
391+
.slice(0, limit);
392+
393+
respond("results", results);
394+
}
395+
} catch (error) {
396+
respondError(error);
397+
}
398+
};

0 commit comments

Comments
 (0)