mirror of https://github.com/vuejs/vitepress
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>pull/5412/head
parent
73bbaea028
commit
914b81c54b
@ -0,0 +1,7 @@
|
||||
---
|
||||
title: Frontmatter Title Resolved
|
||||
---
|
||||
|
||||
# {{ $frontmatter.title }}
|
||||
|
||||
This page uses a frontmatter title expression.
|
||||
@ -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>& {{ 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/frontmatterExpressions', () => {
|
||||
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 <b>& 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><b>&amp; {{ hi }}</b></p>'
|
||||
)
|
||||
// a value containing mustaches must not be interpolated again by Vue
|
||||
expect(await renderBody('{{ $frontmatter.mustache }}')).toBe(
|
||||
'<p>{{ x }}</p>'
|
||||
)
|
||||
expect(
|
||||
await renderBody(
|
||||
'© {{ $frontmatter.title }} / {{ $frontmatter.no }}'
|
||||
)
|
||||
).toBe('<p>© 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
|
||||
---
|
||||
|
||||

|
||||
`)
|
||||
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({ 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)
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,256 @@
|
||||
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 frontmatterExpressionsPlugin = (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_frontmatter_expressions', (state) =>
|
||||
frontmatterExpressions(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 frontmatterExpressions(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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/\{/g, '{')
|
||||
.replace(/\}/g, '}')
|
||||
}
|
||||
|
||||
function hasVPre(token: Token): boolean {
|
||||
return token.attrGet('v-pre') !== null
|
||||
}
|
||||
Loading…
Reference in new issue