import {
createMarkdownRenderer,
disposeMdItInstance,
type MarkdownOptions
} from 'node/markdown/markdown'
import { escapeHtml } from 'node/shared'
// 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 = {}) {
return (await createMd()).renderAsync(src, env)
}
const frontmatter = `\
---
title: Hello World
count: 5
flag: true
nothing: null
date: 2024-01-18
html: 'bold '
mustache: '{{ x }}'
amp: 'a < b'
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, env: Record = {}) {
return (await render(frontmatter + body, env)).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 / 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 () => {
// a value containing mustaches must not be interpolated again by Vue
expect(await renderBody('{{ $frontmatter.mustache }}')).toBe(
'{{ x }}
'
)
// entity look-alikes must survive the template compiler's decoding
expect(await renderBody('{{ $frontmatter.amp }}')).toBe(
'a < b
'
)
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.html }}', // `<` could smuggle markup into titles
'{{ $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('tracks raw inline v-pre elements the way Vue parses them', async () => {
// a quoted attribute value may contain `>`
expect(
await renderBody(
'{{ $frontmatter.title }} '
)
).toContain('{{ $frontmatter.title }} ')
// tag names match case-insensitively, so the inner pair nests
expect(
await renderBody(
'z ab c {{ $frontmatter.title }} {{ $frontmatter.title }}'
)
).toContain('c {{ $frontmatter.title }} Hello World')
// a self-closing same-name tag does not affect the scope
expect(
await renderBody('a b {{ $frontmatter.title }}')
).toContain(' Hello World')
})
test('scopes v-pre in raw html blocks instead of bailing out', async () => {
// mentions of v-pre that open no scope leave the page alone
for (const block of [
'',
'',
'',
'{{ literal }}
'
]) {
const html = await renderBody(`${block}\n\n{{ $frontmatter.title }}`)
expect(html).toContain('Hello World
')
}
// a scope that spans markdown ends at its closing tag
const html = await renderBody(
'\n\n{{ $frontmatter.title }}\n\n
\n\n{{ $frontmatter.title }}'
)
expect(html).toContain('{{ $frontmatter.title }}
')
expect(html).toContain('Hello World
')
// an unclosed scope spans the rest of the page
expect(
await renderBody('\n\n{{ $frontmatter.title }}')
).not.toContain('Hello World')
})
test('leaves whitespace-sensitive spots inside raw inline elements', async () => {
// the runtime drops whitespace-only text nodes at element edges; an
// inlined value would merge with that whitespace and keep it
expect(
await renderBody('a
{{ $frontmatter.title }} b')
).toContain('
{{ $frontmatter.title }} ')
expect(
await renderBody('a
\n{{ $frontmatter.title }}\n b')
).toContain('{{ $frontmatter.title }}')
// non-whitespace neighbors and closing-tag adjacency are safe
expect(await renderBody('a
x {{ $frontmatter.title }} b')).toContain(
'
x Hello World '
)
expect(await renderBody('
x {{ $frontmatter.title }}')).toContain(
' Hello World'
)
})
test('keeps values safe when the text renderer rule is replaced', async () => {
const md = await createMd({
config: (md) => {
md.renderer.rules.text = (tokens, idx) =>
escapeHtml(tokens[idx].content)
}
})
const html = await md.renderAsync(
frontmatter + '{{ $frontmatter.mustache }} and {{ $frontmatter.title }}'
)
// the unsafe value renders through its own token, not the text rule
expect(html).toContain('{{ x }} and Hello World')
expect(html).not.toContain('{{ x }}')
})
test('keeps toc titles escaped like the heading', async () => {
const html = await renderBody(
'## {{ $frontmatter.mustache }} {{ $frontmatter.amp }}\n\n[[toc]]'
)
expect(html).toContain('{{ x }} a < b')
// no live interpolation may reach the toc markup, and the toc must show
// the same text as the heading
const toc = html.slice(html.indexOf('
{
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('records what was inlined on the env', async () => {
const env: Record = {}
await renderBody(
'{{ $frontmatter.title }} {{ $frontmatter.missing }} [x]({{$frontmatter.homepage}})',
env
)
expect(env.eagerInterpolations).toEqual([
{ expression: '$frontmatter.title', value: 'Hello World' },
{ expression: '$frontmatter.homepage', value: 'https://vitepress.dev/' }
])
})
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 destinations even when only encoded delimiters exist', async () => {
const html = await render(`\
---
count: 5
---
[v](https://vitepress.dev/%7B%7B$frontmatter.count%7D%7D)
`)
expect(html).toContain('href="https://vitepress.dev/5"')
})
test('resolves image sources', async () => {
const html = await render(`\
---
logo: /logo.png
---

`)
expect(html).toContain('src="/logo.png"')
})
test('resolves custom container titles', async () => {
const html = await renderBody(
'::: tip {{ $frontmatter.title }}\nbody {{ $frontmatter.count }}\n:::'
)
expect(html).toContain('Hello World
')
expect(html).toContain('body 5
')
})
test('leaves everything alone without frontmatter data', async () => {
expect((await render('{{ $frontmatter.title }}')).trim()).toBe(
'{{ $frontmatter.title }}
'
)
})
// entries passed via `env.frontmatter` must keep merging and inlining -
// a future `renderMd(src, env)` (#2410) relies on this
test('merges and inlines frontmatter provided via env', async () => {
// env entries only, no frontmatter block in the source
const env: Record = {
frontmatter: { intro: 'From Env', n: 42 }
}
expect(
(
await render('{{ $frontmatter.intro }} ({{ $frontmatter.n }})', env)
).trim()
).toBe('From Env (42)
')
// the page's own frontmatter wins on conflicts
const merged: Record = {
frontmatter: { title: 'From Env', extra: 'Extra' }
}
expect(
(
await render(
'---\ntitle: From Page\n---\n\n{{ $frontmatter.title }} / {{ $frontmatter.extra }}',
merged
)
).trim()
).toBe('From Page / Extra
')
expect(merged.frontmatter).toEqual({ title: 'From Page', extra: 'Extra' })
})
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)
}
async function compare(body: string) {
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, {})
// the runtime sees the frontmatter after the `__pageData` JSON
// round-trip
const runtimeData = JSON.parse(JSON.stringify(runtimeEnv.frontmatter))
expect(await ssr(resolvedHtml, runtimeData)).toBe(
await ssr(runtimeHtml, runtimeData)
)
return resolvedHtml
}
test('inlined values render exactly what the runtime would', async () => {
const resolvedHtml = await compare(
[
'Welcome to {{ $frontmatter.title }}!',
'{{ $frontmatter.mustache }}',
'{{ $frontmatter.amp }}',
'{{ $frontmatter.count }} / {{ $frontmatter.flag }}',
'{{ $frontmatter.date }}',
'a {{$frontmatter.title}} b' // whitespace condensing parity
].join('\n\n')
)
// and nothing was left for the runtime to do
expect(resolvedHtml).not.toContain('$frontmatter')
})
test('spots left to the runtime render identically too', async () => {
await compare(
[
'a {{ $frontmatter.title }} b',
'a\n{{ $frontmatter.title }}\n b',
'{{ $frontmatter.title }} ',
'{{ $frontmatter.html }}',
'{{ $frontmatter.spaced }}'
].join('\n\n')
)
})
})
})