From 914b81c54b16d9e4306f88982fe3857905ceeaf9 Mon Sep 17 00:00:00 2001
From: Divyansh Singh <40380293+brc-dd@users.noreply.github.com>
Date: Sun, 30 Aug 2026 20:37:23 +0530
Subject: [PATCH] feat: resolve `$frontmatter` expressions while rendering
markdown
Co-Authored-By: Claude Fable 5
---
.../e2e/local-search/frontmatter-title.md | 7 +
.../e2e/local-search/local-search.test.ts | 22 ++
__tests__/unit/node/markdown/markdown.test.ts | 8 +
.../plugins/frontmatterExpressions.test.ts | 239 ++++++++++++++++
docs/en/guide/frontmatter.md | 2 +
src/node/markdown/markdown.ts | 19 ++
.../plugins/frontmatterExpressions.ts | 256 ++++++++++++++++++
7 files changed, 553 insertions(+)
create mode 100644 __tests__/e2e/local-search/frontmatter-title.md
create mode 100644 __tests__/unit/node/markdown/plugins/frontmatterExpressions.test.ts
create mode 100644 src/node/markdown/plugins/frontmatterExpressions.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..463dd87a 100644
--- a/__tests__/e2e/local-search/local-search.test.ts
+++ b/__tests__/e2e/local-search/local-search.test.ts
@@ -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('custom tokenize function reaches the client', async () => {
await page.locator('.VPNavBarSearchButton').click()
diff --git a/__tests__/unit/node/markdown/markdown.test.ts b/__tests__/unit/node/markdown/markdown.test.ts
index 5db12abd..76063dac 100644
--- a/__tests__/unit/node/markdown/markdown.test.ts
+++ b/__tests__/unit/node/markdown/markdown.test.ts
@@ -42,6 +42,14 @@ describe('node/markdown/markdown', () => {
expect(await render(':tada:', { emoji: false })).toContain(':tada:')
})
+ test('frontmatterExpressions', async () => {
+ const src = '---\ntitle: Hello\n---\n\n{{ $frontmatter.title }}'
+ expect(await render(src)).toContain('Hello
')
+
+ const disabled = await render(src, { frontmatterExpressions: 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/frontmatterExpressions', () => {
+ 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({ frontmatterExpressions: 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 `