Skip to content

Commit 00dc1e6

Browse files
damiengbrc-dd
andauthoredAug 20, 2023
feat: allow customizing markdown renderer used for local search indexing (#2770)
BREAKING CHANGES: `search.options.exclude` for local search is removed in favor of more flexible `search.options._render` Co-authored-by: Divyansh Singh <40380293+brc-dd@users.noreply.github.com>
1 parent e8edd0a commit 00dc1e6

File tree

14 files changed

+297
-232
lines changed

14 files changed

+297
-232
lines changed
 

‎__tests__/e2e/.vitepress/config.ts

+5-2
Original file line numberDiff line numberDiff line change
@@ -91,8 +91,11 @@ export default defineConfig({
9191
search: {
9292
provider: 'local',
9393
options: {
94-
exclude(relativePath) {
95-
return relativePath.startsWith('local-search/excluded')
94+
_render(src, env, md) {
95+
const html = md.render(src, env)
96+
if (env.frontmatter?.search === false) return ''
97+
if (env.relativePath.startsWith('local-search/excluded')) return ''
98+
return html
9699
}
97100
}
98101
}

‎docs/reference/default-theme-search.md

+63-3
Original file line numberDiff line numberDiff line change
@@ -98,18 +98,78 @@ export default defineConfig({
9898

9999
Learn more in [MiniSearch docs](https://lucaong.github.io/minisearch/classes/_minisearch_.minisearch.html).
100100

101-
### Excluding pages from search
101+
### Custom content renderer
102102

103-
You can exclude pages from search by adding `search: false` to the frontmatter of the page. Alternatively, you can also pass `exclude` function to `themeConfig.search.options` to exclude pages based on their path relative to `srcDir`:
103+
You can customize the function used to render the markdown content before indexing it:
104104

105105
```ts
106106
import { defineConfig } from 'vitepress'
107107

108108
export default defineConfig({
109109
themeConfig: {
110110
search: {
111+
provider: 'local',
112+
options: {
113+
/**
114+
* @param {string} src
115+
* @param {import('vitepress').MarkdownEnv} env
116+
* @param {import('markdown-it')} md
117+
*/
118+
_render(src, env, md) {
119+
// return html string
120+
}
121+
}
122+
}
123+
}
124+
})
125+
```
126+
127+
This function will be stripped from client-side site data, so you can use Node.js APIs in it.
128+
129+
#### Example: Excluding pages from search
130+
131+
You can exclude pages from search by adding `search: false` to the frontmatter of the page. Alternatively:
132+
133+
```ts
134+
import { defineConfig } from 'vitepress'
135+
136+
export default defineConfig({
137+
themeConfig: {
138+
search: {
139+
provider: 'local',
111140
options: {
112-
exclude: (path) => path.startsWith('/some/path')
141+
_render(src, env, md) {
142+
const html = md.render(src, env)
143+
if (env.frontmatter?.search === false) return ''
144+
if (env.relativePath.startsWith('some/path')) return ''
145+
return html
146+
}
147+
}
148+
}
149+
}
150+
})
151+
```
152+
153+
::: warning Note
154+
In case a custom `_render` function is provided, you need to handle the `search: false` frontmatter yourself. Also, the `env` object won't be completely populated before `md.render` is called, so any checks on optional `env` properties like `frontmatter` should be done after that.
155+
:::
156+
157+
#### Example: Transforming content - adding anchors
158+
159+
```ts
160+
import { defineConfig } from 'vitepress'
161+
162+
export default defineConfig({
163+
themeConfig: {
164+
search: {
165+
provider: 'local',
166+
options: {
167+
_render(src, env, md) {
168+
const html = md.render(src, env)
169+
if (env.frontmatter?.title)
170+
return md.render(`# ${env.frontmatter.title}`) + html
171+
return html
172+
}
113173
}
114174
}
115175
}

‎src/node/markdown/env.ts

-40
This file was deleted.

‎src/node/markdown/index.ts

+154-2
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,154 @@
1-
export * from './env'
2-
export * from './markdown'
1+
import { componentPlugin } from '@mdit-vue/plugin-component'
2+
import {
3+
frontmatterPlugin,
4+
type FrontmatterPluginOptions
5+
} from '@mdit-vue/plugin-frontmatter'
6+
import {
7+
headersPlugin,
8+
type HeadersPluginOptions
9+
} from '@mdit-vue/plugin-headers'
10+
import { sfcPlugin, type SfcPluginOptions } from '@mdit-vue/plugin-sfc'
11+
import { titlePlugin } from '@mdit-vue/plugin-title'
12+
import { tocPlugin, type TocPluginOptions } from '@mdit-vue/plugin-toc'
13+
import { slugify } from '@mdit-vue/shared'
14+
import MarkdownIt from 'markdown-it'
15+
import anchorPlugin from 'markdown-it-anchor'
16+
import attrsPlugin from 'markdown-it-attrs'
17+
import emojiPlugin from 'markdown-it-emoji'
18+
import type { ILanguageRegistration, IThemeRegistration } from 'shiki'
19+
import type { Logger } from 'vite'
20+
import { containerPlugin } from './plugins/containers'
21+
import { highlight } from './plugins/highlight'
22+
import { highlightLinePlugin } from './plugins/highlightLines'
23+
import { imagePlugin } from './plugins/image'
24+
import { lineNumberPlugin } from './plugins/lineNumbers'
25+
import { linkPlugin } from './plugins/link'
26+
import { preWrapperPlugin } from './plugins/preWrapper'
27+
import { snippetPlugin } from './plugins/snippet'
28+
29+
export type { Header } from '../shared'
30+
31+
export type ThemeOptions =
32+
| IThemeRegistration
33+
| { light: IThemeRegistration; dark: IThemeRegistration }
34+
35+
export interface MarkdownOptions extends MarkdownIt.Options {
36+
lineNumbers?: boolean
37+
preConfig?: (md: MarkdownIt) => void
38+
config?: (md: MarkdownIt) => void
39+
anchor?: anchorPlugin.AnchorOptions
40+
attrs?: {
41+
leftDelimiter?: string
42+
rightDelimiter?: string
43+
allowedAttributes?: string[]
44+
disable?: boolean
45+
}
46+
defaultHighlightLang?: string
47+
frontmatter?: FrontmatterPluginOptions
48+
headers?: HeadersPluginOptions | boolean
49+
sfc?: SfcPluginOptions
50+
theme?: ThemeOptions
51+
languages?: ILanguageRegistration[]
52+
toc?: TocPluginOptions
53+
externalLinks?: Record<string, string>
54+
cache?: boolean
55+
}
56+
57+
export type MarkdownRenderer = MarkdownIt
58+
59+
export const createMarkdownRenderer = async (
60+
srcDir: string,
61+
options: MarkdownOptions = {},
62+
base = '/',
63+
logger: Pick<Logger, 'warn'> = console
64+
): Promise<MarkdownRenderer> => {
65+
const theme = options.theme ?? { light: 'github-light', dark: 'github-dark' }
66+
const hasSingleTheme = typeof theme === 'string' || 'name' in theme
67+
68+
const md = MarkdownIt({
69+
html: true,
70+
linkify: true,
71+
highlight:
72+
options.highlight ||
73+
(await highlight(
74+
theme,
75+
options.languages,
76+
options.defaultHighlightLang,
77+
logger
78+
)),
79+
...options
80+
})
81+
82+
md.linkify.set({ fuzzyLink: false })
83+
84+
if (options.preConfig) {
85+
options.preConfig(md)
86+
}
87+
88+
// custom plugins
89+
md.use(componentPlugin)
90+
.use(highlightLinePlugin)
91+
.use(preWrapperPlugin, { hasSingleTheme })
92+
.use(snippetPlugin, srcDir)
93+
.use(containerPlugin, { hasSingleTheme })
94+
.use(imagePlugin)
95+
.use(
96+
linkPlugin,
97+
{ target: '_blank', rel: 'noreferrer', ...options.externalLinks },
98+
base
99+
)
100+
.use(lineNumberPlugin, options.lineNumbers)
101+
102+
// 3rd party plugins
103+
if (!options.attrs?.disable) {
104+
md.use(attrsPlugin, options.attrs)
105+
}
106+
md.use(emojiPlugin)
107+
108+
// mdit-vue plugins
109+
md.use(anchorPlugin, {
110+
slugify,
111+
permalink: anchorPlugin.permalink.linkInsideHeader({
112+
symbol: '&ZeroWidthSpace;',
113+
renderAttrs: (slug, state) => {
114+
// Find `heading_open` with the id identical to slug
115+
const idx = state.tokens.findIndex((token) => {
116+
const attrs = token.attrs
117+
const id = attrs?.find((attr) => attr[0] === 'id')
118+
return id && slug === id[1]
119+
})
120+
// Get the actual heading content
121+
const title = state.tokens[idx + 1].content
122+
return {
123+
'aria-label': `Permalink to "${title}"`
124+
}
125+
}
126+
}),
127+
...options.anchor
128+
} as anchorPlugin.AnchorOptions).use(frontmatterPlugin, {
129+
...options.frontmatter
130+
} as FrontmatterPluginOptions)
131+
132+
if (options.headers) {
133+
md.use(headersPlugin, {
134+
level: [2, 3, 4, 5, 6],
135+
slugify,
136+
...(typeof options.headers === 'boolean' ? undefined : options.headers)
137+
} as HeadersPluginOptions)
138+
}
139+
140+
md.use(sfcPlugin, {
141+
...options.sfc
142+
} as SfcPluginOptions)
143+
.use(titlePlugin)
144+
.use(tocPlugin, {
145+
...options.toc
146+
} as TocPluginOptions)
147+
148+
// apply user config
149+
if (options.config) {
150+
options.config(md)
151+
}
152+
153+
return md
154+
}

‎src/node/markdown/markdown.ts

-154
This file was deleted.

‎src/node/markdown/plugins/highlight.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import {
1717
type Processor
1818
} from 'shiki-processor'
1919
import type { Logger } from 'vite'
20-
import type { ThemeOptions } from '../markdown'
20+
import type { ThemeOptions } from '..'
2121

2222
const nanoid = customAlphabet('abcdefghijklmnopqrstuvwxyz', 10)
2323

‎src/node/markdown/plugins/link.ts

+6-2
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,13 @@
33
// 2. normalize internal links to end with `.html`
44

55
import type MarkdownIt from 'markdown-it'
6-
import type { MarkdownEnv } from '../env'
76
import { URL } from 'url'
8-
import { EXTERNAL_URL_RE, PATHNAME_PROTOCOL_RE, isExternal } from '../../shared'
7+
import {
8+
EXTERNAL_URL_RE,
9+
PATHNAME_PROTOCOL_RE,
10+
isExternal,
11+
type MarkdownEnv
12+
} from '../../shared'
913

1014
const indexRE = /(^|.*\/)index.md(#?.*)$/i
1115

‎src/node/markdown/plugins/snippet.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import fs from 'fs-extra'
2-
import path from 'path'
32
import type MarkdownIt from 'markdown-it'
43
import type { RuleBlock } from 'markdown-it/lib/parser_block'
5-
import type { MarkdownEnv } from '../env'
4+
import path from 'path'
5+
import type { MarkdownEnv } from '../../shared'
66

77
export function dedent(text: string): string {
88
const lines = text.split('\n')

‎src/node/markdownToVue.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,14 @@ import path from 'path'
66
import type { SiteConfig } from './config'
77
import {
88
createMarkdownRenderer,
9-
type MarkdownEnv,
109
type MarkdownOptions,
1110
type MarkdownRenderer
1211
} from './markdown'
1312
import {
1413
EXTERNAL_URL_RE,
1514
slash,
1615
type HeadConfig,
16+
type MarkdownEnv,
1717
type PageData
1818
} from './shared'
1919
import { getGitTimestamp } from './utils/getGitTimestamp'

‎src/node/plugins/localSearchPlugin.ts

+16-19
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,13 @@ import MiniSearch from 'minisearch'
44
import path from 'path'
55
import type { Plugin, ViteDevServer } from 'vite'
66
import type { SiteConfig } from '../config'
7-
import { createMarkdownRenderer, type MarkdownEnv } from '../markdown'
8-
import { resolveSiteDataByRoute, slash, type DefaultTheme } from '../shared'
7+
import { createMarkdownRenderer } from '../markdown'
8+
import {
9+
resolveSiteDataByRoute,
10+
slash,
11+
type DefaultTheme,
12+
type MarkdownEnv
13+
} from '../shared'
914

1015
const debug = _debug('vitepress:local-search')
1116

@@ -45,23 +50,16 @@ export async function localSearchPlugin(
4550
siteConfig.logger
4651
)
4752

53+
const options = siteConfig.site.themeConfig.search.options || {}
54+
4855
function render(file: string) {
49-
const { srcDir, cleanUrls = false, site } = siteConfig
56+
const { srcDir, cleanUrls = false } = siteConfig
5057
const relativePath = slash(path.relative(srcDir, file))
51-
const env: MarkdownEnv = {
52-
path: file,
53-
relativePath,
54-
cleanUrls
55-
}
56-
const html = md.render(fs.readFileSync(file, 'utf-8'), env)
57-
if (
58-
env.frontmatter?.search === false ||
59-
(site.themeConfig.search?.provider === 'local' &&
60-
site.themeConfig.search.options?.exclude?.(relativePath))
61-
) {
62-
return ''
63-
}
64-
return html
58+
const env: MarkdownEnv = { path: file, relativePath, cleanUrls }
59+
const src = fs.readFileSync(file, 'utf-8')
60+
if (options._render) return options._render(src, env, md)
61+
const html = md.render(src, env)
62+
return env.frontmatter?.search === false ? '' : html
6563
}
6664

6765
const indexByLocales = new Map<string, MiniSearch<IndexObject>>()
@@ -72,8 +70,7 @@ export async function localSearchPlugin(
7270
index = new MiniSearch<IndexObject>({
7371
fields: ['title', 'titles', 'text'],
7472
storeFields: ['title', 'titles'],
75-
...(siteConfig.site.themeConfig?.search?.provider === 'local' &&
76-
siteConfig.site.themeConfig.search.options?.miniSearch?.options)
73+
...options.miniSearch?.options
7774
})
7875
indexByLocales.set(locale, index)
7976
}

‎src/node/utils/fnSerialize.ts

+1
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ export function serializeFunctions(value: any, key?: string): any {
33
return value.map((v) => serializeFunctions(v))
44
} else if (typeof value === 'object' && value !== null) {
55
return Object.keys(value).reduce((acc, key) => {
6+
if (key[0] === '_') return acc
67
acc[key] = serializeFunctions(value[key], key)
78
return acc
89
}, {} as any)

‎src/shared/shared.ts

+3-2
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,11 @@ export type {
77
Header,
88
LocaleConfig,
99
LocaleSpecificConfig,
10+
MarkdownEnv,
1011
PageData,
1112
PageDataPayload,
12-
SiteData,
13-
SSGContext
13+
SSGContext,
14+
SiteData
1415
} from '../../types/shared'
1516

1617
export const EXTERNAL_URL_RE = /^[a-z]+:/i

‎types/default-theme.d.ts

+6-4
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1+
import type MarkdownIt from 'markdown-it'
12
import type { Options as MiniSearchOptions } from 'minisearch'
23
import type { ComputedRef, Ref } from 'vue'
34
import type { DocSearchProps } from './docsearch.js'
45
import type { LocalSearchTranslations } from './local-search.js'
5-
import type { PageData } from './shared.js'
6+
import type { MarkdownEnv, PageData } from './shared.js'
67

78
export namespace DefaultTheme {
89
export interface Config {
@@ -383,15 +384,16 @@ export namespace DefaultTheme {
383384
}
384385

385386
/**
386-
* exclude content from search results
387+
* Allows transformation of content before indexing (node only)
388+
* Return empty string to skip indexing
387389
*/
388-
exclude?: (relativePath: string) => boolean
390+
_render?: (src: string, env: MarkdownEnv, md: MarkdownIt) => string
389391
}
390392

391393
// algolia -------------------------------------------------------------------
392394

393395
/**
394-
* The Algolia search options. Partially copied from
396+
* Algolia search options. Partially copied from
395397
* `@docsearch/react/dist/esm/DocSearch.d.ts`
396398
*/
397399
export interface AlgoliaSearchOptions extends DocSearchProps {

‎types/shared.d.ts

+39
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
// types shared between server and client
2+
import type { MarkdownSfcBlocks } from '@mdit-vue/plugin-sfc'
23
import type { UseDarkOptions } from '@vueuse/core'
34
import type { SSRContext } from 'vue/server-renderer'
45
export type { DefaultTheme } from './default-theme.js'
@@ -98,3 +99,41 @@ export type LocaleConfig<ThemeConfig = any> = Record<
9899
string,
99100
LocaleSpecificConfig<ThemeConfig> & { label: string; link?: string }
100101
>
102+
103+
// Manually declaring all properties as rollup-plugin-dts
104+
// is unable to merge augmented module declarations
105+
106+
export interface MarkdownEnv {
107+
/**
108+
* The raw Markdown content without frontmatter
109+
*/
110+
content?: string
111+
/**
112+
* The excerpt that extracted by `@mdit-vue/plugin-frontmatter`
113+
*
114+
* - Would be the rendered HTML when `renderExcerpt` is enabled
115+
* - Would be the raw Markdown when `renderExcerpt` is disabled
116+
*/
117+
excerpt?: string
118+
/**
119+
* The frontmatter that extracted by `@mdit-vue/plugin-frontmatter`
120+
*/
121+
frontmatter?: Record<string, unknown>
122+
/**
123+
* The headers that extracted by `@mdit-vue/plugin-headers`
124+
*/
125+
headers?: Header[]
126+
/**
127+
* SFC blocks that extracted by `@mdit-vue/plugin-sfc`
128+
*/
129+
sfcBlocks?: MarkdownSfcBlocks
130+
/**
131+
* The title that extracted by `@mdit-vue/plugin-title`
132+
*/
133+
title?: string
134+
path: string
135+
relativePath: string
136+
cleanUrls: boolean
137+
links?: string[]
138+
includes?: string[]
139+
}

0 commit comments

Comments
 (0)
Please sign in to comment.