feat(markdown): resolve $frontmatter expressions while rendering

`{{ $frontmatter.some.key }}` in text is now replaced with the frontmatter
value by the markdown renderer, so the value also reaches the local search
index and content loaders, and heading anchors and the page title are
derived from it. Anything the renderer can't resolve - missing keys,
non-primitive values, other expressions - and everything inside code or
`v-pre` is still left to Vue.

fixes #4934
closes #5162

Co-authored-by: Marco Roth <marco.roth@intergga.ch>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat/render-md
Divyansh Singh 1 month ago
parent 2ad8a1cfba
commit 2e9d665c6f

@ -0,0 +1,7 @@
---
title: Frontmatter Title Resolved
---
# {{ $frontmatter.title }}
This page uses a frontmatter title expression.

@ -83,6 +83,28 @@ describe('local search', () => {
).toBe(0)
})
test('resolves $frontmatter expressions in search results', async () => {
await page.locator('.VPNavBarSearchButton').click()
const input = await page.waitForSelector('input#localsearch-input')
await input.type('Frontmatter Title Resolved')
const searchResults = page.locator('#localsearch-list')
await page.waitForFunction(() => {
return document.querySelectorAll('#localsearch-list li[role=option]')
.length
})
expect(
await searchResults
.filter({ hasText: 'Frontmatter Title Resolved' })
.count()
).toBe(1)
expect(
await searchResults.filter({ hasText: '$frontmatter.title' }).count()
).toBe(0)
})
test('uses the same desktop breakpoint as the nav bar', async () => {
try {
for (const { width, isDesktop } of [

@ -0,0 +1,82 @@
import {
createMarkdownRenderer,
disposeMdItInstance
} from 'node/markdown/markdown'
async function render(src: string, env: Record<string, any> = {}) {
disposeMdItInstance()
const md = await createMarkdownRenderer('.', { highlight: (code) => code })
return md.renderAsync(src, env)
}
describe('node/markdown/plugins/frontmatterExpressions', () => {
test('resolves property paths and escapes the value', async () => {
const html = await render(`---
meta:
title: A <b>& B
count: 2
done: false
---
{{ $frontmatter.meta.title }} / {{$frontmatter.count}} / {{ $frontmatter.done }}
`)
expect(html).toContain('<p>A &lt;b&gt;&amp; B / 2 / false</p>')
})
test('leaves everything else to Vue', async () => {
const expressions = [
'{{ $frontmatter.missing }}',
'{{ $frontmatter.list }}',
'{{ $frontmatter.title.length }}',
"{{ $frontmatter['title'] }}",
'{{ $frontmatter.mustache }}'
]
const html = await render(`---
title: Hi
list: [1, 2]
mustache: "{{ x }}"
---
${expressions.join('\n\n')}
`)
for (const expression of expressions) {
expect(html).toContain(`<p>${expression}</p>`)
}
})
test('skips code and v-pre', async () => {
const html = await render(`---
title: Hi
---
\`{{ $frontmatter.title }}\`
\`\`\`js
{{ $frontmatter.title }}
\`\`\`
::: v-pre
{{ $frontmatter.title }}
:::
<span v-pre>{{ $frontmatter.title }}</span> {{ $frontmatter.title }}
`)
expect(html.match(/\{\{ \$frontmatter\.title \}\}/g)).toHaveLength(4)
expect(html).toContain('</span> Hi</p>')
})
test('feeds the resolved text to anchors and the page title', async () => {
const env: Record<string, any> = {}
const html = await render(
`---
title: Hello World
---
# {{ $frontmatter.title }}
`,
env
)
expect(html).toContain('id="hello-world"')
expect(env.title).toBe('Hello World')
})
})

@ -36,6 +36,8 @@ editLink: true
Guide content
```
Property accesses like `{{ $frontmatter.title }}` are resolved while the Markdown is rendered, so the value also ends up in the local search index, in [content loader](./data-loading#createcontentloader) output and in heading anchors - the heading above gets `id="docs-with-vitepress"`. Other expressions are evaluated by Vue at runtime as usual, and wrapping an expression in [`v-pre`](./using-vue#escaping) shows it literally.
You can also access current page's frontmatter data in `<script setup>` with the [`useData()`](../reference/runtime-api#usedata) helper.
## Alternative Frontmatter Formats

@ -49,6 +49,7 @@ import {
gitHubAlertsPlugin,
type ContainerOptions
} from './plugins/containers'
import { frontmatterExpressionsPlugin } from './plugins/frontmatterExpressions'
import { highlight as createHighlighter } from './plugins/highlight'
import { imagePlugin, type Options as ImageOptions } from './plugins/image'
import {
@ -535,6 +536,7 @@ export async function createMarkdownRenderer(
// https://github.com/jonschlinkert/gray-matter/blob/310f9349381775d10a221cef903989eb5acc8843/index.js#L44-L47
;(options.frontmatter ??= {}).grayMatterOptions ??= {}
frontmatterPlugin(md, options.frontmatter)
frontmatterExpressionsPlugin(md)
if (options.headers) {
headersPlugin(md, {
level: [2, 3, 4, 5, 6],

@ -0,0 +1,62 @@
import type { MarkdownItAsync } from 'markdown-it-async'
import type Token from 'markdown-it/lib/token.mjs'
import type { MarkdownEnv } from '../../shared'
const expressionRE = /\{\{\s*\$frontmatter((?:\.[A-Za-z_$][\w$]*)+)\s*\}\}/g
const tagRE = /^<(\/?)([A-Za-z][\w-]*)/
const vPreRE = /^<[A-Za-z][\w-]*\s[^>]*(?<=\s)v-pre(?=[\s=/>])/
/**
* Resolves `{{ $frontmatter.some.key }}` in text to the page's frontmatter at
* render time, so that the value also reaches consumers that never run Vue -
* the search index, content loaders and `renderMd()` - and Vue has nothing
* left to interpolate. Anything this can't resolve (missing keys, non-primitive
* values, expressions beyond a property path) is left for Vue, as is
* everything inside code, `::: v-pre` containers and inline `v-pre` elements.
*/
export const frontmatterExpressionsPlugin = (md: MarkdownItAsync) => {
md.core.ruler.after('text_join', 'vp_frontmatter_expressions', (state) => {
const { frontmatter } = state.env as MarkdownEnv
if (!frontmatter) return
let preDepth = 0
for (const token of state.tokens) {
if (token.type === 'container_v-pre_open') preDepth++
else if (token.type === 'container_v-pre_close') preDepth--
else if (token.type === 'inline' && !preDepth && token.children) {
resolve(token.children, frontmatter)
}
}
})
}
function resolve(tokens: Token[], frontmatter: Record<string, unknown>) {
let preTag: string | undefined
let preDepth = 0
for (const token of tokens) {
if (token.type === 'html_inline') {
const [, closing, tag] = tagRE.exec(token.content) ?? []
if (preTag) {
if (tag === preTag) preDepth += closing ? -1 : 1
if (!preDepth) preTag = undefined
} else if (vPreRE.test(token.content) && !token.content.endsWith('/>')) {
preTag = tag
preDepth = 1
}
} else if (token.type === 'text' && !preTag) {
token.content = token.content.replace(expressionRE, (match, path) => {
let value: unknown = frontmatter
for (const key of (path as string).slice(1).split('.')) {
if (value == null || typeof value !== 'object') return match
value = (value as Record<string, unknown>)[key]
}
// objects are for Vue's display formatting, and a value with mustaches
// would be interpolated again by Vue if inlined here
return value == null ||
typeof value === 'object' ||
(typeof value === 'string' && value.includes('{{'))
? match
: String(value)
})
}
}
}
Loading…
Cancel
Save