diff --git a/.vscode/settings.json b/.vscode/settings.json index 8b664f72ec..4e4698eaf4 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -60,6 +60,7 @@ "shikijs", "slideend", "slidestart", + "slimsearch", "slugify", "styl", "stylelint", diff --git a/docs/.vuepress/configs/sidebar/en.ts b/docs/.vuepress/configs/sidebar/en.ts index 50de87cd1b..ade79805b0 100644 --- a/docs/.vuepress/configs/sidebar/en.ts +++ b/docs/.vuepress/configs/sidebar/en.ts @@ -134,7 +134,7 @@ export const sidebarEn: SidebarOptions = { 'register-components', ], - '/plugins/search/': ['guidelines', 'docsearch', 'search'], + '/plugins/search/': ['guidelines', 'docsearch', 'search', 'slimsearch'], '/plugins/seo/': [ { diff --git a/docs/.vuepress/configs/sidebar/zh.ts b/docs/.vuepress/configs/sidebar/zh.ts index ead2ad709f..01bfd6e4e0 100644 --- a/docs/.vuepress/configs/sidebar/zh.ts +++ b/docs/.vuepress/configs/sidebar/zh.ts @@ -134,7 +134,7 @@ export const sidebarZh: SidebarOptions = { 'register-components', ], - '/zh/plugins/search/': ['guidelines', 'docsearch', 'search'], + '/zh/plugins/search/': ['guidelines', 'docsearch', 'search', 'slimsearch'], '/zh/plugins/seo/': [ { diff --git a/docs/plugins/search/slimsearch.md b/docs/plugins/search/slimsearch.md new file mode 100644 index 0000000000..a70920b073 --- /dev/null +++ b/docs/plugins/search/slimsearch.md @@ -0,0 +1,612 @@ +# slimsearch + + + +A powerful client-side search plugin with custom indexing and full-text search support. + +## Usage + +```bash +npm i -D @vuepress/plugin-slimsearch@next +``` + +```ts +import { slimsearchPlugin } from '@vuepress/plugin-slimsearch' + +export default { + plugins: [ + slimsearchPlugin({ + // options + }), + ], +} +``` + +## Search Index + +With [`slimsearch`](https://mister-hope.github.io/slimsearch/), searching is ultra fast, even on large sites. + +By default, the plugin will only index headings, article excerpt and custom fields you add. If you want to index all content, you should set `indexContent: true` in the plugin options. + +To prevent a page from being indexed, you can set `search: false` in it's frontmatter. TO programmatically filter pages, you can set [`filter` option](#filter). + +::: important Tokenize every language correctly + +When indexing languages that is not word based, like Chinese, Japanese or Korean, you should set `indexOptions` and `indexLocaleOptions` to perform correct word-splitting, see [Customize Index Generation](#customize-index-generation). + +Meanwhile, for better client search experience, you should customize the `querySplitter` option to split the input query through `defineSearchConfig`, introducing a NLP[^nlp] API could be a good choice. + +::: + +## Custom Fields + +Whether you are a theme developer or a user, adding extra data for a page through page frontmatter or the `extendsPage` lifecycle is common, and in most cases you may want to index these data as well. + +The `customFields` options accepts an array, each element represents a custom search index configuration item. Each configuration item contains 2 parts: + +- `getter`: The getter for this custom field. This function takes `page` object as a parameter and returns the value of the custom field as a string (single), an array of strings (multiple), `null` (the item is missing). +- `formatter`: a string controlling how the item is displayed in the custom search result, where `$content` is replaced with the actual value returned by `getter`. If you're using multiple languages, you can also set it as an object to set the display format for each language individually. + +These data will be added to indexes and the search result will contain them. + +::: tip Example: Adding author to index + +Assuming you add author information via `author` in frontmatter: + +```md +--- +author: Your name +--- + +Your Markdown content... +``` + +You can add author information to the index by setting: + +```ts +import { slimsearchPlugin } from '@vuepress/plugin-slimsearch' +import { defineUserConfig } from 'vuepress' + +export default defineUserConfig({ + plugins: [ + slimsearchPlugin({ + customFields: [ + { + name: 'author', + getter: (page) => page.frontmatter.author, + formatter: 'Author: $content', + }, + ], + }), + ], +}) +``` + +::: + +::: tip Example: Adding Update Time + +Supposed you are using the `@vuepress/plugin-git` plugin and you are putting Chinese and English docs under `/zh/` and `/` respectively. + +Then you can set the following to index the update time: + +```ts +import { slimsearchPlugin } from '@vuepress/plugin-slimsearch' +import { defineUserConfig } from 'vuepress' + +export default defineUserConfig({ + // We assume you are using the following multilingual + locales: { + '/': { + lang: 'en-US', + }, + '/zh/': { + lang: 'zh-CN', + }, + }, + + plugins: [ + slimsearchPlugin({ + customFields: [ + { + name: 'updateTime', + getter: (page) => page.data.git?.updateTime.toLocaleString(), + formatter: { + '/': 'Update time: $content', + '/zh/': '更新时间:$content', + }, + }, + ], + }), + ], +}) +``` + +::: + +## Options + +### indexContent + +- Type: `boolean` +- Default: `false` + +Whether to enable content indexing. + +::: tip + +By default, only headings and excerpt of the page will be indexed along with your custom fields. If you need to index the content of the page, set this option to `true` + +::: + +### autoSuggestions + +- Type: `boolean` +- Default: `true` + +Whether to show suggestions while searching. + +### customFields + +- Type: `CustomFieldOptions[]` + + ```ts + interface CustomFieldOptions { + /** + * Custom field getter + */ + getter: (page: Page) => string[] | string | null | undefined + + /** + * Display content + * + * @description `$content` will be replaced by the content returned by `getter` + * + * @default `$content` + */ + formatter?: Record | string + } + ``` + +- Required: No + +Customize index fields. + +### hotKeys + +- Type: `SearchProHotKeyOptions[]` + + ```ts + interface SearchProHotKeyOptions { + /** + * Value of `event.key` to trigger the hot key + */ + key: string + + /** + * Whether to press `event.altKey` at the same time + * + * @default false + */ + alt?: boolean + + /** + * Whether to press `event.ctrlKey` at the same time + * + * @default false + */ + ctrl?: boolean + + /** + * Whether to press `event.shiftKey` at the same time + * + * @default false + */ + shift?: boolean + + /** + * Whether to press `event.metaKey` at the same time + * + * @default false + */ + meta?: boolean + } + ``` + +- Default: `[{ key: "k", ctrl: true }, { key: "/", ctrl: true }]` + +Specify the [event.key](http://keycode.info/) of the hotkeys. + +When hotkeys are pressed, the search box input will be focused. Set to an empty array to disable hotkeys. + +### queryHistoryCount + +- Type: `number` +- Default: `5` + +Max stored query history count, set `0` to disable it. + +### resultHistoryCount + +- Type: `number` +- Default: `5` + +Max stored matched result history count, set `0` to disable it. + +### searchDelay + +- Type: `number` +- Default: `150` + +Delay to start searching after input. + +::: note + +Performing client search with huge contents could be slow, so under this case you might need to increase this value to ensure user finish input before searching. + +::: + +### filter + +- Type: `(page: Page) => boolean` +- Default: `() => true` + +Function used to filter pages. + +### sortStrategy + +- Type: `"max" | "total"` +- Default: `"max"` + +Result Sort strategy. + +When there are multiple matched results, the result will be sorted by the strategy. `max` means that page having higher total score will be placed in front. `total` means that page having higher max score will be placed in front. + +### worker + +- Type: `string` +- Default: `slimsearch.worker.js` + +Output Worker filename + +### hotReload + +- Type: `boolean` +- Default: Whether using `--debug` flag + +Whether to enable hot reload in the development server. + +::: note + +It is disabled by default because this feature can have a huge performance impact on sites with huge content and drastically increases the speed of hot reloads when editing Markdown. + +::: + +### indexOptions + +- Type: `SlimSearchIndexOptions` + + ```ts + interface SlimSearchIndexOptions { + /** + * Function to tokenize the index field item. + */ + tokenize?: (text: string, fieldName?: string) => string[] + /** + * Function to process or normalize terms in the index field. + */ + processTerm?: (term: string) => string[] | string | false | null | undefined + } + ``` + +- Required: No + +Options used to create index. + +### indexLocaleOptions + +- Type: `Record` +- Required: No + +Options used to create index per locale, the object keys should be the locale path. + +### locales + +- Type: `SlimSearchLocaleConfig` + + ```ts + interface SlimSearchLocaleData { + /** + * Search box placeholder + */ + placeholder: string + + /** + * Search text + */ + search: string + + /** + * Searching text + */ + searching: string + + /** + * Cancel text + */ + cancel: string + + /** + * Default title + */ + defaultTitle: string + + /** + * Select hint + */ + select: string + + /** + * Choose hint + */ + navigate: string + + /** + * Autocomplete hint + */ + autocomplete: string + + /** + * Close hint + */ + exit: string + + /** + * Loading hint + */ + loading: string + + /** + * Search query history title + */ + queryHistory: string + + /** + * Search result history title + */ + resultHistory: string + + /** + * Search history empty hint + */ + emptyHistory: string + + /** + * Empty hint + */ + emptyResult: string + } + + interface SlimSearchLocaleConfig { + [localePath: string]: SlimSearchLocaleData + } + ``` + +- Required: No + +Multilingual configuration of the search plugin. + +::: details Built-in Supported Languages + +- **Simplified Chinese** (zh-CN) +- **Traditional Chinese** (zh-TW) +- **English (United States)** (en-US) +- **German** (de-DE) +- **German (Australia)** (de-AT) +- **Russian** (ru-RU) +- **Ukrainian** (uk-UA) +- **Vietnamese** (vi-VN) +- **Portuguese (Brazil)** (pt-BR) +- **Polish** (pl-PL) +- **French** (fr-FR) +- **Spanish** (es-ES) +- **Slovak** (sk-SK) +- **Japanese** (ja-JP) +- **Turkish** (tr-TR) +- **Korean** (ko-KR) +- **Finnish** (fi-FI) +- **Indonesian** (id-ID) +- **Dutch** (nl-NL) + +::: + +## Frontmatter + +### search + +- Type: `boolean` +- Default: `true` + +Whether to index this page. + +## Advanced + +### Customize Index Generation + +If you are indexing other language which is not using "Words", like Chinese, Japanese or Korean, you should set `indexOptions` and `indexLocaleOptions` to perform correct word-splitting. + +If you are building a Chinese docs, you can use [nodejs-jieba](https://github.com/Mister-Hope/nodejs-jieba) to perform word splitting. (Japanese and Korean do not have built-in dictionary, but you can provide your own dictionary and split words with `nodejs-jieba`). + +If your docs only contain Chinese, you can tokenize the content like this: + +```ts +import { slimsearchPlugin } from '@vuepress/plugin-slimsearch' +import { cut } from 'nodejs-jieba' +import { defineUserConfig } from 'vuepress' + +export default defineUserConfig({ + lang: 'zh-CN', + + plugins: [ + slimsearchPlugin({ + // index all content + indexContent: true, + indexOptions: { + // tokenize the content with nodejs-jieba + tokenize: (text, fieldName) => + fieldName === 'id' ? [text] : cut(text, true), + }, + }), + ], +}) +``` + +If you need word splitting in some locales, you can set `indexLocaleOptions`: + +```ts +import { slimsearchPlugin } from '@vuepress/plugin-slimsearch' +import { cut } from 'nodejs-jieba' +import { defineUserConfig } from 'vuepress' + +export default defineUserConfig({ + locales: { + '/': { + lang: 'en-US', + }, + '/zh/': { + lang: 'zh-CN', + }, + }, + + plugins: [ + slimsearchPlugin({ + indexContent: true, + indexLocaleOptions: { + '/zh/': { + // tokenize the content with nodejs-jieba + tokenize: (text, fieldName) => + fieldName === 'id' ? [text] : cut(text, true), + }, + }, + }), + ], +}) +``` + +### Using with API + +If you want to access the search API, you need to import the `createSearchWorker` function from `@vuepress/plugin-slimsearch/client`: + +```ts +import { createSearchWorker } from '@vuepress/plugin-slimsearch/client' +import { defineClientConfig } from 'vuepress/client' + +const { all, suggest, search, terminate } = createSearchWorker() + +// suggest something +suggest('key').then((suggestions) => { + // display search suggestions +}) + +// search something +search('keyword').then((results) => { + // display search results +}) + +// return both suggestions and results +all('key').then(({ suggestions, results }) => { + // display search suggestions and results +}) + +// terminate the worker when you don't need it +terminate() +``` + +### Limitations in DevServer + +The search service is powered by a worker, and in dev mode we cannot bundle the worker file. + +In order to load search indexes in dev mode, we are using a modern service worker with `type: "module"`, so if you want to try searching in devServer, you should use a supported browser, see [CanIUse](https://caniuse.com/mdn-api_worker_worker_ecmascript_modules) for support details. + +For better performance, adding/editing/deleting markdown contents will not trigger update for search index in dev mode. If you are proofreading or refining your search results, you can enable hot reloading by setting the `hotReload: true` option. + +### Comparing with Server-Search + +Client-side search has advantages, like no backend services and easy to add, but you should be aware that it has disadvantages. + +::: warning Disadvantages + +1. You need to index your website during the build stage, which increases website deployment time and website bundle size. +1. Users need to fetch the entire search index from your server before searching, which will bring additional traffic and bandwidth pressure to your server. The more content you hold on your site, the larger search index will be. +1. To perform a search, users must wait for the search index to be downloaded and parsed locally. This may be much slower than performing a simple web request to get results via Server-search. +1. Since searching is done on users devices, the speed is totally based on device performance. + +::: + +In most cases, if you are building a large site, you should choose a service provider to provide search services for your site if possible, such as [Algolia](https://www.algolia.com/), or choose an open source search crawler tool and host it on your own server to provide a search service and regularly craw your site. This is necessary for large sites because users send search terms to the search API via network requests and get search results directly. + +In particular, [DocSearch](https://docsearch.algolia.com/) is a free search service provided by Algolia for open source projects. If you are creating open source project documentation or an open source technical blog, you can [apply for it](https://docsearch.algolia.com/apply/), and use [`@vuepress/plugin-docsearch`](./docsearch.md) plugin to provide search features. + +## Client Config + +### defineSearchConfig + +Customize [search options](https://mister-hope.github.io/slimsearch/interfaces/SearchOptions.html). + +Since searching is done in a Web Worker, setting function-typed options for `slimsearch` is not supported. + +For more accurate search queries, suggestions, and results, we provide `querySplitter`, `suggestionsFilter`, and `resultsFilter` options. You can set them for specific or all languages: + +```ts +interface SearchLocaleOptions + extends Omit< + SearchOptions, + 'boostDocument' | 'fields' | 'filter' | 'processTerm' | 'tokenize' + > { + /** A function to split words */ + querySplitter?: (query: string) => Promise + + /** A function to filter suggestions */ + suggestionsFilter?: ( + suggestions: string[], + query: string, + locale: string, + pageData: PageData, + ) => string[] + + /** A function to filter search results */ + resultsFilter?: ( + results: SearchResult[], + query: string, + locale: string, + pageData: PageData, + ) => SearchResult[] +} + +interface SearchOptions extends SearchLocaleOptions { + /** Setting different options per locale */ + locales?: Record +} +``` + +```ts title=".vuepress/client.ts" +import { defineSearchConfig } from '@vuepress/plugin-slimsearch/client' + +defineSearchConfig({ + // search options here + + locales: { + '/zh/': { + // set different options for Chinese + }, + }, +}) + +export default {} +``` + +## Components + +- SearchBox + +[^nlp]: **N**atural **L**anguage **P**rocessing diff --git a/docs/zh/plugins/search/slimsearch.md b/docs/zh/plugins/search/slimsearch.md new file mode 100644 index 0000000000..8012da4aff --- /dev/null +++ b/docs/zh/plugins/search/slimsearch.md @@ -0,0 +1,615 @@ +# slimsearch + + + +一个强大的客户端搜索插件,支持自定义索引与全文搜索。 + +## 使用方法 + +```bash +npm i -D @vuepress/plugin-slimsearch@next +``` + +```ts +import { slimsearchPlugin } from '@vuepress/plugin-slimsearch' + +export default { + plugins: [ + slimsearchPlugin({ + // 配置项 + }), + ], +} +``` + +## 搜索索引 + +通过 [`slimsearch`](https://mister-hope.github.io/slimsearch/),搜索速度极快,即使在大型站点上也是如此。 + +默认情况下,插件仅索引标题,文章摘要和你添加的自定义字段。如果你想要索引文章的全部内容,你可以通过设置 `indexContent: true` 来开启。 + +为了阻止页面被索引,你可以在 Frontmatter 中设置 `search: false`。为了程序化地过滤页面,你可以设置 [`filter` 选项](#filter)。 + +::: important 正确的为每个语言分词 + +当索引不基于单词的语言时,例如中文、日语或韩语,你需要设置 `indexOptions` 和 `indexLocaleOptions` 以执行正确的分词,详见[自定义索引生成](#自定义索引生成)。 + +同时为了更好的客户端搜索体验,你应该通过 `defineSearchConfig` 来自定义 `querySplitter` 选项以对输入查询内容进行分词,引入一个 NLP[^nlp] API 是一个不错的选择。 + +::: + +## 自定义索引项 + +无论是主题开发者还是用户,在 Frontmatter 中或者通过 `extendsPage` 生命周期为页面添加额外数据都是常见的,并且很多情况下你可能希望把这些数据也编入索引。 + +`customFields` 选项接受一个数组,其中每一项代表一项自定义搜索索引的配置项。每一个配置项包含两个部分: + +- `getter`: 该自定义项目的获取器。此函数需要接受 `page` 对象作为参数,并以字符串 (单个)、字符串数组 (多个)、`null` (该项目缺失) 的形式返回该自定义项目的值。 +- `formatter`: 一个字符串控制项目该如何在自定义搜索结果中显示,其中 `$content` 会替换成 `getter` 返回的项目值。如果你在使用多语言,你还可以将其设置为对象,以分别设置每一个语言的显示格式。 + +::: tip 案例:在索引中添加作者 + +假定你在 Frontmatter 中通过 `author` 添加作者: + +```md +--- +author: 你的名字 +--- + +Markdown 内容... +``` + +你可以通过如下配置将作者添加到索引中: + +```ts +import { slimsearchPlugin } from '@vuepress/plugin-slimsearch' +import { defineUserConfig } from 'vuepress' + +export default defineUserConfig({ + plugins: [ + slimsearchPlugin({ + customFields: [ + { + name: 'author', + getter: (page) => page.frontmatter.author, + formatter: '作者:$content', + }, + ], + }), + ], +}) +``` + +::: + +::: tip 案例:添加更新时间 + +假设你在使用 `@vuepress/plugin-git` 插件并且在 `/zh/` 和 `/` 下分别放置了中文和英文文档。 + +你需要进行如下配置来索引更新时间: + +```ts +import { slimsearchPlugin } from '@vuepress/plugin-slimsearch' +import { defineUserConfig } from 'vuepress' + +export default defineUserConfig({ + // 我们假定你在使用如下多语言 + locales: { + '/': { + lang: 'en-US', + }, + '/zh/': { + lang: 'zh-CN', + }, + }, + + plugins: [ + slimsearchPlugin({ + customFields: [ + { + name: 'updateTime', + getter: (page) => page.data.git?.updateTime.toLocaleString(), + formatter: { + '/': 'Update time: $content', + '/zh/': '更新时间:$content', + }, + }, + ], + }), + ], +}) +``` + +::: + +## 配置项 + +### indexContent + +- 类型: `boolean` +- 默认值: `false` + +是否索引内容。 + +::: tip + +默认情况下,插件只会索引页面的标题和摘要以及你的自定义索引项。如果需要索引页面的正文内容,将该选项设置为 `true`。 + +::: + +### autoSuggestions + +- 类型: `boolean` +- 默认值: `false` + +是否自动提示搜索建议。 + +### customFields + +- 类型: `CustomFieldOptions[]` + + ```ts + interface CustomFieldOptions { + /** + * 自定义项目的获取器 + */ + getter: (page: Page) => string[] | string | null | undefined + + /** + * 展示的内容 + * + * @description `$content` 会被 `getter` 返回的内容替换 + * + * @default `$content` + */ + formatter?: Record | string + } + ``` + +- 必填: 否 + +自定义搜索索引项。 + +### hotKeys + +- 类型: `SearchProHotKeyOptions[]` + + ```ts + interface SearchProHotKeyOptions { + /** + * 热键的 `event.key` 值 + */ + key: string + + /** + * 是否同时按下 `event.altKey` + * + * @default false + */ + alt?: boolean + + /** + * 是否同时按下 `event.ctrlKey` + * + * @default false + */ + ctrl?: boolean + + /** + * 是否同时按下 `event.shiftKey` + * + * @default false + */ + shift?: boolean + + /** + * 是否同时按下 `event.metaKey` + * + * @default false + */ + meta?: boolean + } + ``` + +- 默认值: `[{ key: "k", ctrl: true }, { key: "/", ctrl: true }]` + +指定热键的 [event.key](http://keycode.info/)。 + +当热键被按下时,搜索框的输入框会被聚焦,设置为空数组以禁用热键。 + +### queryHistoryCount + +- 类型: `number` +- 默认值: `5` + +存储搜索查询词历史的最大数量,可以设置为 `0` 以禁用。 + +### resultHistoryCount + +- 类型: `number` +- 默认值: `5` + +存储搜索结果历史的最大数量,可以设置为 `0` 以禁用。 + +### searchDelay + +- 类型: `number` +- 默认值: `150` + +结束输入到开始搜索的延时 + +::: note + +有大量内容时,进行客户端搜索可能会很慢,在这种情况下你可能需要增加此值来确保开始搜索时用户已完成输入。 + +::: + +### filter + +- 类型: `(page: Page) => boolean` +- 默认值: `() => true` + +用于过滤页面的函数。 + +### sortStrategy + +- 类型: `"max" | "total"` +- 默认值: `"max"` + +结果排序策略 + +当有多个匹配的结果时,会按照策略对结果进行排序。`max` 表示最高分更高的页面会排在前面。`total` 表示总分更高的页面会排在前面。 + +### worker + +- 类型: `string` +- 默认值: `slimsearch.worker.js` + +输出的 Worker 文件名称 + +### hotReload + +- 类型: `boolean` +- 默认值: 是否使用 `--debug` 标记 + +是否在开发服务器中启用实时热重载。 + +::: note + +它是默认禁用的,因为此功能会对内容巨大的站点产生极大性能影响,并且在编辑 Markdown 时剧烈增加热重载的速度。 + +::: + +### indexOptions + +- 类型: `SlimSearchIndexOptions` + + ```ts + interface SlimSearchIndexOptions { + /** + * 用于对索引字段项进行分词的函数。 + */ + tokenize?: (text: string, fieldName?: string) => string[] + /** + * 用于处理或规范索引字段中的术语的函数。 + */ + processTerm?: (term: string) => string[] | string | false | null | undefined + } + ``` + +- 必填: 否 + +创建索引选项。 + +### indexLocaleOptions + +- 类型: `Record` +- 必填: 否 + +分语言的创建索引选项,键为语言路径。 + +### locales + +- 类型: `SlimSearchLocaleConfig` + + ```ts + interface SlimSearchLocaleData { + /** + * 搜索框占位符文字 + */ + placeholder: string + + /** + * 搜索文字 + */ + search: string + + /** + * 搜索中文字 + */ + searching: string + + /** + * 取消文字 + */ + cancel: string + + /** + * 默认标题 + */ + defaultTitle: string + + /** + * 选择提示 + */ + select: string + + /** + * 选择提示 + */ + navigate: string + + /** + * 自动补全提示 + */ + autocomplete: string + + /** + * 关闭提示 + */ + exit: string + + /** + * 加载提示 + */ + loading: string + + /** + * 搜索文字历史 标题 + */ + queryHistory: string + + /** + * 搜索结果历史 标题 + */ + resultHistory: string + + /** + * 无搜索历史提示 + */ + emptyHistory: string + + /** + * 无结果提示 + */ + emptyResult: string + } + + interface SlimSearchLocaleConfig { + [localePath: string]: SlimSearchLocaleData + } + ``` + +- 必填: 否 + +搜索插件的多语言配置。 + +::: details 内置支持语言 + +- **简体中文** (zh-CN) +- **繁体中文** (zh-TW) +- **英文(美国)** (en-US) +- **德语** (de-DE) +- **德语(澳大利亚)** (de-AT) +- **俄语** (ru-RU) +- **乌克兰语** (uk-UA) +- **越南语** (vi-VN) +- **葡萄牙语(巴西)** (pt-BR) +- **波兰语** (pl-PL) +- **法语** (fr-FR) +- **西班牙语** (es-ES) +- **斯洛伐克** (sk-SK) +- **日语** (ja-JP) +- **土耳其语** (tr-TR) +- **韩语** (ko-KR) +- **芬兰语** (fi-FI) +- **印尼语** (id-ID) +- **荷兰语** (nl-NL) + +::: + +## Frontmatter + +### search + +- 类型:`boolean` +- 默认值:`true` + +是否索引该页面。 + +## 高级 + +### 自定义索引生成 + +如果你正在索引其他不使用“单词”的语言,如中文、日语或韩语,你应该设置 `indexOptions` 和 `indexLocaleOptions` 以执行正确的分词。 + +如果你正在构建中文文档,则可以使用 [nodejs-jieba](https://github.com/Mister-Hope/nodejs-jieba) 进行分词。 (日语和韩语没有内置词典,但你可以提供自己的词典,并使用 `nodejs-jieba` 拆分单词)。 + +如果你的文档只包含中文,你可以像这样对内容进行标记: + +```ts +import { slimsearchPlugin } from '@vuepress/plugin-slimsearch' +import { cut } from 'nodejs-jieba' +import { defineUserConfig } from 'vuepress' + +export default defineUserConfig({ + lang: 'zh-CN', + + plugins: [ + slimsearchPlugin({ + // 索引全部内容 + indexContent: true, + indexOptions: { + // 使用 nodejs-jieba 进行分词 + tokenize: (text, fieldName) => + fieldName === 'id' ? [text] : cut(text, true), + }, + }), + ], +}) +``` + +如果你需要在某些语言环境中进行分词,你可以设置 `indexLocaleOptions`: + +```ts +import { slimsearchPlugin } from '@vuepress/plugin-slimsearch' +import { cut } from 'nodejs-jieba' +import { defineUserConfig } from 'vuepress' + +export default defineUserConfig({ + locales: { + '/': { + lang: 'en-US', + }, + '/zh/': { + lang: 'zh-CN', + }, + }, + + plugins: [ + slimsearchPlugin({ + indexContent: true, + indexLocaleOptions: { + '/zh/': { + // 使用 nodejs-jieba 进行分词 + tokenize: (text, fieldName) => + fieldName === 'id' ? [text] : cut(text, true), + }, + }, + }), + ], +}) +``` + +::: tip + +特别提示,我们没有办法在浏览器中使用分词功能,所以任何不基于单词的语言(如中文)的长文本搜索结果会明显表现不佳。 + +::: + +### 通过 API 使用 + +如果你想要访问搜索 API,你可以从 `@vuepress/plugin-slimsearch/client` 中导入 `createSearchWorker` 来获取搜索结果: + +```ts +import { createSearchWorker } from 'vuepress-plugin-search-pro/client' + +const { all, suggest, search, terminate } = createSearchWorker() + +// 自动建议 +suggest('key').then((suggestions) => { + // 显示建议 +}) + +// 搜索 +search('keyword').then((results) => { + // 显示搜索结果 +}) + +// 同时返回建议和搜索结果 +all('key').then(({ suggestions, results }) => { + // 显示建议和搜索结果 +}) + +// 当不需要时终止 Worker +terminate() +``` + +### 开发服务器中的限制 + +搜索服务由 Worker 提供支持,在开发模式下我们无法捆绑 Worker 文件。 + +为了在开发模式下加载搜索索引,我们使用了带有 `type: "module"` 的现代 Worker。因此,如果你想尝试在开发服务器中搜索,你应该使用支持的浏览器,请参阅 [CanIUse](https://caniuse.com/mdn-api_worker_worker_ecmascript_modules) 了解支持详情。 + +为了更好的性能,在开发模式下添加/编辑/删除 Markdown 内容不会触发搜索索引的更新。如果你正在校对或优化你的搜索结果,你可以通过设置 `hotReload: true` 选项来启用热重载。 + +### 与服务端搜索比较 + +客户端搜索有优点,比如没有后台服务,容易添加,但你应该知道它也有缺点。 + +::: warning 缺点 + +1. 你需要在构建阶段为你的网站建立索引,这会增长网站部署时间与网站的构建体积。 +1. 用户在搜索前需要从你的服务器拉取整个索引,会为你的网站服务器带来额外的流量与带宽压力。这通常比在服务端搜索下执行一个网络请求获得结果要慢得多。 +1. 为了进行一次搜索,用户必须等待搜索索引下载并在本地解析完毕。这会为用户消耗不必要的流量、同时增加客户端耗电。 +1. 由于搜索是在用户设备上执行的,速度完全取决于设备性能。 + +::: + +在大多数情况,如果你在构建一个大型站点,你应该选择服务提供商为你的站点提供搜索服务,例如 [Algolia](https://www.algolia.com/),或者选择开源工具在自己的服务器上加载搜索服务并定期为自己的网站生成索引。对于大型站点这很必要因为用户通过网络请求向搜索 API 发送搜索字词,并直接得到搜索结果。 + +特别提示,[DocSearch](https://docsearch.algolia.com/) 是 Algolia 为开源项目提供的免费搜索服务。如果你在创建开源项目文档或开源技术博客,你可 [申请它](https://docsearch.algolia.com/apply/),并使用 [`@vuepress/plugin-docsearch`](./docsearch.md) 插件提供搜索。 + +## 客户端配置 + +### defineSearchConfig + +自定义 [搜索选项](https://mister-hope.github.io/slimsearch/interfaces/SearchOptions.html)。 + +由于搜索是在 Web Worker 中完成的,因此不支持 `slimsearch` 中需要被设置为函数的选项。 + +为了提供更准确的搜索查询、建议和结果,我们额外提供了 `querySplitter` `suggestionsFilter` 和 `resultsFilter` 选项,你可以为特定或所有语言设定它们: + +```ts +interface SearchLocaleOptions + extends Omit< + SearchOptions, + 'boostDocument' | 'fields' | 'filter' | 'processTerm' | 'tokenize' + > { + /** 分词器 */ + querySplitter?: (query: string) => Promise + + /** 过滤建议 */ + suggestionsFilter?: ( + suggestions: string[], + query: string, + locale: string, + pageData: PageData, + ) => string[] + + /** 过滤结果 */ + resultsFilter?: ( + results: SearchResult[], + query: string, + locale: string, + pageData: PageData, + ) => SearchResult[] +} + +interface SearchOptions extends SearchLocaleOptions { + /** 基于每个语言来设置选项 */ + locales?: Record +} +``` + +```ts title=".vuepress/client.ts" +import { defineSearchConfig } from '@vuepress/plugin-slimsearch/client' + +defineSearchConfig({ + // 此处放置搜索选项 + + locales: { + '/zh/': { + // 为特定语言设置搜索选项 + }, + }, +}) + +export default {} +``` + +## 组件 + +- SearchBox + +[^nlp]: **N**atural **L**anguage **P**rocessing 自然语言处理 diff --git a/plugins/search/plugin-slimsearch/package.json b/plugins/search/plugin-slimsearch/package.json new file mode 100644 index 0000000000..004957aeeb --- /dev/null +++ b/plugins/search/plugin-slimsearch/package.json @@ -0,0 +1,62 @@ +{ + "name": "@vuepress/plugin-slimsearch", + "version": "2.0.0-rc.55", + "description": "VuePress plugin - built-in slimsearch", + "keywords": [ + "vuepress-plugin", + "vuepress", + "plugin", + "slimsearch" + ], + "homepage": "https://ecosystem.vuejs.press/plugins/search/slimsearch.html", + "bugs": { + "url": "https://github.com/vuepress/ecosystem/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/vuepress/ecosystem.git", + "directory": "plugins/search/plugin-slimsearch" + }, + "license": "MIT", + "author": { + "name": "Mr.Hope", + "email": "mister-hope@outlook.com", + "url": "https://mister-hope.com" + }, + "type": "module", + "exports": { + ".": "./lib/node/index.js", + "./client": "./lib/client/index.js", + "./client/*": "./lib/client/*", + "./package.json": "./package.json" + }, + "main": "./lib/node/index.js", + "types": "./lib/node/index.d.ts", + "files": [ + "lib" + ], + "scripts": { + "build": "tsc -b tsconfig.build.json", + "bundle": "rollup -c rollup.config.ts --configPlugin esbuild", + "clean": "rimraf --glob ./lib ./*.tsbuildinfo", + "copy": "cpx \"src/**/*.{d.ts,svg}\" lib", + "style": "sass src:lib --embed-sources --style=compressed" + }, + "dependencies": { + "@vuepress/helper": "workspace:*", + "@vueuse/core": "^11.2.0", + "cheerio": "^1.0.0", + "chokidar": "^3.6.0", + "slimsearch": "^2.2.1", + "vue": "^3.5.13" + }, + "peerDependencies": { + "vuepress": "2.0.0-rc.18" + }, + "devDependencies": { + "domhandler": "5.0.3" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/plugins/search/plugin-slimsearch/rollup.config.ts b/plugins/search/plugin-slimsearch/rollup.config.ts new file mode 100644 index 0000000000..db94e9630c --- /dev/null +++ b/plugins/search/plugin-slimsearch/rollup.config.ts @@ -0,0 +1,25 @@ +import { rollupBundle } from '../../../scripts/rollup.js' + +export default [ + ...rollupBundle('node/index', { + external: ['cheerio', 'chokidar', 'slimsearch'], + dtsExternal: ['vuepress/core'], + }), + ...rollupBundle( + { + base: 'client', + files: ['components/SearchResult', 'config', 'index', 'worker/index'], + }, + { + external: ['@internal/pagesComponents', 'slimsearch'], + }, + ), + ...rollupBundle('worker/index', { + resolve: true, + dts: false, + external: [/^@internal\//], + define: { + __VUEPRESS_SSR__: 'false', + }, + }), +] diff --git a/plugins/search/plugin-slimsearch/src/client/components/SearchBox.ts b/plugins/search/plugin-slimsearch/src/client/components/SearchBox.ts new file mode 100644 index 0000000000..fcc30f6e9b --- /dev/null +++ b/plugins/search/plugin-slimsearch/src/client/components/SearchBox.ts @@ -0,0 +1,99 @@ +import { + checkIsIOS, + checkIsMacOS, + checkIsiPad, + useLocaleConfig, +} from '@vuepress/helper/client' +import { useEventListener } from '@vueuse/core' +import type { VNode } from 'vue' +import { computed, defineComponent, h, inject, onMounted, ref } from 'vue' + +import { searchModalSymbol } from '../composables/index.js' +import { hotKeysConfig, locales } from '../define.js' +import { isFocusingTextControl, isKeyMatched } from '../utils/index.js' +import { SearchIcon } from './icons.js' + +import '../styles/search-box.css' + +const primaryHotKey = hotKeysConfig[0] + +export default defineComponent({ + name: 'SearchBox', + + setup() { + const locale = useLocaleConfig(locales) + const isActive = inject(searchModalSymbol)! + const isMacOS = ref(false) + + const controlKeys = computed(() => + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + primaryHotKey + ? [ + ...(isMacOS.value + ? ['⌃', '⇧', '⌥', '⌘'] + : ['Ctrl', 'Shift', 'Alt', 'Win'] + ).filter( + (_, index) => + primaryHotKey[ + (['ctrl', 'shift', 'alt', 'meta'] as const)[index] + ], + ), + primaryHotKey.key.toUpperCase(), + ] + : null, + ) + + useEventListener('keydown', (event: KeyboardEvent): void => { + if ( + // Not active + !isActive.value && + // Key matches + isKeyMatched(event) && + /* + * Event does not come from the search box itself or + * user isn't focusing (and thus perhaps typing in) a text control + */ + !isFocusingTextControl(event.target!) + ) { + event.preventDefault() + isActive.value = true + } + }) + + onMounted(() => { + const { userAgent } = navigator + + isMacOS.value = + checkIsMacOS(userAgent) || + checkIsIOS(userAgent) || + checkIsiPad(userAgent) + }) + + return (): (VNode | null)[] => [ + h( + 'button', + { + 'type': 'button', + 'class': 'slimsearch-button', + 'aria-label': locale.value.search, + 'onClick': () => { + isActive.value = true + }, + }, + [ + h(SearchIcon), + h('div', { class: 'slimsearch-placeholder' }, locale.value.search), + controlKeys.value + ? h( + 'div', + { class: 'slimsearch-key-hints' }, + controlKeys.value.map((key) => + h('kbd', { class: 'slimsearch-key' }, key), + ), + ) + : null, + ], + ), + ] + }, +}) diff --git a/plugins/search/plugin-slimsearch/src/client/components/SearchKeyHints.ts b/plugins/search/plugin-slimsearch/src/client/components/SearchKeyHints.ts new file mode 100644 index 0000000000..ea61ed11fb --- /dev/null +++ b/plugins/search/plugin-slimsearch/src/client/components/SearchKeyHints.ts @@ -0,0 +1,41 @@ +import { useLocaleConfig } from '@vuepress/helper/client' +import type { VNode } from 'vue' +import { defineComponent, h } from 'vue' + +import { useMobile } from '../composables/index.js' +import { locales } from '../define.js' +import { + DOWN_KEY_ICON, + ENTER_KEY_ICON, + ESC_KEY_ICON, + UP_KEY_ICON, +} from '../icons/index.js' + +export default defineComponent({ + name: 'SearchKeyHints', + + setup() { + const locale = useLocaleConfig(locales) + const isMobile = useMobile() + + return (): VNode | null => + // Key hints should only appears in PC + isMobile.value + ? null + : h('div', { class: 'slimsearch-hints' }, [ + h('span', { class: 'slimsearch-hint' }, [ + h('kbd', { innerHTML: ENTER_KEY_ICON }), + locale.value.select, + ]), + h('span', { class: 'slimsearch-hint' }, [ + h('kbd', { innerHTML: UP_KEY_ICON }), + h('kbd', { innerHTML: DOWN_KEY_ICON }), + locale.value.navigate, + ]), + h('span', { class: 'slimsearch-hint' }, [ + h('kbd', { innerHTML: ESC_KEY_ICON }), + locale.value.exit, + ]), + ]) + }, +}) diff --git a/plugins/search/plugin-slimsearch/src/client/components/SearchLoading.ts b/plugins/search/plugin-slimsearch/src/client/components/SearchLoading.ts new file mode 100644 index 0000000000..b1ac286cd6 --- /dev/null +++ b/plugins/search/plugin-slimsearch/src/client/components/SearchLoading.ts @@ -0,0 +1,70 @@ +import type { FunctionalComponent } from 'vue' +import { h } from 'vue' + +export const SearchLoading: FunctionalComponent<{ + hint: string + class?: string +}> = ({ class: className, hint }) => + h('div', { class: [className, 'loading'] }, [ + h( + 'svg', + { + xmlns: 'http://www.w3.org/2000/svg', + width: '32', + height: '32', + preserveAspectRatio: 'xMidYMid', + viewBox: '0 0 100 100', + }, + [ + h( + 'circle', + { cx: '28', cy: '75', r: '11', fill: 'currentColor' }, + h('animate', { + attributeName: 'fill-opacity', + begin: '0s', + dur: '1s', + keyTimes: '0;0.2;1', + repeatCount: 'indefinite', + values: '0;1;1', + }), + ), + h( + 'path', + { + 'fill': 'none', + 'stroke': '#88baf0', + 'stroke-width': '10', + 'd': 'M28 47a28 28 0 0 1 28 28', + }, + h('animate', { + attributeName: 'stroke-opacity', + begin: '0.1s', + dur: '1s', + keyTimes: '0;0.2;1', + repeatCount: 'indefinite', + values: '0;1;1', + }), + ), + h( + 'path', + { + 'fill': 'none', + 'stroke': '#88baf0', + 'stroke-width': '10', + 'd': 'M28 25a50 50 0 0 1 50 50', + }, + h('animate', { + attributeName: 'stroke-opacity', + begin: '0.2s', + dur: '1s', + keyTimes: '0;0.2;1', + repeatCount: 'indefinite', + values: '0;1;1', + }), + ), + ], + ), + hint, + ]) + +SearchLoading.displayName = 'SearchLoading' diff --git a/plugins/search/plugin-slimsearch/src/client/components/SearchModal.ts b/plugins/search/plugin-slimsearch/src/client/components/SearchModal.ts new file mode 100644 index 0000000000..81a37e066b --- /dev/null +++ b/plugins/search/plugin-slimsearch/src/client/components/SearchModal.ts @@ -0,0 +1,254 @@ +import { useLocaleConfig } from '@vuepress/helper/client' +import { + onClickOutside, + useDebounceFn, + useEventListener, + useScrollLock, +} from '@vueuse/core' +import type { VNode } from 'vue' +import { + defineAsyncComponent, + defineComponent, + h, + inject, + nextTick, + onMounted, + onUnmounted, + ref, + shallowRef, + watch, +} from 'vue' +import { useSiteLocaleData } from 'vuepress/client' + +import { + searchModalSymbol, + useArrayCycle, + useSuggestions, +} from '../composables/index.js' +import { enableAutoSuggestions, locales, options } from '../define.js' +import { useSearchOptions } from '../helpers/index.js' +import { CLOSE_ICON } from '../icons/index.js' +import SearchKeyHints from './SearchKeyHints.js' +import { SearchLoading } from './SearchLoading.js' +import { SearchIcon } from './icons.js' + +import '../styles/search-modal.css' + +const SearchResult = defineAsyncComponent({ + loader: () => + import(/* webpackChunkName: "slimsearch-result" */ './SearchResult.js'), + loadingComponent: () => { + const localeConfig = useLocaleConfig(locales) + + return h(SearchLoading, { + class: 'slimsearch-result-wrapper', + hint: localeConfig.value.loading, + }) + }, +}) + +export default defineComponent({ + name: 'SearchModal', + + setup() { + const isActive = inject(searchModalSymbol)! + const siteLocale = useSiteLocaleData() + const locale = useLocaleConfig(locales) + const searchOptions = useSearchOptions() + + const input = ref('') + const queries = ref([]) + const { suggestions } = useSuggestions(queries) + const displaySuggestion = ref(false) + + const { + index: activeSuggestionIndex, + prev: activePreviousSuggestion, + next: activeNextSuggestion, + } = useArrayCycle(suggestions) + + const inputElement = shallowRef() + const suggestionsElement = shallowRef() + + const applySuggestion = (index = activeSuggestionIndex.value): void => { + input.value = suggestions.value[index] + displaySuggestion.value = false + } + + useEventListener('keydown', (event: KeyboardEvent) => { + if (displaySuggestion.value) { + if (event.key === 'ArrowUp') activePreviousSuggestion() + else if (event.key === 'ArrowDown') activeNextSuggestion() + else if (event.key === 'Enter') applySuggestion() + else if (event.key === 'Escape') displaySuggestion.value = false + } else if (event.key === 'Escape') { + isActive.value = false + } + }) + + const updateQueries = useDebounceFn( + (): void => { + void ( + searchOptions.value.querySplitter?.(input.value) ?? + Promise.resolve(input.value.split(' ')) + ).then((result) => { + queries.value = result + }) + }, + Math.min(options.searchDelay, options.suggestDelay), + ) + + watch(input, updateQueries, { immediate: true }) + + onMounted(() => { + const isLocked = useScrollLock(document.body) + + watch(isActive, async (value) => { + isLocked.value = value + if (value) { + await nextTick() + inputElement.value?.focus() + } + }) + + onClickOutside(suggestionsElement, () => { + displaySuggestion.value = false + }) + + onUnmounted(() => { + isLocked.value = false + }) + }) + + return (): VNode | null => + isActive.value + ? h('div', { class: 'slimsearch-modal-wrapper' }, [ + h('div', { + class: 'slimsearch-mask', + onClick: () => { + isActive.value = false + input.value = '' + }, + }), + h('div', { class: 'slimsearch-modal' }, [ + h('div', { class: 'slimsearch-box' }, [ + h('form', [ + h( + 'label', + { 'for': 'search-pro', 'aria-label': locale.value.search }, + h(SearchIcon), + ), + h('input', { + 'ref': inputElement, + 'type': 'search', + 'class': 'slimsearch-input', + 'id': 'search-pro', + 'placeholder': locale.value.placeholder, + 'spellcheck': 'false', + 'autocapitalize': 'off', + 'autocomplete': 'off', + 'autocorrect': 'off', + 'name': `${siteLocale.value.title}-search`, + 'value': input.value, + 'aria-controls': 'slimsearch-results', + 'onKeydown': (event: KeyboardEvent): void => { + const { key } = event + + if (suggestions.value.length) + if (key === 'Tab') { + applySuggestion() + event.preventDefault() + } else if ( + key === 'ArrowDown' || + key === 'ArrowUp' || + key === 'Escape' + ) { + event.preventDefault() + } + }, + 'onInput': ({ target }: InputEvent) => { + input.value = (target as HTMLInputElement).value + displaySuggestion.value = true + activeSuggestionIndex.value = 0 + }, + }), + input.value + ? h('button', { + type: 'reset', + class: 'slimsearch-clear-button', + innerHTML: CLOSE_ICON, + onClick: () => { + input.value = '' + }, + }) + : null, + enableAutoSuggestions && + displaySuggestion.value && + suggestions.value.length + ? h( + 'ul', + { + class: 'slimsearch-suggestions', + ref: suggestionsElement, + }, + suggestions.value.map((suggestion, index) => + h( + 'li', + { + class: [ + 'slimsearch-suggestion', + { + active: index === activeSuggestionIndex.value, + }, + ], + onClick: () => { + applySuggestion(index) + }, + }, + [ + h( + 'kbd', + { + class: 'slimsearch-auto-complete', + title: `Tab ${locale.value.autocomplete}`, + }, + 'Tab', + ), + suggestion, + ], + ), + ), + ) + : null, + ]), + h( + 'button', + { + type: 'button', + class: 'slimsearch-close-button', + onClick: () => { + isActive.value = false + input.value = '' + }, + }, + locale.value.cancel, + ), + ]), + + h(SearchResult, { + queries: queries.value, + isFocusing: !displaySuggestion.value, + onClose: () => { + isActive.value = false + }, + onUpdateQuery: (query: string) => { + input.value = query + }, + }), + + h(SearchKeyHints), + ]), + ]) + : null + }, +}) diff --git a/plugins/search/plugin-slimsearch/src/client/components/SearchResult.ts b/plugins/search/plugin-slimsearch/src/client/components/SearchResult.ts new file mode 100644 index 0000000000..ddf22c9186 --- /dev/null +++ b/plugins/search/plugin-slimsearch/src/client/components/SearchResult.ts @@ -0,0 +1,425 @@ +import { + isPlainObject, + isString, + useLocaleConfig, +} from '@vuepress/helper/client' +import { useEventListener } from '@vueuse/core' +import type { PropType, VNode } from 'vue' +import { computed, defineComponent, h, reactive, ref, toRef, watch } from 'vue' +import { RouteLink, useRouteLocale, useRouter } from 'vuepress/client' + +import { + useQueryHistory, + useResultHistory, + useResults, +} from '../composables/index.js' +import { customFieldConfig, locales } from '../define.js' +import { CLOSE_ICON } from '../icons/index.js' +import type { MatchedItem, Word } from '../typings/index.js' +import { getResultPath } from '../utils/index.js' +import { SearchLoading } from './SearchLoading.js' +import { HeadingIcon, HeartIcon, HistoryIcon, TitleIcon } from './icons.js' + +import '../styles/search-result.css' + +export default defineComponent({ + name: 'SearchResult', + + props: { + /** + * Query string + * + * 查询字符串 + */ + queries: { + type: Array as PropType, + required: true, + }, + + /** + * Whether is focusing + * + * 是否被聚焦 + */ + isFocusing: Boolean, + }, + + emits: ['close', 'updateQuery'], + + setup(props, { emit }) { + const router = useRouter() + const routeLocale = useRouteLocale() + const locale = useLocaleConfig(locales) + const { + enabled: enableQueryHistory, + addQueryHistory, + queryHistories, + removeQueryHistory, + } = useQueryHistory() + const { + enabled: enableResultHistory, + resultHistories, + addResultHistory, + removeResultHistory, + } = useResultHistory() + const enableHistory = enableQueryHistory || enableResultHistory + + const queries = toRef(props, 'queries') + const { results, isSearching } = useResults(queries) + + const activatedHistoryStatus = reactive({ isQuery: true, index: 0 }) + const activatedResultIndex = ref(0) + const activatedResultContentIndex = ref(0) + + const hasHistory = computed( + () => + enableHistory && + (queryHistories.value.length > 0 || resultHistories.value.length > 0), + ) + const hasResults = computed(() => results.value.length > 0) + const activatedResult = computed( + () => results.value[activatedResultIndex.value] || null, + ) + + const activePreviousHistory = (): void => { + const { isQuery, index } = activatedHistoryStatus + + if (index === 0) { + activatedHistoryStatus.isQuery = !isQuery + activatedHistoryStatus.index = isQuery + ? resultHistories.value.length - 1 + : queryHistories.value.length - 1 + } else { + activatedHistoryStatus.index = index - 1 + } + } + + const activeNextHistory = (): void => { + const { isQuery, index } = activatedHistoryStatus + + if ( + index === + (isQuery + ? queryHistories.value.length - 1 + : resultHistories.value.length - 1) + ) { + activatedHistoryStatus.isQuery = !isQuery + activatedHistoryStatus.index = 0 + } else { + activatedHistoryStatus.index = index + 1 + } + } + + const activePreviousResult = (): void => { + activatedResultIndex.value = + activatedResultIndex.value > 0 + ? activatedResultIndex.value - 1 + : results.value.length - 1 + activatedResultContentIndex.value = + activatedResult.value.contents.length - 1 + } + + const activeNextResult = (): void => { + activatedResultIndex.value = + activatedResultIndex.value < results.value.length - 1 + ? activatedResultIndex.value + 1 + : 0 + activatedResultContentIndex.value = 0 + } + + const activeNextResultContent = (): void => { + if ( + activatedResultContentIndex.value < + activatedResult.value.contents.length - 1 + ) + activatedResultContentIndex.value += 1 + else activeNextResult() + } + + const activePreviousResultContent = (): void => { + if (activatedResultContentIndex.value > 0) + activatedResultContentIndex.value -= 1 + else activePreviousResult() + } + + const getVNodes = (display: Word[]): (VNode | string)[] => + display.map((word) => (isString(word) ? word : h(word[0], word[1]))) + + const getDisplay = (matchedItem: MatchedItem): (VNode | string)[] => { + if (matchedItem.type === 'customField') { + const formatterConfig = + customFieldConfig[matchedItem.index] || '$content' + + const [prefix, suffix = ''] = isPlainObject(formatterConfig) + ? formatterConfig[routeLocale.value].split('$content') + : formatterConfig.split('$content') + + return matchedItem.display.map((display) => + h('div', getVNodes([prefix, ...display, suffix])), + ) + } + + return matchedItem.display.map((display) => h('div', getVNodes(display))) + } + + const resetSearchResult = (): void => { + activatedResultIndex.value = 0 + activatedResultContentIndex.value = 0 + emit('updateQuery', '') + emit('close') + } + + const renderSearchQueryHistory = (): VNode | null => + enableQueryHistory + ? h( + 'ul', + { class: 'slimsearch-result-list' }, + h('li', { class: 'slimsearch-result-list-item' }, [ + h( + 'div', + { class: 'slimsearch-result-title' }, + locale.value.queryHistory, + ), + queryHistories.value.map((item, historyIndex) => + h( + 'div', + { + class: [ + 'slimsearch-result-item', + { + active: + activatedHistoryStatus.isQuery && + activatedHistoryStatus.index === historyIndex, + }, + ], + onClick: () => { + emit('updateQuery', item) + }, + }, + [ + h(HistoryIcon, { + class: 'slimsearch-result-type', + }), + h('div', { class: 'slimsearch-result-content' }, item), + h('button', { + class: 'slimsearch-remove-icon', + innerHTML: CLOSE_ICON, + onClick: (event: Event) => { + event.preventDefault() + event.stopPropagation() + removeQueryHistory(historyIndex) + }, + }), + ], + ), + ), + ]), + ) + : null + + const renderSearchResultHistory = (): VNode | null => + enableResultHistory + ? h( + 'ul', + { class: 'slimsearch-result-list' }, + h('li', { class: 'slimsearch-result-list-item' }, [ + h( + 'div', + { class: 'slimsearch-result-title' }, + locale.value.resultHistory, + ), + + resultHistories.value.map((item, historyIndex) => + h( + RouteLink, + { + to: item.link, + class: [ + 'slimsearch-result-item', + { + active: + !activatedHistoryStatus.isQuery && + activatedHistoryStatus.index === historyIndex, + }, + ], + onClick: () => { + resetSearchResult() + }, + }, + () => [ + h(HistoryIcon, { + class: 'slimsearch-result-type', + }), + h('div', { class: 'slimsearch-result-content' }, [ + item.header + ? h('div', { class: 'content-header' }, item.header) + : null, + h( + 'div', + item.display + .map((display) => getVNodes(display)) + .flat(), + ), + ]), + h('button', { + class: 'slimsearch-remove-icon', + innerHTML: CLOSE_ICON, + onClick: (event: Event) => { + event.preventDefault() + event.stopPropagation() + removeResultHistory(historyIndex) + }, + }), + ], + ), + ), + ]), + ) + : null + + useEventListener('keydown', (event: KeyboardEvent) => { + if (!props.isFocusing) return + + if (hasResults.value) { + if (event.key === 'ArrowUp') { + activePreviousResultContent() + } else if (event.key === 'ArrowDown') { + activeNextResultContent() + } else if (event.key === 'Enter') { + const item = + activatedResult.value.contents[activatedResultContentIndex.value] + + addQueryHistory(props.queries.join(' ')) + addResultHistory(item) + void router.push(getResultPath(item)) + resetSearchResult() + } + } else if (enableResultHistory) { + if (event.key === 'ArrowUp') { + activePreviousHistory() + } else if (event.key === 'ArrowDown') { + activeNextHistory() + } else if (event.key === 'Enter') { + const { index } = activatedHistoryStatus + + if (activatedHistoryStatus.isQuery) { + emit('updateQuery', queryHistories.value[index]) + event.preventDefault() + } else { + void router.push(resultHistories.value[index].link) + resetSearchResult() + } + } + } + }) + + watch( + [activatedResultIndex, activatedResultContentIndex], + () => { + document + .querySelector( + '.slimsearch-result-list-item.active .slimsearch-result-item.active', + ) + ?.scrollIntoView(false) + }, + { flush: 'post' }, + ) + + return (): VNode => + h( + 'div', + { + class: [ + 'slimsearch-result-wrapper', + { + empty: props.queries.length + ? !hasResults.value + : !hasHistory.value, + }, + ], + id: 'slimsearch-results', + }, + props.queries.length + ? isSearching.value + ? h(SearchLoading, { hint: locale.value.searching }) + : hasResults.value + ? h( + 'ul', + { class: 'slimsearch-result-list' }, + results.value.map(({ title, contents }, index) => { + const isCurrentResultActive = + activatedResultIndex.value === index + + return h( + 'li', + { + class: [ + 'slimsearch-result-list-item', + { active: isCurrentResultActive }, + ], + }, + [ + h( + 'div', + { class: 'slimsearch-result-title' }, + title || locale.value.defaultTitle, + ), + contents.map((item, contentIndex) => { + const isCurrentContentActive = + isCurrentResultActive && + activatedResultContentIndex.value === contentIndex + + return h( + RouteLink, + { + to: getResultPath(item), + class: [ + 'slimsearch-result-item', + { + 'active': isCurrentContentActive, + 'aria-selected': isCurrentContentActive, + }, + ], + onClick: () => { + addQueryHistory(props.queries.join(' ')) + addResultHistory(item) + resetSearchResult() + }, + }, + () => [ + item.type === 'text' + ? null + : h( + item.type === 'title' + ? TitleIcon + : item.type === 'heading' + ? HeadingIcon + : HeartIcon, + { class: 'slimsearch-result-type' }, + ), + h('div', { class: 'slimsearch-result-content' }, [ + item.type === 'text' && item.header + ? h( + 'div', + { class: 'content-header' }, + item.header, + ) + : null, + h('div', getDisplay(item)), + ]), + ], + ) + }), + ], + ) + }), + ) + : locale.value.emptyResult + : enableHistory + ? hasHistory.value + ? [renderSearchQueryHistory(), renderSearchResultHistory()] + : locale.value.emptyHistory + : locale.value.emptyResult, + ) + }, +}) diff --git a/plugins/search/plugin-slimsearch/src/client/components/icons.ts b/plugins/search/plugin-slimsearch/src/client/components/icons.ts new file mode 100644 index 0000000000..00df82f9b1 --- /dev/null +++ b/plugins/search/plugin-slimsearch/src/client/components/icons.ts @@ -0,0 +1,71 @@ +import type { FunctionalComponent, VNode } from 'vue' +import { h } from 'vue' + +interface SVGWrapperProps { + name?: string + color?: string +} + +const SVGWrapper: FunctionalComponent< + SVGWrapperProps, + Record, + { default: () => VNode | VNode[] } +> = ({ name = '', color = 'currentColor' }, { slots }) => + h( + 'svg', + { + 'xmlns': 'http://www.w3.org/2000/svg', + 'class': ['icon', `${name}-icon`], + 'viewBox': '0 0 1024 1024', + 'fill': color, + 'aria-label': `${name} icon`, + }, + slots.default(), + ) + +SVGWrapper.displayName = 'SVGWrapper' + +export const HeadingIcon: FunctionalComponent = () => + h(SVGWrapper, { name: 'heading' }, () => + h('path', { + d: 'M250.4 704.6H64V595.4h202.4l26.2-166.6H94V319.6h214.4L352 64h127.8l-43.6 255.4h211.2L691 64h126.2l-43.6 255.4H960v109.2H756.2l-24.6 166.6H930v109.2H717L672 960H545.8l43.6-255.4H376.6L333 960H206.8l43.6-255.4zm168.4-276L394 595.4h211.2l24.6-166.6h-211z', + }), + ) + +HeadingIcon.displayName = 'HeadingIcon' + +export const HeartIcon: FunctionalComponent = () => + h(SVGWrapper, { name: 'heart' }, () => + h('path', { + d: 'M1024 358.156C1024 195.698 892.3 64 729.844 64c-86.362 0-164.03 37.218-217.844 96.49C458.186 101.218 380.518 64 294.156 64 131.698 64 0 195.698 0 358.156 0 444.518 37.218 522.186 96.49 576H96l320 320c32 32 64 64 96 64s64-32 96-64l320-320h-.49c59.272-53.814 96.49-131.482 96.49-217.844zM841.468 481.232 517.49 805.49a2981.962 2981.962 0 0 1-5.49 5.48c-1.96-1.95-3.814-3.802-5.49-5.48L182.532 481.234C147.366 449.306 128 405.596 128 358.156 128 266.538 202.538 192 294.156 192c47.44 0 91.15 19.366 123.076 54.532L512 350.912l94.768-104.378C638.696 211.366 682.404 192 729.844 192 821.462 192 896 266.538 896 358.156c0 47.44-19.368 91.15-54.532 123.076z', + }), + ) + +HeartIcon.displayName = 'HeartIcon' + +export const HistoryIcon: FunctionalComponent = () => + h(SVGWrapper, { name: 'history' }, () => + h('path', { + d: 'M512 1024a512 512 0 1 1 512-512 512 512 0 0 1-512 512zm0-896a384 384 0 1 0 384 384 384 384 0 0 0-384-384zm192 448H512a64 64 0 0 1-64-64V320a64 64 0 0 1 128 0v128h128a64 64 0 0 1 0 128z', + }), + ) + +HistoryIcon.displayName = 'HistoryIcon' + +export const TitleIcon: FunctionalComponent = () => + h(SVGWrapper, { name: 'title' }, () => + h('path', { + d: 'M512 256c70.656 0 134.656 28.672 180.992 75.008A254.933 254.933 0 0 1 768 512c0 83.968-41.024 157.888-103.488 204.48C688.96 748.736 704 788.48 704 832c0 105.984-86.016 192-192 192-106.048 0-192-86.016-192-192h128a64 64 0 1 0 128 0 64 64 0 0 0-64-64 255.19 255.19 0 0 1-181.056-75.008A255.403 255.403 0 0 1 256 512c0-83.968 41.024-157.824 103.488-204.544C335.04 275.264 320 235.584 320 192A192 192 0 0 1 512 0c105.984 0 192 85.952 192 192H576a64.021 64.021 0 0 0-128 0c0 35.328 28.672 64 64 64zM384 512c0 70.656 57.344 128 128 128s128-57.344 128-128-57.344-128-128-128-128 57.344-128 128z', + }), + ) + +TitleIcon.displayName = 'TitleIcon' + +export const SearchIcon: FunctionalComponent = () => + h(SVGWrapper, { name: 'search' }, () => + h('path', { + d: 'M192 480a256 256 0 1 1 512 0 256 256 0 0 1-512 0m631.776 362.496-143.2-143.168A318.464 318.464 0 0 0 768 480c0-176.736-143.264-320-320-320S128 303.264 128 480s143.264 320 320 320a318.016 318.016 0 0 0 184.16-58.592l146.336 146.368c12.512 12.48 32.768 12.48 45.28 0 12.48-12.512 12.48-32.768 0-45.28', + }), + ) + +SearchIcon.displayName = 'SearchIcon' diff --git a/plugins/search/plugin-slimsearch/src/client/composables/index.ts b/plugins/search/plugin-slimsearch/src/client/composables/index.ts new file mode 100644 index 0000000000..e7d1806cf2 --- /dev/null +++ b/plugins/search/plugin-slimsearch/src/client/composables/index.ts @@ -0,0 +1,7 @@ +export * from './setup.js' +export * from './useArrayCycle.js' +export * from './useMobile.js' +export * from './useQueryHistory.js' +export * from './useResultHistory.js' +export * from './useResults.js' +export * from './useSuggestions.js' diff --git a/plugins/search/plugin-slimsearch/src/client/composables/setup.ts b/plugins/search/plugin-slimsearch/src/client/composables/setup.ts new file mode 100644 index 0000000000..1b41cafd6c --- /dev/null +++ b/plugins/search/plugin-slimsearch/src/client/composables/setup.ts @@ -0,0 +1,14 @@ +import type { InjectionKey, Ref } from 'vue' +import { provide, ref } from 'vue' + +declare const __VUEPRESS_DEV__: boolean + +export const searchModalSymbol: InjectionKey> = Symbol( + __VUEPRESS_DEV__ ? 'search-pro' : '', +) + +export const setupSearchModal = (): void => { + const isActive = ref(false) + + provide(searchModalSymbol, isActive) +} diff --git a/plugins/search/plugin-slimsearch/src/client/composables/useArrayCycle.ts b/plugins/search/plugin-slimsearch/src/client/composables/useArrayCycle.ts new file mode 100644 index 0000000000..fc1910a634 --- /dev/null +++ b/plugins/search/plugin-slimsearch/src/client/composables/useArrayCycle.ts @@ -0,0 +1,36 @@ +import type { Ref } from 'vue' +import { computed, ref, watch } from 'vue' + +export interface ArrayCycle { + index: Ref + item: Ref + prev: () => void + next: () => void +} + +export const useArrayCycle = ( + target: Ref, + preserveIndexWhenChange = false, +): ArrayCycle => { + const index = ref(0) + const item = computed(() => target.value[index.value]) + + const prev = (): void => { + index.value = index.value > 0 ? index.value - 1 : target.value.length - 1 + } + + const next = (): void => { + index.value = index.value < target.value.length - 1 ? index.value + 1 : 0 + } + + watch(target, () => { + if (!preserveIndexWhenChange) index.value = 0 + }) + + return { + index, + item, + prev, + next, + } +} diff --git a/plugins/search/plugin-slimsearch/src/client/composables/useMobile.ts b/plugins/search/plugin-slimsearch/src/client/composables/useMobile.ts new file mode 100644 index 0000000000..f6daa9276f --- /dev/null +++ b/plugins/search/plugin-slimsearch/src/client/composables/useMobile.ts @@ -0,0 +1,15 @@ +import { useSupported } from '@vueuse/core' +import type { ComputedRef } from 'vue' +import { computed } from 'vue' + +export const useMobile = (): ComputedRef => { + const supportUserAgent = useSupported( + () => typeof window !== 'undefined' && 'userAgent' in window.navigator, + ) + + return computed( + () => + supportUserAgent.value && + /\b(?:Android|iPhone)/i.test(navigator.userAgent), + ) +} diff --git a/plugins/search/plugin-slimsearch/src/client/composables/useQueryHistory.ts b/plugins/search/plugin-slimsearch/src/client/composables/useQueryHistory.ts new file mode 100644 index 0000000000..a2404e7b57 --- /dev/null +++ b/plugins/search/plugin-slimsearch/src/client/composables/useQueryHistory.ts @@ -0,0 +1,47 @@ +import { useLocalStorage } from '@vueuse/core' +import type { Ref } from 'vue' + +import { options } from '../define.js' + +const SLIMSEARCH_HISTORY_QUERY_STORAGE = 'SLIMSEARCH_QUERY_HISTORY' + +export interface QueryHistory { + enabled: boolean + queryHistories: Ref + addQueryHistory: (item: string) => void + removeQueryHistory: (index: number) => void +} + +const searchProQueryStorage = useLocalStorage( + SLIMSEARCH_HISTORY_QUERY_STORAGE, + [], +) + +export const useQueryHistory = (): QueryHistory => { + const { queryHistoryCount } = options + const enabled = queryHistoryCount > 0 + + const addQueryHistory = (item: string): void => { + if (enabled) + searchProQueryStorage.value = Array.from( + new Set([ + item, + ...searchProQueryStorage.value.slice(0, queryHistoryCount - 1), + ]), + ) + } + + const removeQueryHistory = (index: number): void => { + searchProQueryStorage.value = [ + ...searchProQueryStorage.value.slice(0, index), + ...searchProQueryStorage.value.slice(index + 1), + ] + } + + return { + enabled, + queryHistories: searchProQueryStorage, + addQueryHistory, + removeQueryHistory, + } +} diff --git a/plugins/search/plugin-slimsearch/src/client/composables/useResultHistory.ts b/plugins/search/plugin-slimsearch/src/client/composables/useResultHistory.ts new file mode 100644 index 0000000000..a75af39680 --- /dev/null +++ b/plugins/search/plugin-slimsearch/src/client/composables/useResultHistory.ts @@ -0,0 +1,62 @@ +import { useLocalStorage } from '@vueuse/core' +import type { Ref } from 'vue' + +import { options } from '../define.js' +import type { MatchedItem, Word } from '../typings/index.js' +import { getResultPath } from '../utils/index.js' + +const SLIMSEARCH_RESULT_HISTORY_STORAGE = 'SLIMSEARCH_RESULT_HISTORY' + +export interface ResultHistoryItem { + header?: string + link: string + display: Word[][] +} + +export interface ResultHistory { + enabled: boolean + resultHistories: Ref + addResultHistory: (item: MatchedItem) => void + removeResultHistory: (index: number) => void +} + +const { resultHistoryCount } = options + +const searchProResultStorage = useLocalStorage( + SLIMSEARCH_RESULT_HISTORY_STORAGE, + [], +) + +export const useResultHistory = (): ResultHistory => { + const enabled = resultHistoryCount > 0 + + const addResultHistory = (item: MatchedItem): void => { + if (enabled) { + const resultHistory: ResultHistoryItem = { + link: getResultPath(item), + display: item.display, + } + + if ('header' in item) resultHistory.header = item.header + + searchProResultStorage.value = [ + resultHistory, + ...searchProResultStorage.value.slice(0, resultHistoryCount - 1), + ] + } + } + + const removeResultHistory = (index: number): void => { + searchProResultStorage.value = [ + ...searchProResultStorage.value.slice(0, index), + ...searchProResultStorage.value.slice(index + 1), + ] + } + + return { + enabled, + resultHistories: searchProResultStorage, + addResultHistory, + removeResultHistory, + } +} diff --git a/plugins/search/plugin-slimsearch/src/client/composables/useResults.ts b/plugins/search/plugin-slimsearch/src/client/composables/useResults.ts new file mode 100644 index 0000000000..3edb2302f4 --- /dev/null +++ b/plugins/search/plugin-slimsearch/src/client/composables/useResults.ts @@ -0,0 +1,79 @@ +import { useDebounceFn } from '@vueuse/core' +import type { Ref } from 'vue' +import { computed, onMounted, onUnmounted, ref, shallowRef, watch } from 'vue' +import { usePageData, useRouteLocale } from 'vuepress/client' + +import { options } from '../define.js' +import { useSearchOptions } from '../helpers/index.js' +import type { SearchResult } from '../typings/index.js' +import { createSearchWorker } from '../utils/index.js' + +export interface Results { + results: Ref + isSearching: Ref +} + +export const useResults = (queries: Ref): Results => { + const searchOptions = useSearchOptions() + const routeLocale = useRouteLocale() + const pageData = usePageData() + + const searchingProcessNumber = ref(0) + const isSearching = computed(() => searchingProcessNumber.value > 0) + const results = shallowRef([]) + + onMounted(() => { + const { search, terminate } = createSearchWorker() + + const performSearch = useDebounceFn( + (query: string): void => { + const { + resultsFilter = (items): SearchResult[] => items, + querySplitter, + suggestionsFilter, + ...rest + } = searchOptions.value + + if (query) { + searchingProcessNumber.value += 1 + + search(query, routeLocale.value, rest) + .then((items) => + resultsFilter(items, query, routeLocale.value, pageData.value), + ) + .then((items) => { + searchingProcessNumber.value -= 1 + results.value = items + }) + .catch((err: unknown) => { + // eslint-disable-next-line no-console + console.warn(err) + searchingProcessNumber.value -= 1 + if (!searchingProcessNumber.value) results.value = [] + }) + } else { + results.value = [] + } + }, + options.searchDelay - options.suggestDelay, + { maxWait: 5000 }, + ) + + watch( + [queries, routeLocale], + ([newQueries]) => { + void performSearch(newQueries.join(' ')) + }, + { immediate: true }, + ) + + onUnmounted(() => { + terminate() + }) + }) + + return { + isSearching, + results, + } +} diff --git a/plugins/search/plugin-slimsearch/src/client/composables/useSuggestions.ts b/plugins/search/plugin-slimsearch/src/client/composables/useSuggestions.ts new file mode 100644 index 0000000000..17ebad7af7 --- /dev/null +++ b/plugins/search/plugin-slimsearch/src/client/composables/useSuggestions.ts @@ -0,0 +1,75 @@ +import { startsWith } from '@vuepress/helper/client' +import type { Ref } from 'vue' +import { onMounted, onUnmounted, ref, watch } from 'vue' +import { usePageData, useRouteLocale } from 'vuepress/client' + +import { enableAutoSuggestions } from '../define.js' +import { useSearchOptions } from '../helpers/index.js' +import { createSearchWorker } from '../utils/index.js' + +export interface SuggestionsRef { + suggestions: Ref +} + +export const useSuggestions = (queries: Ref): SuggestionsRef => { + const suggestions = ref([]) + + if (enableAutoSuggestions) { + const searchOptions = useSearchOptions() + const pageData = usePageData() + const routeLocale = useRouteLocale() + + onMounted(() => { + const { suggest, terminate } = createSearchWorker() + + const performSuggestion = (query: string): void => { + const { + resultsFilter, + querySplitter, + suggestionsFilter = (items): string[] => items, + ...options + } = searchOptions.value + + if (query) + suggest(query, routeLocale.value, options) + .then((items) => + suggestionsFilter( + items, + query, + routeLocale.value, + pageData.value, + ), + ) + .then((items) => { + suggestions.value = items.length + ? startsWith(items[0], query) && + !items[0].slice(query.length).includes(' ') + ? items + : [query, ...items] + : [] + }) + .catch((err: unknown) => { + // eslint-disable-next-line no-console + console.error(err) + }) + else suggestions.value = [] + } + + watch( + [queries, routeLocale], + ([newQueries]) => { + performSuggestion(newQueries.join(' ')) + }, + { immediate: true }, + ) + + onUnmounted(() => { + terminate() + }) + }) + } + + return { + suggestions, + } +} diff --git a/plugins/search/plugin-slimsearch/src/client/config.ts b/plugins/search/plugin-slimsearch/src/client/config.ts new file mode 100644 index 0000000000..9f85abc239 --- /dev/null +++ b/plugins/search/plugin-slimsearch/src/client/config.ts @@ -0,0 +1,17 @@ +import { defineClientConfig } from 'vuepress/client' + +import SearchBox from './components/SearchBox.js' +import SearchModal from './components/SearchModal.js' +import { setupSearchModal } from './composables/index.js' +import { injectSearchConfig } from './helpers/index.js' + +export default defineClientConfig({ + enhance({ app }) { + injectSearchConfig(app) + app.component('SearchBox', SearchBox) + }, + setup() { + setupSearchModal() + }, + rootComponents: [SearchModal], +}) diff --git a/plugins/search/plugin-slimsearch/src/client/define.ts b/plugins/search/plugin-slimsearch/src/client/define.ts new file mode 100644 index 0000000000..2556bbdb1f --- /dev/null +++ b/plugins/search/plugin-slimsearch/src/client/define.ts @@ -0,0 +1,28 @@ +import type { + SlimSearchCustomFieldFormatter, + SlimSearchKeyOptions, + SlimSearchLocaleConfig, +} from '../shared/index.js' + +type SlimSearchClientCustomFiledConfig = Record< + string, + SlimSearchCustomFieldFormatter +> + +declare const __SLIMSEARCH_AUTO_SUGGESTIONS__: boolean +declare const __SLIMSEARCH_CUSTOM_FIELDS__: SlimSearchClientCustomFiledConfig +declare const __SLIMSEARCH_OPTIONS__: { + searchDelay: number + suggestDelay: number + queryHistoryCount: number + resultHistoryCount: number + hotKeys: SlimSearchKeyOptions[] + worker: string +} +declare const __SLIMSEARCH_LOCALES__: SlimSearchLocaleConfig + +export const customFieldConfig = __SLIMSEARCH_CUSTOM_FIELDS__ +export const enableAutoSuggestions = __SLIMSEARCH_AUTO_SUGGESTIONS__ +export const options = __SLIMSEARCH_OPTIONS__ +export const hotKeysConfig = options.hotKeys +export const locales = __SLIMSEARCH_LOCALES__ diff --git a/plugins/search/plugin-slimsearch/src/client/helpers/index.ts b/plugins/search/plugin-slimsearch/src/client/helpers/index.ts new file mode 100644 index 0000000000..66878efecc --- /dev/null +++ b/plugins/search/plugin-slimsearch/src/client/helpers/index.ts @@ -0,0 +1 @@ +export * from './search.js' diff --git a/plugins/search/plugin-slimsearch/src/client/helpers/search.ts b/plugins/search/plugin-slimsearch/src/client/helpers/search.ts new file mode 100644 index 0000000000..1e7cb64bd9 --- /dev/null +++ b/plugins/search/plugin-slimsearch/src/client/helpers/search.ts @@ -0,0 +1,57 @@ +import type { App, ComputedRef, InjectionKey } from 'vue' +import { computed, inject } from 'vue' +import type { PageData } from 'vuepress/client' +import { useRouteLocale } from 'vuepress/client' + +import type { SearchResult, WorkerSearchOptions } from '../typings/index.js' + +declare const __VUEPRESS_DEV__: boolean + +export interface SearchLocaleOptions extends WorkerSearchOptions { + /** A function to split words */ + querySplitter?: (query: string) => Promise + + /** A function to filter suggestions */ + suggestionsFilter?: ( + suggestions: string[], + query: string, + locale: string, + pageData: PageData, + ) => string[] + + /** A function to filter search results */ + resultsFilter?: ( + results: SearchResult[], + query: string, + locale: string, + pageData: PageData, + ) => SearchResult[] +} + +export interface SearchOptions extends SearchLocaleOptions { + locales?: Record +} + +let searchOptions: SearchOptions = {} + +const slimsearchSymbol: InjectionKey = Symbol( + __VUEPRESS_DEV__ ? 'slimsearch' : '', +) + +export const defineSearchConfig = (options: SearchOptions): void => { + searchOptions = options as unknown as SearchOptions +} + +export const useSearchOptions = (): ComputedRef => { + const routeLocale = useRouteLocale() + const { locales = {}, ...options } = inject(slimsearchSymbol)! + + return computed(() => ({ + ...options, + ...locales[routeLocale.value], + })) +} + +export const injectSearchConfig = (app: App): void => { + app.provide(slimsearchSymbol, searchOptions) +} diff --git a/plugins/search/plugin-slimsearch/src/client/icons/closeIcon.ts b/plugins/search/plugin-slimsearch/src/client/icons/closeIcon.ts new file mode 100644 index 0000000000..0c028e8c86 --- /dev/null +++ b/plugins/search/plugin-slimsearch/src/client/icons/closeIcon.ts @@ -0,0 +1,2 @@ +export const CLOSE_ICON = + '' diff --git a/plugins/search/plugin-slimsearch/src/client/icons/index.ts b/plugins/search/plugin-slimsearch/src/client/icons/index.ts new file mode 100644 index 0000000000..6eee2125eb --- /dev/null +++ b/plugins/search/plugin-slimsearch/src/client/icons/index.ts @@ -0,0 +1,2 @@ +export * from './closeIcon.js' +export * from './keyIcons.js' diff --git a/plugins/search/plugin-slimsearch/src/client/icons/keyIcons.ts b/plugins/search/plugin-slimsearch/src/client/icons/keyIcons.ts new file mode 100644 index 0000000000..9888780f29 --- /dev/null +++ b/plugins/search/plugin-slimsearch/src/client/icons/keyIcons.ts @@ -0,0 +1,11 @@ +export const ENTER_KEY_ICON = + '' + +export const DOWN_KEY_ICON = + '' + +export const UP_KEY_ICON = + '' + +export const ESC_KEY_ICON = + '' diff --git a/plugins/search/plugin-slimsearch/src/client/index.ts b/plugins/search/plugin-slimsearch/src/client/index.ts new file mode 100644 index 0000000000..54941918d5 --- /dev/null +++ b/plugins/search/plugin-slimsearch/src/client/index.ts @@ -0,0 +1,2 @@ +export * from './helpers/index.js' +export { type SearchWorker, createSearchWorker } from './utils/index.js' diff --git a/plugins/search/plugin-slimsearch/src/client/styles/search-box.scss b/plugins/search/plugin-slimsearch/src/client/styles/search-box.scss new file mode 100644 index 0000000000..063d51c34c --- /dev/null +++ b/plugins/search/plugin-slimsearch/src/client/styles/search-box.scss @@ -0,0 +1,81 @@ +.slimsearch-button { + display: inline-flex; + align-items: center; + + box-sizing: content-box; + height: 1.25rem; + margin-inline: 1rem 0; + margin-top: 0; + margin-bottom: 0; + padding: 0.5rem; + border: 1px solid transparent; + border-radius: 1rem; + + background: transparent; + background: var(--vp-c-control); + color: var(--vp-c-text); + + font-weight: 500; + + cursor: pointer; + + transition: + background var(--vp-t-color), + color var(--vp-t-color); + + @media print { + display: none; + } + + @media (max-width: 959px) { + border-radius: 50%; + } + + &:hover { + border: 1px solid var(--vp-c-accent-bg); + background-color: var(--vp-c-control-hover); + } + + .search-icon { + width: 1.25rem; + height: 1.25rem; + } +} + +.slimsearch-placeholder { + margin-inline: 0.25rem; + font-size: 1rem; + + @media (max-width: 959px) { + display: none; + } +} + +.slimsearch-key-hints { + font-size: 0.75rem; + + @media (max-width: 959px) { + display: none; + } +} + +.slimsearch-key { + display: inline-block; + + min-width: 1em; + margin-inline: 0.125rem; + padding: 0.25rem; + border: 1px solid var(--vp-c-border); + border-radius: 4px; + + box-shadow: 1px 1px 4px 0 var(--vp-c-shadow); + + line-height: 1; + letter-spacing: -0.1em; + + transition: + background var(--vp-t-color), + color var(--vp-t-color), + border var(--vp-t-color), + box-shadow var(--vp-t-transform); +} diff --git a/plugins/search/plugin-slimsearch/src/client/styles/search-modal.scss b/plugins/search/plugin-slimsearch/src/client/styles/search-modal.scss new file mode 100644 index 0000000000..093db9f6d7 --- /dev/null +++ b/plugins/search/plugin-slimsearch/src/client/styles/search-modal.scss @@ -0,0 +1,266 @@ +@keyframes slimsearch-fade-in { + from { + opacity: 0.2; + } + + to { + opacity: 1; + } +} + +.slimsearch-modal-wrapper { + position: fixed; + inset: 0; + z-index: 997; + + display: flex; + align-items: center; + justify-content: center; + + overflow: auto; + + cursor: default; +} + +.slimsearch-mask { + position: fixed; + inset: 0; + z-index: 998; + + backdrop-filter: blur(10px); + + animation: 0.25s slimsearch-fade-in; +} + +.slimsearch-modal { + position: absolute; + z-index: 999; + + display: flex; + flex-flow: column; + + width: calc(100% - 6rem); + max-width: 50em; + border-radius: 10px; + + background: var(--vp-c-bg); + box-shadow: 2px 2px 10px 0 var(--vp-c-shadow); + + transition: background var(--vp-t-color); + + animation: 0.15s pwa-opened; + + @media (max-width: 1280px) { + animation: 0.25s pwa-mobile; + } + + @media (max-width: 719px) { + inset: 0; + + box-sizing: border-box; + width: 100%; + max-width: unset; + padding: env(--safe-area-inset-top) env(--safe-area-inset-right) + env(--safe-area-inset-bottom) env(--safe-area-inset-left); + } +} + +.slimsearch-box { + display: flex; + margin: 1rem; + + form { + position: relative; + display: flex; + flex: 1; + } + + label { + position: absolute; + inset-inline-start: 0.5rem; + top: calc(50% - 0.75rem); + color: var(--vp-c-accent); + + .search-icon { + width: 1.5rem; + height: 1.5rem; + } + } +} + +.slimsearch-clear-button { + position: absolute; + inset-inline-end: 0.75rem; + top: calc(50% - 10px); + + padding: 0; + border-width: 0; + + background: transparent; + color: var(--vp-c-accent-bg); + + cursor: pointer; + + &:hover { + border-radius: 50%; + background-color: rgb(0 0 0 / 10%); + } +} + +.slimsearch-close-button { + display: none; + + margin-inline: 0.5rem -0.5rem; + padding: 0.5rem; + border-width: 0; + + background: transparent; + color: var(--vp-c-text-mute); + + font-size: 1rem; + + cursor: pointer; + + @media (max-width: 719px) { + display: block; + } +} + +.slimsearch-input { + flex: 1; + + width: 0; + margin: 0; + padding-block: 0.25rem; + padding-inline: 2.5rem 2rem; + border: 0; + border: 2px solid var(--vp-c-accent-bg); + border-radius: 8px; + + background: var(--vp-c-bg); + color: var(--vp-c-text); + outline: none; + + font-size: 1.25rem; + line-height: 2.5; + + appearance: none; + + &::-webkit-search-cancel-button { + display: none; + } +} + +.slimsearch-suggestions { + position: absolute; + inset: calc(100% + 4px) 0 auto; + z-index: 20; + + overflow: visible; + overflow-y: auto; + + max-height: 50vh; + margin: 0; + padding: 0; + border-radius: 0.5rem; + + background-color: var(--vp-c-bg); + box-shadow: 2px 2px 10px 0 var(--vp-c-shadow); + list-style: none; + + line-height: 1.5; +} + +.slimsearch-suggestion { + padding: 0.25rem 1rem; + border-top: 1px solid var(--vp-c-border); + cursor: pointer; + + &:first-child { + border-top: none; + } + + &.active, + &:hover { + background-color: var(--vp-c-bg-alt); + } +} + +.slimsearch-auto-complete { + display: none; + float: right; + + margin: 0 0.5rem; + padding: 4px; + border: 1px solid var(--vp-c-border); + border-radius: 4px; + + box-shadow: 1px 1px 4px 0 var(--vp-c-shadow); + + font-size: 12px; + line-height: 1; + + .slimsearch-suggestion.active & { + display: block; + } +} + +.slimsearch-result-wrapper { + flex-grow: 1; + + overflow-y: auto; + + min-height: 40vh; + max-height: calc(80vh - 10rem); + padding: 0 1rem; + + @media (max-width: 719px) { + min-height: unset; + max-height: unset; + } + + &.loading, + &.empty { + display: flex; + align-items: center; + justify-content: center; + + padding: 1.5rem; + + font-weight: 600; + font-size: 22px; + text-align: center; + } +} + +.slimsearch-hints { + margin-top: 1rem; + padding: 0.75rem 0.5rem; + box-shadow: 0 -1px 4px 0 var(--vp-c-shadow); + line-height: 1; +} + +.slimsearch-hint { + display: inline-flex; + align-items: center; + margin: 0 0.5rem; + + kbd { + margin: 0 0.5rem; + padding: 2px; + border: 1px solid var(--vp-c-border); + border-radius: 4px; + + box-shadow: 1px 1px 4px 0 var(--vp-c-shadow); + + + kbd { + margin-inline-start: -0.25rem; + } + } + + svg { + display: block; + width: 15px; + height: 15px; + } +} diff --git a/plugins/search/plugin-slimsearch/src/client/styles/search-result.scss b/plugins/search/plugin-slimsearch/src/client/styles/search-result.scss new file mode 100644 index 0000000000..a32936fcef --- /dev/null +++ b/plugins/search/plugin-slimsearch/src/client/styles/search-result.scss @@ -0,0 +1,149 @@ +.slimsearch-result-wrapper { + scrollbar-color: var(--vp-c-accent) var(--vp-c-border); + scrollbar-width: thin; + + @media (max-width: 419px) { + font-size: 14px; + } + + &::-webkit-scrollbar { + width: 6px; + height: 6px; + } + + &::-webkit-scrollbar-track-piece { + border-radius: 6px; + background: rgb(0 0 0 / 10%); + } + + &::-webkit-scrollbar-thumb { + border-radius: 6px; + background: var(--vp-c-accent-bg); + + &:active { + background: var(--vp-c-accent-hover); + } + } + + mark { + border-radius: 0.25em; + line-height: 1; + } +} + +.slimsearch-result-list { + margin: 0; + padding: 0; +} + +.slimsearch-result-list-item { + display: block; + list-style: none; +} + +.slimsearch-result-title { + position: sticky; + top: -2px; + z-index: 10; + + margin: -4px; + margin-bottom: 0.25rem; + padding: 4px; + + background: var(--vp-c-bg); + color: var(--vp-c-accent); + + font-weight: 600; + font-size: 0.875em; + line-height: 2rem; + text-indent: 0.5em; + + .slimsearch-result-item.active & { + color: var(--vp-c-accent); + } +} + +.slimsearch-result-type { + display: block; + + width: 1rem; + height: 1rem; + margin-inline-start: -0.5rem; + padding: 0.5rem; + + color: var(--vp-c-accent); +} + +.slimsearch-remove-icon { + box-sizing: content-box; + height: 1.5rem; + padding: 0; + border-width: 0; + border-radius: 50%; + + background: transparent; + color: var(--vp-c-accent); + + font-size: 1rem; + + cursor: pointer; + + svg { + width: 1.5rem; + height: 1.5rem; + } + + &:hover { + background: rgb(128 128 128 / 30%); + } +} + +.slimsearch-result-content { + display: flex; + flex-flow: column; + flex-grow: 1; + align-items: stretch; + justify-content: center; + + line-height: 1.5; + + .content-header { + margin-bottom: 0.25rem; + border-bottom: 1px solid var(--vp-c-border-hard); + font-size: 0.9em; + } +} + +.slimsearch-result-item { + display: flex; + align-items: center; + + margin: 0.5rem 0; + padding: 0.5rem 0.75rem; + border-radius: 0.25rem; + + background: var(--vp-c-bg-alt); + color: inherit; + box-shadow: 0 1px 3px 0 var(--vp-c-shadow); + + font-weight: normal; + white-space: pre-wrap; + word-wrap: break-word; + + strong { + color: var(--vp-c-accent); + } + + &:hover, + &.active { + background-color: var(--vp-c-accent-hover); + color: var(--vp-c-white); + cursor: pointer; + + .slimsearch-result-type, + .slimsearch-remove-icon, + strong { + color: var(--vp-c-white); + } + } +} diff --git a/plugins/search/plugin-slimsearch/src/client/typings/index.ts b/plugins/search/plugin-slimsearch/src/client/typings/index.ts new file mode 100644 index 0000000000..f216fbec5b --- /dev/null +++ b/plugins/search/plugin-slimsearch/src/client/typings/index.ts @@ -0,0 +1,2 @@ +export type * from './result.js' +export type * from './worker.js' diff --git a/plugins/search/plugin-slimsearch/src/client/typings/result.ts b/plugins/search/plugin-slimsearch/src/client/typings/result.ts new file mode 100644 index 0000000000..5c211e8420 --- /dev/null +++ b/plugins/search/plugin-slimsearch/src/client/typings/result.ts @@ -0,0 +1,45 @@ +export type Word = string | [tag: string, content: string] + +export interface TitleMatchedItem { + type: 'title' + id: number + display: Word[][] +} + +export interface HeadingMatchedItem { + type: 'heading' + id: number + anchor: string + display: Word[][] +} + +export interface ContentMatchedItem { + type: 'text' + id: number + header?: string + anchor?: string + display: Word[][] +} + +export interface CustomMatchedItem { + type: 'customField' + id: number + index: string + display: Word[][] +} + +export type MatchedItem = + | ContentMatchedItem + | CustomMatchedItem + | HeadingMatchedItem + | TitleMatchedItem + +export interface SearchResult { + title: string + contents: MatchedItem[] +} + +export interface QueryResult { + suggestions: string[] + results: SearchResult[] +} diff --git a/plugins/search/plugin-slimsearch/src/client/typings/worker.ts b/plugins/search/plugin-slimsearch/src/client/typings/worker.ts new file mode 100644 index 0000000000..6c59e00814 --- /dev/null +++ b/plugins/search/plugin-slimsearch/src/client/typings/worker.ts @@ -0,0 +1,19 @@ +import type { SearchOptions } from 'slimsearch' + +import type { IndexItem } from '../../shared/index.js' + +export type WorkerSearchOptions = Omit< + SearchOptions, + 'boostDocument' | 'fields' | 'filter' | 'processTerm' | 'tokenize' +> + +export interface MessageData { + /** + * @default "all" + */ + type?: 'all' | 'search' | 'suggest' + query: string + locale: string + options?: WorkerSearchOptions + id: number +} diff --git a/plugins/search/plugin-slimsearch/src/client/utils/createSearchWorker.ts b/plugins/search/plugin-slimsearch/src/client/utils/createSearchWorker.ts new file mode 100644 index 0000000000..e4663ae9ee --- /dev/null +++ b/plugins/search/plugin-slimsearch/src/client/utils/createSearchWorker.ts @@ -0,0 +1,177 @@ +import { values } from '@vuepress/helper/client' +import type { SearchOptions } from 'slimsearch' + +import type { IndexItem } from '../../shared/index.js' +import { options } from '../define.js' +import type { QueryResult, SearchResult } from '../typings/index.js' + +declare const __VUEPRESS_BASE__: string +declare const __VUEPRESS_DEV__: boolean + +interface PromiseItem { + id: number + // eslint-disable-next-line @typescript-eslint/no-explicit-any + resolve: (args: any) => void + reject: (err: Error) => void +} + +export interface SearchWorker { + /** + * Get both suggestions and results + * + * 同时获取建议和结果 + * + * @param query - search query 搜索词 + * @param localePath - locale path 语言路径 + * @param options - search options 搜索选项 + */ + all: ( + query: string, + localePath?: string, + options?: SearchOptions, + ) => Promise + + /** + * Get suggestions + * + * 获取建议 + * + * @param query - search query 搜索词 + * @param localePath - locale path 语言路径 + * @param searchOptions - search options 搜索选项 + */ + suggest: ( + query: string, + localePath?: string, + options?: SearchOptions, + ) => Promise + + /** + * Get search results + * + * 获取搜索结果 + * + * @param query - search query 搜索词 + * @param localePath - locale path 语言路径 + * @param searchOptions - search options 搜索选项 + */ + search: ( + query: string, + localePath?: string, + options?: SearchOptions, + ) => Promise + + /** + * Terminate current worker + * + * 终止当前 worker + */ + terminate: () => void +} + +const ERR_MSG = 'Canceled because of new search request.' + +export const createSearchWorker = (): SearchWorker => { + const worker = new Worker( + __VUEPRESS_DEV__ + ? new URL('./worker/index.js', import.meta.url) + : `${__VUEPRESS_BASE__}${options.worker}`, + __VUEPRESS_DEV__ ? { type: 'module' } : {}, + ) + + const states: Record<'all' | 'search' | 'suggest', PromiseItem | null> = { + suggest: null, + search: null, + all: null, + } + + worker.addEventListener( + 'message', + ({ + data, + }: MessageEvent< + | ['all', number, QueryResult] + | ['search', number, SearchResult[]] + | ['suggest', number, string[]] + >) => { + const [type, timestamp, result] = data + const state = states[type] + + if (state?.id === timestamp) state.resolve(result) + }, + ) + + worker.addEventListener('error', (err) => { + // eslint-disable-next-line no-console + console.warn('Search Worker error:', err) + }) + + return { + suggest: ( + query: string, + locale?: string, + searchOptions?: SearchOptions, + ): Promise => + new Promise((resolve, reject) => { + states.suggest?.reject(new Error(ERR_MSG)) + const id = Date.now() + + worker.postMessage({ + type: 'suggest', + id, + query, + locale, + options: searchOptions, + }) + states.suggest = { id, resolve, reject } + }), + + search: ( + query: string, + locale?: string, + searchOptions?: SearchOptions, + ): Promise => + new Promise((resolve, reject) => { + states.search?.reject(new Error(ERR_MSG)) + + const id = Date.now() + + worker.postMessage({ + type: 'search', + id, + query, + locale, + options: searchOptions, + }) + states.search = { id, resolve, reject } + }), + + all: ( + query: string, + locale?: string, + searchOptions?: SearchOptions, + ): Promise => + new Promise((resolve, reject) => { + states.all?.reject(new Error(ERR_MSG)) + + const id = Date.now() + + worker.postMessage({ + type: 'all', + id, + query, + locale, + options: searchOptions, + }) + states.all = { id, resolve, reject } + }), + + terminate: (): void => { + worker.terminate() + + values(states).forEach((item) => { + item?.reject(new Error('Worker has been terminated.')) + }) + }, + } +} diff --git a/plugins/search/plugin-slimsearch/src/client/utils/getResultPath.ts b/plugins/search/plugin-slimsearch/src/client/utils/getResultPath.ts new file mode 100644 index 0000000000..1bfd16594f --- /dev/null +++ b/plugins/search/plugin-slimsearch/src/client/utils/getResultPath.ts @@ -0,0 +1,6 @@ +import { store } from '@temp/slimsearch/store.js' + +import type { MatchedItem } from '../typings/index.js' + +export const getResultPath = (item: MatchedItem): string => + store[item.id] + ('anchor' in item ? `#${item.anchor}` : '') diff --git a/plugins/search/plugin-slimsearch/src/client/utils/index.ts b/plugins/search/plugin-slimsearch/src/client/utils/index.ts new file mode 100644 index 0000000000..c8acdd3099 --- /dev/null +++ b/plugins/search/plugin-slimsearch/src/client/utils/index.ts @@ -0,0 +1,4 @@ +export * from './createSearchWorker.js' +export * from './getResultPath.js' +export * from './isFocusingTextControl.js' +export * from './isKeyMatched.js' diff --git a/plugins/search/plugin-slimsearch/src/client/utils/isFocusingTextControl.ts b/plugins/search/plugin-slimsearch/src/client/utils/isFocusingTextControl.ts new file mode 100644 index 0000000000..80cc43cd3b --- /dev/null +++ b/plugins/search/plugin-slimsearch/src/client/utils/isFocusingTextControl.ts @@ -0,0 +1,15 @@ +/** + * Determines whether the user is currently focusing a text control. + * In this case, the search plugin shouldn’t hijack any hotkeys because + * the user might be typing into a text field, using type-ahead search + * in a `select` element, etc. + */ +export const isFocusingTextControl = (target: EventTarget): boolean => { + if (!(target instanceof Element)) return false + + return ( + document.activeElement === target && + (['TEXTAREA', 'SELECT', 'INPUT'].includes(target.tagName) || + target.hasAttribute('contenteditable')) + ) +} diff --git a/plugins/search/plugin-slimsearch/src/client/utils/isKeyMatched.ts b/plugins/search/plugin-slimsearch/src/client/utils/isKeyMatched.ts new file mode 100644 index 0000000000..293e2a1af1 --- /dev/null +++ b/plugins/search/plugin-slimsearch/src/client/utils/isKeyMatched.ts @@ -0,0 +1,14 @@ +import { hotKeysConfig } from '../define.js' + +export const isKeyMatched = (event: KeyboardEvent): boolean => + hotKeysConfig.some((item) => { + const { key, ctrl = false, shift = false, alt = false, meta = false } = item + + return ( + key === event.key && + ctrl === event.ctrlKey && + shift === event.shiftKey && + alt === event.altKey && + meta === event.metaKey + ) + }) diff --git a/plugins/search/plugin-slimsearch/src/client/worker/index.ts b/plugins/search/plugin-slimsearch/src/client/worker/index.ts new file mode 100644 index 0000000000..8f4bd9ab44 --- /dev/null +++ b/plugins/search/plugin-slimsearch/src/client/worker/index.ts @@ -0,0 +1,44 @@ +/* eslint-disable no-restricted-globals */ +import database from '@temp/slimsearch/index.js' +import { loadJSONIndex } from 'slimsearch' + +import type { IndexItem } from '../../shared/index.js' +import type { MessageData } from '../typings/index.js' +import { getResults } from './result.js' +import { getSuggestions } from './suggestion.js' + +self.onmessage = async ({ + data: { type = 'all', query, locale = '/', options, id }, +}: MessageEvent): Promise => { + const { default: localeIndex } = await database[locale]() + + const searchLocaleIndex = loadJSONIndex( + localeIndex, + { + fields: [/** Heading */ 'h', /** Text */ 't', /** CustomFields */ 'c'], + storeFields: [ + /** Heading */ 'h', + /** Text */ 't', + /** CustomFields */ 'c', + ], + }, + ) + + if (type === 'suggest') + self.postMessage([ + type, + id, + getSuggestions(query, searchLocaleIndex, options), + ]) + else if (type === 'search') + self.postMessage([type, id, getResults(query, searchLocaleIndex, options)]) + else + self.postMessage({ + suggestions: [ + type, + id, + getSuggestions(query, searchLocaleIndex, options), + ], + results: [type, id, getResults(query, searchLocaleIndex, options)], + }) +} diff --git a/plugins/search/plugin-slimsearch/src/client/worker/matchContent.ts b/plugins/search/plugin-slimsearch/src/client/worker/matchContent.ts new file mode 100644 index 0000000000..f89f73c728 --- /dev/null +++ b/plugins/search/plugin-slimsearch/src/client/worker/matchContent.ts @@ -0,0 +1,69 @@ +import type { Word } from '../typings/index.js' + +const MAX_LENGTH = 100 +const SUFFIX_LENGTH = 20 + +export const getMatchedContent = ( + content: string, + queryString: string, +): Word[] | null => { + const contentLowerCase = content.toLowerCase() + const queryStringLowerCase = queryString.toLowerCase() + const result: Word[] = [] + + let startIndex = 0 + let contentLength = 0 + + const addResult = (text: string, isEnd = false): void => { + let display: string + + // A beginning of a long string + if (contentLength === 0) + display = + text.length > SUFFIX_LENGTH ? `… ${text.slice(-SUFFIX_LENGTH)}` : text + // Already the last text + else if (isEnd) + display = + // If the string will be longer than maxLength + text.length + contentLength > MAX_LENGTH + ? `${text.slice(0, MAX_LENGTH - contentLength)}… ` + : text + // Text is at the middle + else + display = + text.length > SUFFIX_LENGTH + ? `${text.slice(0, SUFFIX_LENGTH)} … ${text.slice(-SUFFIX_LENGTH)}` + : text + + if (display) result.push(display) + contentLength += display.length + + if (!isEnd) { + result.push(['mark', queryString]) + contentLength += queryString.length + + if (contentLength >= MAX_LENGTH) result.push(' …') + } + } + + let matchIndex = contentLowerCase.indexOf(queryStringLowerCase, startIndex) + + if (matchIndex === -1) return null + + while (matchIndex >= 0) { + const endIndex = matchIndex + queryStringLowerCase.length + + // Append content before + addResult(content.slice(startIndex, matchIndex)) + + startIndex = endIndex + + if (contentLength > MAX_LENGTH) break + + matchIndex = contentLowerCase.indexOf(queryStringLowerCase, startIndex) + } + + if (contentLength < MAX_LENGTH) addResult(content.slice(startIndex), true) + + return result +} diff --git a/plugins/search/plugin-slimsearch/src/client/worker/result.ts b/plugins/search/plugin-slimsearch/src/client/worker/result.ts new file mode 100644 index 0000000000..f344a5bcbd --- /dev/null +++ b/plugins/search/plugin-slimsearch/src/client/worker/result.ts @@ -0,0 +1,158 @@ +import { entries } from '@vuepress/helper/client' +import type { MatchInfo, SearchIndex } from 'slimsearch' +import { getStoredFields, search } from 'slimsearch' + +import type { + CustomFieldIndexItem, + IndexItem, + PageIndexItem, +} from '../../shared/index.js' +import type { + HeadingMatchedItem, + MatchedItem, + SearchResult, + TitleMatchedItem, + Word, + WorkerSearchOptions, +} from '../typings/index.js' +import { getMatchedContent } from './matchContent.js' + +declare const __SLIMSEARCH_SORT_STRATEGY__: 'max' | 'total' + +export type MiniSearchResult = IndexItem & { + terms: string[] + score: number + match: MatchInfo +} + +interface PageResult { + title: string + contents: [result: MatchedItem, score: number][] +} + +type ResultMap = Record + +const sortWithTotal = (valueA: PageResult, valueB: PageResult): number => + valueB.contents.reduce((total, [, score]) => total + score, 0) - + valueA.contents.reduce((total, [, score]) => total + score, 0) + +const sortWithMax = (valueA: PageResult, valueB: PageResult): number => + Math.max(...valueB.contents.map(([, score]) => score)) - + Math.max(...valueA.contents.map(([, score]) => score)) + +export const getResults = ( + query: string, + localeIndex: SearchIndex, + searchOptions: WorkerSearchOptions = {}, +): SearchResult[] => { + const resultMap: ResultMap = {} + + const results = search(localeIndex, query, { + boost: { + // eslint-disable-next-line no-useless-computed-key + [/** Heading */ 'h']: 2, + // eslint-disable-next-line no-useless-computed-key + [/** Text */ 't']: 1, + // eslint-disable-next-line no-useless-computed-key + [/** CustomFields */ 'c']: 4, + }, + prefix: true, + ...searchOptions, + }) + + results.forEach((result) => { + const { id, terms, score } = result + const isCustomField = id.includes('@') + const isSection = id.includes('#') + const [pageIndex, info] = id.split(/[#@]/) + const pageId = Number(pageIndex) + + const displayTerms = terms + .sort((a, b) => a.length - b.length) + .filter((item, index) => + terms.slice(index + 1).every((term) => !term.includes(item)), + ) + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition, no-multi-assign + const { contents } = (resultMap[pageId] ??= { + title: '', + contents: [], + }) + + // CustomFieldIndexItem + if (isCustomField) { + contents.push([ + { + type: 'customField', + id: pageId, + index: info, + display: displayTerms + .map((term) => + (result as CustomFieldIndexItem).c.map((field) => + getMatchedContent(field, term), + ), + ) + .flat() + .filter((item): item is Word[] => item !== null), + }, + score, + ]) + } else { + const headerContent = displayTerms + .map((term) => getMatchedContent((result as PageIndexItem).h, term)) + .filter((item): item is Word[] => item !== null) + + if (headerContent.length) + contents.push([ + { + type: isSection ? 'heading' : 'title', + id: pageId, + ...(isSection && { anchor: info }), + display: headerContent, + } as HeadingMatchedItem | TitleMatchedItem, + score, + ]) + + if (/** Text */ 't' in result && result.t) + for (const text of result.t) { + const matchedContent = displayTerms + .map((term) => getMatchedContent(text, term)) + .filter((item): item is Word[] => item !== null) + + if (matchedContent.length) + contents.push([ + { + type: 'text', + id: pageId, + ...(isSection && { anchor: info }), + display: matchedContent, + }, + score, + ]) + } + } + }) + + return entries(resultMap) + .sort(([, valueA], [, valueB]) => + __SLIMSEARCH_SORT_STRATEGY__ === 'total' + ? sortWithTotal(valueA, valueB) + : sortWithMax(valueA, valueB), + ) + .map(([id, { title, contents }]) => { + // Search to get title + if (!title) { + const pageIndex = getStoredFields(localeIndex, id) as unknown as + | PageIndexItem + | undefined + + // eslint-disable-next-line no-param-reassign + if (pageIndex) title = pageIndex.h + } + + return { + title, + contents: contents.map(([result]) => result), + } + }) +} diff --git a/plugins/search/plugin-slimsearch/src/client/worker/suggestion.ts b/plugins/search/plugin-slimsearch/src/client/worker/suggestion.ts new file mode 100644 index 0000000000..6a4697715c --- /dev/null +++ b/plugins/search/plugin-slimsearch/src/client/worker/suggestion.ts @@ -0,0 +1,22 @@ +import type { SearchIndex } from 'slimsearch' +import { autoSuggest } from 'slimsearch' + +import type { IndexItem } from '../../shared/index.js' +import type { WorkerSearchOptions } from '../typings/index.js' + +export const getSuggestions = ( + query: string, + localeIndex: SearchIndex, + searchOptions: WorkerSearchOptions = {}, +): string[] => { + const suggestions = autoSuggest(localeIndex, query, { + fuzzy: 0.2, + maxFuzzy: 3, + ...searchOptions, + }).map(({ suggestion }) => suggestion) + + // filter multi-word suggestions if query is not multi-word + return query.includes(' ') + ? suggestions + : suggestions.filter((suggestion) => !suggestion.includes(' ')) +} diff --git a/plugins/search/plugin-slimsearch/src/node/generateIndex.ts b/plugins/search/plugin-slimsearch/src/node/generateIndex.ts new file mode 100644 index 0000000000..bab9c07603 --- /dev/null +++ b/plugins/search/plugin-slimsearch/src/node/generateIndex.ts @@ -0,0 +1,226 @@ +/* eslint-disable @typescript-eslint/no-unsafe-enum-comparison */ +import { entries, fromEntries, isArray, keys } from '@vuepress/helper' +import { load } from 'cheerio' +import type { AnyNode, Element } from 'domhandler' +import { addAllAsync, createIndex } from 'slimsearch' +import type { App, Page } from 'vuepress/core' + +import type { + IndexItem, + LocaleIndex, + PageIndexId, + PageIndexItem, + SearchIndexStore, + SectionIndexItem, +} from '../shared/index.js' +import type { CustomFieldOptions, SlimSearchPluginOptions } from './options.js' +import type { PathStore } from './pathStore.js' + +/** + * These tags are valid HTML tags which can contain content. + */ + +/** + * @description h1 is removed because it's the title of the page. + */ +const HEADING_TAGS = 'h2,h3,h4,h5,h6'.split(',') + +/** + * @description Not all the block tags are included, because some of them shall not be indexed + */ +const CONTENT_BLOCK_TAGS = + 'header,nav,section,div,dd,dl,dt,figcaption,figure,picture,hr,li,main,ol,p,ul,caption,table,thead,tbody,tfoot,th,tr,td,datalist,fieldset,form,legend,optgroup,option,select,details,dialog,menu,menuitem,summary,blockquote,pre'.split( + ',', + ) + +/** + * @description Not all the inline tags are included, because some of them shall not be indexed + * + * routelink and routerlink are added to the list, because they are link components + */ +const CONTENT_INLINE_TAGS = + 'routelink,routerlink,a,b,abbr,bdi,bdo,cite,code,dfn,em,i,kbd,mark,q,rp,rt,ruby,s,samp,small,span,strong,sub,sup,time,u,var,wbr,del,ins,button,label,legend,meter,optgroup,option,output,progress,select'.split( + ',', + ) + +const isExcerptMarker = (node: AnyNode): boolean => + node.type === 'comment' && node.data.trim() === 'more' + +const $ = load('') + +const renderHeader = (node: Element): string => { + if ( + node.children.length === 1 && + node.children[0].type === 'tag' && + node.children[0].tagName === 'a' && + node.children[0].attribs.class === 'header-anchor' + ) + node.children = (node.children[0].children[0] as Element).children + + return node.children + .map((childNode) => (childNode.type === 'text' ? childNode.data : null)) + .filter(Boolean) + .join(' ') + .replace(/\s+/gu, ' ') + .trim() +} + +export const generatePageIndex = ( + page: Page<{ excerpt?: string }>, + store: PathStore, + customFieldsGetter: CustomFieldOptions[] = [], + indexContent = false, +): IndexItem[] => { + const { contentRendered, data, title } = page + const pageId = store.addPath(page.path).toString() as PageIndexId + const hasExcerpt = Boolean(data.excerpt?.length) + + const pageIndex: PageIndexItem = { id: pageId, h: title } + const results: IndexItem[] = [pageIndex] + + // Here are some variables holding the current state of the parser + let shouldIndexContent = hasExcerpt || indexContent + let sectionIndex: PageIndexItem | SectionIndexItem | null = null + let indexedText = '' + let foundFirstHeader = false + + const addTextToIndex = (): void => { + if (indexedText && shouldIndexContent) { + ;((foundFirstHeader ? sectionIndex! : pageIndex).t ??= []).push( + indexedText.replace(/[\n\s]+/gu, ' '), + ) + indexedText = '' + } + } + + const render = (node: AnyNode, preserveSpace = false): void => { + if (node.type === 'tag') { + if (HEADING_TAGS.includes(node.name)) { + const { id } = node.attribs + const header = renderHeader(node) + + addTextToIndex() + + // Update current section index only if it has an id + if (id) { + if (!foundFirstHeader) foundFirstHeader = true + else results.push(sectionIndex!) + + sectionIndex = { + id: `${pageId}#${id}`, + h: header, + } + } else if (header) { + ;((sectionIndex ?? pageIndex).t ??= []).push(header) + } + } else if (CONTENT_BLOCK_TAGS.includes(node.name)) { + addTextToIndex() + node.childNodes.forEach((item) => { + render(item, preserveSpace || node.name === 'pre') + }) + } else if (CONTENT_INLINE_TAGS.includes(node.name)) { + node.childNodes.forEach((item) => { + render(item, preserveSpace) + }) + } + } else if (node.type === 'text') { + indexedText += preserveSpace || node.data.trim() ? node.data : '' + } else if ( + // We are expecting to stop at excerpt marker if content is not indexed + hasExcerpt && + !indexContent && + isExcerptMarker(node) + ) { + shouldIndexContent = false + } + } + + // The types are not correct, null is returned if contentRendered is empty + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + const nodes = $.parseHTML(contentRendered) ?? [] + + // Get custom fields + const customFields = fromEntries( + customFieldsGetter + .map(({ getter }, index) => { + const result = getter(page) + + return isArray(result) + ? [index.toString(), result] + : result + ? [index.toString(), [result]] + : null + }) + .filter((item): item is [string, string[]] => item !== null), + ) + + // No content in page and no customFields + if (!nodes.length && !keys(customFields).length) return [] + + // Walk through nodes and extract indexes + nodes.forEach((node) => { + render(node) + }) + + // Push contents in last block tags + addTextToIndex() + + // Push last section + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (sectionIndex) results.push(sectionIndex) + + // Add custom fields + entries(customFields).forEach(([customField, values]) => { + results.push({ + id: `${pageId}@${customField}`, + c: values, + }) + }) + + return results +} + +export const getSearchIndexStore = async ( + app: App, + { + customFields, + indexContent, + filter = (): boolean => true, + indexOptions, + indexLocaleOptions, + }: SlimSearchPluginOptions, + store: PathStore, +): Promise => { + const indexesByLocale: LocaleIndex = {} + + app.pages.forEach((page) => { + if (filter(page) && page.frontmatter.search !== false) + (indexesByLocale[page.pathLocale] ??= []).push( + ...generatePageIndex(page, store, customFields, indexContent), + ) + }) + + const searchIndex: SearchIndexStore = {} + + await Promise.all( + entries(indexesByLocale).map(async ([localePath, indexes]) => { + const index = createIndex({ + ...indexOptions, + ...indexLocaleOptions?.[localePath], + fields: [/** Heading */ 'h', /** Text */ 't', /** CustomFields */ 'c'], + storeFields: [ + /** Heading */ 'h', + /** Anchor */ 'a', + /** Text */ 't', + /** CustomFields */ 'c', + ], + }) + + await addAllAsync(index, indexes) + + searchIndex[localePath] = index + }), + ) + + return searchIndex +} diff --git a/plugins/search/plugin-slimsearch/src/node/generateWorker.ts b/plugins/search/plugin-slimsearch/src/node/generateWorker.ts new file mode 100644 index 0000000000..5fd3fcb977 --- /dev/null +++ b/plugins/search/plugin-slimsearch/src/node/generateWorker.ts @@ -0,0 +1,28 @@ +import type { App } from 'vuepress/core' +import { fs, path } from 'vuepress/utils' + +import type { SearchIndexStore } from '../shared/index.js' +import type { SlimSearchPluginOptions } from './options.js' +import { WORKER_FILE } from './utils.js' + +export const generateWorker = async ( + app: App, + options: SlimSearchPluginOptions, + searchStore: SearchIndexStore, +): Promise => { + const workerFilePath = app.dir.dest(options.worker ?? 'slimsearch.worker.js') + const searchIndexContent = JSON.stringify(searchStore) + + const workerFileContent = await fs.readFile(WORKER_FILE, 'utf8') + + await fs.ensureDir(path.dirname(workerFilePath)) + await fs.writeFile( + workerFilePath, + workerFileContent + .replace('__SLIMSEARCH_INDEX__', () => JSON.stringify(searchIndexContent)) + .replace( + '__SLIMSEARCH_SORT_STRATEGY__', + JSON.stringify(options.sortStrategy ?? 'max'), + ), + ) +} diff --git a/plugins/search/plugin-slimsearch/src/node/index.ts b/plugins/search/plugin-slimsearch/src/node/index.ts new file mode 100644 index 0000000000..fa4bc1a370 --- /dev/null +++ b/plugins/search/plugin-slimsearch/src/node/index.ts @@ -0,0 +1,3 @@ +export type * from './options.js' +export * from './slimsearchPlugin.js' +export * from '../shared/index.js' diff --git a/plugins/search/plugin-slimsearch/src/node/locales.ts b/plugins/search/plugin-slimsearch/src/node/locales.ts new file mode 100644 index 0000000000..f910ad7616 --- /dev/null +++ b/plugins/search/plugin-slimsearch/src/node/locales.ts @@ -0,0 +1,344 @@ +import type { SlimSearchLocaleConfig } from '../shared/index.js' + +/** Multi language config for slimsearch popup */ +export const locales: SlimSearchLocaleConfig = { + '/en/': { + cancel: 'Cancel', + placeholder: 'Search', + search: 'Search', + searching: 'Searching', + defaultTitle: 'Documentation', + select: 'to select', + navigate: 'to navigate', + autocomplete: 'to autocomplete', + exit: 'to exit', + queryHistory: 'Search History', + resultHistory: 'Result History', + emptyHistory: 'Empty Search History', + emptyResult: 'No results found', + loading: 'Loading search indexes...', + }, + + '/zh/': { + cancel: '取消', + placeholder: '搜索', + search: '搜索', + searching: '搜索中', + defaultTitle: '文档', + select: '选择', + navigate: '切换', + autocomplete: '自动补全', + exit: '关闭', + queryHistory: '搜索历史', + resultHistory: '历史结果', + emptyHistory: '无搜索历史', + emptyResult: '没有找到结果', + loading: '正在加载搜索索引...', + }, + + '/zh-tw/': { + cancel: '取消', + placeholder: '搜索', + search: '搜索', + searching: '搜索中', + defaultTitle: '文檔', + select: '選擇', + navigate: '切換', + autocomplete: '自動補全', + exit: '關閉', + queryHistory: '搜索歷史', + resultHistory: '歷史結果', + emptyHistory: '無搜索歷史', + emptyResult: '沒有找到結果', + loading: '正在加載搜索索引...', + }, + + '/de/': { + cancel: 'Abbrechen', + placeholder: 'Suche', + search: 'Suche', + searching: 'Suche', + defaultTitle: 'Dokumentation', + select: 'auswählen', + navigate: 'wechseln', + autocomplete: 'automatisch vervollständigen', + exit: 'schließen', + queryHistory: 'Suchverlauf', + resultHistory: 'Ergebnisverlauf', + emptyHistory: 'Suchverlauf leeren', + emptyResult: 'Keine Ergebnisse gefunden', + loading: 'Suchindex wird geladen...', + }, + + '/de-at/': { + cancel: 'Abbrechen', + placeholder: 'Suche', + search: 'Suche', + searching: 'Suche', + defaultTitle: 'Dokumentation', + select: 'auswählen', + navigate: 'wechseln', + autocomplete: 'automatisch vervollständigen', + exit: 'schließen', + queryHistory: 'Suchverlauf', + resultHistory: 'Ergebnisverlauf', + emptyHistory: 'Suchverlauf leeren', + emptyResult: 'Keine Ergebnisse gefunden', + loading: 'Suchindex wird geladen...', + }, + + '/vi/': { + cancel: 'Hủy', + placeholder: 'Tìm kiếm', + search: 'Tìm kiếm', + searching: 'Đang tìm kiếm', + defaultTitle: 'Tài liệu', + select: 'chọn', + navigate: 'chuyển', + autocomplete: 'tự động hoàn thành', + exit: 'đóng', + queryHistory: 'Lịch sử tìm kiếm', + resultHistory: 'Lịch sử kết quả', + emptyHistory: 'Xóa lịch sử tìm kiếm', + emptyResult: 'Không tìm thấy kết quả', + loading: 'Đang tải chỉ mục tìm kiếm...', + }, + + '/uk/': { + cancel: 'Скасувати', + placeholder: 'Пошук', + search: 'Пошук', + searching: 'Пошук', + defaultTitle: 'Документація', + select: 'вибрати', + navigate: 'перейти', + autocomplete: 'автозаповнення', + exit: 'закрити', + queryHistory: 'Історія пошуку', + resultHistory: 'Історія результатів', + emptyHistory: 'Очистити історію пошуку', + emptyResult: 'Нічого не знайдено', + loading: 'Завантаження пошукових індексів...', + }, + + '/ru/': { + cancel: 'Отмена', + placeholder: 'Поиск', + search: 'Поиск', + searching: 'Поиск', + defaultTitle: 'Документация', + select: 'выбрать', + navigate: 'переключить', + autocomplete: 'автозаполнение', + exit: 'закрыть', + queryHistory: 'История поиска', + resultHistory: 'История результатов', + emptyHistory: 'Очистить историю поиска', + emptyResult: 'Ничего не найдено', + loading: 'Загрузка поисковых индексов...', + }, + + '/br/': { + cancel: 'Cancelar', + placeholder: 'Pesquisar', + search: 'Pesquisar', + searching: 'Pesquisando', + defaultTitle: 'Documentação', + select: 'selecionar', + navigate: 'navegar', + autocomplete: 'autocompletar', + exit: 'fechar', + queryHistory: 'Histórico de pesquisa', + resultHistory: 'Histórico de resultados', + emptyHistory: 'Limpar histórico de pesquisa', + emptyResult: 'Nenhum resultado encontrado', + loading: 'Carregando índices de pesquisa...', + }, + + '/pl/': { + cancel: 'Anuluj', + placeholder: 'Szukaj', + search: 'Szukaj', + searching: 'Szukanie', + defaultTitle: 'Dokumentacja', + select: 'wybierz', + navigate: 'przejdź', + autocomplete: 'autouzupełnianie', + exit: 'zamknij', + queryHistory: 'Historia wyszukiwania', + resultHistory: 'Historia wyników', + emptyHistory: 'Wyczyść historię wyszukiwania', + emptyResult: 'Nie znaleziono wyników', + loading: 'Ładowanie indeksów wyszukiwania...', + }, + + '/sk/': { + cancel: 'Zrušiť', + placeholder: 'Hľadať', + search: 'Hľadať', + searching: 'Hľadanie', + defaultTitle: 'Dokumentácia', + select: 'vybrať', + navigate: 'prepnúť', + autocomplete: 'automatické dopĺňanie', + exit: 'zavrieť', + queryHistory: 'História vyhľadávania', + resultHistory: 'História výsledkov', + emptyHistory: 'Vymazať históriu vyhľadávania', + emptyResult: 'Nenašli sa žiadne výsledky', + loading: 'Načítavajú sa vyhľadávacie indexy...', + }, + + '/fr/': { + cancel: 'Annuler', + placeholder: 'Rechercher', + search: 'Rechercher', + searching: 'Recherche', + defaultTitle: 'Documentation', + select: 'sélectionner', + navigate: 'naviguer', + autocomplete: 'auto-complétion', + exit: 'fermer', + queryHistory: 'Historique de recherche', + resultHistory: 'Historique des résultats', + emptyHistory: "Vider l'historique de recherche", + emptyResult: 'Aucun résultat trouvé', + loading: 'Chargement des index de recherche...', + }, + + '/es/': { + cancel: 'Cancelar', + placeholder: 'Buscar', + search: 'Buscar', + searching: 'Buscando', + defaultTitle: 'Documentación', + select: 'seleccionar', + navigate: 'navegar', + autocomplete: 'autocompletar', + exit: 'cerrar', + queryHistory: 'Historial de búsqueda', + resultHistory: 'Historial de resultados', + emptyHistory: 'Vaciar historial de búsqueda', + emptyResult: 'No se encontraron resultados', + loading: 'Cargando índices de búsqueda...', + }, + + '/ja/': { + cancel: 'キャンセル', + placeholder: '検索', + search: '検索', + searching: '検索中', + defaultTitle: 'ドキュメント', + select: '選択', + navigate: '切り替え', + autocomplete: 'オートコンプリート', + exit: '閉じる', + queryHistory: '検索履歴', + resultHistory: '結果履歴', + emptyHistory: '検索履歴をクリア', + emptyResult: '結果が見つかりません', + loading: '検索インデックスを読み込んでいます...', + }, + + '/tr/': { + cancel: 'İptal', + placeholder: 'Ara', + search: 'Ara', + searching: 'Aranıyor', + defaultTitle: 'Dökümantasyon', + select: 'seç', + navigate: 'geç', + autocomplete: 'otomatik tamamlama', + exit: 'kapat', + queryHistory: 'Arama geçmişi', + resultHistory: 'Sonuç geçmişi', + emptyHistory: 'Arama geçmişini temizle', + emptyResult: 'Sonuç bulunamadı', + loading: 'Arama dizinleri yükleniyor...', + }, + + '/ko/': { + cancel: '취소', + placeholder: '검색', + search: '검색', + searching: '검색 중', + defaultTitle: '문서', + select: '선택', + navigate: '이동', + autocomplete: '자동 완성', + exit: '닫기', + queryHistory: '검색 기록', + resultHistory: '결과 기록', + emptyHistory: '검색 기록 지우기', + emptyResult: '결과를 찾을 수 없습니다', + loading: '검색 인덱스를 로드하는 중...', + }, + + '/fi/': { + cancel: 'Peruuta', + placeholder: 'Etsi', + search: 'Etsi', + searching: 'Etsitään', + defaultTitle: 'Dokumentaatio', + select: 'valitaksesi', + navigate: 'navigoidaksesi', + autocomplete: 'automaattinen täydennys', + exit: 'poistuaksesi', + queryHistory: 'Hakuhistoria', + resultHistory: 'Tuloshistoria', + emptyHistory: 'Tyhjennä hakuhistoria', + emptyResult: 'Tuloksia ei löytynyt', + loading: 'Ladataan hakuindeksiä...', + }, + + '/hu/': { + cancel: 'Mégse', + placeholder: 'Keresés', + search: 'Keresés', + searching: 'Keresés', + defaultTitle: 'Dokumentáció', + select: 'kiválasztáshoz', + navigate: 'navigáláshoz', + autocomplete: 'automatikus kiegészítés', + exit: 'kilépéshez', + queryHistory: 'Keresési előzmények', + resultHistory: 'Eredmények előzményei', + emptyHistory: 'Üres keresési előzmények', + emptyResult: 'Nincs találat', + loading: 'A keresési indexek betöltése...', + }, + + '/id/': { + cancel: 'Batal', + placeholder: 'Cari sesuatu 🔎', + search: 'Cari', + searching: 'Sedang mencari', + defaultTitle: 'Dokumentasi', + select: 'pilih', + navigate: 'navigasi', + autocomplete: 'autoselesai', + exit: 'keluar', + queryHistory: 'Riwayat Penelusuran', + resultHistory: 'Riwayat Hasil', + emptyHistory: 'Tidak ada riwayat penelusuran', + emptyResult: 'Hasil penelusuran tidak tersedia', + loading: 'Memuat indeks penelusuran...', + }, + + '/nl/': { + cancel: 'Annuleren', + placeholder: 'Zoeken', + search: 'Zoeken', + searching: 'Zoeken', + defaultTitle: 'Documentatie', + select: 'Selecteren', + navigate: 'to navigate', + autocomplete: 'autocompletion', + exit: 'to exit', + queryHistory: 'Zoekgeschiedenis', + resultHistory: 'Resultaatgeschiedenis', + emptyHistory: 'Zoekgeschiedenis Leegmaken', + emptyResult: 'Geen resultaten gevonden', + loading: 'Laden van zoekindexen...', + }, +} diff --git a/plugins/search/plugin-slimsearch/src/node/options.ts b/plugins/search/plugin-slimsearch/src/node/options.ts new file mode 100644 index 0000000000..0d433423b3 --- /dev/null +++ b/plugins/search/plugin-slimsearch/src/node/options.ts @@ -0,0 +1,204 @@ +import type { LocaleConfig, Page } from 'vuepress/core' + +import type { + SlimSearchCustomFieldFormatter, + SlimSearchKeyOptions, + SlimSearchLocaleData, +} from '../shared/index.js' + +export interface SlimSearchIndexOptions { + /** + * Function to tokenize the index field item. + * + * 用于对索引字段项进行分词的函数。 + */ + tokenize?: (text: string, fieldName?: string) => string[] + /** + * Function to process or normalize terms in the index field. + * + * 用于处理或规范索引字段中的术语的函数。 + */ + processTerm?: (term: string) => string[] | string | false | null | undefined +} + +export interface CustomFieldOptions { + /** + * Custom field getter + * + * 自定义项目的获取器 + */ + getter: < + ExtraPageData extends Record = Record, + ExtraPageFrontmatter extends Record = Record< + string, + unknown + >, + ExtraPageFields extends Record = Record, + >( + page: Page, + ) => string[] | string | null | undefined + + /** + * Display content + * + * @description `$content` will be replaced by the content returned by `getter` + * + * 展示的内容 + * + * @description `$content` 会被 `getter` 返回的内容替换 + * + * @default `$content` + */ + formatter?: SlimSearchCustomFieldFormatter +} + +export interface SlimSearchPluginOptions { + /** + * Whether index page content + * + * @description By default only headings and excerpt of the page will be indexed, and the content of the page will not be indexed. If you need to index the content of the page, you can set this option to `true` + * + * 是否索引正文内容 + * + * @description 默认情况下,只会索引页面的标题和摘要,不会索引页面的正文内容。如果需要索引页面的正文内容,可以将该选项设置为 `true` + * + * @default false + */ + indexContent?: boolean + + /** + * Whether provide auto suggestions while typing + * + * 是否在输入时提供自动建议 + * + * @default true + */ + autoSuggestions?: boolean + + /** + * Max stored query history count + * + * @description You can set it to `0` to disable it + * + * 存储查询历史的最大数量 + * + * @description 可以将其设置为 `0` 来禁用 + * + * @default 5 + */ + queryHistoryCount?: number + + /** + * Max stored matched result history count + * + * @description You can set it to `0` to disable it + * + * 存储结果历史的最大数量 + * + * @description 可以将其设置为 `0` 来禁用 + * + * @default 5 + */ + resultHistoryCount?: number + + /** + * Delay to start searching after input + * + * 结束输入到开始搜索的延时 + * + * @default 150 + */ + searchDelay?: number + + /* + * Delay to start auto-suggesting after input + * + * 结束输入到开始自动建议的延时 + * + * @default 0 + */ + suggestDelay?: number + + /** + * Custom field for search + */ + customFields?: CustomFieldOptions[] + + /** + * Specify the [event.key](http://keycode.info/) of the hotkeys + * + * @description When hotkeys are pressed, the search box input will be focused. Set to an empty array to disable hotkeys + * + * 指定热键的 [event.key](http://keycode.info/) + * + * @description 当热键被按下时,搜索框的输入框会被聚焦,设置为空数组以禁用热键 + * + * @default [ + * { key: "k", ctrl: true }, + * { key: "/", ctrl: true }, + * ] + */ + hotKeys?: SlimSearchKeyOptions[] + + /** + * Output worker filename + * + * Worker 输出文件名 + * + * @default "slimsearch.worker.js" + */ + worker?: string + + /** + * Whether enable hmr + * + * 是否启用 hmr + * + * @default false + */ + hotReload?: boolean + + /** + * Locales config + * + * 多语言选项 + */ + locales?: LocaleConfig + + /** + * Result Sort strategy + * + * @description When there are multiple matched results, the result will be sorted by the strategy. `max` means that page having higher total score will be placed in front. `total` means that page having higher max score will be placed in front. + * + * 结果排序策略 + * + * @description 当有多个匹配的结果时,会按照策略对结果进行排序。`max` 表示最高分更高的页面会排在前面。`total` 表示总分更高的页面会排在前面 + * + * @default "max" + */ + sortStrategy?: 'max' | 'total' + + /** + * Create Index option + * + * 创建索引选项 + */ + indexOptions?: SlimSearchIndexOptions + + /** + * Create Index option per locale + * + * 按语言的创建索引选项 + */ + indexLocaleOptions?: Record + + /** + * Filter pages to be indexed + * + * 过滤需要索引的页面 + * + * @param page Page + * @returns whether the page should be indexed + */ + filter?: (page: Page) => boolean +} diff --git a/plugins/search/plugin-slimsearch/src/node/pathStore.ts b/plugins/search/plugin-slimsearch/src/node/pathStore.ts new file mode 100644 index 0000000000..43dd4b8b2e --- /dev/null +++ b/plugins/search/plugin-slimsearch/src/node/pathStore.ts @@ -0,0 +1,37 @@ +export class PathStore { + private store: string[] + + public constructor() { + this.store = [] + } + + public addPath(item: string): number { + const index = this.store.indexOf(item) + + if (index === -1) { + this.store.push(item) + + return this.store.length - 1 + } + + return index + } + + public addPaths(items: string[]): number[] { + return items.map((item) => this.addPath(item)) + } + + public deletePath(item: string): void { + const index = this.store.indexOf(item) + + if (index !== -1) this.store[index] = '' + } + + public clear(): void { + this.store = [] + } + + public toJSON(): string { + return JSON.stringify(this.store) + } +} diff --git a/plugins/search/plugin-slimsearch/src/node/prepare.ts b/plugins/search/plugin-slimsearch/src/node/prepare.ts new file mode 100644 index 0000000000..deb3710cde --- /dev/null +++ b/plugins/search/plugin-slimsearch/src/node/prepare.ts @@ -0,0 +1,135 @@ +import { entries, keys } from '@vuepress/helper' +import { addAll, discard, vacuum } from 'slimsearch' +import type { App } from 'vuepress/core' + +import type { PageIndexId, SearchIndexStore } from '../shared/index.js' +import { generatePageIndex } from './generateIndex.js' +import type { SlimSearchPluginOptions } from './options.js' +import type { PathStore } from './pathStore.js' +import { getLocaleChunkName, inferFilePath } from './utils.js' + +export const prepareStore = async ( + app: App, + store: PathStore, +): Promise => { + await app.writeTemp( + `slimsearch/store.js`, + `\ +export const store = ${store.toJSON()}; +`, + ) +} + +export const prepareSearchIndex = async ( + app: App, + searchIndexStore: SearchIndexStore, +): Promise => { + await Promise.all( + entries(searchIndexStore).map(([locale, documents]) => + app.writeTemp( + `slimsearch/${getLocaleChunkName(locale)}.js`, + `export default ${JSON.stringify(JSON.stringify(documents))};`, + ), + ), + ) + + await app.writeTemp( + `slimsearch/index.js`, + `export default {${keys(searchIndexStore) + .map( + (locale) => + `${JSON.stringify(locale)}: () => import('./${getLocaleChunkName( + locale, + )}.js')`, + ) + .join(',')}}`, + ) +} + +export const updateSearchIndex = async ( + app: App, + options: SlimSearchPluginOptions, + searchIndexStore: SearchIndexStore, + store: PathStore, + path: string, +): Promise => { + const filePath = inferFilePath(path) + + const page = app.pages.find( + ({ filePathRelative }) => + filePathRelative?.toLowerCase() === filePath.toLowerCase(), + ) + + if (page) { + const pageIndexes = generatePageIndex( + page, + store, + options.customFields, + options.indexContent, + ) + const { pathLocale } = page + const pageId = store.addPath(page.path).toString() as PageIndexId + const localeSearchIndex = searchIndexStore[pathLocale] + + // Update index + // Remove previous index + Array.from(localeSearchIndex._documentIds.values()) + .filter((id) => id.startsWith(pageId)) + .forEach((id) => { + discard(localeSearchIndex, id) + }) + + addAll(localeSearchIndex, pageIndexes) + + await vacuum(localeSearchIndex) + + // Search index file content + const content = `\ +export default ${JSON.stringify(JSON.stringify(localeSearchIndex))} +` + + await app.writeTemp( + `slimsearch/${getLocaleChunkName(pathLocale)}.js`, + content, + ) + } +} + +export const removeSearchIndex = async ( + app: App, + searchIndexStore: SearchIndexStore, + store: PathStore, + path: string, +): Promise => { + const filePath = inferFilePath(path) + + const page = app.pages.find( + ({ filePathRelative }) => + filePathRelative?.toLowerCase() === filePath.toLowerCase(), + ) + + if (page) { + const { pathLocale } = page + const pageId = store.addPath(page.path).toString() as PageIndexId + const localeSearchIndex = searchIndexStore[pathLocale] + + // Remove previous index + Array.from(localeSearchIndex._documentIds.values()) + .filter((id) => id.startsWith(pageId)) + .forEach((id) => { + discard(localeSearchIndex, id) + }) + + await vacuum(localeSearchIndex) + + // Search index file content + const content = `\ +export default ${JSON.stringify(JSON.stringify(localeSearchIndex))} +` + + await app.writeTemp( + `slimsearch/${getLocaleChunkName(pathLocale)}.js`, + content, + ) + } +} diff --git a/plugins/search/plugin-slimsearch/src/node/setPagesExcerpt.ts b/plugins/search/plugin-slimsearch/src/node/setPagesExcerpt.ts new file mode 100644 index 0000000000..4e476cc9c9 --- /dev/null +++ b/plugins/search/plugin-slimsearch/src/node/setPagesExcerpt.ts @@ -0,0 +1,20 @@ +import { getPageExcerpt } from '@vuepress/helper' +import type { App, Page } from 'vuepress/core' + +export const setPagesExcerpt = (app: App): void => { + const { pages, pluginApi } = app + const isBlogPluginEnabled = pluginApi.plugins.some( + ({ name }) => name === '@vuepress/plugin-blog', + ) + const hasExcerpt = + isBlogPluginEnabled || pages.some((page) => 'excerpt' in page.data) + + if (!hasExcerpt) + pages.forEach( + (page: Page & { excerpt?: string }>) => { + page.data.excerpt = getPageExcerpt(app, page, { + length: 300, + }) + }, + ) +} diff --git a/plugins/search/plugin-slimsearch/src/node/slimsearchPlugin.ts b/plugins/search/plugin-slimsearch/src/node/slimsearchPlugin.ts new file mode 100644 index 0000000000..4a192450ba --- /dev/null +++ b/plugins/search/plugin-slimsearch/src/node/slimsearchPlugin.ts @@ -0,0 +1,116 @@ +import { + addViteOptimizeDepsInclude, + addViteSsrNoExternal, + fromEntries, + getLocaleConfig, +} from '@vuepress/helper' +import { watch } from 'chokidar' +import type { PluginFunction } from 'vuepress/core' + +import type { SearchIndexStore } from '../shared/index.js' +import { getSearchIndexStore } from './generateIndex.js' +import { generateWorker } from './generateWorker.js' +import { locales } from './locales.js' +import type { SlimSearchPluginOptions } from './options.js' +import { PathStore } from './pathStore.js' +import { + prepareSearchIndex, + prepareStore, + removeSearchIndex, + updateSearchIndex, +} from './prepare.js' +import { setPagesExcerpt } from './setPagesExcerpt.js' +import { CLIENT_FOLDER, PLUGIN_NAME, logger } from './utils.js' + +export const slimsearchPlugin = + (options: SlimSearchPluginOptions = {}): PluginFunction => + (app) => { + if (app.env.isDebug) logger.info('Options:', options) + + const store = new PathStore() + let searchIndexStore: SearchIndexStore | null = null + + return { + name: PLUGIN_NAME, + + define: { + __SLIMSEARCH_AUTO_SUGGESTIONS__: options.autoSuggestions ?? true, + __SLIMSEARCH_CUSTOM_FIELDS__: fromEntries( + options.customFields + ?.map(({ formatter }, index) => + formatter ? [index.toString(), formatter] : null, + ) + .filter((item): item is [string, string] => item !== null) ?? [], + ), + __SLIMSEARCH_LOCALES__: getLocaleConfig({ + app, + name: PLUGIN_NAME, + config: options.locales, + default: locales, + }), + __SLIMSEARCH_OPTIONS__: { + searchDelay: options.searchDelay ?? 150, + suggestDelay: options.suggestDelay ?? 0, + queryHistoryCount: options.queryHistoryCount ?? 5, + resultHistoryCount: options.resultHistoryCount ?? 5, + hotKeys: options.hotKeys ?? [ + { key: 'k', ctrl: true }, + { key: '/', ctrl: true }, + ], + worker: options.worker ?? 'slimsearch.worker.js', + }, + __SLIMSEARCH_SORT_STRATEGY__: JSON.stringify( + options.sortStrategy ?? 'max', + ), + }, + + clientConfigFile: `${CLIENT_FOLDER}config.js`, + + extendsBundlerOptions: (bundlerOptions: unknown): void => { + addViteOptimizeDepsInclude(bundlerOptions, app, 'slimsearch', true) + addViteSsrNoExternal(bundlerOptions, app, [ + '@vuepress/helper', + 'fflate', + 'vuepress-shared', + ]) + }, + + onInitialized: async (): Promise => { + setPagesExcerpt(app) + searchIndexStore = await getSearchIndexStore(app, options, store) + }, + + onPrepared: async (): Promise => { + if (app.env.isDev) await prepareSearchIndex(app, searchIndexStore!) + await prepareStore(app, store) + // clean store in build to save memory + if (app.env.isBuild) store.clear() + }, + + onWatched: (_, watchers): void => { + const hotReload = options.hotReload ?? app.env.isDebug + + if (hotReload) { + // This ensure the page is generated or updated + const searchIndexWatcher = watch('pages/**/*.vue', { + cwd: app.dir.temp(), + ignoreInitial: true, + }) + + searchIndexWatcher.on('add', (path) => { + void updateSearchIndex(app, options, searchIndexStore!, store, path) + }) + searchIndexWatcher.on('change', (path) => { + void updateSearchIndex(app, options, searchIndexStore!, store, path) + }) + searchIndexWatcher.on('unlink', (path) => { + void removeSearchIndex(app, searchIndexStore!, store, path) + }) + + watchers.push(searchIndexWatcher) + } + }, + + onGenerated: () => generateWorker(app, options, searchIndexStore!), + } + } diff --git a/plugins/search/plugin-slimsearch/src/node/utils.ts b/plugins/search/plugin-slimsearch/src/node/utils.ts new file mode 100644 index 0000000000..d556ffb48d --- /dev/null +++ b/plugins/search/plugin-slimsearch/src/node/utils.ts @@ -0,0 +1,23 @@ +import { Logger, ensureEndingSlash } from '@vuepress/helper' +import { getDirname, path } from 'vuepress/utils' + +const __dirname = getDirname(import.meta.url) + +export const PLUGIN_NAME = '@vuepress/plugin-slimsearch' + +export const logger = new Logger(PLUGIN_NAME) + +export const CLIENT_FOLDER = ensureEndingSlash( + path.resolve(__dirname, '../client/'), +) + +export const WORKER_FILE = path.resolve(__dirname, '../worker/index.js') + +export const getLocaleChunkName = (locale: string): string => + locale.replace(/\//g, '') || 'root' + +export const inferFilePath = (vuePath: string): string => + vuePath + .replace(/^pages\//, '') + .replace(/\/index\.html\.vue/, '/README.md') + .replace(/\.html\.vue/, '.md') diff --git a/plugins/search/plugin-slimsearch/src/shared/data.ts b/plugins/search/plugin-slimsearch/src/shared/data.ts new file mode 100644 index 0000000000..3efe704891 --- /dev/null +++ b/plugins/search/plugin-slimsearch/src/shared/data.ts @@ -0,0 +1,40 @@ +import type { SearchIndex } from 'slimsearch' + +export const enum IndexField { + Heading = 'h', + Anchor = 'a', + Text = 't', + CustomFields = 'c', +} + +export type PageIndexId = `${number}` + +export interface PageIndexItem { + id: PageIndexId + /** Heading */ h: string + /** Text */ t?: string[] +} + +export type SectionIndexId = `${PageIndexId}#${string}` + +export interface SectionIndexItem { + id: SectionIndexId + /** Heading */ h: string + /** Text */ t?: string[] +} + +export type CustomFieldIndexID = `${PageIndexId}@${number}` + +export interface CustomFieldIndexItem { + id: string + /** CustomFields */ c: string[] +} + +export type IndexItem = CustomFieldIndexItem | PageIndexItem | SectionIndexItem + +export type LocaleIndex = Record + +export type SearchIndexStore = Record< + string, + SearchIndex +> diff --git a/plugins/search/plugin-slimsearch/src/shared/formatter.ts b/plugins/search/plugin-slimsearch/src/shared/formatter.ts new file mode 100644 index 0000000000..81c7d66580 --- /dev/null +++ b/plugins/search/plugin-slimsearch/src/shared/formatter.ts @@ -0,0 +1 @@ +export type SlimSearchCustomFieldFormatter = Record | string diff --git a/plugins/search/plugin-slimsearch/src/shared/hotkeys.ts b/plugins/search/plugin-slimsearch/src/shared/hotkeys.ts new file mode 100644 index 0000000000..56a8820999 --- /dev/null +++ b/plugins/search/plugin-slimsearch/src/shared/hotkeys.ts @@ -0,0 +1,44 @@ +export interface SlimSearchKeyOptions { + /** + * Value of `event.key` to trigger the hot key + * + * 热键的 `event.key` 值 + */ + key: string + + /** + * Whether to press `event.altKey` at the same time + * + * 是否同时按下 `event.altKey` + * + * @default false + */ + alt?: boolean + + /** + * Whether to press `event.ctrlKey` at the same time + * + * 是否同时按下 `event.ctrlKey` + * + * @default false + */ + ctrl?: boolean + + /** + * Whether to press `event.shiftKey` at the same time + * + * 是否同时按下 `event.shiftKey` + * + * @default false + */ + shift?: boolean + + /** + * Whether to press `event.metaKey` at the same time + * + * 是否同时按下 `event.metaKey` + * + * @default false + */ + meta?: boolean +} diff --git a/plugins/search/plugin-slimsearch/src/shared/index.ts b/plugins/search/plugin-slimsearch/src/shared/index.ts new file mode 100644 index 0000000000..f084d6e8b4 --- /dev/null +++ b/plugins/search/plugin-slimsearch/src/shared/index.ts @@ -0,0 +1,4 @@ +export * from './data.js' +export type * from './formatter.js' +export type * from './hotkeys.js' +export type * from './locales.js' diff --git a/plugins/search/plugin-slimsearch/src/shared/locales.ts b/plugins/search/plugin-slimsearch/src/shared/locales.ts new file mode 100644 index 0000000000..244f48432a --- /dev/null +++ b/plugins/search/plugin-slimsearch/src/shared/locales.ts @@ -0,0 +1,108 @@ +import type { ExactLocaleConfig } from '@vuepress/helper' + +/** + * Multi language config for `@vuepress/plugin-slimsearch` + * + * `@vuepress/plugin-slimsearch` 的多语言配置 + */ +export interface SlimSearchLocaleData { + /** + * Search box placeholder + * + * 搜索框占位符文字 + */ + placeholder: string + + /** + * Search text + * + * 搜索文字 + */ + search: string + + /** + * Searching text + * + * 搜索中文字 + */ + searching: string + + /** + * Cancel text + * + * 取消文字 + */ + cancel: string + + /** + * Default title + * + * 默认标题 + */ + defaultTitle: string + + /** + * Select hint + * + * 选择提示 + */ + select: string + + /** + * Choose hint + * + * 选择提示 + */ + navigate: string + + /** + * Autocomplete hint + * + * 自动补全提示 + */ + autocomplete: string + + /** + * Close hint + * + * 关闭提示 + */ + exit: string + + /** + * Loading hint + * + * 加载提示 + */ + loading: string + + /** + * Search query history title + * + * 搜索文字历史 标题 + */ + queryHistory: string + + /** + * Search result history title + * + * 搜索结果历史 标题 + */ + resultHistory: string + + /** + * Search history empty hint + * + * 无搜索历史提示 + */ + emptyHistory: string + + /** + * Empty hint + * + * 无结果提示 + */ + emptyResult: string +} + +export type SlimSearchLocaleConfig = ExactLocaleConfig diff --git a/plugins/search/plugin-slimsearch/src/shims.d.ts b/plugins/search/plugin-slimsearch/src/shims.d.ts new file mode 100644 index 0000000000..6b6a503c4e --- /dev/null +++ b/plugins/search/plugin-slimsearch/src/shims.d.ts @@ -0,0 +1,19 @@ +declare module '@internal/pagesComponents' { + import type { ComponentOptions } from 'vue' + + export const pagesComponents: Record +} + +declare module '@temp/slimsearch/index.js' { + export type SearchIndexStore = Record< + string, + () => Promise<{ default: string }> + > + + const database: SearchIndexStore + export default database +} + +declare module '@temp/slimsearch/store.js' { + export const store: string[] +} diff --git a/plugins/search/plugin-slimsearch/src/worker/index.ts b/plugins/search/plugin-slimsearch/src/worker/index.ts new file mode 100644 index 0000000000..2461d9f9ff --- /dev/null +++ b/plugins/search/plugin-slimsearch/src/worker/index.ts @@ -0,0 +1,51 @@ +/* eslint-disable no-restricted-globals */ +import { entries, fromEntries } from '@vuepress/helper/client' +import type { IndexObject } from 'slimsearch' +import { loadIndex } from 'slimsearch' + +import type { MessageData } from '../client/typings/index.js' +import { getResults } from '../client/worker/result.js' +import { getSuggestions } from '../client/worker/suggestion.js' +import type { IndexItem, SearchIndexStore } from '../shared/index.js' + +declare const __SLIMSEARCH_INDEX__: string + +const searchIndex: SearchIndexStore = fromEntries( + entries( + JSON.parse(__SLIMSEARCH_INDEX__) as Record>, + ).map(([localePath, index]) => [ + localePath, + loadIndex(index, { + fields: [/** Heading */ 'h', /** Text */ 't', /** CustomFields */ 'c'], + storeFields: [ + /** Heading */ 'h', + /** Text */ 't', + /** CustomFields */ 'c', + ], + }), + ]), +) + +self.onmessage = ({ + data: { type = 'all', query, locale, options, id }, +}: MessageEvent): void => { + const searchLocaleIndex = searchIndex[locale] + + if (type === 'suggest') + self.postMessage([ + type, + id, + getSuggestions(query, searchLocaleIndex, options), + ]) + else if (type === 'search') + self.postMessage([type, id, getResults(query, searchLocaleIndex, options)]) + else + self.postMessage({ + suggestions: [ + type, + id, + getSuggestions(query, searchLocaleIndex, options), + ], + results: [type, id, getResults(query, searchLocaleIndex, options)], + }) +} diff --git a/plugins/search/plugin-slimsearch/tests/__fixtures__/src/demo.md b/plugins/search/plugin-slimsearch/tests/__fixtures__/src/demo.md new file mode 100644 index 0000000000..f439a01373 --- /dev/null +++ b/plugins/search/plugin-slimsearch/tests/__fixtures__/src/demo.md @@ -0,0 +1,33 @@ +--- +title: Demo Page +author: Mr.Hope +date: 2021-01-01 +category: + - Demo +tag: + - Demo +--- + +Here is **article excerpt**. + +```js +const a = 1 +``` + + + +## Content + +Here is main content of **article**. + +1. A +1. B +1. C + +```js +const a = 1 +``` + +## Title with Special Characters #@%/ + +Content with regexp sequence 'a$' test. diff --git a/plugins/search/plugin-slimsearch/tests/__fixtures__/src/example.md b/plugins/search/plugin-slimsearch/tests/__fixtures__/src/example.md new file mode 100644 index 0000000000..53dbe30586 --- /dev/null +++ b/plugins/search/plugin-slimsearch/tests/__fixtures__/src/example.md @@ -0,0 +1,114 @@ +# Heading 1 + +## Heading 2 + +### Heading 3 + +#### Heading 4 + +##### Heading 5 + +###### Heading 6 + +## Text + +This sentence has **bold**、_italic_ and ~~delete~~ style text. + +## Paragraph + +This is a paragraph. + +This is another paragraph. + +## Line Break + +I would like to line break at +this point + +::: tip + +In codes above, two spaces are behind `at`. + +::: + +## Blockquotes + +> Blockquotes can also be nested... +> +> > ...by using greater-than signs right next to each other... +> > +> > > ...or with spaces between arrows. + +## List + +### Unordered List + +- Create a list by starting a line with `-` +- Make sub-lists by indenting 2 spaces: + + - Marker character change forces new list start: + + - Ac tristique libero volutpat at + - Facilisis in pretium nisl aliquet + - Nulla volutpat aliquam velit + link break + + New paragraph + +- It’s easy! + +### Ordered List + +1. Lorem ipsum dolor sit amet +1. Consectetur adipiscing elit + line break + line break again +1. Integer molestie lorem at massa + +## HR + +--- + +## Link + +[Home page using absolute path](/) + +[Home page using relative path](../../README.md) + +## Image + +![Logo](/logo.svg) + +## Emoji + +Classic: + +:wink: :cry: :laughing: :yum: + +## Tables + +| center | right | left | +| :------------------------: | -----------------------: | :---------------------- | +| For center align use `:-:` | For right align use `-:` | For left align use `:-` | +| b | aaaaaaaaa | aaaa | +| c | aaaa | a | + +## Codes + +Inline Code: `code` + +Block code: + +``` +Sample text here... +``` + +Syntax highlighting: + +```js +function foo(bar) { + return `foo${bar}` +} + +console.log(foo(5)) +``` diff --git a/plugins/search/plugin-slimsearch/tests/__fixtures__/src/single.md b/plugins/search/plugin-slimsearch/tests/__fixtures__/src/single.md new file mode 100644 index 0000000000..9a0a380a79 --- /dev/null +++ b/plugins/search/plugin-slimsearch/tests/__fixtures__/src/single.md @@ -0,0 +1,6 @@ +--- +title: Tag Test +tag: markdown +--- + +Markdown content. diff --git a/plugins/search/plugin-slimsearch/tests/__fixtures__/src/tagwithoutTitle.md b/plugins/search/plugin-slimsearch/tests/__fixtures__/src/tagwithoutTitle.md new file mode 100644 index 0000000000..ed1050143a --- /dev/null +++ b/plugins/search/plugin-slimsearch/tests/__fixtures__/src/tagwithoutTitle.md @@ -0,0 +1,6 @@ +--- +tag: + - markdown +--- + +Markdown content. diff --git a/plugins/search/plugin-slimsearch/tests/__fixtures__/src/zh.md b/plugins/search/plugin-slimsearch/tests/__fixtures__/src/zh.md new file mode 100644 index 0000000000..a2c4c0d2c6 --- /dev/null +++ b/plugins/search/plugin-slimsearch/tests/__fixtures__/src/zh.md @@ -0,0 +1,139 @@ +--- +title: 西游记第一回 +--- + +灵根育孕源流出 心性修持大道生 + +诗曰: + +混沌未分天地乱,茫茫渺渺无人见。 + +自从盘古破鸿蒙,开辟从兹清浊辨。 + +覆载群生仰至仁,发明万物皆成善。 + +欲知造化会元功,须看西游释厄传。 + +盖闻天地之数,有十二万九千六百岁为一元。将一元分为十二会,乃子、丑、寅、卯、辰、巳、午、未、申、酉、戌、亥之十二支也。每会该一万八百岁。且就一日而论:子时得阳气,而丑则鸡鸣;寅不通光,而卯则日出;辰时食后,而巳则挨排;日午天中,而未则西蹉;申时晡而日落酉;戌黄昏而人定亥。譬于大数,若到戌会之终,则天地昏蒙而万物否矣。再去五千四百岁,交亥会之初,则当黑暗,而两间人物俱无矣,故曰混沌。又五千四百岁,亥会将终,贞下起元,近子之会,而复逐渐开明。邵康节曰:“冬至子之半,天心无改移。一阳初动处,万物未生时。”到此,天始有根。 + +再五千四百岁,正当子会,轻清上腾,有日,有月,有星,有辰。日、月、星、辰,谓之四象。故曰,天开于子。又经五千四百岁,子会将终,近丑之会,而逐渐坚实。易曰:“大哉乾元!至哉坤元!万物资生,乃顺承天。”至此,地始凝结。再五千四百岁,正当丑会,重浊下凝,有水,有火,有山,有石,有土。水、火、山、石、土谓之五形。故曰,地辟于丑。又经五千四百岁,丑会终而寅会之初,发生万物。历曰:“天气下降,地气上升;天地交合,群物皆生。”至此,天清地爽,阴阳交合。再五千四百岁,正当寅会,生人,生兽,生禽,正谓天地人,三才定位。故曰,人生于寅。 + +感盘古开辟,三皇治世,五帝定伦,世界之间,遂分为四大部洲:曰东胜神洲,曰西牛贺洲,曰南赡部洲,曰北俱芦洲。这部书单表东胜神洲。海外有一国土,名曰傲来国。国近大海,海中有一座山,唤为花果山。此山乃十洲之祖脉,三岛之来龙,自开清浊而立,鸿蒙判后而成。真个好山!有词赋为证。赋曰: + +势镇汪洋,威宁瑶海。势镇汪洋,潮涌银山鱼入穴;威宁瑶海,波翻雪浪蜃离渊。木火方隅高积上,东海之处耸崇巅。丹崖怪石,削壁奇峰。丹崖上,彩凤双鸣;削壁前,麒麟独卧。峰头时听锦鸡鸣,石窟每观龙出入。林中有寿鹿仙狐,树上有灵禽玄鹤。瑶草奇花不谢,青松翠柏长春。仙桃常结果,修竹每留云。一条涧壑藤萝密,四面原堤草色新。正是百川会处擎天柱,万劫无移大地根。 + +那座山,正当顶上,有一块仙石。其石有三丈六尺五寸高,有二丈四尺围圆。三丈六尺五寸高,按周天三百六十五度;二丈四尺围圆,按政历二十四气。上有九窍八孔,按九宫八卦。四面更无树木遮阴,左右倒有芝兰相衬。盖自开辟以来,每受天真地秀,日精月华,感之既久,遂有灵通之意。内育仙胞,一日迸裂,产一石卵,似圆球样大。因见风,化作一个石猴,五官俱备,四肢皆全。便就学爬学走,拜了四方。目运两道金光,射冲斗府。惊动高天上圣大慈仁者玉皇大天尊玄穹高上帝,驾座金阙云宫灵霄宝殿,聚集仙卿,见有金光焰焰,即命千里眼、顺风耳开南天门观看。二将果奉旨出门外,看的真,听的明。须臾回报道:“臣奉旨观听金光之处,乃东胜神洲海东傲来小国之界,有一座花果山,山上有一仙石,石产一卵,见风化一石猴,在那里拜四方,眼运金光,射冲斗府。如今服饵水食,金光将潜息矣。”玉帝垂赐恩慈曰:“下方之物,乃天地精华所生,不足为异。” + +那猴在山中,却会行走跳跃,食草木,饮涧泉,采山花,觅树果;与狼虫为伴,虎豹为群,獐鹿为友,猕猿为亲;夜宿石崖之下,朝游峰洞之中。真是“山中无甲子,寒尽不知年。”一朝天气炎热,与群猴避暑,都在松阴之下顽耍。你看他一个个: + +跳树攀枝,采花觅果;抛弹子,邷么儿;跑沙窝,砌宝塔;赶蜻蜓,扑八蜡;参老天,拜菩萨;扯葛藤,编草帓;捉虱子,咬又掐;理毛衣,剔指甲;挨的挨,擦的擦;推的推,压的压;扯的扯,拉的拉,青松林下任他顽,绿水涧边随洗濯。一群猴子耍了一会,却去那山涧中洗澡。见那股涧水奔流,真个似滚瓜涌溅。古云:“禽有禽言,兽有兽语。”众猴都道:“这股水不知是那里的水。我们今日赶闲无事,顺涧边往上溜头寻看源流,耍子去耶!”喊一声,都拖男挈女,呼弟呼兄,一齐跑来,顺涧爬山,直至源流之处,乃是一股瀑布飞泉。但见那: + +一派白虹起,千寻雪浪飞;海风吹不断,江月照还依。 + +冷气分青嶂,馀流润翠微;潺湲名瀑布,真似挂帘帷。 + +众猴拍手称扬道:“好水!好水!原来此处远通山脚之下,直接大海之波。”又道:“那一个有本事的,钻进去寻个源头出来,不伤身体者,我等即拜他为王。”连呼了三声,忽见丛杂中跳出一名石猴,应声高叫道:“我进去!我进去!”好猴!也是他: + +今日芳名显,时来大运通;有缘居此地,王遣入仙宫。 + +你看他瞑目蹲身,将身一纵,径跳入瀑布泉中,忽睁睛抬头观看,那里边却无水无波,明明朗朗的一架桥梁。他住了身,定了神,仔细再看,原来是座铁板桥。桥下之水,冲贯于石窍之间,倒挂流出去,遮闭了桥门。却又欠身上桥头,再走再看,却似有人家住处一般,真个好所在。但见那: + +翠藓堆蓝,白云浮玉,光摇片片烟霞。虚窗静室,滑凳板生花。乳窟龙珠倚挂,萦回满地奇葩。锅灶傍崖存火迹,樽罍靠案见肴渣。石座石床真可爱,石盆石碗更堪夸。又见那一竿两竿修竹,三点五点梅花。几树青松常带雨,浑然相个人家。 + +看罢多时,跳过桥中间,左右观看,只见正当中有一石碣。碣上有一行楷书大字,镌着“花果山福地,水帘洞洞天。”石猴喜不自胜,急抽身往外便走,复瞑目蹲身,跳出水外,打了两个呵呵道:“大造化!大造化!”众猴把他围住,问道:“里面怎么样?水有多深?”石猴道:“没水!没水!原来是一座铁板桥。桥那边是一座天造地设的家当。”众猴道:“怎见得是个家当?”石猴笑道:“这股水乃是桥下冲贯石桥,倒挂下来遮闭门户的。桥边有花有树,乃是一座石房。房内有石窝、石灶、石碗、石盆、石床、石凳。中间一块石碣上,镌着‘花果山福地,水帘洞洞天。’真个是我们安身之处。里面且是宽阔,容得千百口老小。我们都进去住也,省得受老天之气。这里边: + +刮风有处躲,下雨好存身。霜雪全无惧,雷声永不闻。 + +烟霞常照耀,祥瑞每蒸熏。松竹年年秀,奇花日日新。 + +众猴听得,个个欢喜,都道:“你还先走,带我们进去,进去!”石猴却又瞑目蹲身,往里一跳,叫道:“都随我进来!进来!”那些猴有胆大的,都跳进去了;胆小的,一个个伸头缩颈,抓耳挠腮,大声叫喊,缠一会,也都进去了。跳过桥头,一个个抢盆夺碗,占灶争床,搬过来,移过去,正是猴性顽劣,再无一个宁时,只搬得力倦神疲方止。石猿端坐上面道:“列位呵,‘人而无信,不知其可。’你们才说有本事进得来,出得去,不伤身体者,就拜他为王。我如今进来又出去,出去又进来,寻了这一个洞天与列位安眠稳睡,各享成家之福,何不拜我为王?”众猴听说,即拱伏无违。一个个序齿排班,朝上礼拜,都称“千岁大王”。自此,石猴高登王位,将“石”字儿隐了,遂称美猴王。有诗为证。诗曰: + +三阳交泰产群生,仙石胞含日月精。 + +借卵化猴完大道,假他名姓配丹成。 + +内观不识因无相,外合明知作有形。 + +历代人人皆属此,称王称圣任纵横。 + +美猴王领一群猿猴、猕猴、马猴等,分派了君臣佐使,朝游花果山,暮宿水帘洞,合契同情,不入飞鸟之丛,不从走兽之类,独自为王,不胜欢乐。是以: + +春采百花为饮食,夏寻诸果作生涯。 + +秋收芋栗延时节,冬觅黄精度岁华。 + +美猴王享乐天真,何期有三五百载。一日,与群猴喜宴之间,忽然忧恼,堕下泪来。众猴慌忙罗拜道:“大王何为烦恼?”猴王道:“我虽在欢喜之时,却有一点儿远虑,故此烦恼。”众猴又笑道:“大王好不知足!我等日日欢会,在仙山福地,古洞神州,不伏麒麟辖,不伏凤凰管,又不伏人间王位所拘束,自由自在,乃无量之福,为何远虑而忧也?”猴王道:“今日虽不归人王法律,不惧禽兽威服,将来年老血衰,暗中有阎王老子管着,一旦身亡,可不枉生世界之中,不得久住天人之内?”众猴闻此言,一个个掩面悲啼,俱以无常为虑。 + +只见那班部中,忽跳出一个通背猿猴,厉声高叫道:“大王若是这般远虑,真所谓道心开发也!如今五虫之内,惟有三等名色,不伏阎王老子所管。”猴王道:“你知那三等人?”猿猴道:“乃是佛与仙与神圣三者,躲过轮回,不生不灭,与天地山川齐寿。”猴王道:“此三者居于何所?”猿猴道:“他只在阎浮世界之中,古洞仙山之内。”猴王闻之,满心欢喜,道:“我明日就辞汝等下山,云游海角,远涉天涯,务必访此三者,学一个不老长生,常躲过阎君之难。”噫!这句话,顿教跳出轮回网,致使齐天大圣成。众猴鼓掌称扬,都道:“善哉!善哉!我等明日越岭登山,广寻些果品,大设筵宴送大王也。” + +次日,众猴果去采仙桃,摘异果,刨山药,劚黄精,芝兰香蕙,瑶草奇花,般般件件,整整齐齐,摆开石凳石桌,排列仙酒仙肴。但见那: + +金丸珠弹,红绽黄肥。金丸珠弹腊樱桃,色真甘美;红绽黄肥熟梅子,味果香酸。鲜龙眼,肉甜皮薄;火荔枝,核小囊红。林檎碧实连枝献,枇杷缃苞带叶擎。兔头梨子鸡心枣,消渴除烦更解酲。香桃烂杏,美甘甘似玉液琼浆;脆李杨梅,酸荫荫如脂酸膏酪。红囊黑子熟西瓜,四瓣黄皮大柿子。石榴裂破,丹砂粒现火晶珠;芋栗剖开,坚硬肉团金玛瑙。胡桃银杏可传茶,椰子葡萄能做酒。榛松榧柰满盘盛,橘蔗柑橙盈案摆。熟煨山药,烂煮黄精,捣碎茯苓并薏苡,石锅微火漫炊羹。人间纵有珍馐味,怎比山猴乐更宁? + +群猴尊美猴王上坐,各依齿肩排于下边,一个个轮流上前,奉酒,奉花,奉果,痛饮了一日。次日,美猴王早起,教:“小的们,替我折些枯松,编作筏子,取个竹竿作篙,收拾些果品之类,我将去也。”果独自登筏,尽力撑开,飘飘荡荡,径向大海波中,趁天风,来渡南赡部洲地界。这一去,正是那: + +天产仙猴道行隆,离山驾筏趁天风。 + +飘洋过海寻仙道,立志潜心建大功。 + +有分有缘休俗愿,无忧无虑会元龙。 + +料应必遇知音者,说破源流万法通。 + +也是他运至时来,自登木筏之后,连日东南风紧,将他送到西北岸前,乃是南赡部洲地界。持篙试水,偶得浅水,弃了筏子,跳上岸来,只见海边有人捕鱼、打雁、挖蛤、淘盐。他走近前,弄个把戏,妆个𡤫虎,吓得那些人丢筐弃网,四散奔跑。将那跑不动的拿住一个,剥了他衣裳,也学人穿在身上,摇摇摆摆,穿州过府,在市尘中,学人礼,学人话。朝餐夜宿,一心里访问佛仙神圣之道,觅个长生不老之方。见世人都是为名为利之徒,更无一个为身命者。正是那: + +争名夺利几时休?早起迟眠不自由! + +骑着驴骡思骏马,官居宰相望王侯。 + +只愁衣食耽劳碌,何怕阎君就取勾? + +继子荫孙图富贵,更无一个肯回头! + +猴王参访仙道,无缘得遇。在于南赡部洲,串长城,游小县,不觉八九年馀。忽行至西洋大海,他想着海外必有神仙。独自个依前作筏,又飘过西海,直至西牛贺洲地界。登岸偏访多时,忽见一座高山秀丽,林麓幽深。他也不怕狼虫,不惧虎豹,登山顶上观看。果是好山: + +千峰开戟,万仞开屏。日映岚光轻锁翠,雨收黛色冷含青。枯藤缠老树,古渡界幽程。奇花瑞草,修竹乔松。修竹乔松,万载常青欺福地;奇花瑞草,四时不谢赛蓬瀛。幽鸟啼声近,源泉响溜清。重重谷壑芝兰绕,处处巉崖苔藓生。起伏峦头龙脉好,必有高人隐姓名。 + +正观看间,忽闻得林深之处,有人言语,急忙趋步,穿入林中,侧耳而听,原来是歌唱之声。歌曰: + +“观棋柯烂,伐木丁丁,云边谷口徐行,卖薪沽酒,狂笑自陶情。苍迳秋高,对月枕松根,一觉天明。认旧林,登崖过岭,持斧断枯藤。 + +收来成一担,行歌市上,易米三升。更无些子争竞,时价平平,不会机谋巧算,没荣辱,恬淡延生。相逢处,非仙即道,静坐讲《黄庭》。” + +美猴王听得此言,满心欢喜道:“神仙原来藏在这里!”急忙跳入里面,仔细再看,乃是一个樵子,在那里举斧砍柴。但看他打扮非常: + +头上戴箬笠,乃是新笋初脱之箨。身上穿布衣,乃是木绵捻就之纱。腰间系环绦,乃是老蚕口吐之丝。足下踏草履,乃是枯莎搓就之爽。手执衠钢斧,担挽火麻绳。扳松劈枯树,争似此樵能! + +猴王近前叫道:“老神仙!弟子起手。”那樵汉慌忙丢了斧,转身答礼道:“不当人!不当人!我拙汉衣食不全,怎敢当‘神仙’二字?”猴王道:“你不是神仙,如何说出神仙的话来?”樵夫道:“我说甚么神仙话?”猴王道:“我才来至林边,只听的你说:‘相逢处非仙即道,静坐讲《黄庭》。’《黄庭》乃道德真言,非神仙而何?”樵夫笑道:“实不瞒你说,这个词名做满庭芳,乃一神仙教我的。那神仙与我舍下相邻。他见我家事劳苦,日常烦恼,教我遇烦恼时,即把这词儿念念。一则散心,二则解困。我才有些不足处思虑,故此念念。不期被你听了。”猴王道:“你家既与神仙相邻,何不从他修行?学得个不老之方?却不是好?”樵夫道:“我一生命苦,自幼蒙父母养育至八九岁,才知人事,不幸父丧,母亲居孀。再无兄弟姊妹,只我一人,没奈何,早晚侍奉。如今母老,一发不敢抛离。却又田园荒芜,衣食不足,只得斫两束柴薪,挑向市尘之间,货几文钱,籴几升米,自炊自造,安排些茶饭,供养老母,所以不能修行。” + +猴王道:“据你说起来,乃是一个行孝的君子,向后必有好处。但望你指与我那神仙住处,却好拜访去也。”樵夫道:“不远,不远。此山叫做灵台方寸山。山中有座斜月三星洞。那洞中有一个神仙,称名须菩提祖师。那祖师出去的徒弟,也不计其数,见今还有三四十人从他修行。你顺那条小路儿,向南行七八里远近,即是他家了。”猴王用手扯住樵夫道:“老兄,你便同我去去。若还得了好处,决不忘你指引之恩。”樵夫道:“你这汉子,甚不通变。我方才这般与你说了,你还不省?假若我与你去了,却不误了我的生意?老母何人奉养?我要斫柴,你自去,自去。” + +猴王听说,只得相辞。出深林,找上路径,过一山坡,约有七八里远,果然望见一座洞府。挺身观看,真好去处!但见: + +烟霞散彩,日月摇光。千株老柏,万节修篁。千株老柏,带雨半空青冉冉;万节修篁,含烟一壑色苍苍。门外奇花布锦,桥边瑶草喷香。石崖突兀青苔润,悬壁高张翠藓长。时闻仙鹤唳,每见凤凰翔。仙鹤唳时,声振九皋霄汉远;凤凰翔起,翎毛五色彩云光。玄猿白鹿随隐见,金狮玉象任行藏。细观灵福地,真个赛天堂!又见那洞门紧闭,静悄悄杳无人迹。忽回头,见崖头立一石牌,约有三丈馀高、八尺馀阔,上有一行十个大字,乃是“灵台方寸山,斜月三星洞”。美猴王十分欢喜道:“此间人果是朴实。果有此山此洞。”看勾多时,不敢敲门。且去跳上松枝梢头,摘松子吃了顽耍。 + +少顷间,只听得呀的一声,洞门开处,里面走出一个仙童,真个丰姿英伟,像貌清奇,比寻常俗子不同。但见他: + +髽髻双丝绾,宽袍两袖风。貌和身自别,心与相俱空。 + +物外长年客,山中永寿童。一尘全不染,甲子任翻腾。 + +那童子出得门来,高叫道:“甚么人在此搔扰?”猴王扑的跳下树来,上前躬身道:“仙童,我是个访道学仙之弟子,更不敢在此搔扰。”仙童笑道:“你是个访道的么?”猴王道:“是。”童子道:“我家师父,正才下榻,登坛讲道。还未说出原由,就教我出来开门。说:‘外面有个修行的来了,可去接待接待。’想必就是你了?”猴王笑道:“是我,是我。”童子道:“你跟我进来。” + +这猴王整衣端肃,随童子径入洞天深处观看:一层层深阁琼楼,一进进珠宫贝阙,说不尽那静室幽居,直至瑶台之下。见那菩提祖师端坐在台上,两边有三十个小仙侍立台下。果然是: + +大觉金仙没垢姿,西方妙相祖菩提; + +不生不灭三三行,全气全神万万慈。 + +空寂自然随变化,真如本性任为之; + +与天同寿庄严体,历劫明心大法师。 + +美猴王一见,倒身下拜,磕头不计其数,口中只道:“师父!师父!我弟子志心朝礼!志心朝礼!”祖师道:“你是那方人氏?且说个乡贯姓名明白,再拜。”猴王道:“弟子东胜神洲傲来国花果山水帘洞人氏。”祖师喝令:“赶出去!他本是个撒诈捣虚之徒,那里修甚么道果!”猴王慌忙磕头不住道:“弟子是老实之言,决无虚诈。”祖师道:“你既老实,怎么说东胜神洲?那去处到我这里,隔两重大海,一座南赡部洲,如何就得到此?”猴王叩头道:“弟子飘洋过海,登界游方,有十数个年头,方才访到此处。” + +祖师道:“既是逐渐行来的也罢。你姓甚么?”猴王又道:“我无性。人若骂我,我也不恼;若打我,我也不嗔,只是陪个礼儿就罢了。一生无性。”祖师道:“不是这个性。你父母原来姓甚么?”猴王道:“我也无父母。”祖师道:“既无父母,想是树上生的?”猴王道:“我虽不是树生,却是石里长的。我只记得花果山上有一块仙石,其年石破,我便生也。”祖师闻言,暗喜道:“这等说,却是天地生成的。你起来走走我看。”猴王纵身跳起,拐呀拐的走了两遍。祖师笑道:“你身躯虽是鄙陋,却像个食松果的猢狲。我与你就身上取个姓氏,意思教你姓‘猢’。猢字去了个兽傍,乃是古月。 + +古者,老也;月者,阴也。老阴不能化育,教你姓‘狲’倒好。狲字去了兽傍,乃是个子系。子者,儿男也;系者,婴细也。正合婴儿之本论。教你姓‘孙’罢。”猴王听说,满心欢喜,朝上叩头道:“好!好!好!今日方知姓也。万望师父慈悲!既然有姓,再乞赐个名字,却好呼唤。”祖师道:“我门中有十二个字,分派起名到你乃第十辈之小徒矣。”猴王道:“那十二个字?”祖师道:“乃广、大、智、慧、真、如、性、海、颖、悟、圆、觉十二字。排到你,正当‘悟’字。与你起个法名叫做‘孙悟空’好么?”猴王笑道:“好!好!好!自今就叫做孙悟空也!”正是:鸿蒙初辟原无姓,打破顽空须悟空。 + +毕竟不之向后修些甚么道果,且听下回分解。 diff --git a/plugins/search/plugin-slimsearch/tests/__fixtures__/theme/empty.ts b/plugins/search/plugin-slimsearch/tests/__fixtures__/theme/empty.ts new file mode 100644 index 0000000000..ebf455fd73 --- /dev/null +++ b/plugins/search/plugin-slimsearch/tests/__fixtures__/theme/empty.ts @@ -0,0 +1,5 @@ +import type { Theme } from 'vuepress/core' + +export const emptyTheme: Theme = { + name: 'vuepress-theme-empty', +} diff --git a/plugins/search/plugin-slimsearch/tests/__snapshots__/generateIndex.spec.ts.snap b/plugins/search/plugin-slimsearch/tests/__snapshots__/generateIndex.spec.ts.snap new file mode 100644 index 0000000000..d4a344564e --- /dev/null +++ b/plugins/search/plugin-slimsearch/tests/__snapshots__/generateIndex.spec.ts.snap @@ -0,0 +1,889 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`generateIndex > Should generate full index 1`] = ` +[ + { + "h": "Demo Page", + "id": "0", + "t": [ + "Here is article excerpt.", + "const a = 1 ", + ], + }, + { + "h": "Content", + "id": "0#content", + "t": [ + "Here is main content of article.", + "A", + "B", + "C", + "const a = 1 ", + ], + }, + { + "h": "Title with Special Characters #@%/", + "id": "0#title-with-special-characters", + "t": [ + "Content with regexp sequence 'a$' test.", + ], + }, +] +`; + +exports[`generateIndex > Should generate full index 2`] = ` +[ + { + "h": "Heading 1", + "id": "1", + }, + { + "h": "Heading 2", + "id": "1#heading-2", + }, + { + "h": "Heading 3", + "id": "1#heading-3", + }, + { + "h": "Heading 4", + "id": "1#heading-4", + }, + { + "h": "Heading 5", + "id": "1#heading-5", + }, + { + "h": "Heading 6", + "id": "1#heading-6", + }, + { + "h": "Text", + "id": "1#text", + "t": [ + "This sentence has bold、italic and delete style text.", + ], + }, + { + "h": "Paragraph", + "id": "1#paragraph", + "t": [ + "This is a paragraph.", + "This is another paragraph.", + ], + }, + { + "h": "Line Break", + "id": "1#line-break", + "t": [ + "I would like to line break at this point", + "::: tip", + "In codes above, two spaces are behind at.", + ":::", + ], + }, + { + "h": "Blockquotes", + "id": "1#blockquotes", + "t": [ + "Blockquotes can also be nested...", + "...by using greater-than signs right next to each other...", + "...or with spaces between arrows.", + ], + }, + { + "h": "List", + "id": "1#list", + }, + { + "h": "Unordered List", + "id": "1#unordered-list", + "t": [ + "Create a list by starting a line with -", + "Make sub-lists by indenting 2 spaces:", + "Marker character change forces new list start:", + "Ac tristique libero volutpat at", + "Facilisis in pretium nisl aliquet", + "Nulla volutpat aliquam velit link break", + "New paragraph", + "It’s easy!", + ], + }, + { + "h": "Ordered List", + "id": "1#ordered-list", + "t": [ + "Lorem ipsum dolor sit amet", + "Consectetur adipiscing elit line break line break again", + "Integer molestie lorem at massa", + ], + }, + { + "h": "HR", + "id": "1#hr", + }, + { + "h": "Link", + "id": "1#link", + "t": [ + "Home page using absolute path", + "Home page using relative path", + ], + }, + { + "h": "Image", + "id": "1#image", + }, + { + "h": "Emoji", + "id": "1#emoji", + "t": [ + "Classic:", + "😉 😢 😆 😋", + ], + }, + { + "h": "Tables", + "id": "1#tables", + "t": [ + "center", + "right", + "left", + "For center align use :-:", + "For right align use -:", + "For left align use :-", + "b", + "aaaaaaaaa", + "aaaa", + "c", + "aaaa", + "a", + ], + }, + { + "h": "Codes", + "id": "1#codes", + "t": [ + "Inline Code: code", + "Block code:", + "Sample text here... ", + "Syntax highlighting:", + "function foo(bar) { return \`foo\${bar}\` } console.log(foo(5)) ", + ], + }, +] +`; + +exports[`generateIndex > Should generate full index 3`] = ` +[ + { + "h": "Tag Test", + "id": "2", + "t": [ + "Markdown content.", + ], + }, +] +`; + +exports[`generateIndex > Should generate full index 4`] = ` +[ + { + "h": "", + "id": "3", + "t": [ + "Markdown content.", + ], + }, +] +`; + +exports[`generateIndex > Should generate full index 5`] = ` +[ + { + "h": "西游记第一回", + "id": "4", + "t": [ + "灵根育孕源流出 心性修持大道生", + "诗曰:", + "混沌未分天地乱,茫茫渺渺无人见。", + "自从盘古破鸿蒙,开辟从兹清浊辨。", + "覆载群生仰至仁,发明万物皆成善。", + "欲知造化会元功,须看西游释厄传。", + "盖闻天地之数,有十二万九千六百岁为一元。将一元分为十二会,乃子、丑、寅、卯、辰、巳、午、未、申、酉、戌、亥之十二支也。每会该一万八百岁。且就一日而论:子时得阳气,而丑则鸡鸣;寅不通光,而卯则日出;辰时食后,而巳则挨排;日午天中,而未则西蹉;申时晡而日落酉;戌黄昏而人定亥。譬于大数,若到戌会之终,则天地昏蒙而万物否矣。再去五千四百岁,交亥会之初,则当黑暗,而两间人物俱无矣,故曰混沌。又五千四百岁,亥会将终,贞下起元,近子之会,而复逐渐开明。邵康节曰:“冬至子之半,天心无改移。一阳初动处,万物未生时。”到此,天始有根。", + "再五千四百岁,正当子会,轻清上腾,有日,有月,有星,有辰。日、月、星、辰,谓之四象。故曰,天开于子。又经五千四百岁,子会将终,近丑之会,而逐渐坚实。易曰:“大哉乾元!至哉坤元!万物资生,乃顺承天。”至此,地始凝结。再五千四百岁,正当丑会,重浊下凝,有水,有火,有山,有石,有土。水、火、山、石、土谓之五形。故曰,地辟于丑。又经五千四百岁,丑会终而寅会之初,发生万物。历曰:“天气下降,地气上升;天地交合,群物皆生。”至此,天清地爽,阴阳交合。再五千四百岁,正当寅会,生人,生兽,生禽,正谓天地人,三才定位。故曰,人生于寅。", + "感盘古开辟,三皇治世,五帝定伦,世界之间,遂分为四大部洲:曰东胜神洲,曰西牛贺洲,曰南赡部洲,曰北俱芦洲。这部书单表东胜神洲。海外有一国土,名曰傲来国。国近大海,海中有一座山,唤为花果山。此山乃十洲之祖脉,三岛之来龙,自开清浊而立,鸿蒙判后而成。真个好山!有词赋为证。赋曰:", + "势镇汪洋,威宁瑶海。势镇汪洋,潮涌银山鱼入穴;威宁瑶海,波翻雪浪蜃离渊。木火方隅高积上,东海之处耸崇巅。丹崖怪石,削壁奇峰。丹崖上,彩凤双鸣;削壁前,麒麟独卧。峰头时听锦鸡鸣,石窟每观龙出入。林中有寿鹿仙狐,树上有灵禽玄鹤。瑶草奇花不谢,青松翠柏长春。仙桃常结果,修竹每留云。一条涧壑藤萝密,四面原堤草色新。正是百川会处擎天柱,万劫无移大地根。", + "那座山,正当顶上,有一块仙石。其石有三丈六尺五寸高,有二丈四尺围圆。三丈六尺五寸高,按周天三百六十五度;二丈四尺围圆,按政历二十四气。上有九窍八孔,按九宫八卦。四面更无树木遮阴,左右倒有芝兰相衬。盖自开辟以来,每受天真地秀,日精月华,感之既久,遂有灵通之意。内育仙胞,一日迸裂,产一石卵,似圆球样大。因见风,化作一个石猴,五官俱备,四肢皆全。便就学爬学走,拜了四方。目运两道金光,射冲斗府。惊动高天上圣大慈仁者玉皇大天尊玄穹高上帝,驾座金阙云宫灵霄宝殿,聚集仙卿,见有金光焰焰,即命千里眼、顺风耳开南天门观看。二将果奉旨出门外,看的真,听的明。须臾回报道:“臣奉旨观听金光之处,乃东胜神洲海东傲来小国之界,有一座花果山,山上有一仙石,石产一卵,见风化一石猴,在那里拜四方,眼运金光,射冲斗府。如今服饵水食,金光将潜息矣。”玉帝垂赐恩慈曰:“下方之物,乃天地精华所生,不足为异。”", + "那猴在山中,却会行走跳跃,食草木,饮涧泉,采山花,觅树果;与狼虫为伴,虎豹为群,獐鹿为友,猕猿为亲;夜宿石崖之下,朝游峰洞之中。真是“山中无甲子,寒尽不知年。”一朝天气炎热,与群猴避暑,都在松阴之下顽耍。你看他一个个:", + "跳树攀枝,采花觅果;抛弹子,邷么儿;跑沙窝,砌宝塔;赶蜻蜓,扑八蜡;参老天,拜菩萨;扯葛藤,编草帓;捉虱子,咬又掐;理毛衣,剔指甲;挨的挨,擦的擦;推的推,压的压;扯的扯,拉的拉,青松林下任他顽,绿水涧边随洗濯。一群猴子耍了一会,却去那山涧中洗澡。见那股涧水奔流,真个似滚瓜涌溅。古云:“禽有禽言,兽有兽语。”众猴都道:“这股水不知是那里的水。我们今日赶闲无事,顺涧边往上溜头寻看源流,耍子去耶!”喊一声,都拖男挈女,呼弟呼兄,一齐跑来,顺涧爬山,直至源流之处,乃是一股瀑布飞泉。但见那:", + "一派白虹起,千寻雪浪飞;海风吹不断,江月照还依。", + "冷气分青嶂,馀流润翠微;潺湲名瀑布,真似挂帘帷。", + "众猴拍手称扬道:“好水!好水!原来此处远通山脚之下,直接大海之波。”又道:“那一个有本事的,钻进去寻个源头出来,不伤身体者,我等即拜他为王。”连呼了三声,忽见丛杂中跳出一名石猴,应声高叫道:“我进去!我进去!”好猴!也是他:", + "今日芳名显,时来大运通;有缘居此地,王遣入仙宫。", + "你看他瞑目蹲身,将身一纵,径跳入瀑布泉中,忽睁睛抬头观看,那里边却无水无波,明明朗朗的一架桥梁。他住了身,定了神,仔细再看,原来是座铁板桥。桥下之水,冲贯于石窍之间,倒挂流出去,遮闭了桥门。却又欠身上桥头,再走再看,却似有人家住处一般,真个好所在。但见那:", + "翠藓堆蓝,白云浮玉,光摇片片烟霞。虚窗静室,滑凳板生花。乳窟龙珠倚挂,萦回满地奇葩。锅灶傍崖存火迹,樽罍靠案见肴渣。石座石床真可爱,石盆石碗更堪夸。又见那一竿两竿修竹,三点五点梅花。几树青松常带雨,浑然相个人家。", + "看罢多时,跳过桥中间,左右观看,只见正当中有一石碣。碣上有一行楷书大字,镌着“花果山福地,水帘洞洞天。”石猴喜不自胜,急抽身往外便走,复瞑目蹲身,跳出水外,打了两个呵呵道:“大造化!大造化!”众猴把他围住,问道:“里面怎么样?水有多深?”石猴道:“没水!没水!原来是一座铁板桥。桥那边是一座天造地设的家当。”众猴道:“怎见得是个家当?”石猴笑道:“这股水乃是桥下冲贯石桥,倒挂下来遮闭门户的。桥边有花有树,乃是一座石房。房内有石窝、石灶、石碗、石盆、石床、石凳。中间一块石碣上,镌着‘花果山福地,水帘洞洞天。’真个是我们安身之处。里面且是宽阔,容得千百口老小。我们都进去住也,省得受老天之气。这里边:", + "刮风有处躲,下雨好存身。霜雪全无惧,雷声永不闻。", + "烟霞常照耀,祥瑞每蒸熏。松竹年年秀,奇花日日新。", + "众猴听得,个个欢喜,都道:“你还先走,带我们进去,进去!”石猴却又瞑目蹲身,往里一跳,叫道:“都随我进来!进来!”那些猴有胆大的,都跳进去了;胆小的,一个个伸头缩颈,抓耳挠腮,大声叫喊,缠一会,也都进去了。跳过桥头,一个个抢盆夺碗,占灶争床,搬过来,移过去,正是猴性顽劣,再无一个宁时,只搬得力倦神疲方止。石猿端坐上面道:“列位呵,‘人而无信,不知其可。’你们才说有本事进得来,出得去,不伤身体者,就拜他为王。我如今进来又出去,出去又进来,寻了这一个洞天与列位安眠稳睡,各享成家之福,何不拜我为王?”众猴听说,即拱伏无违。一个个序齿排班,朝上礼拜,都称“千岁大王”。自此,石猴高登王位,将“石”字儿隐了,遂称美猴王。有诗为证。诗曰:", + "三阳交泰产群生,仙石胞含日月精。", + "借卵化猴完大道,假他名姓配丹成。", + "内观不识因无相,外合明知作有形。", + "历代人人皆属此,称王称圣任纵横。", + "美猴王领一群猿猴、猕猴、马猴等,分派了君臣佐使,朝游花果山,暮宿水帘洞,合契同情,不入飞鸟之丛,不从走兽之类,独自为王,不胜欢乐。是以:", + "春采百花为饮食,夏寻诸果作生涯。", + "秋收芋栗延时节,冬觅黄精度岁华。", + "美猴王享乐天真,何期有三五百载。一日,与群猴喜宴之间,忽然忧恼,堕下泪来。众猴慌忙罗拜道:“大王何为烦恼?”猴王道:“我虽在欢喜之时,却有一点儿远虑,故此烦恼。”众猴又笑道:“大王好不知足!我等日日欢会,在仙山福地,古洞神州,不伏麒麟辖,不伏凤凰管,又不伏人间王位所拘束,自由自在,乃无量之福,为何远虑而忧也?”猴王道:“今日虽不归人王法律,不惧禽兽威服,将来年老血衰,暗中有阎王老子管着,一旦身亡,可不枉生世界之中,不得久住天人之内?”众猴闻此言,一个个掩面悲啼,俱以无常为虑。", + "只见那班部中,忽跳出一个通背猿猴,厉声高叫道:“大王若是这般远虑,真所谓道心开发也!如今五虫之内,惟有三等名色,不伏阎王老子所管。”猴王道:“你知那三等人?”猿猴道:“乃是佛与仙与神圣三者,躲过轮回,不生不灭,与天地山川齐寿。”猴王道:“此三者居于何所?”猿猴道:“他只在阎浮世界之中,古洞仙山之内。”猴王闻之,满心欢喜,道:“我明日就辞汝等下山,云游海角,远涉天涯,务必访此三者,学一个不老长生,常躲过阎君之难。”噫!这句话,顿教跳出轮回网,致使齐天大圣成。众猴鼓掌称扬,都道:“善哉!善哉!我等明日越岭登山,广寻些果品,大设筵宴送大王也。”", + "次日,众猴果去采仙桃,摘异果,刨山药,劚黄精,芝兰香蕙,瑶草奇花,般般件件,整整齐齐,摆开石凳石桌,排列仙酒仙肴。但见那:", + "金丸珠弹,红绽黄肥。金丸珠弹腊樱桃,色真甘美;红绽黄肥熟梅子,味果香酸。鲜龙眼,肉甜皮薄;火荔枝,核小囊红。林檎碧实连枝献,枇杷缃苞带叶擎。兔头梨子鸡心枣,消渴除烦更解酲。香桃烂杏,美甘甘似玉液琼浆;脆李杨梅,酸荫荫如脂酸膏酪。红囊黑子熟西瓜,四瓣黄皮大柿子。石榴裂破,丹砂粒现火晶珠;芋栗剖开,坚硬肉团金玛瑙。胡桃银杏可传茶,椰子葡萄能做酒。榛松榧柰满盘盛,橘蔗柑橙盈案摆。熟煨山药,烂煮黄精,捣碎茯苓并薏苡,石锅微火漫炊羹。人间纵有珍馐味,怎比山猴乐更宁?", + "群猴尊美猴王上坐,各依齿肩排于下边,一个个轮流上前,奉酒,奉花,奉果,痛饮了一日。次日,美猴王早起,教:“小的们,替我折些枯松,编作筏子,取个竹竿作篙,收拾些果品之类,我将去也。”果独自登筏,尽力撑开,飘飘荡荡,径向大海波中,趁天风,来渡南赡部洲地界。这一去,正是那:", + "天产仙猴道行隆,离山驾筏趁天风。", + "飘洋过海寻仙道,立志潜心建大功。", + "有分有缘休俗愿,无忧无虑会元龙。", + "料应必遇知音者,说破源流万法通。", + "也是他运至时来,自登木筏之后,连日东南风紧,将他送到西北岸前,乃是南赡部洲地界。持篙试水,偶得浅水,弃了筏子,跳上岸来,只见海边有人捕鱼、打雁、挖蛤、淘盐。他走近前,弄个把戏,妆个𡤫虎,吓得那些人丢筐弃网,四散奔跑。将那跑不动的拿住一个,剥了他衣裳,也学人穿在身上,摇摇摆摆,穿州过府,在市尘中,学人礼,学人话。朝餐夜宿,一心里访问佛仙神圣之道,觅个长生不老之方。见世人都是为名为利之徒,更无一个为身命者。正是那:", + "争名夺利几时休?早起迟眠不自由!", + "骑着驴骡思骏马,官居宰相望王侯。", + "只愁衣食耽劳碌,何怕阎君就取勾?", + "继子荫孙图富贵,更无一个肯回头!", + "猴王参访仙道,无缘得遇。在于南赡部洲,串长城,游小县,不觉八九年馀。忽行至西洋大海,他想着海外必有神仙。独自个依前作筏,又飘过西海,直至西牛贺洲地界。登岸偏访多时,忽见一座高山秀丽,林麓幽深。他也不怕狼虫,不惧虎豹,登山顶上观看。果是好山:", + "千峰开戟,万仞开屏。日映岚光轻锁翠,雨收黛色冷含青。枯藤缠老树,古渡界幽程。奇花瑞草,修竹乔松。修竹乔松,万载常青欺福地;奇花瑞草,四时不谢赛蓬瀛。幽鸟啼声近,源泉响溜清。重重谷壑芝兰绕,处处巉崖苔藓生。起伏峦头龙脉好,必有高人隐姓名。", + "正观看间,忽闻得林深之处,有人言语,急忙趋步,穿入林中,侧耳而听,原来是歌唱之声。歌曰:", + "“观棋柯烂,伐木丁丁,云边谷口徐行,卖薪沽酒,狂笑自陶情。苍迳秋高,对月枕松根,一觉天明。认旧林,登崖过岭,持斧断枯藤。", + "收来成一担,行歌市上,易米三升。更无些子争竞,时价平平,不会机谋巧算,没荣辱,恬淡延生。相逢处,非仙即道,静坐讲《黄庭》。”", + "美猴王听得此言,满心欢喜道:“神仙原来藏在这里!”急忙跳入里面,仔细再看,乃是一个樵子,在那里举斧砍柴。但看他打扮非常:", + "头上戴箬笠,乃是新笋初脱之箨。身上穿布衣,乃是木绵捻就之纱。腰间系环绦,乃是老蚕口吐之丝。足下踏草履,乃是枯莎搓就之爽。手执衠钢斧,担挽火麻绳。扳松劈枯树,争似此樵能!", + "猴王近前叫道:“老神仙!弟子起手。”那樵汉慌忙丢了斧,转身答礼道:“不当人!不当人!我拙汉衣食不全,怎敢当‘神仙’二字?”猴王道:“你不是神仙,如何说出神仙的话来?”樵夫道:“我说甚么神仙话?”猴王道:“我才来至林边,只听的你说:‘相逢处非仙即道,静坐讲《黄庭》。’《黄庭》乃道德真言,非神仙而何?”樵夫笑道:“实不瞒你说,这个词名做满庭芳,乃一神仙教我的。那神仙与我舍下相邻。他见我家事劳苦,日常烦恼,教我遇烦恼时,即把这词儿念念。一则散心,二则解困。我才有些不足处思虑,故此念念。不期被你听了。”猴王道:“你家既与神仙相邻,何不从他修行?学得个不老之方?却不是好?”樵夫道:“我一生命苦,自幼蒙父母养育至八九岁,才知人事,不幸父丧,母亲居孀。再无兄弟姊妹,只我一人,没奈何,早晚侍奉。如今母老,一发不敢抛离。却又田园荒芜,衣食不足,只得斫两束柴薪,挑向市尘之间,货几文钱,籴几升米,自炊自造,安排些茶饭,供养老母,所以不能修行。”", + "猴王道:“据你说起来,乃是一个行孝的君子,向后必有好处。但望你指与我那神仙住处,却好拜访去也。”樵夫道:“不远,不远。此山叫做灵台方寸山。山中有座斜月三星洞。那洞中有一个神仙,称名须菩提祖师。那祖师出去的徒弟,也不计其数,见今还有三四十人从他修行。你顺那条小路儿,向南行七八里远近,即是他家了。”猴王用手扯住樵夫道:“老兄,你便同我去去。若还得了好处,决不忘你指引之恩。”樵夫道:“你这汉子,甚不通变。我方才这般与你说了,你还不省?假若我与你去了,却不误了我的生意?老母何人奉养?我要斫柴,你自去,自去。”", + "猴王听说,只得相辞。出深林,找上路径,过一山坡,约有七八里远,果然望见一座洞府。挺身观看,真好去处!但见:", + "烟霞散彩,日月摇光。千株老柏,万节修篁。千株老柏,带雨半空青冉冉;万节修篁,含烟一壑色苍苍。门外奇花布锦,桥边瑶草喷香。石崖突兀青苔润,悬壁高张翠藓长。时闻仙鹤唳,每见凤凰翔。仙鹤唳时,声振九皋霄汉远;凤凰翔起,翎毛五色彩云光。玄猿白鹿随隐见,金狮玉象任行藏。细观灵福地,真个赛天堂!又见那洞门紧闭,静悄悄杳无人迹。忽回头,见崖头立一石牌,约有三丈馀高、八尺馀阔,上有一行十个大字,乃是“灵台方寸山,斜月三星洞”。美猴王十分欢喜道:“此间人果是朴实。果有此山此洞。”看勾多时,不敢敲门。且去跳上松枝梢头,摘松子吃了顽耍。", + "少顷间,只听得呀的一声,洞门开处,里面走出一个仙童,真个丰姿英伟,像貌清奇,比寻常俗子不同。但见他:", + "髽髻双丝绾,宽袍两袖风。貌和身自别,心与相俱空。", + "物外长年客,山中永寿童。一尘全不染,甲子任翻腾。", + "那童子出得门来,高叫道:“甚么人在此搔扰?”猴王扑的跳下树来,上前躬身道:“仙童,我是个访道学仙之弟子,更不敢在此搔扰。”仙童笑道:“你是个访道的么?”猴王道:“是。”童子道:“我家师父,正才下榻,登坛讲道。还未说出原由,就教我出来开门。说:‘外面有个修行的来了,可去接待接待。’想必就是你了?”猴王笑道:“是我,是我。”童子道:“你跟我进来。”", + "这猴王整衣端肃,随童子径入洞天深处观看:一层层深阁琼楼,一进进珠宫贝阙,说不尽那静室幽居,直至瑶台之下。见那菩提祖师端坐在台上,两边有三十个小仙侍立台下。果然是:", + "大觉金仙没垢姿,西方妙相祖菩提;", + "不生不灭三三行,全气全神万万慈。", + "空寂自然随变化,真如本性任为之;", + "与天同寿庄严体,历劫明心大法师。", + "美猴王一见,倒身下拜,磕头不计其数,口中只道:“师父!师父!我弟子志心朝礼!志心朝礼!”祖师道:“你是那方人氏?且说个乡贯姓名明白,再拜。”猴王道:“弟子东胜神洲傲来国花果山水帘洞人氏。”祖师喝令:“赶出去!他本是个撒诈捣虚之徒,那里修甚么道果!”猴王慌忙磕头不住道:“弟子是老实之言,决无虚诈。”祖师道:“你既老实,怎么说东胜神洲?那去处到我这里,隔两重大海,一座南赡部洲,如何就得到此?”猴王叩头道:“弟子飘洋过海,登界游方,有十数个年头,方才访到此处。”", + "祖师道:“既是逐渐行来的也罢。你姓甚么?”猴王又道:“我无性。人若骂我,我也不恼;若打我,我也不嗔,只是陪个礼儿就罢了。一生无性。”祖师道:“不是这个性。你父母原来姓甚么?”猴王道:“我也无父母。”祖师道:“既无父母,想是树上生的?”猴王道:“我虽不是树生,却是石里长的。我只记得花果山上有一块仙石,其年石破,我便生也。”祖师闻言,暗喜道:“这等说,却是天地生成的。你起来走走我看。”猴王纵身跳起,拐呀拐的走了两遍。祖师笑道:“你身躯虽是鄙陋,却像个食松果的猢狲。我与你就身上取个姓氏,意思教你姓‘猢’。猢字去了个兽傍,乃是古月。", + "古者,老也;月者,阴也。老阴不能化育,教你姓‘狲’倒好。狲字去了兽傍,乃是个子系。子者,儿男也;系者,婴细也。正合婴儿之本论。教你姓‘孙’罢。”猴王听说,满心欢喜,朝上叩头道:“好!好!好!今日方知姓也。万望师父慈悲!既然有姓,再乞赐个名字,却好呼唤。”祖师道:“我门中有十二个字,分派起名到你乃第十辈之小徒矣。”猴王道:“那十二个字?”祖师道:“乃广、大、智、慧、真、如、性、海、颖、悟、圆、觉十二字。排到你,正当‘悟’字。与你起个法名叫做‘孙悟空’好么?”猴王笑道:“好!好!好!自今就叫做孙悟空也!”正是:鸿蒙初辟原无姓,打破顽空须悟空。", + "毕竟不之向后修些甚么道果,且听下回分解。", + ], + }, +] +`; + +exports[`generateIndex > Should generate full index 6`] = ` +[ + { + "h": "", + "id": "5", + "t": [ + "404 Not Found", + ], + }, +] +`; + +exports[`generateIndex > Should generate index 1`] = ` +[ + { + "h": "Demo Page", + "id": "0", + "t": [ + "Here is article excerpt.", + ], + }, + { + "h": "Content", + "id": "0#content", + }, + { + "h": "Title with Special Characters #@%/", + "id": "0#title-with-special-characters", + }, +] +`; + +exports[`generateIndex > Should generate index 2`] = ` +[ + { + "h": "Heading 1", + "id": "1", + }, + { + "h": "Heading 2", + "id": "1#heading-2", + }, + { + "h": "Heading 3", + "id": "1#heading-3", + }, + { + "h": "Heading 4", + "id": "1#heading-4", + }, + { + "h": "Heading 5", + "id": "1#heading-5", + }, + { + "h": "Heading 6", + "id": "1#heading-6", + }, + { + "h": "Text", + "id": "1#text", + }, + { + "h": "Paragraph", + "id": "1#paragraph", + }, + { + "h": "Line Break", + "id": "1#line-break", + }, + { + "h": "Blockquotes", + "id": "1#blockquotes", + }, + { + "h": "List", + "id": "1#list", + }, + { + "h": "Unordered List", + "id": "1#unordered-list", + }, + { + "h": "Ordered List", + "id": "1#ordered-list", + }, + { + "h": "HR", + "id": "1#hr", + }, + { + "h": "Link", + "id": "1#link", + }, + { + "h": "Image", + "id": "1#image", + }, + { + "h": "Emoji", + "id": "1#emoji", + }, + { + "h": "Tables", + "id": "1#tables", + }, + { + "h": "Codes", + "id": "1#codes", + }, +] +`; + +exports[`generateIndex > Should generate index 3`] = ` +[ + { + "h": "Tag Test", + "id": "2", + }, +] +`; + +exports[`generateIndex > Should generate index 4`] = ` +[ + { + "h": "", + "id": "3", + }, +] +`; + +exports[`generateIndex > Should generate index 5`] = ` +[ + { + "h": "西游记第一回", + "id": "4", + }, +] +`; + +exports[`generateIndex > Should generate index 6`] = ` +[ + { + "h": "", + "id": "5", + }, +] +`; + +exports[`generateIndex > Should support customFields 1`] = ` +[ + { + "h": "Demo Page", + "id": "0", + "t": [ + "Here is article excerpt.", + ], + }, + { + "h": "Content", + "id": "0#content", + }, + { + "h": "Title with Special Characters #@%/", + "id": "0#title-with-special-characters", + }, + { + "c": [ + "Demo", + ], + "id": "0@0", + }, +] +`; + +exports[`generateIndex > Should support customFields 2`] = ` +[ + { + "h": "Heading 1", + "id": "1", + }, + { + "h": "Heading 2", + "id": "1#heading-2", + }, + { + "h": "Heading 3", + "id": "1#heading-3", + }, + { + "h": "Heading 4", + "id": "1#heading-4", + }, + { + "h": "Heading 5", + "id": "1#heading-5", + }, + { + "h": "Heading 6", + "id": "1#heading-6", + }, + { + "h": "Text", + "id": "1#text", + }, + { + "h": "Paragraph", + "id": "1#paragraph", + }, + { + "h": "Line Break", + "id": "1#line-break", + }, + { + "h": "Blockquotes", + "id": "1#blockquotes", + }, + { + "h": "List", + "id": "1#list", + }, + { + "h": "Unordered List", + "id": "1#unordered-list", + }, + { + "h": "Ordered List", + "id": "1#ordered-list", + }, + { + "h": "HR", + "id": "1#hr", + }, + { + "h": "Link", + "id": "1#link", + }, + { + "h": "Image", + "id": "1#image", + }, + { + "h": "Emoji", + "id": "1#emoji", + }, + { + "h": "Tables", + "id": "1#tables", + }, + { + "h": "Codes", + "id": "1#codes", + }, +] +`; + +exports[`generateIndex > Should support customFields 3`] = ` +[ + { + "h": "Tag Test", + "id": "2", + }, + { + "c": [ + "markdown", + ], + "id": "2@0", + }, +] +`; + +exports[`generateIndex > Should support customFields 4`] = ` +[ + { + "h": "", + "id": "3", + }, + { + "c": [ + "markdown", + ], + "id": "3@0", + }, +] +`; + +exports[`generateIndex > Should support customFields 5`] = ` +[ + { + "h": "西游记第一回", + "id": "4", + }, +] +`; + +exports[`generateIndex > Should support customFields 6`] = ` +[ + { + "h": "", + "id": "5", + }, +] +`; + +exports[`generateIndex > Should support customFields with full index 1`] = ` +[ + { + "h": "Demo Page", + "id": "0", + "t": [ + "Here is article excerpt.", + "const a = 1 ", + ], + }, + { + "h": "Content", + "id": "0#content", + "t": [ + "Here is main content of article.", + "A", + "B", + "C", + "const a = 1 ", + ], + }, + { + "h": "Title with Special Characters #@%/", + "id": "0#title-with-special-characters", + "t": [ + "Content with regexp sequence 'a$' test.", + ], + }, + { + "c": [ + "Demo", + ], + "id": "0@0", + }, +] +`; + +exports[`generateIndex > Should support customFields with full index 2`] = ` +[ + { + "h": "Heading 1", + "id": "1", + }, + { + "h": "Heading 2", + "id": "1#heading-2", + }, + { + "h": "Heading 3", + "id": "1#heading-3", + }, + { + "h": "Heading 4", + "id": "1#heading-4", + }, + { + "h": "Heading 5", + "id": "1#heading-5", + }, + { + "h": "Heading 6", + "id": "1#heading-6", + }, + { + "h": "Text", + "id": "1#text", + "t": [ + "This sentence has bold、italic and delete style text.", + ], + }, + { + "h": "Paragraph", + "id": "1#paragraph", + "t": [ + "This is a paragraph.", + "This is another paragraph.", + ], + }, + { + "h": "Line Break", + "id": "1#line-break", + "t": [ + "I would like to line break at this point", + "::: tip", + "In codes above, two spaces are behind at.", + ":::", + ], + }, + { + "h": "Blockquotes", + "id": "1#blockquotes", + "t": [ + "Blockquotes can also be nested...", + "...by using greater-than signs right next to each other...", + "...or with spaces between arrows.", + ], + }, + { + "h": "List", + "id": "1#list", + }, + { + "h": "Unordered List", + "id": "1#unordered-list", + "t": [ + "Create a list by starting a line with -", + "Make sub-lists by indenting 2 spaces:", + "Marker character change forces new list start:", + "Ac tristique libero volutpat at", + "Facilisis in pretium nisl aliquet", + "Nulla volutpat aliquam velit link break", + "New paragraph", + "It’s easy!", + ], + }, + { + "h": "Ordered List", + "id": "1#ordered-list", + "t": [ + "Lorem ipsum dolor sit amet", + "Consectetur adipiscing elit line break line break again", + "Integer molestie lorem at massa", + ], + }, + { + "h": "HR", + "id": "1#hr", + }, + { + "h": "Link", + "id": "1#link", + "t": [ + "Home page using absolute path", + "Home page using relative path", + ], + }, + { + "h": "Image", + "id": "1#image", + }, + { + "h": "Emoji", + "id": "1#emoji", + "t": [ + "Classic:", + "😉 😢 😆 😋", + ], + }, + { + "h": "Tables", + "id": "1#tables", + "t": [ + "center", + "right", + "left", + "For center align use :-:", + "For right align use -:", + "For left align use :-", + "b", + "aaaaaaaaa", + "aaaa", + "c", + "aaaa", + "a", + ], + }, + { + "h": "Codes", + "id": "1#codes", + "t": [ + "Inline Code: code", + "Block code:", + "Sample text here... ", + "Syntax highlighting:", + "function foo(bar) { return \`foo\${bar}\` } console.log(foo(5)) ", + ], + }, +] +`; + +exports[`generateIndex > Should support customFields with full index 3`] = ` +[ + { + "h": "Tag Test", + "id": "2", + "t": [ + "Markdown content.", + ], + }, + { + "c": [ + "markdown", + ], + "id": "2@0", + }, +] +`; + +exports[`generateIndex > Should support customFields with full index 4`] = ` +[ + { + "h": "", + "id": "3", + "t": [ + "Markdown content.", + ], + }, + { + "c": [ + "markdown", + ], + "id": "3@0", + }, +] +`; + +exports[`generateIndex > Should support customFields with full index 5`] = ` +[ + { + "h": "西游记第一回", + "id": "4", + "t": [ + "灵根育孕源流出 心性修持大道生", + "诗曰:", + "混沌未分天地乱,茫茫渺渺无人见。", + "自从盘古破鸿蒙,开辟从兹清浊辨。", + "覆载群生仰至仁,发明万物皆成善。", + "欲知造化会元功,须看西游释厄传。", + "盖闻天地之数,有十二万九千六百岁为一元。将一元分为十二会,乃子、丑、寅、卯、辰、巳、午、未、申、酉、戌、亥之十二支也。每会该一万八百岁。且就一日而论:子时得阳气,而丑则鸡鸣;寅不通光,而卯则日出;辰时食后,而巳则挨排;日午天中,而未则西蹉;申时晡而日落酉;戌黄昏而人定亥。譬于大数,若到戌会之终,则天地昏蒙而万物否矣。再去五千四百岁,交亥会之初,则当黑暗,而两间人物俱无矣,故曰混沌。又五千四百岁,亥会将终,贞下起元,近子之会,而复逐渐开明。邵康节曰:“冬至子之半,天心无改移。一阳初动处,万物未生时。”到此,天始有根。", + "再五千四百岁,正当子会,轻清上腾,有日,有月,有星,有辰。日、月、星、辰,谓之四象。故曰,天开于子。又经五千四百岁,子会将终,近丑之会,而逐渐坚实。易曰:“大哉乾元!至哉坤元!万物资生,乃顺承天。”至此,地始凝结。再五千四百岁,正当丑会,重浊下凝,有水,有火,有山,有石,有土。水、火、山、石、土谓之五形。故曰,地辟于丑。又经五千四百岁,丑会终而寅会之初,发生万物。历曰:“天气下降,地气上升;天地交合,群物皆生。”至此,天清地爽,阴阳交合。再五千四百岁,正当寅会,生人,生兽,生禽,正谓天地人,三才定位。故曰,人生于寅。", + "感盘古开辟,三皇治世,五帝定伦,世界之间,遂分为四大部洲:曰东胜神洲,曰西牛贺洲,曰南赡部洲,曰北俱芦洲。这部书单表东胜神洲。海外有一国土,名曰傲来国。国近大海,海中有一座山,唤为花果山。此山乃十洲之祖脉,三岛之来龙,自开清浊而立,鸿蒙判后而成。真个好山!有词赋为证。赋曰:", + "势镇汪洋,威宁瑶海。势镇汪洋,潮涌银山鱼入穴;威宁瑶海,波翻雪浪蜃离渊。木火方隅高积上,东海之处耸崇巅。丹崖怪石,削壁奇峰。丹崖上,彩凤双鸣;削壁前,麒麟独卧。峰头时听锦鸡鸣,石窟每观龙出入。林中有寿鹿仙狐,树上有灵禽玄鹤。瑶草奇花不谢,青松翠柏长春。仙桃常结果,修竹每留云。一条涧壑藤萝密,四面原堤草色新。正是百川会处擎天柱,万劫无移大地根。", + "那座山,正当顶上,有一块仙石。其石有三丈六尺五寸高,有二丈四尺围圆。三丈六尺五寸高,按周天三百六十五度;二丈四尺围圆,按政历二十四气。上有九窍八孔,按九宫八卦。四面更无树木遮阴,左右倒有芝兰相衬。盖自开辟以来,每受天真地秀,日精月华,感之既久,遂有灵通之意。内育仙胞,一日迸裂,产一石卵,似圆球样大。因见风,化作一个石猴,五官俱备,四肢皆全。便就学爬学走,拜了四方。目运两道金光,射冲斗府。惊动高天上圣大慈仁者玉皇大天尊玄穹高上帝,驾座金阙云宫灵霄宝殿,聚集仙卿,见有金光焰焰,即命千里眼、顺风耳开南天门观看。二将果奉旨出门外,看的真,听的明。须臾回报道:“臣奉旨观听金光之处,乃东胜神洲海东傲来小国之界,有一座花果山,山上有一仙石,石产一卵,见风化一石猴,在那里拜四方,眼运金光,射冲斗府。如今服饵水食,金光将潜息矣。”玉帝垂赐恩慈曰:“下方之物,乃天地精华所生,不足为异。”", + "那猴在山中,却会行走跳跃,食草木,饮涧泉,采山花,觅树果;与狼虫为伴,虎豹为群,獐鹿为友,猕猿为亲;夜宿石崖之下,朝游峰洞之中。真是“山中无甲子,寒尽不知年。”一朝天气炎热,与群猴避暑,都在松阴之下顽耍。你看他一个个:", + "跳树攀枝,采花觅果;抛弹子,邷么儿;跑沙窝,砌宝塔;赶蜻蜓,扑八蜡;参老天,拜菩萨;扯葛藤,编草帓;捉虱子,咬又掐;理毛衣,剔指甲;挨的挨,擦的擦;推的推,压的压;扯的扯,拉的拉,青松林下任他顽,绿水涧边随洗濯。一群猴子耍了一会,却去那山涧中洗澡。见那股涧水奔流,真个似滚瓜涌溅。古云:“禽有禽言,兽有兽语。”众猴都道:“这股水不知是那里的水。我们今日赶闲无事,顺涧边往上溜头寻看源流,耍子去耶!”喊一声,都拖男挈女,呼弟呼兄,一齐跑来,顺涧爬山,直至源流之处,乃是一股瀑布飞泉。但见那:", + "一派白虹起,千寻雪浪飞;海风吹不断,江月照还依。", + "冷气分青嶂,馀流润翠微;潺湲名瀑布,真似挂帘帷。", + "众猴拍手称扬道:“好水!好水!原来此处远通山脚之下,直接大海之波。”又道:“那一个有本事的,钻进去寻个源头出来,不伤身体者,我等即拜他为王。”连呼了三声,忽见丛杂中跳出一名石猴,应声高叫道:“我进去!我进去!”好猴!也是他:", + "今日芳名显,时来大运通;有缘居此地,王遣入仙宫。", + "你看他瞑目蹲身,将身一纵,径跳入瀑布泉中,忽睁睛抬头观看,那里边却无水无波,明明朗朗的一架桥梁。他住了身,定了神,仔细再看,原来是座铁板桥。桥下之水,冲贯于石窍之间,倒挂流出去,遮闭了桥门。却又欠身上桥头,再走再看,却似有人家住处一般,真个好所在。但见那:", + "翠藓堆蓝,白云浮玉,光摇片片烟霞。虚窗静室,滑凳板生花。乳窟龙珠倚挂,萦回满地奇葩。锅灶傍崖存火迹,樽罍靠案见肴渣。石座石床真可爱,石盆石碗更堪夸。又见那一竿两竿修竹,三点五点梅花。几树青松常带雨,浑然相个人家。", + "看罢多时,跳过桥中间,左右观看,只见正当中有一石碣。碣上有一行楷书大字,镌着“花果山福地,水帘洞洞天。”石猴喜不自胜,急抽身往外便走,复瞑目蹲身,跳出水外,打了两个呵呵道:“大造化!大造化!”众猴把他围住,问道:“里面怎么样?水有多深?”石猴道:“没水!没水!原来是一座铁板桥。桥那边是一座天造地设的家当。”众猴道:“怎见得是个家当?”石猴笑道:“这股水乃是桥下冲贯石桥,倒挂下来遮闭门户的。桥边有花有树,乃是一座石房。房内有石窝、石灶、石碗、石盆、石床、石凳。中间一块石碣上,镌着‘花果山福地,水帘洞洞天。’真个是我们安身之处。里面且是宽阔,容得千百口老小。我们都进去住也,省得受老天之气。这里边:", + "刮风有处躲,下雨好存身。霜雪全无惧,雷声永不闻。", + "烟霞常照耀,祥瑞每蒸熏。松竹年年秀,奇花日日新。", + "众猴听得,个个欢喜,都道:“你还先走,带我们进去,进去!”石猴却又瞑目蹲身,往里一跳,叫道:“都随我进来!进来!”那些猴有胆大的,都跳进去了;胆小的,一个个伸头缩颈,抓耳挠腮,大声叫喊,缠一会,也都进去了。跳过桥头,一个个抢盆夺碗,占灶争床,搬过来,移过去,正是猴性顽劣,再无一个宁时,只搬得力倦神疲方止。石猿端坐上面道:“列位呵,‘人而无信,不知其可。’你们才说有本事进得来,出得去,不伤身体者,就拜他为王。我如今进来又出去,出去又进来,寻了这一个洞天与列位安眠稳睡,各享成家之福,何不拜我为王?”众猴听说,即拱伏无违。一个个序齿排班,朝上礼拜,都称“千岁大王”。自此,石猴高登王位,将“石”字儿隐了,遂称美猴王。有诗为证。诗曰:", + "三阳交泰产群生,仙石胞含日月精。", + "借卵化猴完大道,假他名姓配丹成。", + "内观不识因无相,外合明知作有形。", + "历代人人皆属此,称王称圣任纵横。", + "美猴王领一群猿猴、猕猴、马猴等,分派了君臣佐使,朝游花果山,暮宿水帘洞,合契同情,不入飞鸟之丛,不从走兽之类,独自为王,不胜欢乐。是以:", + "春采百花为饮食,夏寻诸果作生涯。", + "秋收芋栗延时节,冬觅黄精度岁华。", + "美猴王享乐天真,何期有三五百载。一日,与群猴喜宴之间,忽然忧恼,堕下泪来。众猴慌忙罗拜道:“大王何为烦恼?”猴王道:“我虽在欢喜之时,却有一点儿远虑,故此烦恼。”众猴又笑道:“大王好不知足!我等日日欢会,在仙山福地,古洞神州,不伏麒麟辖,不伏凤凰管,又不伏人间王位所拘束,自由自在,乃无量之福,为何远虑而忧也?”猴王道:“今日虽不归人王法律,不惧禽兽威服,将来年老血衰,暗中有阎王老子管着,一旦身亡,可不枉生世界之中,不得久住天人之内?”众猴闻此言,一个个掩面悲啼,俱以无常为虑。", + "只见那班部中,忽跳出一个通背猿猴,厉声高叫道:“大王若是这般远虑,真所谓道心开发也!如今五虫之内,惟有三等名色,不伏阎王老子所管。”猴王道:“你知那三等人?”猿猴道:“乃是佛与仙与神圣三者,躲过轮回,不生不灭,与天地山川齐寿。”猴王道:“此三者居于何所?”猿猴道:“他只在阎浮世界之中,古洞仙山之内。”猴王闻之,满心欢喜,道:“我明日就辞汝等下山,云游海角,远涉天涯,务必访此三者,学一个不老长生,常躲过阎君之难。”噫!这句话,顿教跳出轮回网,致使齐天大圣成。众猴鼓掌称扬,都道:“善哉!善哉!我等明日越岭登山,广寻些果品,大设筵宴送大王也。”", + "次日,众猴果去采仙桃,摘异果,刨山药,劚黄精,芝兰香蕙,瑶草奇花,般般件件,整整齐齐,摆开石凳石桌,排列仙酒仙肴。但见那:", + "金丸珠弹,红绽黄肥。金丸珠弹腊樱桃,色真甘美;红绽黄肥熟梅子,味果香酸。鲜龙眼,肉甜皮薄;火荔枝,核小囊红。林檎碧实连枝献,枇杷缃苞带叶擎。兔头梨子鸡心枣,消渴除烦更解酲。香桃烂杏,美甘甘似玉液琼浆;脆李杨梅,酸荫荫如脂酸膏酪。红囊黑子熟西瓜,四瓣黄皮大柿子。石榴裂破,丹砂粒现火晶珠;芋栗剖开,坚硬肉团金玛瑙。胡桃银杏可传茶,椰子葡萄能做酒。榛松榧柰满盘盛,橘蔗柑橙盈案摆。熟煨山药,烂煮黄精,捣碎茯苓并薏苡,石锅微火漫炊羹。人间纵有珍馐味,怎比山猴乐更宁?", + "群猴尊美猴王上坐,各依齿肩排于下边,一个个轮流上前,奉酒,奉花,奉果,痛饮了一日。次日,美猴王早起,教:“小的们,替我折些枯松,编作筏子,取个竹竿作篙,收拾些果品之类,我将去也。”果独自登筏,尽力撑开,飘飘荡荡,径向大海波中,趁天风,来渡南赡部洲地界。这一去,正是那:", + "天产仙猴道行隆,离山驾筏趁天风。", + "飘洋过海寻仙道,立志潜心建大功。", + "有分有缘休俗愿,无忧无虑会元龙。", + "料应必遇知音者,说破源流万法通。", + "也是他运至时来,自登木筏之后,连日东南风紧,将他送到西北岸前,乃是南赡部洲地界。持篙试水,偶得浅水,弃了筏子,跳上岸来,只见海边有人捕鱼、打雁、挖蛤、淘盐。他走近前,弄个把戏,妆个𡤫虎,吓得那些人丢筐弃网,四散奔跑。将那跑不动的拿住一个,剥了他衣裳,也学人穿在身上,摇摇摆摆,穿州过府,在市尘中,学人礼,学人话。朝餐夜宿,一心里访问佛仙神圣之道,觅个长生不老之方。见世人都是为名为利之徒,更无一个为身命者。正是那:", + "争名夺利几时休?早起迟眠不自由!", + "骑着驴骡思骏马,官居宰相望王侯。", + "只愁衣食耽劳碌,何怕阎君就取勾?", + "继子荫孙图富贵,更无一个肯回头!", + "猴王参访仙道,无缘得遇。在于南赡部洲,串长城,游小县,不觉八九年馀。忽行至西洋大海,他想着海外必有神仙。独自个依前作筏,又飘过西海,直至西牛贺洲地界。登岸偏访多时,忽见一座高山秀丽,林麓幽深。他也不怕狼虫,不惧虎豹,登山顶上观看。果是好山:", + "千峰开戟,万仞开屏。日映岚光轻锁翠,雨收黛色冷含青。枯藤缠老树,古渡界幽程。奇花瑞草,修竹乔松。修竹乔松,万载常青欺福地;奇花瑞草,四时不谢赛蓬瀛。幽鸟啼声近,源泉响溜清。重重谷壑芝兰绕,处处巉崖苔藓生。起伏峦头龙脉好,必有高人隐姓名。", + "正观看间,忽闻得林深之处,有人言语,急忙趋步,穿入林中,侧耳而听,原来是歌唱之声。歌曰:", + "“观棋柯烂,伐木丁丁,云边谷口徐行,卖薪沽酒,狂笑自陶情。苍迳秋高,对月枕松根,一觉天明。认旧林,登崖过岭,持斧断枯藤。", + "收来成一担,行歌市上,易米三升。更无些子争竞,时价平平,不会机谋巧算,没荣辱,恬淡延生。相逢处,非仙即道,静坐讲《黄庭》。”", + "美猴王听得此言,满心欢喜道:“神仙原来藏在这里!”急忙跳入里面,仔细再看,乃是一个樵子,在那里举斧砍柴。但看他打扮非常:", + "头上戴箬笠,乃是新笋初脱之箨。身上穿布衣,乃是木绵捻就之纱。腰间系环绦,乃是老蚕口吐之丝。足下踏草履,乃是枯莎搓就之爽。手执衠钢斧,担挽火麻绳。扳松劈枯树,争似此樵能!", + "猴王近前叫道:“老神仙!弟子起手。”那樵汉慌忙丢了斧,转身答礼道:“不当人!不当人!我拙汉衣食不全,怎敢当‘神仙’二字?”猴王道:“你不是神仙,如何说出神仙的话来?”樵夫道:“我说甚么神仙话?”猴王道:“我才来至林边,只听的你说:‘相逢处非仙即道,静坐讲《黄庭》。’《黄庭》乃道德真言,非神仙而何?”樵夫笑道:“实不瞒你说,这个词名做满庭芳,乃一神仙教我的。那神仙与我舍下相邻。他见我家事劳苦,日常烦恼,教我遇烦恼时,即把这词儿念念。一则散心,二则解困。我才有些不足处思虑,故此念念。不期被你听了。”猴王道:“你家既与神仙相邻,何不从他修行?学得个不老之方?却不是好?”樵夫道:“我一生命苦,自幼蒙父母养育至八九岁,才知人事,不幸父丧,母亲居孀。再无兄弟姊妹,只我一人,没奈何,早晚侍奉。如今母老,一发不敢抛离。却又田园荒芜,衣食不足,只得斫两束柴薪,挑向市尘之间,货几文钱,籴几升米,自炊自造,安排些茶饭,供养老母,所以不能修行。”", + "猴王道:“据你说起来,乃是一个行孝的君子,向后必有好处。但望你指与我那神仙住处,却好拜访去也。”樵夫道:“不远,不远。此山叫做灵台方寸山。山中有座斜月三星洞。那洞中有一个神仙,称名须菩提祖师。那祖师出去的徒弟,也不计其数,见今还有三四十人从他修行。你顺那条小路儿,向南行七八里远近,即是他家了。”猴王用手扯住樵夫道:“老兄,你便同我去去。若还得了好处,决不忘你指引之恩。”樵夫道:“你这汉子,甚不通变。我方才这般与你说了,你还不省?假若我与你去了,却不误了我的生意?老母何人奉养?我要斫柴,你自去,自去。”", + "猴王听说,只得相辞。出深林,找上路径,过一山坡,约有七八里远,果然望见一座洞府。挺身观看,真好去处!但见:", + "烟霞散彩,日月摇光。千株老柏,万节修篁。千株老柏,带雨半空青冉冉;万节修篁,含烟一壑色苍苍。门外奇花布锦,桥边瑶草喷香。石崖突兀青苔润,悬壁高张翠藓长。时闻仙鹤唳,每见凤凰翔。仙鹤唳时,声振九皋霄汉远;凤凰翔起,翎毛五色彩云光。玄猿白鹿随隐见,金狮玉象任行藏。细观灵福地,真个赛天堂!又见那洞门紧闭,静悄悄杳无人迹。忽回头,见崖头立一石牌,约有三丈馀高、八尺馀阔,上有一行十个大字,乃是“灵台方寸山,斜月三星洞”。美猴王十分欢喜道:“此间人果是朴实。果有此山此洞。”看勾多时,不敢敲门。且去跳上松枝梢头,摘松子吃了顽耍。", + "少顷间,只听得呀的一声,洞门开处,里面走出一个仙童,真个丰姿英伟,像貌清奇,比寻常俗子不同。但见他:", + "髽髻双丝绾,宽袍两袖风。貌和身自别,心与相俱空。", + "物外长年客,山中永寿童。一尘全不染,甲子任翻腾。", + "那童子出得门来,高叫道:“甚么人在此搔扰?”猴王扑的跳下树来,上前躬身道:“仙童,我是个访道学仙之弟子,更不敢在此搔扰。”仙童笑道:“你是个访道的么?”猴王道:“是。”童子道:“我家师父,正才下榻,登坛讲道。还未说出原由,就教我出来开门。说:‘外面有个修行的来了,可去接待接待。’想必就是你了?”猴王笑道:“是我,是我。”童子道:“你跟我进来。”", + "这猴王整衣端肃,随童子径入洞天深处观看:一层层深阁琼楼,一进进珠宫贝阙,说不尽那静室幽居,直至瑶台之下。见那菩提祖师端坐在台上,两边有三十个小仙侍立台下。果然是:", + "大觉金仙没垢姿,西方妙相祖菩提;", + "不生不灭三三行,全气全神万万慈。", + "空寂自然随变化,真如本性任为之;", + "与天同寿庄严体,历劫明心大法师。", + "美猴王一见,倒身下拜,磕头不计其数,口中只道:“师父!师父!我弟子志心朝礼!志心朝礼!”祖师道:“你是那方人氏?且说个乡贯姓名明白,再拜。”猴王道:“弟子东胜神洲傲来国花果山水帘洞人氏。”祖师喝令:“赶出去!他本是个撒诈捣虚之徒,那里修甚么道果!”猴王慌忙磕头不住道:“弟子是老实之言,决无虚诈。”祖师道:“你既老实,怎么说东胜神洲?那去处到我这里,隔两重大海,一座南赡部洲,如何就得到此?”猴王叩头道:“弟子飘洋过海,登界游方,有十数个年头,方才访到此处。”", + "祖师道:“既是逐渐行来的也罢。你姓甚么?”猴王又道:“我无性。人若骂我,我也不恼;若打我,我也不嗔,只是陪个礼儿就罢了。一生无性。”祖师道:“不是这个性。你父母原来姓甚么?”猴王道:“我也无父母。”祖师道:“既无父母,想是树上生的?”猴王道:“我虽不是树生,却是石里长的。我只记得花果山上有一块仙石,其年石破,我便生也。”祖师闻言,暗喜道:“这等说,却是天地生成的。你起来走走我看。”猴王纵身跳起,拐呀拐的走了两遍。祖师笑道:“你身躯虽是鄙陋,却像个食松果的猢狲。我与你就身上取个姓氏,意思教你姓‘猢’。猢字去了个兽傍,乃是古月。", + "古者,老也;月者,阴也。老阴不能化育,教你姓‘狲’倒好。狲字去了兽傍,乃是个子系。子者,儿男也;系者,婴细也。正合婴儿之本论。教你姓‘孙’罢。”猴王听说,满心欢喜,朝上叩头道:“好!好!好!今日方知姓也。万望师父慈悲!既然有姓,再乞赐个名字,却好呼唤。”祖师道:“我门中有十二个字,分派起名到你乃第十辈之小徒矣。”猴王道:“那十二个字?”祖师道:“乃广、大、智、慧、真、如、性、海、颖、悟、圆、觉十二字。排到你,正当‘悟’字。与你起个法名叫做‘孙悟空’好么?”猴王笑道:“好!好!好!自今就叫做孙悟空也!”正是:鸿蒙初辟原无姓,打破顽空须悟空。", + "毕竟不之向后修些甚么道果,且听下回分解。", + ], + }, +] +`; + +exports[`generateIndex > Should support customFields with full index 6`] = ` +[ + { + "h": "", + "id": "5", + "t": [ + "404 Not Found", + ], + }, +] +`; diff --git a/plugins/search/plugin-slimsearch/tests/generateIndex.spec.ts b/plugins/search/plugin-slimsearch/tests/generateIndex.spec.ts new file mode 100644 index 0000000000..2ff297b01a --- /dev/null +++ b/plugins/search/plugin-slimsearch/tests/generateIndex.spec.ts @@ -0,0 +1,82 @@ +import { getPageExcerpt } from '@vuepress/helper' +import { describe, expect, it } from 'vitest' +import type { Bundler, Page } from 'vuepress/core' +import { createBaseApp } from 'vuepress/core' +import { path } from 'vuepress/utils' + +import { generatePageIndex } from '../src/node/generateIndex.js' +import { PathStore } from '../src/node/pathStore.js' +import { emptyTheme } from './__fixtures__/theme/empty.js' + +const app = createBaseApp({ + bundler: {} as Bundler, + source: path.resolve(__dirname, './__fixtures__/src'), + theme: emptyTheme, +}) + +await app.init() + +describe('generateIndex', () => { + it('Should generate index', () => { + const store = new PathStore() + + app.pages.forEach((page) => { + page.data.excerpt = getPageExcerpt(app, page, { + length: 0, + }) + + expect(generatePageIndex(page, store)).toMatchSnapshot() + }) + }) + + it('Should generate full index', () => { + const store = new PathStore() + + app.pages.forEach((page) => { + page.data.excerpt = getPageExcerpt(app, page, { + length: 0, + }) + + expect(generatePageIndex(page, store, [], true)).toMatchSnapshot() + }) + }) + + it('Should support customFields', () => { + const store = new PathStore() + + app.pages.forEach((page) => { + page.data.excerpt = getPageExcerpt(app, page, { + length: 0, + }) + + expect( + generatePageIndex(page, store, [ + { + getter: ({ frontmatter }: Page): string[] | string | null => + (frontmatter.tag as string[] | string) || null, + }, + ]), + ).toMatchSnapshot() + }) + }) + + it('Should support customFields with full index', () => { + const store = new PathStore() + + app.pages.forEach((page) => { + expect( + generatePageIndex( + page, + store, + [ + { + getter: ({ frontmatter }: Page): string[] | string | null => + (frontmatter.tag as string[] | string) || null, + }, + ], + true, + ), + ).toMatchSnapshot() + }) + }) +}) diff --git a/plugins/search/plugin-slimsearch/tests/matchContent.spec.ts b/plugins/search/plugin-slimsearch/tests/matchContent.spec.ts new file mode 100644 index 0000000000..1892b2bbd5 --- /dev/null +++ b/plugins/search/plugin-slimsearch/tests/matchContent.spec.ts @@ -0,0 +1,258 @@ +import { describe, expect, it } from 'vitest' + +import { getMatchedContent } from '../src/client/worker/matchContent.js' + +describe('matchContent', () => { + it('Should match content', () => { + expect(getMatchedContent('a b c d', 'a')).toEqual([['mark', 'a'], ' b c d']) + expect(getMatchedContent('a b c d', 'b')).toEqual([ + 'a ', + ['mark', 'b'], + ' c d', + ]) + expect(getMatchedContent('apple banana cherry', 'banana')).toEqual([ + 'apple ', + ['mark', 'banana'], + ' cherry', + ]) + }) + + it('Should return null if no content is matched', () => { + expect(getMatchedContent('b c d', 'a')).toEqual(null) + }) + + it('Should match content multiple times', () => { + expect(getMatchedContent('a b c d c b a', 'b')).toEqual([ + 'a ', + ['mark', 'b'], + ' c d c ', + ['mark', 'b'], + ' a', + ]) + }) + + it('Should cut of long content', () => { + expect( + getMatchedContent( + "The apple is red, and it's veeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeery delicious", + 'apple', + ), + ).toEqual([ + 'The ', + ['mark', 'apple'], + " is red, and it's veeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee… ", + ]) + + expect( + getMatchedContent( + "The apple is red, and it's veeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeery delicious. The banana is yellow, and it's veeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeery delicious", + 'is', + ), + ).toEqual([ + 'The apple ', + ['mark', 'is'], + " red, and it's veeee … licious. The banana ", + ['mark', 'is'], + " yellow, and it's veeeeeeeeeeeeeeeeeeeeeeee… ", + ]) + + expect( + getMatchedContent( + "The apple is red, and it's veeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeery delicious. The banana is yellow, and it's veeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeery delicious", + 'The', + ), + ).toEqual([ + ['mark', 'The'], + ' apple is red, and i … eeeeeery delicious. ', + ['mark', 'The'], + " banana is yellow, and it's veeeeeeeeeeeeeeeeeeeeee… ", + ]) + + expect( + getMatchedContent( + "The apple is red, and it's veeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeery delicious. The banana is yellow, and it's veeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeery delicious", + 'delicious', + ), + ).toEqual([ + '… eeeeeeeeeeeeeeeeery ', + ['mark', 'delicious'], + '. The banana is yell … eeeeeeeeeeeeeeeeery ', + ['mark', 'delicious'], + ]) + + expect( + getMatchedContent( + "The apple is red, and it's veeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeery delicious. The banana is yellow, and it's veeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeery delicious", + 'T', + ), + ).toEqual([ + ['mark', 'T'], + 'he apple is red, and … apple is red, and i', + ['mark', 'T'], + "'s veeeeeeeeeeeeeeee … eeeeeery delicious. ", + ['mark', 'T'], + 'he banana is yellow, … ana is yellow, and i', + ['mark', 'T'], + ' …', + ]) + + expect( + getMatchedContent( + "The apple is red, and it's veeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeery delicious. The banana is yellow, and it's veeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeery delicious", + 'h', + ), + ).toEqual([ + 'T', + ['mark', 'h'], + 'e apple is red, and … eeeeery delicious. T', + ['mark', 'h'], + "e banana is yellow, and it's veeeeeeeeeeeeeeeeeeeeeeee… ", + ]) + + expect( + getMatchedContent( + "The apple is red, and it's veeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeery delicious. The banana is yellow, and it's veeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeery delicious", + 'Th', + ), + ).toEqual([ + ['mark', 'Th'], + 'e apple is red, and … eeeeeery delicious. ', + ['mark', 'Th'], + "e banana is yellow, and it's veeeeeeeeeeeeeeeeeeeeeee… ", + ]) + + expect( + getMatchedContent( + "The apple is red, and it's veeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeery delicious. The banana is yellow, and it's veeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeery delicious", + 'e', + ), + ).toEqual([ + 'Th', + ['mark', 'e'], + ' appl', + ['mark', 'e'], + ' is r', + ['mark', 'e'], + "d, and it's v", + ['mark', 'e'], + ['mark', 'e'], + ['mark', 'e'], + ['mark', 'e'], + ['mark', 'e'], + ['mark', 'e'], + ['mark', 'e'], + ['mark', 'e'], + ['mark', 'e'], + ['mark', 'e'], + ['mark', 'e'], + ['mark', 'e'], + ['mark', 'e'], + ['mark', 'e'], + ['mark', 'e'], + ['mark', 'e'], + ['mark', 'e'], + ['mark', 'e'], + ['mark', 'e'], + ['mark', 'e'], + ['mark', 'e'], + ['mark', 'e'], + ['mark', 'e'], + ['mark', 'e'], + ['mark', 'e'], + ['mark', 'e'], + ['mark', 'e'], + ['mark', 'e'], + ['mark', 'e'], + ['mark', 'e'], + ['mark', 'e'], + ['mark', 'e'], + ['mark', 'e'], + ['mark', 'e'], + ['mark', 'e'], + ['mark', 'e'], + ['mark', 'e'], + ['mark', 'e'], + ['mark', 'e'], + ['mark', 'e'], + ['mark', 'e'], + ['mark', 'e'], + ['mark', 'e'], + ['mark', 'e'], + ['mark', 'e'], + ['mark', 'e'], + ['mark', 'e'], + ['mark', 'e'], + ['mark', 'e'], + ['mark', 'e'], + ['mark', 'e'], + ['mark', 'e'], + ['mark', 'e'], + ['mark', 'e'], + ['mark', 'e'], + ['mark', 'e'], + ['mark', 'e'], + ['mark', 'e'], + ['mark', 'e'], + ['mark', 'e'], + ['mark', 'e'], + ['mark', 'e'], + ['mark', 'e'], + ['mark', 'e'], + ['mark', 'e'], + ['mark', 'e'], + ['mark', 'e'], + ['mark', 'e'], + ['mark', 'e'], + ['mark', 'e'], + ['mark', 'e'], + ['mark', 'e'], + ' …', + ['mark', 'e'], + ' …', + ]) + + expect( + getMatchedContent( + "The apple is red, and it's veeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeery delicious. The banana is yellow, and it's veeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeery delicious", + 's', + ), + ).toEqual([ + 'The apple i', + ['mark', 's'], + " red, and it'", + ['mark', 's'], + ' veeeeeeeeeeeeeeeeee … eeeeeeeeery deliciou', + ['mark', 's'], + '. The banana i', + ['mark', 's'], + " yellow, and it'", + ['mark', 's'], + ' …', + ]) + + expect( + getMatchedContent( + "The apple is red, and it's veeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeery delicious. The banana is yellow, and it's veeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeery delicious", + 'u', + ), + ).toEqual([ + '… eeeeeeeeeery delicio', + ['mark', 'u'], + 's. The banana is yel … eeeeeeeeeery delicio', + ['mark', 'u'], + 's', + ]) + + expect( + getMatchedContent( + "The apple is red, and it's veeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeery delicious. The banana is yellow, and it's veeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeery delicious", + 'us', + ), + ).toEqual([ + '… eeeeeeeeeery delicio', + ['mark', 'us'], + '. The banana is yell … eeeeeeeeeery delicio', + ['mark', 'us'], + ]) + }) +}) diff --git a/plugins/search/plugin-slimsearch/tsconfig.build.json b/plugins/search/plugin-slimsearch/tsconfig.build.json new file mode 100644 index 0000000000..f7f7fe795a --- /dev/null +++ b/plugins/search/plugin-slimsearch/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "extends": "../../../tsconfig.build.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./lib", + "types": ["vuepress/client-types", "vite/client", "webpack-env"] + }, + "include": ["./src"], + "references": [{ "path": "../../../tools/helper/tsconfig.build.json" }] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index efc0ff9d68..b83b89dbf0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -986,6 +986,34 @@ importers: specifier: 2.0.0-rc.18 version: 2.0.0-rc.18(@vuepress/bundler-vite@2.0.0-rc.18(@types/node@22.9.0)(jiti@1.21.6)(lightningcss@1.28.1)(sass-embedded@1.81.0)(sass@1.81.0)(terser@5.36.0)(tsx@4.19.2)(typescript@5.6.3)(yaml@2.4.5))(@vuepress/bundler-webpack@2.0.0-rc.18(esbuild@0.23.1)(typescript@5.6.3))(typescript@5.6.3)(vue@3.5.13(typescript@5.6.3)) + plugins/search/plugin-slimsearch: + dependencies: + '@vuepress/helper': + specifier: workspace:* + version: link:../../../tools/helper + '@vueuse/core': + specifier: ^11.2.0 + version: 11.2.0(vue@3.5.13(typescript@5.6.3)) + cheerio: + specifier: ^1.0.0 + version: 1.0.0 + chokidar: + specifier: ^3.6.0 + version: 3.6.0 + slimsearch: + specifier: ^2.2.1 + version: 2.2.1 + vue: + specifier: ^3.5.13 + version: 3.5.13(typescript@5.6.3) + vuepress: + specifier: 2.0.0-rc.18 + version: 2.0.0-rc.18(@vuepress/bundler-vite@2.0.0-rc.18(@types/node@22.9.0)(jiti@1.21.6)(lightningcss@1.28.1)(sass-embedded@1.81.0)(sass@1.81.0)(terser@5.36.0)(tsx@4.19.2)(typescript@5.6.3)(yaml@2.4.5))(@vuepress/bundler-webpack@2.0.0-rc.18(esbuild@0.23.1)(typescript@5.6.3))(typescript@5.6.3)(vue@3.5.13(typescript@5.6.3)) + devDependencies: + domhandler: + specifier: 5.0.3 + version: 5.0.3 + plugins/seo/plugin-seo: dependencies: '@vuepress/helper': @@ -7365,6 +7393,10 @@ packages: resolution: {integrity: sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==} engines: {node: '>=10'} + slimsearch@2.2.1: + resolution: {integrity: sha512-XvRjYHgyK4VXIvqYElyBpUFceK2Reh0CeIM3wzehFeTKJR9mg7qL42OCeXCY2F2GL9IkBNaGWXoZ/YyT86XgtA==} + engines: {node: '>=18.18.0'} + smart-buffer@4.2.0: resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} @@ -15277,6 +15309,8 @@ snapshots: astral-regex: 2.0.0 is-fullwidth-code-point: 3.0.0 + slimsearch@2.2.1: {} + smart-buffer@4.2.0: {} smob@1.5.0: {} diff --git a/tsconfig.build.json b/tsconfig.build.json index c4a7ad3486..4315e4adbc 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -85,6 +85,7 @@ // search { "path": "./plugins/search/plugin-docsearch/tsconfig.build.json" }, { "path": "./plugins/search/plugin-search/tsconfig.build.json" }, + { "path": "./plugins/search/plugin-slimsearch/tsconfig.build.json" }, // seo { "path": "./plugins/seo/plugin-seo/tsconfig.build.json" },