Skip to content

Commit

Permalink
feat: allow customizing markdown renderer used for local search index…
Browse files Browse the repository at this point in the history
…ing (#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>
  • Loading branch information
damieng and brc-dd committed Aug 20, 2023
1 parent e8edd0a commit 00dc1e6
Show file tree
Hide file tree
Showing 14 changed files with 297 additions and 232 deletions.
7 changes: 5 additions & 2 deletions __tests__/e2e/.vitepress/config.ts
Expand Up @@ -91,8 +91,11 @@ export default defineConfig({
search: {
provider: 'local',
options: {
exclude(relativePath) {
return relativePath.startsWith('local-search/excluded')
_render(src, env, md) {
const html = md.render(src, env)
if (env.frontmatter?.search === false) return ''
if (env.relativePath.startsWith('local-search/excluded')) return ''
return html
}
}
}
Expand Down
66 changes: 63 additions & 3 deletions docs/reference/default-theme-search.md
Expand Up @@ -98,18 +98,78 @@ export default defineConfig({

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

### Excluding pages from search
### Custom content renderer

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`:
You can customize the function used to render the markdown content before indexing it:

```ts
import { defineConfig } from 'vitepress'

export default defineConfig({
themeConfig: {
search: {
provider: 'local',
options: {
/**
* @param {string} src
* @param {import('vitepress').MarkdownEnv} env
* @param {import('markdown-it')} md
*/
_render(src, env, md) {
// return html string
}
}
}
}
})
```

This function will be stripped from client-side site data, so you can use Node.js APIs in it.

#### Example: Excluding pages from search

You can exclude pages from search by adding `search: false` to the frontmatter of the page. Alternatively:

```ts
import { defineConfig } from 'vitepress'

export default defineConfig({
themeConfig: {
search: {
provider: 'local',
options: {
exclude: (path) => path.startsWith('/some/path')
_render(src, env, md) {
const html = md.render(src, env)
if (env.frontmatter?.search === false) return ''
if (env.relativePath.startsWith('some/path')) return ''
return html
}
}
}
}
})
```

::: warning Note
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.
:::

#### Example: Transforming content - adding anchors

```ts
import { defineConfig } from 'vitepress'

export default defineConfig({
themeConfig: {
search: {
provider: 'local',
options: {
_render(src, env, md) {
const html = md.render(src, env)
if (env.frontmatter?.title)
return md.render(`# ${env.frontmatter.title}`) + html
return html
}
}
}
}
Expand Down
40 changes: 0 additions & 40 deletions src/node/markdown/env.ts

This file was deleted.

156 changes: 154 additions & 2 deletions src/node/markdown/index.ts
@@ -1,2 +1,154 @@
export * from './env'
export * from './markdown'
import { componentPlugin } from '@mdit-vue/plugin-component'
import {
frontmatterPlugin,
type FrontmatterPluginOptions
} from '@mdit-vue/plugin-frontmatter'
import {
headersPlugin,
type HeadersPluginOptions
} from '@mdit-vue/plugin-headers'
import { sfcPlugin, type SfcPluginOptions } from '@mdit-vue/plugin-sfc'
import { titlePlugin } from '@mdit-vue/plugin-title'
import { tocPlugin, type TocPluginOptions } from '@mdit-vue/plugin-toc'
import { slugify } from '@mdit-vue/shared'
import MarkdownIt from 'markdown-it'
import anchorPlugin from 'markdown-it-anchor'
import attrsPlugin from 'markdown-it-attrs'
import emojiPlugin from 'markdown-it-emoji'
import type { ILanguageRegistration, IThemeRegistration } from 'shiki'
import type { Logger } from 'vite'
import { containerPlugin } from './plugins/containers'
import { highlight } from './plugins/highlight'
import { highlightLinePlugin } from './plugins/highlightLines'
import { imagePlugin } from './plugins/image'
import { lineNumberPlugin } from './plugins/lineNumbers'
import { linkPlugin } from './plugins/link'
import { preWrapperPlugin } from './plugins/preWrapper'
import { snippetPlugin } from './plugins/snippet'

export type { Header } from '../shared'

export type ThemeOptions =
| IThemeRegistration
| { light: IThemeRegistration; dark: IThemeRegistration }

export interface MarkdownOptions extends MarkdownIt.Options {
lineNumbers?: boolean
preConfig?: (md: MarkdownIt) => void
config?: (md: MarkdownIt) => void
anchor?: anchorPlugin.AnchorOptions
attrs?: {
leftDelimiter?: string
rightDelimiter?: string
allowedAttributes?: string[]
disable?: boolean
}
defaultHighlightLang?: string
frontmatter?: FrontmatterPluginOptions
headers?: HeadersPluginOptions | boolean
sfc?: SfcPluginOptions
theme?: ThemeOptions
languages?: ILanguageRegistration[]
toc?: TocPluginOptions
externalLinks?: Record<string, string>
cache?: boolean
}

export type MarkdownRenderer = MarkdownIt

export const createMarkdownRenderer = async (
srcDir: string,
options: MarkdownOptions = {},
base = '/',
logger: Pick<Logger, 'warn'> = console
): Promise<MarkdownRenderer> => {
const theme = options.theme ?? { light: 'github-light', dark: 'github-dark' }
const hasSingleTheme = typeof theme === 'string' || 'name' in theme

const md = MarkdownIt({
html: true,
linkify: true,
highlight:
options.highlight ||
(await highlight(
theme,
options.languages,
options.defaultHighlightLang,
logger
)),
...options
})

md.linkify.set({ fuzzyLink: false })

if (options.preConfig) {
options.preConfig(md)
}

// custom plugins
md.use(componentPlugin)
.use(highlightLinePlugin)
.use(preWrapperPlugin, { hasSingleTheme })
.use(snippetPlugin, srcDir)
.use(containerPlugin, { hasSingleTheme })
.use(imagePlugin)
.use(
linkPlugin,
{ target: '_blank', rel: 'noreferrer', ...options.externalLinks },
base
)
.use(lineNumberPlugin, options.lineNumbers)

// 3rd party plugins
if (!options.attrs?.disable) {
md.use(attrsPlugin, options.attrs)
}
md.use(emojiPlugin)

// mdit-vue plugins
md.use(anchorPlugin, {
slugify,
permalink: anchorPlugin.permalink.linkInsideHeader({
symbol: '&ZeroWidthSpace;',
renderAttrs: (slug, state) => {
// Find `heading_open` with the id identical to slug
const idx = state.tokens.findIndex((token) => {
const attrs = token.attrs
const id = attrs?.find((attr) => attr[0] === 'id')
return id && slug === id[1]
})
// Get the actual heading content
const title = state.tokens[idx + 1].content
return {
'aria-label': `Permalink to "${title}"`
}
}
}),
...options.anchor
} as anchorPlugin.AnchorOptions).use(frontmatterPlugin, {
...options.frontmatter
} as FrontmatterPluginOptions)

if (options.headers) {
md.use(headersPlugin, {
level: [2, 3, 4, 5, 6],
slugify,
...(typeof options.headers === 'boolean' ? undefined : options.headers)
} as HeadersPluginOptions)
}

md.use(sfcPlugin, {
...options.sfc
} as SfcPluginOptions)
.use(titlePlugin)
.use(tocPlugin, {
...options.toc
} as TocPluginOptions)

// apply user config
if (options.config) {
options.config(md)
}

return md
}

0 comments on commit 00dc1e6

Please sign in to comment.