362 lines
11 KiB
JavaScript
362 lines
11 KiB
JavaScript
import MiniSearch from "minisearch";
|
|
import pMap from "p-map";
|
|
import { colors, getDirname, logger, path } from "vuepress/utils";
|
|
import { addViteOptimizeDepsInclude, getFullLocaleConfig } from "@vuepress/helper";
|
|
import chokidar from "chokidar";
|
|
|
|
export * from "../shared/index.js"
|
|
|
|
//#region src/node/prepareSearchIndex.ts
|
|
const SEARCH_INDEX_DIR = "internal/minisearchIndex/";
|
|
const indexByLocales = /* @__PURE__ */ new Map();
|
|
const indexCache = /* @__PURE__ */ new Map();
|
|
function getIndexByLocale(locale, options) {
|
|
let index = indexByLocales.get(locale);
|
|
if (!index) {
|
|
index = new MiniSearch({
|
|
fields: [
|
|
"title",
|
|
"titles",
|
|
"text"
|
|
],
|
|
storeFields: ["title", "titles"],
|
|
...options.miniSearch?.options
|
|
});
|
|
indexByLocales.set(locale, index);
|
|
}
|
|
return index;
|
|
}
|
|
function getIndexCache(filepath) {
|
|
let index = indexCache.get(filepath);
|
|
if (!index) {
|
|
index = [];
|
|
indexCache.set(filepath, index);
|
|
}
|
|
return index;
|
|
}
|
|
async function prepareSearchIndex({ app, isSearchable, searchOptions }) {
|
|
const start = performance.now();
|
|
indexByLocales.clear();
|
|
indexCache.clear();
|
|
const pages = isSearchable ? app.pages.filter(isSearchable) : app.pages;
|
|
await pMap(pages, (p) => indexFile(p, searchOptions), { concurrency: 64 });
|
|
await writeTemp(app);
|
|
if (app.env.isDebug) logger.info(`\n[${colors.green("@vuepress-plume/plugin-search")}] prepare search time spent: ${(performance.now() - start).toFixed(2)}ms`);
|
|
}
|
|
async function onSearchIndexUpdated(filepath, { app, isSearchable, searchOptions }) {
|
|
if ((isSearchable ? app.pages.filter(isSearchable) : app.pages).some((p) => p.filePathRelative?.endsWith(filepath))) {
|
|
await indexFile(app.pages.find((p) => p.filePathRelative?.endsWith(filepath)), searchOptions);
|
|
await writeTemp(app);
|
|
}
|
|
}
|
|
async function onSearchIndexRemoved(filepath, { app, isSearchable, searchOptions }) {
|
|
if ((isSearchable ? app.pages.filter(isSearchable) : app.pages).some((p) => p.filePathRelative?.endsWith(filepath))) {
|
|
const page = app.pages.find((p) => p.filePathRelative?.endsWith(filepath));
|
|
const fileId = page.path;
|
|
const locale = page.pathLocale;
|
|
const index = getIndexByLocale(locale, searchOptions);
|
|
const cache = getIndexCache(fileId);
|
|
if (cache && cache.length) index.removeAll(cache);
|
|
await writeTemp(app);
|
|
}
|
|
}
|
|
async function writeTemp(app) {
|
|
const records = [];
|
|
for (const [locale] of indexByLocales) {
|
|
const index = indexByLocales.get(locale);
|
|
const filename = `searchBox-${locale.replace(/^\/|\/$/g, "").replace(/\//g, "_") || "default"}.js`;
|
|
records.push(`${JSON.stringify(locale)}: () => import('@${SEARCH_INDEX_DIR}${filename}')`);
|
|
await app.writeTemp(`${SEARCH_INDEX_DIR}${filename}`, `export default ${JSON.stringify(JSON.stringify(index) ?? {})}`);
|
|
}
|
|
await app.writeTemp(`${SEARCH_INDEX_DIR}index.js`, `export const searchIndex = {${records.join(",")}}${app.env.isDev ? `\n${genHmrCode("searchIndex")}` : ""}`);
|
|
}
|
|
async function indexFile(page, options) {
|
|
if (!page.filePath) return;
|
|
if (page.frontmatter?.search === false) return;
|
|
const fileId = page.path;
|
|
const locale = page.pathLocale;
|
|
const index = getIndexByLocale(locale, options);
|
|
const cache = getIndexCache(fileId);
|
|
const html = `<h1><a href="#"><span>${page.frontmatter.title || page.title}</span></a></h1>
|
|
${page.contentRendered}`;
|
|
const sections = splitPageIntoSections(html);
|
|
if (cache && cache.length) index.removeAll(cache);
|
|
for await (const section of sections) {
|
|
if (!section || !(section.text || section.titles)) break;
|
|
const { anchor, text, titles } = section;
|
|
const id = anchor ? [fileId, anchor].join("#") : fileId;
|
|
if (index.has(id)) if (anchor) logger.error(`${colors.green("[@vuepress-plume/plugin-search]")} duplicate heading anchor : ${colors.cyan(titles.join(" >> "))} \n at ${colors.cyan(fileId)}`);
|
|
else logger.error(`${colors.green("[@vuepress-plume/plugin-search]")} duplicate page permalink : ${colors.cyan(fileId)}`);
|
|
else {
|
|
const item = {
|
|
id,
|
|
text,
|
|
title: titles.at(-1),
|
|
titles: titles.slice(0, -1)
|
|
};
|
|
index.add(item);
|
|
cache.push(item);
|
|
}
|
|
}
|
|
}
|
|
const headingRegex = /<h(\d*).*?>(<a.*? href="#.*?".*?>[\s\S]*?<\/a>)<\/h\1>/gi;
|
|
const headingContentRegex = /<a.*? href="#(.*?)".*?><span>([\s\S]*?)<\/span><\/a>/i;
|
|
const ignoreHeadingRegex = /<template[^>]*>[\s\S]*<\/template>/gi;
|
|
/**
|
|
* Splits HTML into sections based on headings
|
|
*/
|
|
function* splitPageIntoSections(html) {
|
|
const result = html.split(headingRegex);
|
|
result.shift();
|
|
let parentTitles = [];
|
|
for (let i = 0; i < result.length; i += 3) {
|
|
const level = Number.parseInt(result[i]) - 1;
|
|
const heading = result[i + 1];
|
|
const headingResult = headingContentRegex.exec(heading);
|
|
const title = clearHtmlTags(headingResult?.[2] ?? "").trim();
|
|
const anchor = headingResult?.[1] ?? "";
|
|
const content = result[i + 2];
|
|
if (!title || !content) continue;
|
|
if (level === 0) parentTitles = [title];
|
|
else parentTitles[level] = title;
|
|
let titles = parentTitles.slice(0, level);
|
|
titles[level] = title;
|
|
titles = titles.filter(Boolean);
|
|
yield {
|
|
anchor,
|
|
titles,
|
|
text: getSearchableText(content)
|
|
};
|
|
}
|
|
}
|
|
function getSearchableText(content) {
|
|
content = clearHtmlTags(content);
|
|
return content;
|
|
}
|
|
function clearHtmlTags(str) {
|
|
str = str.replace(ignoreHeadingRegex, "");
|
|
return str.replace(/<[^>]*>/g, "");
|
|
}
|
|
function genHmrCode(m) {
|
|
const func = `update${m[0].toUpperCase()}${m.slice(1)}`;
|
|
return `
|
|
if (import.meta.webpackHot) {
|
|
import.meta.webpackHot.accept()
|
|
if (__VUE_HMR_RUNTIME__.${m}) {
|
|
__VUE_HMR_RUNTIME__.${func}(${m})
|
|
}
|
|
}
|
|
|
|
if (import.meta.hot) {
|
|
import.meta.hot.accept(({ ${m} }) => {
|
|
__VUE_HMR_RUNTIME__.${func}(${m})
|
|
})
|
|
}
|
|
`;
|
|
}
|
|
|
|
//#endregion
|
|
//#region src/node/locales/de.ts
|
|
const deSearchLocale = {
|
|
placeholder: "Dokumente durchsuchen",
|
|
resetButtonTitle: "Suche zurücksetzen",
|
|
backButtonTitle: "Schließen",
|
|
noResultsText: "Keine Suchergebnisse:",
|
|
footer: {
|
|
selectText: "Auswählen",
|
|
selectKeyAriaLabel: "Eingabe",
|
|
navigateText: "Wechseln",
|
|
navigateUpKeyAriaLabel: "Nach oben",
|
|
navigateDownKeyAriaLabel: "Nach unten",
|
|
closeText: "Schließen",
|
|
closeKeyAriaLabel: "Beenden"
|
|
}
|
|
};
|
|
|
|
//#endregion
|
|
//#region src/node/locales/en.ts
|
|
const enSearchLocale = {
|
|
placeholder: "Search",
|
|
resetButtonTitle: "Reset search",
|
|
backButtonTitle: "Close search",
|
|
noResultsText: "No results for",
|
|
footer: {
|
|
selectText: "to select",
|
|
selectKeyAriaLabel: "enter",
|
|
navigateText: "to navigate",
|
|
navigateUpKeyAriaLabel: "up arrow",
|
|
navigateDownKeyAriaLabel: "down arrow",
|
|
closeText: "to close",
|
|
closeKeyAriaLabel: "escape"
|
|
}
|
|
};
|
|
|
|
//#endregion
|
|
//#region src/node/locales/fr.ts
|
|
const frSearchLocale = {
|
|
placeholder: "Rechercher dans la documentation",
|
|
resetButtonTitle: "Réinitialiser la recherche",
|
|
backButtonTitle: "Fermer",
|
|
noResultsText: "Aucun résultat trouvé :",
|
|
footer: {
|
|
selectText: "sélectionner",
|
|
selectKeyAriaLabel: "Entrée",
|
|
navigateText: "naviguer",
|
|
navigateUpKeyAriaLabel: "haut",
|
|
navigateDownKeyAriaLabel: "bas",
|
|
closeText: "fermer",
|
|
closeKeyAriaLabel: "sortie"
|
|
}
|
|
};
|
|
|
|
//#endregion
|
|
//#region src/node/locales/ja.ts
|
|
const jaSearchLocale = {
|
|
placeholder: "ドキュメントを検索",
|
|
resetButtonTitle: "検索をリセット",
|
|
backButtonTitle: "閉じる",
|
|
noResultsText: "検索結果がありません:",
|
|
footer: {
|
|
selectText: "選択",
|
|
selectKeyAriaLabel: "入力",
|
|
navigateText: "切り替え",
|
|
navigateUpKeyAriaLabel: "上へ",
|
|
navigateDownKeyAriaLabel: "下へ",
|
|
closeText: "閉じる",
|
|
closeKeyAriaLabel: "終了"
|
|
}
|
|
};
|
|
|
|
//#endregion
|
|
//#region src/node/locales/ru.ts
|
|
const ruSearchLocale = {
|
|
placeholder: "Поиск по документации",
|
|
resetButtonTitle: "Сбросить поиск",
|
|
backButtonTitle: "Закрыть",
|
|
noResultsText: "Нет результатов поиска:",
|
|
footer: {
|
|
selectText: "Выбрать",
|
|
selectKeyAriaLabel: "Ввод",
|
|
navigateText: "Переключить",
|
|
navigateUpKeyAriaLabel: "Вверх",
|
|
navigateDownKeyAriaLabel: "Вниз",
|
|
closeText: "Закрыть",
|
|
closeKeyAriaLabel: "Выход"
|
|
}
|
|
};
|
|
|
|
//#endregion
|
|
//#region src/node/locales/zh-tw.ts
|
|
const zhTwSearchLocale = {
|
|
placeholder: "搜索文檔",
|
|
resetButtonTitle: "重置搜索",
|
|
backButtonTitle: "關閉",
|
|
noResultsText: "無搜索結果:",
|
|
footer: {
|
|
selectText: "選擇",
|
|
selectKeyAriaLabel: "輸入",
|
|
navigateText: "切換",
|
|
navigateUpKeyAriaLabel: "向上",
|
|
navigateDownKeyAriaLabel: "向下",
|
|
closeText: "關閉",
|
|
closeKeyAriaLabel: "退出"
|
|
}
|
|
};
|
|
|
|
//#endregion
|
|
//#region src/node/locales/zh.ts
|
|
const zhSearchLocale = {
|
|
placeholder: "搜索文档",
|
|
resetButtonTitle: "重置搜索",
|
|
backButtonTitle: "关闭",
|
|
noResultsText: "无搜索结果:",
|
|
footer: {
|
|
selectText: "选择",
|
|
selectKeyAriaLabel: "输入",
|
|
navigateText: "切换",
|
|
navigateUpKeyAriaLabel: "向上",
|
|
navigateDownKeyAriaLabel: "向下",
|
|
closeText: "关闭",
|
|
closeKeyAriaLabel: "退出"
|
|
}
|
|
};
|
|
|
|
//#endregion
|
|
//#region src/node/locales/index.ts
|
|
const SEARCH_LOCALES = [
|
|
[["en", "en-US"], enSearchLocale],
|
|
[[
|
|
"zh",
|
|
"zh-CN",
|
|
"zh-Hans",
|
|
"zh-Hant"
|
|
], zhSearchLocale],
|
|
[["zh-TW"], zhTwSearchLocale],
|
|
[["de", "de-DE"], deSearchLocale],
|
|
[["fr", "fr-FR"], frSearchLocale],
|
|
[["ru", "ru-RU"], ruSearchLocale],
|
|
[["ja", "ja-JP"], jaSearchLocale]
|
|
];
|
|
|
|
//#endregion
|
|
//#region src/node/searchPlugin.ts
|
|
const __dirname = getDirname(import.meta.url);
|
|
function searchPlugin({ locales = {}, isSearchable,...searchOptions } = {}) {
|
|
return (app) => ({
|
|
name: "@vuepress-plume/plugin-search",
|
|
clientConfigFile: path.resolve(__dirname, "../client/config.js"),
|
|
define: {
|
|
__SEARCH_LOCALES__: getFullLocaleConfig({
|
|
app,
|
|
name: "@vuepress-plume/plugin-search",
|
|
default: SEARCH_LOCALES,
|
|
config: locales
|
|
}),
|
|
__SEARCH_OPTIONS__: searchOptions
|
|
},
|
|
extendsBundlerOptions(bundlerOptions) {
|
|
addViteOptimizeDepsInclude(bundlerOptions, app, [
|
|
"mark.js/src/vanilla.js",
|
|
"@vueuse/integrations/useFocusTrap",
|
|
"minisearch"
|
|
]);
|
|
},
|
|
onPrepared: (app$1) => prepareSearchIndex({
|
|
app: app$1,
|
|
isSearchable,
|
|
searchOptions
|
|
}),
|
|
onWatched: (app$1, watchers) => {
|
|
const searchIndexWatcher = chokidar.watch("pages", {
|
|
cwd: app$1.dir.temp(),
|
|
ignoreInitial: true,
|
|
ignored: (filepath, stats) => Boolean(stats?.isFile()) && !filepath.endsWith(".js")
|
|
});
|
|
searchIndexWatcher.on("add", (filepath) => {
|
|
onSearchIndexUpdated(filepath, {
|
|
app: app$1,
|
|
isSearchable,
|
|
searchOptions
|
|
});
|
|
});
|
|
searchIndexWatcher.on("change", (filepath) => {
|
|
onSearchIndexUpdated(filepath, {
|
|
app: app$1,
|
|
isSearchable,
|
|
searchOptions
|
|
});
|
|
});
|
|
searchIndexWatcher.on("unlink", (filepath) => {
|
|
onSearchIndexRemoved(filepath, {
|
|
app: app$1,
|
|
isSearchable,
|
|
searchOptions
|
|
});
|
|
});
|
|
watchers.push(searchIndexWatcher);
|
|
}
|
|
});
|
|
}
|
|
|
|
//#endregion
|
|
export { prepareSearchIndex, searchPlugin }; |