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>&amp; {{ 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 +--- + +![logo]({{$frontmatter.logo}}) +`) + 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 `