diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 1ee18adc..bcf51c60 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -47,11 +47,13 @@ export default defineConfig({ copyright: 'Copyright © 2019-present Evan You' }, - algolia: { - appId: '8J64VVRP8K', - apiKey: 'a18e2f4cc5665f6602c5631fd868adfd', - indexName: 'vitepress' - }, + // algolia: { + // appId: '8J64VVRP8K', + // apiKey: 'a18e2f4cc5665f6602c5631fd868adfd', + // indexName: 'vitepress' + // }, + + offlineSearch: true, carbonAds: { code: 'CEBDT27Y', @@ -209,7 +211,7 @@ function sidebarReference() { link: '/reference/default-theme-last-updated' }, { - text: 'Algolia Search', + text: 'Search', link: '/reference/default-theme-search' }, { diff --git a/docs/public/search.png b/docs/public/search.png new file mode 100644 index 00000000..d68b85c4 Binary files /dev/null and b/docs/public/search.png differ diff --git a/docs/reference/default-theme-search.md b/docs/reference/default-theme-search.md index 26b77a29..4a549932 100644 --- a/docs/reference/default-theme-search.md +++ b/docs/reference/default-theme-search.md @@ -1,5 +1,59 @@ # Search +## Offline Search + +VitePress supports fuzzy full-text search using a in-browser index thanks to [minisearch](https://github.com/lucaong/minisearch/). You can enable it in your `.vitepress/config.ts` with the `offlineSearch` theme config: + +```ts +import { defineConfig } from 'vitepress' + +export default defineConfig({ + themeConfig: { + offlineSearch: true + } +}) +``` + +Example result: + +![screenshot of the search modal](/search.png) + +Alternatively, you can use [Algolia DocSearch](#algolia-search) or some community plugins like or . +### i18n + +You can use a config like this to use multilingual search: + +```ts +import { defineConfig } from 'vitepress' + +export default defineConfig({ + themeConfig: { + offlineSearch: { + locales: { + zh: { + translations: { + button: { + buttonText: '搜索文档', + buttonAriaLabel: '搜索文档' + }, + modal: { + noResultsText: '无法找到相关结果', + resetButtonTitle: '清除查询条件', + footer: { + selectText: '选择', + navigateText: '切换' + } + } + } + } + } + } + } +}) +``` + +## Algolia Search + VitePress supports searching your docs site using [Algolia DocSearch](https://docsearch.algolia.com/docs/what-is-docsearch). Refer their getting started guide. In your `.vitepress/config.ts` you'll need to provide at least the following to make it work: ```ts @@ -16,9 +70,7 @@ export default defineConfig({ }) ``` -If you are not eligible for DocSearch, you might wanna use some community plugins like or explore some custom solutions on [this GitHub thread](https://github.com/vuejs/vitepress/issues/670). - -## i18n +### i18n You can use a config like this to use multilingual search: diff --git a/package.json b/package.json index 1898a58f..2e641bdc 100644 --- a/package.json +++ b/package.json @@ -93,6 +93,7 @@ "@vue/devtools-api": "^6.5.0", "@vueuse/core": "^9.13.0", "body-scroll-lock": "4.0.0-beta.0", + "minisearch": "^6.0.1", "shiki": "^0.14.1", "vite": "^4.2.1", "vue": "^3.2.47" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d934109b..687082b0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22,6 +22,9 @@ importers: body-scroll-lock: specifier: 4.0.0-beta.0 version: 4.0.0-beta.0 + minisearch: + specifier: ^6.0.1 + version: 6.0.1 shiki: specifier: ^0.14.1 version: 0.14.1 @@ -3136,6 +3139,10 @@ packages: engines: {node: '>=8'} dev: true + /minisearch@6.0.1: + resolution: {integrity: sha512-Ly1w0nHKnlhAAh6/BF/+9NgzXfoJxaJ8nhopFhQ3NcvFJrFIL+iCg9gw9e9UMBD+XIsp/RyznJ/o5UIe5Kw+kg==} + dev: false + /mlly@1.2.0: resolution: {integrity: sha512-+c7A3CV0KGdKcylsI6khWyts/CYrGTrRVo4R/I7u/cUsy0Conxa6LUhiEzVKIw14lc2L5aiO4+SeVe4TeGRKww==} dependencies: diff --git a/src/client/shim.d.ts b/src/client/shim.d.ts index 19543f62..4fad996e 100644 --- a/src/client/shim.d.ts +++ b/src/client/shim.d.ts @@ -20,3 +20,8 @@ declare module '@theme/index' { const theme: Theme export default theme } + +declare module '@offlineSearchIndex' { + const data: Record Promise<{ default: string }>> + export default data +} diff --git a/src/client/theme-default/components/VPNavBarSearch.vue b/src/client/theme-default/components/VPNavBarSearch.vue index 70d1f63e..585ba3d5 100644 --- a/src/client/theme-default/components/VPNavBarSearch.vue +++ b/src/client/theme-default/components/VPNavBarSearch.vue @@ -1,5 +1,6 @@ @@ -154,178 +154,6 @@ function poll() { } } -.DocSearch { - --docsearch-primary-color: var(--vp-c-brand); - --docsearch-highlight-color: var(--docsearch-primary-color); - --docsearch-text-color: var(--vp-c-text-1); - --docsearch-muted-color: var(--vp-c-text-2); - --docsearch-searchbox-shadow: none; - --docsearch-searchbox-focus-background: transparent; - --docsearch-key-gradient: transparent; - --docsearch-key-shadow: none; - --docsearch-modal-background: var(--vp-c-bg-soft); - --docsearch-footer-background: var(--vp-c-bg); -} - -.dark .DocSearch { - --docsearch-modal-shadow: none; - --docsearch-footer-shadow: none; - --docsearch-logo-color: var(--vp-c-text-2); - --docsearch-hit-background: var(--vp-c-bg-soft-mute); - --docsearch-hit-color: var(--vp-c-text-2); - --docsearch-hit-shadow: none; -} - -.DocSearch-Button { - display: flex; - justify-content: center; - align-items: center; - margin: 0; - padding: 0; - width: 32px; - height: 55px; - background: transparent; - transition: border-color 0.25s; -} - -.DocSearch-Button:hover { - background: transparent; -} - -.DocSearch-Button:focus { - outline: 1px dotted; - outline: 5px auto -webkit-focus-ring-color; -} - -.DocSearch-Button:focus:not(:focus-visible) { - outline: none !important; -} - -@media (min-width: 768px) { - .DocSearch-Button { - justify-content: flex-start; - border: 1px solid transparent; - border-radius: 8px; - padding: 0 10px 0 12px; - width: 100%; - height: 40px; - background-color: var(--vp-c-bg-alt); - } - - .DocSearch-Button:hover { - border-color: var(--vp-c-brand); - background: var(--vp-c-bg-alt); - } -} - -.DocSearch-Button .DocSearch-Button-Container { - display: flex; - align-items: center; -} - -.DocSearch-Button .DocSearch-Search-Icon { - position: relative; - width: 16px; - height: 16px; - color: var(--vp-c-text-1); - fill: currentColor; - transition: color 0.5s; -} - -.DocSearch-Button:hover .DocSearch-Search-Icon { - color: var(--vp-c-text-1); -} - -@media (min-width: 768px) { - .DocSearch-Button .DocSearch-Search-Icon { - top: 1px; - margin-right: 8px; - width: 14px; - height: 14px; - color: var(--vp-c-text-2); - } -} - -.DocSearch-Button .DocSearch-Button-Placeholder { - display: none; - margin-top: 2px; - padding: 0 16px 0 0; - font-size: 13px; - font-weight: 500; - color: var(--vp-c-text-2); - transition: color 0.5s; -} - -.DocSearch-Button:hover .DocSearch-Button-Placeholder { - color: var(--vp-c-text-1); -} - -@media (min-width: 768px) { - .DocSearch-Button .DocSearch-Button-Placeholder { - display: inline-block; - } -} - -.DocSearch-Button .DocSearch-Button-Keys { - /*rtl:ignore*/ - direction: ltr; - display: none; - min-width: auto; -} - -@media (min-width: 768px) { - .DocSearch-Button .DocSearch-Button-Keys { - display: flex; - align-items: center; - } -} - -.DocSearch-Button .DocSearch-Button-Key { - display: block; - margin: 2px 0 0 0; - border: 1px solid var(--vp-c-divider); - /*rtl:begin:ignore*/ - border-right: none; - border-radius: 4px 0 0 4px; - padding-left: 6px; - /*rtl:end:ignore*/ - min-width: 0; - width: auto; - height: 22px; - line-height: 22px; - font-family: var(--vp-font-family-base); - font-size: 12px; - font-weight: 500; - transition: color 0.5s, border-color 0.5s; -} - -.DocSearch-Button .DocSearch-Button-Key + .DocSearch-Button-Key { - /*rtl:begin:ignore*/ - border-right: 1px solid var(--vp-c-divider); - border-left: none; - border-radius: 0 4px 4px 0; - padding-left: 2px; - padding-right: 6px; - /*rtl:end:ignore*/ -} - -.DocSearch-Button .DocSearch-Button-Key:first-child { - font-size: 1px; - letter-spacing: -12px; - color: transparent; -} - -.DocSearch-Button .DocSearch-Button-Key:first-child:after { - content: v-bind(metaKey); - font-size: 12px; - letter-spacing: normal; - color: var(--docsearch-muted-color); -} - -.DocSearch-Button .DocSearch-Button-Key:first-child > * { - display: none; -} - .dark .DocSearch-Footer { border-top: 1px solid var(--vp-c-divider); } diff --git a/src/client/theme-default/components/VPNavBarSearchButton.vue b/src/client/theme-default/components/VPNavBarSearchButton.vue new file mode 100644 index 00000000..96cf7437 --- /dev/null +++ b/src/client/theme-default/components/VPNavBarSearchButton.vue @@ -0,0 +1,222 @@ + + + + + diff --git a/src/client/theme-default/components/VPOfflineSearchBox.vue b/src/client/theme-default/components/VPOfflineSearchBox.vue new file mode 100644 index 00000000..c59c2562 --- /dev/null +++ b/src/client/theme-default/components/VPOfflineSearchBox.vue @@ -0,0 +1,635 @@ + + + + + diff --git a/src/client/theme-default/styles/vars.css b/src/client/theme-default/styles/vars.css index f68f3923..66536074 100644 --- a/src/client/theme-default/styles/vars.css +++ b/src/client/theme-default/styles/vars.css @@ -97,6 +97,9 @@ --vp-c-mute-lighter: #ffffff; --vp-c-mute-dark: #e3e3e5; --vp-c-mute-darker: #d7d7d9; + + --vp-c-highlight-bg: var(--vp-c-yellow-lighter); + --vp-c-highlight-text: var(--vp-c-black); } .dark { diff --git a/src/client/theme-default/support/translation.ts b/src/client/theme-default/support/translation.ts new file mode 100644 index 00000000..607d5db3 --- /dev/null +++ b/src/client/theme-default/support/translation.ts @@ -0,0 +1,60 @@ +import { useData } from '../composables/data' + +/** + * @param themeObject Can be an object with `translations` and `locales` properties + */ +export function createTranslate( + themeObject: any, + defaultTranslations: Record +): (key: string) => string { + const { localeIndex } = useData() + + function translate(key: string): string { + const keyPath = key.split('.') + + const isObject = themeObject && typeof themeObject === 'object' + const locales = + (isObject && themeObject.locales?.[localeIndex.value]?.translations) || + null + const translations = (isObject && themeObject.translations) || null + + let localeResult: Record | null = locales + let translationResult: Record | null = translations + let defaultResult: Record | null = defaultTranslations + + const lastKey = keyPath.pop()! + for (const k of keyPath) { + let fallbackResult: Record | null = null + const foundInFallback: any = defaultResult?.[k] + if (foundInFallback) { + fallbackResult = defaultResult = foundInFallback + } + const foundInTranslation: any = translationResult?.[k] + if (foundInTranslation) { + fallbackResult = translationResult = foundInTranslation + } + const foundInLocale: any = localeResult?.[k] + if (foundInLocale) { + fallbackResult = localeResult = foundInLocale + } + // Put fallback into unresolved results + if (!foundInFallback) { + defaultResult = fallbackResult + } + if (!foundInTranslation) { + translationResult = fallbackResult + } + if (!foundInLocale) { + localeResult = fallbackResult + } + } + return ( + localeResult?.[lastKey] ?? + translationResult?.[lastKey] ?? + defaultResult?.[lastKey] ?? + '' + ) + } + + return translate +} diff --git a/src/node/plugin.ts b/src/node/plugin.ts index 9e8a6617..55f5dde6 100644 --- a/src/node/plugin.ts +++ b/src/node/plugin.ts @@ -22,6 +22,7 @@ import { staticDataPlugin } from './plugins/staticDataPlugin' import { webFontsPlugin } from './plugins/webFontsPlugin' import { dynamicRoutesPlugin } from './plugins/dynamicRoutesPlugin' import { rewritesPlugin } from './plugins/rewritesPlugin' +import { offlineSearchPlugin } from './plugins/offlineSearchPlugin.js' import { serializeFunctions, deserializeFunctions } from './utils/fnSerialize' declare module 'vite' { @@ -360,6 +361,7 @@ export async function createVitePressPlugin( vuePlugin, webFontsPlugin(siteConfig.useWebFonts), ...(userViteConfig?.plugins || []), + await offlineSearchPlugin(siteConfig), staticDataPlugin, await dynamicRoutesPlugin(siteConfig) ] diff --git a/src/node/plugins/offlineSearchPlugin.ts b/src/node/plugins/offlineSearchPlugin.ts new file mode 100644 index 00000000..de62061d --- /dev/null +++ b/src/node/plugins/offlineSearchPlugin.ts @@ -0,0 +1,274 @@ +import path from 'node:path' +import type { Plugin, ViteDevServer } from 'vite' +import MiniSearch from 'minisearch' +import fs from 'fs-extra' +import _debug from 'debug' +import type { SiteConfig } from '../config' +import { createMarkdownRenderer } from '../markdown/markdown.js' +import { resolveSiteDataByRoute } from '../shared.js' + +const debug = _debug('vitepress:offline-search') + +const OFFLINE_SEARCH_INDEX_ID = '@offlineSearchIndex' +const OFFLINE_SEARCH_INDEX_REQUEST_PATH = '/' + OFFLINE_SEARCH_INDEX_ID + +interface IndexObject { + id: string + text: string + title: string + titles: string[] +} + +export async function offlineSearchPlugin( + siteConfig: SiteConfig +): Promise { + if ( + siteConfig.userConfig.themeConfig?.algolia || + !siteConfig.userConfig.themeConfig?.offlineSearch + ) { + return { + name: 'vitepress:offline-search', + resolveId(id) { + if (id.startsWith(OFFLINE_SEARCH_INDEX_ID)) { + return `/${id}` + } + }, + load(id) { + if (id.startsWith(OFFLINE_SEARCH_INDEX_REQUEST_PATH)) { + return `export default '{}'` + } + } + } + } + + const md = await createMarkdownRenderer( + siteConfig.srcDir, + siteConfig.userConfig.markdown, + siteConfig.userConfig.base, + siteConfig.logger + ) + + const indexByLocales = new Map>() + + function getIndexByLocale(locale: string) { + let index = indexByLocales.get(locale) + if (!index) { + index = new MiniSearch({ + fields: ['title', 'titles', 'text'], + storeFields: ['title', 'titles'] + }) + indexByLocales.set(locale, index) + } + return index + } + + function getLocaleForPath(file: string) { + const relativePath = path.relative(siteConfig.srcDir, file) + const siteData = resolveSiteDataByRoute(siteConfig.site, relativePath) + return siteData?.localeIndex ?? 'root' + } + + function getIndexForPath(file: string) { + const locale = getLocaleForPath(file) + return getIndexByLocale(locale) + } + + let server: ViteDevServer | undefined + + async function onIndexUpdated() { + if (server) { + server.moduleGraph.onFileChange(OFFLINE_SEARCH_INDEX_REQUEST_PATH) + // HMR + const mod = server.moduleGraph.getModuleById( + OFFLINE_SEARCH_INDEX_REQUEST_PATH + ) + if (!mod) return + server.ws.send({ + type: 'update', + updates: [ + { + acceptedPath: mod.url, + path: mod.url, + timestamp: Date.now(), + type: 'js-update' + } + ] + }) + } + } + + function getDocId(file: string) { + let relFile = path.relative(siteConfig.srcDir, file) + relFile = siteConfig.rewrites.map[relFile] || relFile + let id = path.join(siteConfig.userConfig.base ?? '', relFile) + id = id.replace(/\.md$/, siteConfig.cleanUrls ? '' : '.html') + return id + } + + async function indexAllFiles(files: string[]) { + const documentsByLocale = new Map() + await Promise.all( + files + .filter((file) => fs.existsSync(file)) + .map(async (file) => { + const fileId = getDocId(file) + const sections = splitPageIntoSections( + await md.render(await fs.readFile(file, 'utf-8')) + ) + const locale = getLocaleForPath(file) + let documents = documentsByLocale.get(locale) + if (!documents) { + documents = [] + documentsByLocale.set(locale, documents) + } + documents.push( + ...sections.map((section) => ({ + id: `${fileId}#${section.anchor}`, + text: section.text, + title: section.titles.at(-1)!, + titles: section.titles.slice(0, -1) + })) + ) + }) + ) + for (const [locale, documents] of documentsByLocale) { + const index = getIndexByLocale(locale) + index.removeAll() + await index.addAllAsync(documents) + } + debug(`🔍️ Indexed ${files.length} files`) + } + + async function scanForBuild() { + await indexAllFiles( + siteConfig.pages.map((f) => path.join(siteConfig.srcDir, f)) + ) + } + + return { + name: 'vitepress:offline-search', + + configureServer(_server) { + server = _server + + server.watcher.on('ready', async () => { + const watched = server!.watcher.getWatched() + const files = Object.keys(watched).reduce((acc, dir) => { + acc.push( + ...watched[dir] + .map((file) => dir + '/' + file) + .filter((file) => file.endsWith('.md')) + ) + return acc + }, [] as string[]) + await indexAllFiles(files) + onIndexUpdated() + }) + }, + + resolveId(id) { + if (id.startsWith(OFFLINE_SEARCH_INDEX_ID)) { + return `/${id}` + } + }, + + async load(id) { + if (id === OFFLINE_SEARCH_INDEX_REQUEST_PATH) { + if (process.env.NODE_ENV === 'production') { + await scanForBuild() + } + let records: string[] = [] + for (const [locale] of indexByLocales) { + records.push( + `${JSON.stringify( + locale + )}: () => import('@offlineSearchIndex${locale}')` + ) + } + return `export default {${records.join(',')}}` + } else if (id.startsWith(OFFLINE_SEARCH_INDEX_REQUEST_PATH)) { + return `export default ${JSON.stringify( + JSON.stringify( + indexByLocales.get( + id.replace(OFFLINE_SEARCH_INDEX_REQUEST_PATH, '') + ) ?? {} + ) + )}` + } + }, + + async handleHotUpdate(ctx) { + if (ctx.file.endsWith('.md')) { + const fileId = getDocId(ctx.file) + if (!fs.existsSync(ctx.file)) { + return + } + const index = getIndexForPath(ctx.file) + const sections = splitPageIntoSections( + await md.render(await fs.readFile(ctx.file, 'utf-8')) + ) + for (const section of sections) { + const id = `${fileId}#${section.anchor}` + if (index.has(id)) { + index.discard(id) + } + index.add({ + id, + text: section.text, + title: section.titles.at(-1)!, + titles: section.titles.slice(0, -1) + }) + } + debug('🔍️ Updated', ctx.file) + + onIndexUpdated() + } + } + } +} + +const headingRegex = /(.*?.*?<\/a>)<\/h\1>/gi +const headingContentRegex = /(.*?).*?<\/a>/i + +interface PageSection { + anchor: string + titles: string[] + text: string +} + +/** + * Splits HTML into sections based on headings + */ +function splitPageIntoSections(html: string) { + const result = html.split(headingRegex) + result.shift() + let parentTitles: string[] = [] + const sections: PageSection[] = [] + for (let i = 0; i < result.length; i += 3) { + const level = parseInt(result[i]) - 1 + const heading = result[i + 1] + const headingResult = headingContentRegex.exec(heading) + const title = clearHtmlTags(headingResult?.[1] ?? '').trim() + const anchor = headingResult?.[2] ?? '' + const content = result[i + 2] + if (!title || !content) continue + const titles = [...parentTitles] + titles[level] = title + sections.push({ anchor, titles, text: getSearchableText(content) }) + if (level === 0) { + parentTitles = [title] + } else { + parentTitles[level] = title + } + } + return sections +} + +function getSearchableText(content: string) { + content = clearHtmlTags(content) + return content +} + +function clearHtmlTags(str: string) { + return str.replace(/<[^>]*>/g, '') +} diff --git a/types/default-theme.d.ts b/types/default-theme.d.ts index 52003849..67337d07 100644 --- a/types/default-theme.d.ts +++ b/types/default-theme.d.ts @@ -1,4 +1,5 @@ import { DocSearchProps } from './docsearch.js' +import { OfflineSearchTranslations } from './offline-search.js' export namespace DefaultTheme { export interface Config { @@ -105,6 +106,11 @@ export namespace DefaultTheme { */ algolia?: AlgoliaSearchOptions + /** + * The offline search options. Set to `true` or an object to enable, `false` to disable. + */ + offlineSearch?: OfflineSearchOptions | boolean + /** * The carbon ads options. Leave it undefined to disable the ads feature. */ @@ -295,6 +301,13 @@ export namespace DefaultTheme { locales?: Record> } + // offline search ------------------------------------------------------------ + + export interface OfflineSearchOptions { + translations?: OfflineSearchTranslations + locales?: Record>> + } + // carbon ads ---------------------------------------------------------------- export interface CarbonAdsOptions { diff --git a/types/offline-search.d.ts b/types/offline-search.d.ts new file mode 100644 index 00000000..7b162c1e --- /dev/null +++ b/types/offline-search.d.ts @@ -0,0 +1,25 @@ +export interface OfflineSearchTranslations { + button?: ButtonTranslations + modal?: ModalTranslations +} + +interface ButtonTranslations { + buttonText?: string + buttonAriaLabel?: string +} + +interface ModalTranslations { + displayDetails?: string + resetButtonTitle?: string + backButtonTitle?: string + noResultsText?: string + footer?: FooterTranslations +} + +interface FooterTranslations { + selectText?: string + selectKeyAriaLabel?: string + navigateText?: string + navigateUpKeyAriaLabel?: string + navigateDownKeyAriaLabel?: string +}