From a1eb28496e67f881fc50da776c11aa45a73c4f40 Mon Sep 17 00:00:00 2001
From: Divyansh Singh <40380293+brc-dd@users.noreply.github.com>
Date: Sun, 30 Aug 2026 20:54:17 +0530
Subject: [PATCH] feat: resolve `$frontmatter` expressions while rendering
markdown (#5412)
Co-authored-by: Claude Fable 5
---
.../e2e/local-search/frontmatter-title.md | 7 +
.../e2e/local-search/local-search.test.ts | 102 ++++---
__tests__/unit/node/markdown/markdown.test.ts | 10 +
.../eagerFrontmatterInterpolation.test.ts | 239 ++++++++++++++++
docs/en/guide/frontmatter.md | 2 +
src/node/markdown/markdown.ts | 19 ++
.../plugins/eagerFrontmatterInterpolation.ts | 261 ++++++++++++++++++
7 files changed, 598 insertions(+), 42 deletions(-)
create mode 100644 __tests__/e2e/local-search/frontmatter-title.md
create mode 100644 __tests__/unit/node/markdown/plugins/eagerFrontmatterInterpolation.test.ts
create mode 100644 src/node/markdown/plugins/eagerFrontmatterInterpolation.ts
diff --git a/__tests__/e2e/local-search/frontmatter-title.md b/__tests__/e2e/local-search/frontmatter-title.md
new file mode 100644
index 00000000..0000a15b
--- /dev/null
+++ b/__tests__/e2e/local-search/frontmatter-title.md
@@ -0,0 +1,7 @@
+---
+title: Frontmatter Title Resolved
+---
+
+# {{ $frontmatter.title }}
+
+This page uses a frontmatter title expression.
diff --git a/__tests__/e2e/local-search/local-search.test.ts b/__tests__/e2e/local-search/local-search.test.ts
index fe507e5b..8248b46a 100644
--- a/__tests__/e2e/local-search/local-search.test.ts
+++ b/__tests__/e2e/local-search/local-search.test.ts
@@ -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(
diff --git a/__tests__/unit/node/markdown/markdown.test.ts b/__tests__/unit/node/markdown/markdown.test.ts
index 5db12abd..af5d2fef 100644
--- a/__tests__/unit/node/markdown/markdown.test.ts
+++ b/__tests__/unit/node/markdown/markdown.test.ts
@@ -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('Hello
')
+
+ const disabled = await render(src, {
+ eagerFrontmatterInterpolation: false
+ })
+ expect(disabled).toContain('{{ $frontmatter.title }}
')
+ })
+
test('tasklist', async () => {
const src = '- [ ] todo'
expect(await render(src)).toContain(' code, ...options })
+}
+
+async function render(src: string, env: Record = {}) {
+ return (await createMd()).renderAsync(src, env)
+}
+
+const frontmatter = `\
+---
+title: Hello World
+count: 5
+flag: true
+nothing: null
+date: 2024-01-18
+html: '& {{ hi }} '
+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
+count: 2
+done: false
+---
+
+{{ $frontmatter.meta.title }} / {{$frontmatter.count}} / {{ $frontmatter.done }}
+`)
+ expect(html).toContain(' A <b>& B / 2 / false
')
+ })
+
+ test('resolves bracket paths and dates', async () => {
+ expect(await renderBody("{{ $frontmatter['k-y'] }}")).toBe('dashed
')
+ expect(await renderBody('{{ $frontmatter["k-y"] }}')).toBe('dashed
')
+ expect(await renderBody('{{ $frontmatter.list[1] }}')).toBe('b
')
+ expect(await renderBody('{{ $frontmatter.list.length }}')).toBe('2
')
+ // dates are normalized the same way the `__pageData` JSON round-trip
+ // normalizes them for the runtime
+ expect(await renderBody('{{ $frontmatter.date }}')).toBe(
+ '2024-01-18T00:00:00.000Z
'
+ )
+ })
+
+ test('escapes values so they render as this exact text', async () => {
+ expect(await renderBody('{{ $frontmatter.html }}')).toBe(
+ '<b>& {{ hi }}</b>
'
+ )
+ // a value containing mustaches must not be interpolated again by Vue
+ expect(await renderBody('{{ $frontmatter.mustache }}')).toBe(
+ '{{ x }}
'
+ )
+ expect(
+ await renderBody(
+ '© {{ $frontmatter.title }} / {{ $frontmatter.no }}'
+ )
+ ).toBe('© Hello World / {{ $frontmatter.no }}
')
+ })
+
+ 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(`${expression}
`)
+ }
+ })
+
+ test('skips code and v-pre', async () => {
+ const html = await render(`\
+---
+title: Hi
+---
+
+\`{{ $frontmatter.title }}\`
+
+\`\`\`js
+{{ $frontmatter.title }}
+\`\`\`
+
+::: v-pre
+{{ $frontmatter.title }}
+:::
+
+{{ $frontmatter.title }} {{ $frontmatter.title }}
+`)
+ expect(html.match(/\{\{ \$frontmatter\.title \}\}/g)).toHaveLength(4)
+ expect(html).toContain(' Hi
')
+ })
+
+ test('skips v-pre scopes from attrs', async () => {
+ const html = await renderBody(
+ '**{{ $frontmatter.title }}**{v-pre} {{ $frontmatter.title }}'
+ )
+ expect(html).toContain(
+ '{{ $frontmatter.title }} Hello World'
+ )
+ })
+
+ test('stops at v-pre html blocks', async () => {
+ const html = await renderBody(
+ '{{ $frontmatter.title }}\n\n\n\n{{ $frontmatter.title }}\n\n
'
+ )
+ // content before the block cannot be inside its scope
+ expect(html).toContain('Hello World
')
+ expect(html).toContain('{{ $frontmatter.title }}
')
+ })
+
+ test('feeds the resolved text to anchors and the page title', async () => {
+ const env: Record = {}
+ 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
+---
+
+
+`)
+ expect(html).toContain('src="/logo.png"')
+ })
+
+ test('leaves everything alone without frontmatter data', async () => {
+ expect((await render('{{ $frontmatter.title }}')).trim()).toBe(
+ '{{ $frontmatter.title }}
'
+ )
+ })
+
+ describe('equivalence with runtime interpolation', () => {
+ async function ssr(html: string, $frontmatter: unknown) {
+ const app = createSSRApp({ template: `${html}
` })
+ 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)
+ )
+ })
+ })
+})
diff --git a/docs/en/guide/frontmatter.md b/docs/en/guide/frontmatter.md
index dc205816..e8dd329a 100644
--- a/docs/en/guide/frontmatter.md
+++ b/docs/en/guide/frontmatter.md
@@ -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 `