mirror of https://github.com/vuejs/vitepress
`{{ $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
parent
2ad8a1cfba
commit
2e9d665c6f
@ -0,0 +1,7 @@
|
||||
---
|
||||
title: Frontmatter Title Resolved
|
||||
---
|
||||
|
||||
# {{ $frontmatter.title }}
|
||||
|
||||
This page uses a frontmatter title expression.
|
||||
@ -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 <b>& 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')
|
||||
})
|
||||
})
|
||||
@ -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…
Reference in new issue