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 = `

${page.frontmatter.title || page.title}

${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 = /([\s\S]*?<\/a>)<\/h\1>/gi; const headingContentRegex = /([\s\S]*?)<\/span><\/a>/i; const ignoreHeadingRegex = /]*>[\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 };