Files
meshero.fr/en.search.js
T
2026-11-08 15:17:21 +01:00

226 lines
6.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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);
},
};
})();