first commit
This commit is contained in:
commit
b903eb32cf
61 files changed
+17306
No files matched your search
+225
@@ -0,0 +1,225 @@
|
||||
// FlexSearch data plane. Builds the page + section indexes lazily on first
|
||||
// use, runs queries, and returns normalized results. UI lives in
|
||||
// search-dialog.js and consumes this via `window.hextraSearch`.
|
||||
|
||||
// Render the search data as JSON.
|
||||
//
|
||||
//
|
||||
//
|
||||
|
||||
(function () {
|
||||
const searchDataURL = '/en.search-data.json';
|
||||
|
||||
let pageIndex;
|
||||
let sectionIndex;
|
||||
let indexPromise = null;
|
||||
|
||||
/**
|
||||
* Preloads the search index by fetching data and adding it to the FlexSearch index.
|
||||
* @returns {Promise<void>} A promise that resolves when the index is preloaded.
|
||||
*/
|
||||
function preloadIndex() {
|
||||
if (indexPromise) return indexPromise;
|
||||
const attempt = (async () => {
|
||||
const tokenize = 'forward';
|
||||
|
||||
// https://github.com/TryGhost/Ghost/pull/21148
|
||||
const regex = new RegExp(
|
||||
`[\u{4E00}-\u{9FFF}\u{3040}-\u{309F}\u{30A0}-\u{30FF}\u{AC00}-\u{D7A3}\u{3400}-\u{4DBF}\u{20000}-\u{2A6DF}\u{2A700}-\u{2B73F}\u{2B740}-\u{2B81F}\u{2B820}-\u{2CEAF}\u{2CEB0}-\u{2EBEF}\u{30000}-\u{3134F}\u{31350}-\u{323AF}\u{2EBF0}-\u{2EE5F}\u{F900}-\u{FAFF}\u{2F800}-\u{2FA1F}]|[0-9A-Za-zа-яÀ-ſЀ-ӿ-ۿঀ-Ḁ-ỿ-]+`,
|
||||
'mug'
|
||||
);
|
||||
const encode = (str) => { return ('' + str).toLowerCase().match(regex) ?? []; }
|
||||
|
||||
pageIndex = new FlexSearch.Document({
|
||||
tokenize,
|
||||
encode,
|
||||
cache: 100,
|
||||
document: {
|
||||
id: 'id',
|
||||
store: ['title', 'breadcrumbs'],
|
||||
index: "content"
|
||||
}
|
||||
});
|
||||
|
||||
sectionIndex = new FlexSearch.Document({
|
||||
tokenize,
|
||||
encode,
|
||||
cache: 100,
|
||||
document: {
|
||||
id: 'id',
|
||||
store: ['title', 'content', 'url', 'display', 'crumb'],
|
||||
index: "content",
|
||||
tag: [{
|
||||
field: "pageId"
|
||||
}]
|
||||
}
|
||||
});
|
||||
|
||||
const resp = await fetch(searchDataURL);
|
||||
const data = await resp.json();
|
||||
let pageId = 0;
|
||||
for (const route in data) {
|
||||
let pageContent = '';
|
||||
++pageId;
|
||||
const urlParts = route.split('/').filter(x => x !== "" && !x.startsWith('#'));
|
||||
|
||||
let crumb = '';
|
||||
const crumbParts = [];
|
||||
let searchUrl = '/';
|
||||
for (let i = 0; i < urlParts.length; i++) {
|
||||
const urlPart = urlParts[i];
|
||||
searchUrl += urlPart + '/'
|
||||
|
||||
const crumbData = data[searchUrl];
|
||||
if (!crumbData) {
|
||||
console.debug('Excluded page', searchUrl, '- will not be included for search result breadcrumb for', route);
|
||||
continue;
|
||||
}
|
||||
|
||||
let title = data[searchUrl].title;
|
||||
if (title === "_index") {
|
||||
title = urlPart.split("-").map(x => x).join(" ");
|
||||
}
|
||||
crumbParts.push(title);
|
||||
crumb += title;
|
||||
|
||||
if (i < urlParts.length - 1) {
|
||||
crumb += ' > ';
|
||||
}
|
||||
}
|
||||
|
||||
for (const heading in data[route].data) {
|
||||
const separator = heading.indexOf('#');
|
||||
const hash = separator === -1 ? heading : heading.slice(0, separator);
|
||||
const text = separator === -1 ? '' : heading.slice(separator + 1);
|
||||
const url = hash ? `${route}#${hash}` : route;
|
||||
const title = text || data[route].title;
|
||||
|
||||
const content = data[route].data[heading] || '';
|
||||
const paragraphs = content.split('\n').filter(Boolean);
|
||||
|
||||
sectionIndex.add({
|
||||
id: url,
|
||||
url,
|
||||
title,
|
||||
crumb,
|
||||
pageId: `page_${pageId}`,
|
||||
content: title,
|
||||
...(paragraphs[0] && { display: paragraphs[0] })
|
||||
});
|
||||
|
||||
for (let i = 0; i < paragraphs.length; i++) {
|
||||
sectionIndex.add({
|
||||
id: `${url}_${i}`,
|
||||
url,
|
||||
title,
|
||||
crumb,
|
||||
pageId: `page_${pageId}`,
|
||||
content: paragraphs[i]
|
||||
});
|
||||
}
|
||||
|
||||
pageContent += ` ${title} ${content}`;
|
||||
}
|
||||
|
||||
pageIndex.add({
|
||||
id: pageId,
|
||||
title: data[route].title,
|
||||
breadcrumbs: crumbParts.slice(0, -1),
|
||||
content: pageContent
|
||||
});
|
||||
}
|
||||
})();
|
||||
// Clear the cached promise on failure so the next call retries instead of
|
||||
// returning the rejection forever.
|
||||
const wrapped = attempt.catch((err) => {
|
||||
if (indexPromise === wrapped) indexPromise = null;
|
||||
pageIndex = undefined;
|
||||
sectionIndex = undefined;
|
||||
throw err;
|
||||
});
|
||||
indexPromise = wrapped;
|
||||
return indexPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the actual FlexSearch query and return sorted, deduped page groups.
|
||||
* @param {string} query
|
||||
* @returns {Array<{id: string, route: string, title: string, breadcrumbs: string[], matches: Array<{id: string, route: string, title: string, content: string}>}>}
|
||||
*/
|
||||
function performSearch(query) {
|
||||
const maxPageResults = parseInt('20', 10);
|
||||
const maxSectionResults = parseInt('10', 10);
|
||||
const pageResults = pageIndex.search(query, maxPageResults, { enrich: true, suggest: true })[0]?.result || [];
|
||||
|
||||
const groups = [];
|
||||
const pageTitleMatches = {};
|
||||
|
||||
for (let i = 0; i < pageResults.length; i++) {
|
||||
const result = pageResults[i];
|
||||
pageTitleMatches[i] = 0;
|
||||
|
||||
const sectionResults = sectionIndex.search(query,
|
||||
{ enrich: true, suggest: true, tag: { 'pageId': `page_${result.id}` } })[0]?.result || [];
|
||||
const occurred = {};
|
||||
const group = {
|
||||
_page_rk: i,
|
||||
route: '',
|
||||
title: result.doc.title,
|
||||
breadcrumbs: result.doc.breadcrumbs || [],
|
||||
matches: []
|
||||
};
|
||||
|
||||
const nResults = Math.min(sectionResults.length, maxSectionResults);
|
||||
for (let j = 0; j < nResults; j++) {
|
||||
const { doc } = sectionResults[j];
|
||||
const isMatchingTitle = doc.display !== undefined;
|
||||
if (isMatchingTitle) {
|
||||
pageTitleMatches[i]++;
|
||||
}
|
||||
const { url, title } = doc;
|
||||
const content = doc.display || doc.content;
|
||||
|
||||
if (occurred[url + '@' + content]) continue;
|
||||
occurred[url + '@' + content] = true;
|
||||
if (!group.route) group.route = url.split('#')[0];
|
||||
group.matches.push({
|
||||
route: url,
|
||||
title,
|
||||
content
|
||||
});
|
||||
}
|
||||
|
||||
if (group.matches.length) groups.push(group);
|
||||
}
|
||||
|
||||
let optionId = 0;
|
||||
return groups
|
||||
.sort((a, b) => {
|
||||
if (pageTitleMatches[a._page_rk] !== pageTitleMatches[b._page_rk]) {
|
||||
return pageTitleMatches[b._page_rk] - pageTitleMatches[a._page_rk];
|
||||
}
|
||||
return a._page_rk - b._page_rk;
|
||||
})
|
||||
.map((res) => ({
|
||||
id: `hextra-search-opt-${optionId++}`,
|
||||
route: res.route,
|
||||
title: res.title,
|
||||
breadcrumbs: res.breadcrumbs,
|
||||
matches: res.matches.map((match) => ({
|
||||
id: `hextra-search-opt-${optionId++}`,
|
||||
route: match.route,
|
||||
title: match.title,
|
||||
content: match.content
|
||||
}))
|
||||
}));
|
||||
}
|
||||
|
||||
window.hextraSearch = {
|
||||
preload: preloadIndex,
|
||||
async search(query) {
|
||||
await preloadIndex();
|
||||
return performSearch(query);
|
||||
},
|
||||
};
|
||||
})();
|
||||
Reference in new issue
Block a user