feat: resolve `$frontmatter` expressions while rendering markdown (#5412)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
pull/5413/head
Divyansh Singh 2 weeks ago committed by GitHub
parent 73bbaea028
commit a1eb28496e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

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

@ -16,7 +16,7 @@ describe('local search', () => {
})
try {
await page.locator('.VPNavBarSearchButton').click()
await openSearch()
const loading = page.locator('.search-loading')
const results = page.locator('.results')
@ -49,22 +49,10 @@ describe('local search', () => {
)
test('exclude content from search results', async () => {
await page.locator('.VPNavBarSearchButton').click()
const input = await page.waitForSelector('input#localsearch-input')
await input.type('local')
await searchFor('local')
await waitForSearchResults({ text: 'Local search included', count: 1 })
const searchResults = page.locator('#localsearch-list')
await page.waitForFunction(() => {
const options = [
...document.querySelectorAll('#localsearch-list li[role=option]')
]
return (
options.length === 1 &&
options[0].textContent?.includes('Local search included')
)
})
expect(await searchResults.locator('li[role=option]').count()).toBe(1)
@ -83,26 +71,28 @@ describe('local search', () => {
).toBe(0)
})
test('custom tokenize function reaches the client', async () => {
await page.locator('.VPNavBarSearchButton').click()
test('resolves $frontmatter expressions in search results', async () => {
await searchFor('Frontmatter Title Resolved')
await waitForSearchResults({ text: 'Frontmatter Title Resolved' })
const input = await page.waitForSelector('input#localsearch-input')
const searchResults = page.locator('#localsearch-list')
expect(
await searchResults
.filter({ hasText: 'Frontmatter Title Resolved' })
.count()
).toBe(1)
expect(
await searchResults.filter({ hasText: '$frontmatter.title' }).count()
).toBe(0)
})
test('custom tokenize function reaches the client', async () => {
// '#hash-probe' survives as one token only under the custom tokenizer —
// MiniSearch's default one would degrade the query to 'hash'/'probe'
// and miss the index built with the custom tokenizer
await input.type('#hash-probe')
await page.waitForFunction(() => {
const options = [
...document.querySelectorAll('#localsearch-list li[role=option]')
]
return (
options.length === 1 &&
options[0].textContent?.includes('Local search included')
)
})
const input = await searchFor('#hash-probe')
await waitForSearchResults({ text: 'Local search included', count: 1 })
// a fragment of a kept-whole token must not match anything
await input.fill('linked-words')
@ -118,8 +108,7 @@ describe('local search', () => {
]) {
await page.setViewportSize({ width, height: 600 })
await goto('/')
await page.locator('.VPNavBarSearchButton').click()
await page.waitForSelector('input#localsearch-input')
await openSearch()
expect(await page.locator('.VPNavBarHamburger').isVisible()).toBe(
!isDesktop
@ -135,17 +124,9 @@ describe('local search', () => {
test('navigate results with macOS Ctrl shortcuts', async () => {
await page.evaluate(() => document.documentElement.classList.add('mac'))
await page.locator('.VPNavBarSearchButton').click()
const input = await page.waitForSelector('input#localsearch-input')
await input.type('lorem')
await page.waitForFunction(() => {
return (
document.querySelectorAll('#localsearch-list li[role=option]').length >
1
)
})
const input = await searchFor('lorem')
await waitForSearchResults({ minCount: 2 })
expect(await input.getAttribute('aria-activedescendant')).toBe(
'localsearch-item-0'
@ -163,6 +144,43 @@ describe('local search', () => {
})
})
async function openSearch() {
await page.locator('.VPNavBarSearchButton').click()
return page.waitForSelector('input#localsearch-input')
}
// fills the query in one step, so exactly one search runs and the result
// list settles into the state for this query and nothing else
async function searchFor(query: string) {
const input = await openSearch()
await input.fill(query)
return input
}
// waits until the result list matches, so assertions never run against the
// results of an earlier query
function waitForSearchResults(condition: {
/** some result must contain this text */
text?: string
/** exactly this many results */
count?: number
/** at least this many results */
minCount?: number
}) {
return page.waitForFunction(({ text, count, minCount }) => {
const options = [
...document.querySelectorAll('#localsearch-list li[role=option]')
]
return (
(count === undefined || options.length === count) &&
(minCount === undefined || options.length >= minCount) &&
(text === undefined ||
options.some((option) => option.textContent?.includes(text)))
)
}, condition)
}
function pressMacCtrl(key: string) {
return page.evaluate((key) => {
window.dispatchEvent(

@ -42,6 +42,16 @@ describe('node/markdown/markdown', () => {
expect(await render(':tada:', { emoji: false })).toContain(':tada:')
})
test('eagerFrontmatterInterpolation', async () => {
const src = '---\ntitle: Hello\n---\n\n{{ $frontmatter.title }}'
expect(await render(src)).toContain('<p>Hello</p>')
const disabled = await render(src, {
eagerFrontmatterInterpolation: false
})
expect(disabled).toContain('<p>{{ $frontmatter.title }}</p>')
})
test('tasklist', async () => {
const src = '- [ ] todo'
expect(await render(src)).toContain('<input type="checkbox"')

@ -0,0 +1,239 @@
import {
createMarkdownRenderer,
disposeMdItInstance,
type MarkdownOptions
} from 'node/markdown/markdown'
// the full build, for compiling templates the way the Vue plugin would
// @ts-expect-error no types for dist builds
import { createSSRApp } from 'vue/dist/vue.cjs.js'
import { renderToString } from 'vue/server-renderer'
async function createMd(options: MarkdownOptions = {}) {
disposeMdItInstance()
return createMarkdownRenderer('.', { highlight: (code) => code, ...options })
}
async function render(src: string, env: Record<string, any> = {}) {
return (await createMd()).renderAsync(src, env)
}
const frontmatter = `\
---
title: Hello World
count: 5
flag: true
nothing: null
date: 2024-01-18
html: '<b>&amp; {{ hi }}</b>'
mustache: '{{ x }}'
k-y: dashed
spaced: 'a b'
multiline: |
line one
line two
homepage: https://vitepress.dev/
nested:
deep: value
list:
- a
- b
---
`
async function renderBody(body: string) {
return (await render(frontmatter + body)).trim()
}
describe('node/markdown/plugins/eagerFrontmatterInterpolation', () => {
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('resolves bracket paths and dates', async () => {
expect(await renderBody("{{ $frontmatter['k-y'] }}")).toBe('<p>dashed</p>')
expect(await renderBody('{{ $frontmatter["k-y"] }}')).toBe('<p>dashed</p>')
expect(await renderBody('{{ $frontmatter.list[1] }}')).toBe('<p>b</p>')
expect(await renderBody('{{ $frontmatter.list.length }}')).toBe('<p>2</p>')
// dates are normalized the same way the `__pageData` JSON round-trip
// normalizes them for the runtime
expect(await renderBody('{{ $frontmatter.date }}')).toBe(
'<p>2024-01-18T00:00:00.000Z</p>'
)
})
test('escapes values so they render as this exact text', async () => {
expect(await renderBody('{{ $frontmatter.html }}')).toBe(
'<p>&lt;b&gt;&amp;amp; &#123;&#123; hi &#125;&#125;&lt;/b&gt;</p>'
)
// a value containing mustaches must not be interpolated again by Vue
expect(await renderBody('{{ $frontmatter.mustache }}')).toBe(
'<p>&#123;&#123; x &#125;&#125;</p>'
)
expect(
await renderBody(
'&copy; {{ $frontmatter.title }} / {{ $frontmatter.no }}'
)
).toBe('<p>&copy; Hello World / {{ $frontmatter.no }}</p>')
})
test('leaves everything else to Vue', async () => {
const expressions = [
'{{ $frontmatter.missing }}', // key not in frontmatter
'{{ $frontmatter.title.length }}', // path through a non-object
'{{ $frontmatter.nothing.x }}',
'{{ $frontmatter.nothing }}', // renders '' but may be transformed later
'{{ $frontmatter }}',
'{{ $frontmatter.nested }}', // objects are for Vue's display formatting
'{{ $frontmatter.list }}',
'{{ $frontmatter.spaced }}', // double space would be condensed
'{{ $frontmatter.multiline }}',
'{{ $frontmatter.list[01] }}',
'{{ $frontmatter.title.toUpperCase() }}',
'{{ $frontmatter[title] }}',
'{{ $frontmatterX }}',
'{{ $params.id }}',
'{{ frontmatter.title }}'
]
const html = await renderBody(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('skips v-pre scopes from attrs', async () => {
const html = await renderBody(
'**{{ $frontmatter.title }}**{v-pre} {{ $frontmatter.title }}'
)
expect(html).toContain(
'<strong v-pre="">{{ $frontmatter.title }}</strong> Hello World'
)
})
test('stops at v-pre html blocks', async () => {
const html = await renderBody(
'{{ $frontmatter.title }}\n\n<div v-pre>\n\n{{ $frontmatter.title }}\n\n</div>'
)
// content before the block cannot be inside its scope
expect(html).toContain('<p>Hello World</p>')
expect(html).toContain('<p>{{ $frontmatter.title }}</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')
})
test('resolves link and image destinations', async () => {
const html = await renderBody(
[
'[home]({{$frontmatter.homepage}})',
'[docs](<{{ $frontmatter.homepage }}>)',
'[nope]({{$frontmatter.nope}})'
].join('\n\n')
)
expect(html).toContain('href="https://vitepress.dev/"')
// external link handling applies to the resolved destination
expect(html).toContain('target="_blank"')
// unresolvable destinations keep their expression
expect(html).toContain('$frontmatter.nope')
})
test('resolves image sources', async () => {
const html = await render(`\
---
logo: /logo.png
---
![logo]({{$frontmatter.logo}})
`)
expect(html).toContain('src="/logo.png"')
})
test('leaves everything alone without frontmatter data', async () => {
expect((await render('{{ $frontmatter.title }}')).trim()).toBe(
'<p>{{ $frontmatter.title }}</p>'
)
})
describe('equivalence with runtime interpolation', () => {
async function ssr(html: string, $frontmatter: unknown) {
const app = createSSRApp({ template: `<div>${html}</div>` })
app.config.globalProperties.$frontmatter = $frontmatter
app.config.warnHandler = () => {}
return renderToString(app)
}
test('inlined values render exactly what the runtime would', async () => {
const body = [
'Welcome to {{ $frontmatter.title }}!',
'{{ $frontmatter.html }}',
'{{ $frontmatter.mustache }}',
'{{ $frontmatter.count }} / {{ $frontmatter.flag }}',
'{{ $frontmatter.date }}',
'a {{$frontmatter.title}} b' // whitespace condensing parity
].join('\n\n')
const runtimeEnv: any = {}
const runtimeMd = await createMd({ eagerFrontmatterInterpolation: false })
const runtimeHtml = await runtimeMd.renderAsync(
frontmatter + body,
runtimeEnv
)
const resolvedHtml = await (
await createMd()
).renderAsync(frontmatter + body, {})
expect(resolvedHtml).not.toContain('$frontmatter')
// the runtime sees the frontmatter after the `__pageData` JSON
// round-trip; the resolved template must not need it at all
const runtimeData = JSON.parse(JSON.stringify(runtimeEnv.frontmatter))
expect(await ssr(resolvedHtml, { poisoned: true })).toBe(
await ssr(runtimeHtml, runtimeData)
)
})
})
})

@ -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, in heading anchors - the heading above gets `id="docs-with-vitepress"` - and in link targets written without spaces around the expression, like `[text]({{$frontmatter.link}})`. 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

@ -51,6 +51,7 @@ import {
gitHubAlertsPlugin,
type ContainerOptions
} from './plugins/containers'
import { eagerFrontmatterInterpolationPlugin } from './plugins/eagerFrontmatterInterpolation'
import { highlight as createHighlighter } from './plugins/highlight'
import { imagePlugin, type Options as ImageOptions } from './plugins/image'
import {
@ -326,6 +327,21 @@ export interface MarkdownOptions extends MarkdownItAsyncOptions {
* @see https://github.com/mdit-vue/mdit-vue/tree/main/packages/plugin-frontmatter
*/
frontmatter?: FrontmatterPluginOptions
/**
* Resolve `{{ $frontmatter.<path> }}` interpolations to their values while
* rendering markdown, so the value also reaches consumers that never run
* Vue - heading anchors and titles, the local search index, content loader
* output, link destinations - and the compiled Vue template gets static
* text instead of a runtime expression. Only bare property paths resolving
* to simple primitive values in the page's own frontmatter are inlined -
* anything else (complex expressions, missing keys, non-primitive values,
* `v-pre` scopes) keeps its runtime interpolation. Set to `false` to leave
* all interpolation to the Vue runtime.
*
* @experimental
* @default true
*/
eagerFrontmatterInterpolation?: boolean
/**
* Options for `@mdit-vue/plugin-sfc`.
* @see https://github.com/mdit-vue/mdit-vue/tree/main/packages/plugin-sfc
@ -537,6 +553,9 @@ export async function createMarkdownRenderer(
// https://github.com/jonschlinkert/gray-matter/blob/310f9349381775d10a221cef903989eb5acc8843/index.js#L44-L47
;(options.frontmatter ??= {}).grayMatterOptions ??= {}
frontmatterPlugin(md, options.frontmatter)
if (options.eagerFrontmatterInterpolation !== false) {
eagerFrontmatterInterpolationPlugin(md)
}
if (options.headers) {
headersPlugin(md, {
level: [2, 3, 4, 5, 6],

@ -0,0 +1,261 @@
import type { MarkdownItAsync } from 'markdown-it-async'
import type StateCore from 'markdown-it/lib/rules_core/state_core.mjs'
import type Token from 'markdown-it/lib/token.mjs'
import type { MarkdownEnv } from '../../shared'
// matches an interpolation the way Vue's template parser does: from `{{` up
// to the nearest `}}`
const interpolationRE = /\{\{([^]+?)\}\}/g
// the same inside a link destination - markdown-it percent-encodes
// destinations while tokenizing, so the delimiters may appear encoded
const destInterpolationRE = /(?:\{\{|%7B%7B)([^]*?)(?:\}\}|%7D%7D)/gi
// a statically resolvable path after `$frontmatter`: any number of `.key`,
// `[<index>]`, `['<key>']` or `["<key>"]` segments. Leading-zero indices and
// string escapes are excluded - the runtime handles those. Keep both
// expressions in sync.
const pathRE =
/^(?:\s*(?:\.\s*[A-Za-z_$][\w$]*|\[\s*(?:0|[1-9]\d*|'[^'\\]*'|"[^"\\]*")\s*\]))*$/
const segmentRE =
/\.\s*([A-Za-z_$][\w$]*)|\[\s*(?:(0|[1-9]\d*)|'([^'\\]*)'|"([^"\\]*)")\s*\]/g
// an opening html tag carrying `v-pre`, e.g. `<span v-pre>`, and the tag
// name of any html tag, for tracking where that scope ends
const vPreOpenRE = /^<[A-Za-z][\w-]*\s[^>]*(?<=\s)v-pre(?=[\s=/>])/
const htmlTagRE = /^<(\/?)([A-Za-z][\w-]*)/
const vPreRE = /\bv-pre\b/
type Resolve = (expr: string) => string | undefined
export const eagerFrontmatterInterpolationPlugin = (md: MarkdownItAsync) => {
// before the rules other plugins push (anchor, toc, ...), so slugs and
// extracted titles are derived from the resolved text
md.core.ruler.after(
'text_join',
'vp_eager_frontmatter_interpolation',
(state) => eagerFrontmatterInterpolation(md, state)
)
// resolved values render with their own escaping (see `escapeValue`);
// everything else keeps the existing text rule
const textRule = md.renderer.rules.text!
md.renderer.rules.text = (tokens, idx, options, env, self) =>
tokens[idx].meta?.frontmatterValue
? escapeValue(tokens[idx].content)
: textRule(tokens, idx, options, env, self)
}
function eagerFrontmatterInterpolation(
md: MarkdownItAsync,
state: StateCore
): void {
const { frontmatter } = state.env as MarkdownEnv
if (!frontmatter || !state.src.includes('{{')) return
// the runtime `$frontmatter` is the frontmatter after the JSON round-trip
// into `__pageData` (see `injectPageDataCode`), so resolve against the
// same view of the data - dates become ISO strings and non-JSON values
// are dropped
let data: unknown
let failed = false
const resolve: Resolve = (rawExpr) => {
const expr = rawExpr.trim()
if (!expr.startsWith('$frontmatter')) return undefined
const path = expr.slice('$frontmatter'.length)
if (!pathRE.test(path)) return undefined
if (data === undefined && !failed) {
try {
data = JSON.parse(JSON.stringify(frontmatter))
} catch {
failed = true
}
}
if (failed) return undefined
let value = data
for (const m of path.matchAll(segmentRE)) {
const key = (m[1] ?? m[2] ?? m[3] ?? m[4])!
// a missing key may still be provided by `transformPageData`, and a
// path through a non-object would throw at runtime - leave both alone
if (
value === null ||
typeof value !== 'object' ||
!Object.hasOwn(value, key)
) {
return undefined
}
value = (value as Record<string, unknown>)[key]
}
return display(value)
}
let skipLevel: number | null = null
for (const token of state.tokens) {
if (skipLevel !== null) {
if (token.nesting === -1 && token.level === skipLevel) skipLevel = null
} else if (token.type === 'html_block') {
// a raw html block can open a `v-pre` scope spanning the markdown
// after it; that cannot be delimited without parsing the html, so
// leave the rest of the page to the runtime
if (vPreRE.test(token.content)) return
} else if (
token.nesting === 1 &&
(token.type === 'container_v-pre_open' || hasVPre(token))
) {
skipLevel = token.level
} else if (token.type === 'inline' && token.children) {
processInline(md, state, token, resolve)
}
}
}
function processInline(
md: MarkdownItAsync,
state: StateCore,
inline: Token,
resolve: Resolve
): void {
const children = inline.children!
let out: Token[] | undefined
let skipLevel: number | null = null
let preTag: string | undefined
let preDepth = 0
for (let i = 0; i < children.length; i++) {
const child = children[i]
let replacement: Token[] | undefined
if (skipLevel !== null) {
if (child.nesting === -1 && child.level === skipLevel) skipLevel = null
} else if (child.type === 'html_inline') {
// track raw inline `v-pre` elements the way Vue scopes them
const [, closing, tag] = htmlTagRE.exec(child.content) ?? []
if (preTag) {
if (tag === preTag) preDepth += closing ? -1 : 1
if (!preDepth) preTag = undefined
} else if (
vPreOpenRE.test(child.content) &&
!child.content.endsWith('/>')
) {
preTag = tag
preDepth = 1
}
} else if (preTag) {
// inside a raw v-pre element - leave everything to the runtime
} else if (child.nesting === 1 && hasVPre(child)) {
skipLevel = child.level
} else if (child.type === 'text' && child.content.includes('{{')) {
replacement = replaceInterpolations(state, child, resolve)
} else if (child.type === 'link_open' || child.type === 'image') {
resolveDest(md, child, child.type === 'image' ? 'src' : 'href', resolve)
}
if (replacement && !out) out = children.slice(0, i)
if (out) out.push(...(replacement ?? [child]))
}
if (out) inline.children = out
}
// splits a text token around its resolved interpolations; the values become
// text tokens marked as `frontmatterValue` so extraction (anchors, headers,
// toc, search) sees them as plain text while the renderer applies value
// escaping
function replaceInterpolations(
state: StateCore,
token: Token,
resolve: Resolve
): Token[] | undefined {
const src = token.content
let out: Token[] | undefined
let lastIndex = 0
for (const m of src.matchAll(interpolationRE)) {
const value = resolve(m[1])
if (value === undefined) continue
out ??= []
if (m.index > lastIndex) {
out.push(textToken(state, token, src.slice(lastIndex, m.index)))
}
const valueToken = textToken(state, token, value)
valueToken.meta = { frontmatterValue: true }
out.push(valueToken)
lastIndex = m.index + m[0].length
}
if (out && lastIndex < src.length) {
out.push(textToken(state, token, src.slice(lastIndex)))
}
return out
}
// resolves interpolations in a link href or image src, making
// `[text]({{$frontmatter.link}})` a real link (#2240, #2099); the resolved
// destination goes through the same normalization and validation a literal
// one would
function resolveDest(
md: MarkdownItAsync,
token: Token,
attr: string,
resolve: Resolve
): void {
const url = token.attrGet(attr)
if (!url) return
let changed = false
const resolved = url.replace(destInterpolationRE, (match, rawExpr) => {
let expr = rawExpr as string
try {
expr = decodeURIComponent(expr)
} catch {}
const value = resolve(expr)
if (value === undefined) return match
changed = true
return value
})
if (!changed) return
const normalized = md.normalizeLink(resolved)
if (md.validateLink(normalized)) token.attrSet(attr, normalized)
}
function textToken(state: StateCore, from: Token, content: string): Token {
const token = new state.Token('text', '', 0)
token.content = content
token.level = from.level
return token
}
// whitespace the Vue template compiler's default `condense` mode would not
// read back verbatim: any [\t\n\r\f], runs of spaces, or edge spaces that
// could merge with adjacent whitespace
const unstableWhitespaceRE = /[\t\n\r\f]|^ | $| {2}/
// mirrors Vue's `toDisplayString`, restricted to values that inline
// losslessly: non-empty primitive text that survives the template
// compiler's whitespace condensing. Anything else - including `null`, which
// renders as an empty string but is also the classic "filled in later by
// `transformPageData`" placeholder - stays on the runtime.
function display(value: unknown): string | undefined {
if (value == null || typeof value === 'object') return undefined
const text = typeof value === 'string' ? value : String(value)
return text === '' || unstableWhitespaceRE.test(text) ? undefined : text
}
// entity-encode the value so the Vue template compiler reads back exactly
// this text: html syntax must not be parsed as markup, entity look-alikes
// must survive the compiler's decoding, and `{` must never reach the
// compiler as a possible interpolation start
function escapeValue(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\{/g, '&#123;')
.replace(/\}/g, '&#125;')
}
function hasVPre(token: Token): boolean {
return token.attrGet('v-pre') !== null
}
Loading…
Cancel
Save