fix: harden eager frontmatter interpolation (#5413)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
pull/5416/head
Divyansh Singh 2 weeks ago committed by GitHub
parent a1eb28496e
commit 09f9672ee1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -87,6 +87,19 @@ describe('local search', () => {
).toBe(0)
})
test('typing replaces the persisted query', async () => {
await searchFor('lorem')
await waitForSearchResults({ minCount: 2 })
await page.keyboard.press('Escape')
// reopening restores the persisted query pre-selected, so keystrokes
// must replace it instead of appending to it
const input = await openSearch()
await input.type('Frontmatter Title Resolved')
await waitForSearchResults({ text: 'Frontmatter Title Resolved' })
expect(await input.inputValue()).toBe('Frontmatter Title Resolved')
})
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'

@ -49,4 +49,19 @@ describe('node/contentLoader', () => {
expect(data[0].html).toContain('href="./other"')
expect(data[0].html).not.toContain('./other.html')
})
test('excerpts resolve $frontmatter without render', async () => {
await setup(false)
const { writeFile } = await import('node:fs/promises')
await writeFile(
path.join(root!, 'post.md'),
'---\ntitle: My Post\n---\n\nIntro says {{ $frontmatter.title }}.\n\n---\n\nBody.\n'
)
const data = await createContentLoader('post.md', {
excerpt: true
}).load()
expect(data[0].excerpt).toContain('Intro says My Post.')
})
})

@ -3,6 +3,7 @@ import {
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'
@ -24,8 +25,9 @@ count: 5
flag: true
nothing: null
date: 2024-01-18
html: '<b>&amp; {{ hi }}</b>'
html: '<b>bold</b>'
mustache: '{{ x }}'
amp: 'a &lt; b'
k-y: dashed
spaced: 'a b'
multiline: |
@ -41,8 +43,8 @@ list:
`
async function renderBody(body: string) {
return (await render(frontmatter + body)).trim()
async function renderBody(body: string, env: Record<string, any> = {}) {
return (await render(frontmatter + body, env)).trim()
}
describe('node/markdown/plugins/eagerFrontmatterInterpolation', () => {
@ -50,14 +52,14 @@ describe('node/markdown/plugins/eagerFrontmatterInterpolation', () => {
const html = await render(`\
---
meta:
title: A <b>& B
title: A & B
count: 2
done: false
---
{{ $frontmatter.meta.title }} / {{$frontmatter.count}} / {{ $frontmatter.done }}
`)
expect(html).toContain('<p>A &lt;b&gt;&amp; B / 2 / false</p>')
expect(html).toContain('<p>A &#38; B / 2 / false</p>')
})
test('resolves bracket paths and dates', async () => {
@ -73,13 +75,14 @@ done: false
})
test('escapes values so they render as this exact text', async () => {
expect(await renderBody('{{ $frontmatter.html }}')).toBe(
'<p>&lt;b&gt;&amp;amp; &#123;&#123; hi &#125;&#125;&lt;/b&gt;</p>'
)
// a value containing mustaches must not be interpolated again by Vue
expect(await renderBody('{{ $frontmatter.mustache }}')).toBe(
'<p>&#123;&#123; x &#125;&#125;</p>'
)
// entity look-alikes must survive the template compiler's decoding
expect(await renderBody('{{ $frontmatter.amp }}')).toBe(
'<p>a &#38;lt; b</p>'
)
expect(
await renderBody(
'&copy; {{ $frontmatter.title }} / {{ $frontmatter.no }}'
@ -96,6 +99,7 @@ done: false
'{{ $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] }}',
@ -142,13 +146,94 @@ title: Hi
)
})
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>'
test('tracks raw inline v-pre elements the way Vue parses them', async () => {
// a quoted attribute value may contain `>`
expect(
await renderBody(
'<span title="a>b" v-pre>{{ $frontmatter.title }}</span>'
)
// content before the block cannot be inside its scope
).toContain('<span title="a>b" v-pre>{{ $frontmatter.title }}</span>')
// tag names match case-insensitively, so the inner pair nests
expect(
await renderBody(
'z <span v-pre>a<SPAN>b</span>c {{ $frontmatter.title }}</SPAN> {{ $frontmatter.title }}'
)
).toContain('c {{ $frontmatter.title }}</SPAN> Hello World')
// a self-closing same-name tag does not affect the scope
expect(
await renderBody('<span v-pre>a<span/>b</span> {{ $frontmatter.title }}')
).toContain('</span> 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 [
'<style>\n.v-pre { color: red }\n</style>',
'<script setup>\nconst a = "v-pre"\n</script>',
'<!-- see v-pre -->',
'<div v-pre>{{ literal }}</div>'
]) {
const html = await renderBody(`${block}\n\n{{ $frontmatter.title }}`)
expect(html).toContain('<p>Hello World</p>')
}
// a scope that spans markdown ends at its closing tag
const html = await renderBody(
'<div v-pre>\n\n{{ $frontmatter.title }}\n\n</div>\n\n{{ $frontmatter.title }}'
)
expect(html).toContain('<p>{{ $frontmatter.title }}</p>')
expect(html).toContain('<p>Hello World</p>')
// an unclosed scope spans the rest of the page
expect(
await renderBody('<div v-pre>\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<code> {{ $frontmatter.title }} </code>b')
).toContain('<code> {{ $frontmatter.title }} </code>')
expect(
await renderBody('a<em>\n{{ $frontmatter.title }}\n</em>b')
).toContain('{{ $frontmatter.title }}')
// non-whitespace neighbors and closing-tag adjacency are safe
expect(await renderBody('a<em>x {{ $frontmatter.title }}</em>b')).toContain(
'<em>x Hello World</em>'
)
expect(await renderBody('<em>x</em> {{ $frontmatter.title }}')).toContain(
'</em> 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('&#123;&#123; x &#125;&#125; 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('&#123;&#123; x &#125;&#125; a &#38;lt; 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('<nav'))
expect(toc).not.toContain('{{ x }}')
expect(toc).toContain('&#123;&#123; x &#125;&#125;')
expect(toc).toContain('a &#38;lt; b')
})
test('feeds the resolved text to anchors and the page title', async () => {
@ -167,6 +252,18 @@ title: Hello World
expect(env.title).toBe('Hello World')
})
test('records what was inlined on the env', async () => {
const env: Record<string, any> = {}
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(
[
@ -182,6 +279,17 @@ title: Hello World
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(`\
---
@ -193,12 +301,48 @@ 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('<p class="custom-block-title">Hello World</p>')
expect(html).toContain('<p>body 5</p>')
})
test('leaves everything alone without frontmatter data', async () => {
expect((await render('{{ $frontmatter.title }}')).trim()).toBe(
'<p>{{ $frontmatter.title }}</p>'
)
})
// 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<string, any> = {
frontmatter: { intro: 'From Env', n: 42 }
}
expect(
(
await render('{{ $frontmatter.intro }} ({{ $frontmatter.n }})', env)
).trim()
).toBe('<p>From Env (42)</p>')
// the page's own frontmatter wins on conflicts
const merged: Record<string, any> = {
frontmatter: { title: 'From Env', extra: 'Extra' }
}
expect(
(
await render(
'---\ntitle: From Page\n---\n\n{{ $frontmatter.title }} / {{ $frontmatter.extra }}',
merged
)
).trim()
).toBe('<p>From Page / Extra</p>')
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: `<div>${html}</div>` })
@ -207,16 +351,7 @@ logo: /logo.png
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')
async function compare(body: string) {
const runtimeEnv: any = {}
const runtimeMd = await createMd({ eagerFrontmatterInterpolation: false })
const runtimeHtml = await runtimeMd.renderAsync(
@ -226,14 +361,41 @@ logo: /logo.png
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
// round-trip
const runtimeData = JSON.parse(JSON.stringify(runtimeEnv.frontmatter))
expect(await ssr(resolvedHtml, { poisoned: true })).toBe(
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<code> {{ $frontmatter.title }} </code>b',
'a<em>\n{{ $frontmatter.title }}\n</em>b',
'<span title="a>b" v-pre>{{ $frontmatter.title }}</span>',
'{{ $frontmatter.html }}',
'{{ $frontmatter.spaced }}'
].join('\n\n')
)
})
})
})

@ -532,4 +532,26 @@ describe('node/markdown/plugins/include', () => {
expect(html).toContain('href="https://example.com/x"')
expect(html).toContain('href="/abs/target.html"')
})
test('does not rebase destinations resolved from frontmatter', async () => {
await write(
'guide/shared/note.md',
'![logo]({{$frontmatter.logo}}) [x]({{$frontmatter.doc}}) ![lit](./local.png)'
)
const { html, env } = await render(
'---\nlogo: ./assets/a.png\ndoc: ./other.md\n---\n\n<!-- @include: ./shared/note.md -->',
{},
{
path: path.join(root, 'guide/index.md'),
relativePath: 'guide/index.md'
}
)
// values from the including page's frontmatter keep meaning what they
// meant there
expect(html).toContain('src="./assets/a.png"')
expect(html).toContain('href="./other.html"')
expect(env.links).toContain('./other')
// urls authored in the included file still rebase
expect(html).toContain('src="./shared/local.png"')
})
})

@ -3,6 +3,7 @@ import { tmpdir } from 'node:os'
import path from 'node:path'
import { resolveConfig } from 'node/config'
import { disposeMdItInstance } from 'node/markdown/markdown'
import { createMarkdownToVueRenderFn } from 'node/markdownToVue'
describe('node/markdownToVue', () => {
@ -153,4 +154,47 @@ describe('node/markdownToVue', () => {
expect(result.pageData.relativePath).toBe('index.md')
})
test('warns when transformPageData rewrites an interpolated value', async () => {
disposeMdItInstance()
root = await mkdtemp(path.join(tmpdir(), 'vitepress-eager-'))
const file = path.join(root, 'index.md')
const src = '---\ntitle: Old\n---\n\n# {{ $frontmatter.title }}\n'
await writeFile(file, src)
const siteConfig = await resolveConfig(root, 'build', 'production')
const warnings: string[] = []
siteConfig.logger = {
...siteConfig.logger,
warn: (msg: string) => warnings.push(msg)
}
siteConfig.transformPageData = (pageData) => {
pageData.frontmatter.title = 'New'
}
const render = await createMarkdownToVueRenderFn(
siteConfig.srcDir,
{ cache: false },
'/',
false,
false,
siteConfig
)
const result = await render(src, file)
expect(result.vueSrc).toContain('Old')
expect(warnings.join('\n')).toContain('{{ $frontmatter.title }}')
// keys only added by the transform are left to the runtime - no warning
warnings.length = 0
siteConfig.transformPageData = (pageData) => {
pageData.frontmatter.added = 'later'
}
const src2 =
'---\ntitle: Old\n---\n\n{{ $frontmatter.title }} {{ $frontmatter.added }}\n'
await writeFile(file, src2)
await render(src2, file)
expect(warnings).toHaveLength(0)
})
})

@ -150,7 +150,10 @@ export function createContentLoader<T = ContentData[]>(
path: file,
relativePath,
cleanUrls: !!config.cleanUrls,
realPath: file
realPath: file,
// excerpts are rendered on their own, without the frontmatter
// block - provide the data so `$frontmatter` still resolves
frontmatter
}
const html = options.render

@ -336,7 +336,10 @@ export interface MarkdownOptions extends MarkdownItAsyncOptions {
* to simple primitive values in the page's own frontmatter are inlined -
* anything else (complex expressions, missing keys, non-primitive values,
* `v-pre` scopes) keeps its runtime interpolation. Set to `false` to leave
* all interpolation to the Vue runtime.
* all interpolation to the Vue runtime - for example when
* `transformPageData` rewrites frontmatter values that pages interpolate,
* which would otherwise render the pre-transform value (a warning is
* logged when that happens).
*
* @experimental
* @default true
@ -553,9 +556,6 @@ export async function createMarkdownRenderer(
// https://github.com/jonschlinkert/gray-matter/blob/310f9349381775d10a221cef903989eb5acc8843/index.js#L44-L47
;(options.frontmatter ??= {}).grayMatterOptions ??= {}
frontmatterPlugin(md, options.frontmatter)
if (options.eagerFrontmatterInterpolation !== false) {
eagerFrontmatterInterpolationPlugin(md)
}
if (options.headers) {
headersPlugin(md, {
level: [2, 3, 4, 5, 6],
@ -576,6 +576,12 @@ export async function createMarkdownRenderer(
}
})
}
// applied after anchor/title so its finalize rule runs once their rules
// have extracted the plain resolved text; its main rule is anchored right
// after `text_join` regardless of when the plugin is applied
if (options.eagerFrontmatterInterpolation !== false) {
eagerFrontmatterInterpolationPlugin(md)
}
// apply user config
if (options.config) {

@ -138,7 +138,7 @@ function createOpenRender(
if (noTitle) return `<div ${renderedAttrs}>\n`
const title = md.renderInline(
info || titlesFor(titles, env.localeIndex)[name],
{ references: env.references }
{ references: env.references, frontmatter: env.frontmatter }
)
if (name === 'details')
return `<details ${renderedAttrs}><summary>${title}</summary>\n`

@ -12,6 +12,9 @@ const interpolationRE = /\{\{([^]+?)\}\}/g
// destinations while tokenizing, so the delimiters may appear encoded
const destInterpolationRE = /(?:\{\{|%7B%7B)([^]*?)(?:\}\}|%7D%7D)/gi
// page-level gate for either spelling
const anyInterpolationRE = /\{\{|%7B%7B/i
// 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
@ -21,23 +24,43 @@ const pathRE =
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/
// one raw html tag: closing slash, name, attributes (a quoted value may
// contain `>`), self-closing slash
const htmlTagRE = /<(\/?)([A-Za-z][\w-]*)((?:[^"'>]|"[^"]*"|'[^']*')*?)(\/?)>/g
const htmlCommentRE = /<!--[^]*?-->/g
// script/style/textarea/title content is raw text, not markup
const rawTextElementRE = /<(script|style|textarea|title)\b[^]*?<\/\1\s*>/gi
const vPreAttrRE = /(?:^|\s)v-pre(?=[\s=/]|$)/
// void elements never take a closing tag, so v-pre on them opens no scope
const voidTagRE =
/^(?:area|base|br|col|embed|hr|img|input|link|meta|param|source|track|wbr)$/
type Resolve = (expr: string) => string | undefined
interface EagerInterpolation {
expression: string
value: string
}
interface VPreScope {
tag: string
depth: number
}
export const eagerFrontmatterInterpolationPlugin = (md: MarkdownItAsync) => {
// before the rules other plugins push (anchor, toc, ...), so slugs and
// extracted titles are derived from the resolved text
// the main rule is anchored right after `text_join`, before the rules
// other plugins push (anchor, title, ...), so slugs and extracted titles
// are derived from the resolved text
md.core.ruler.after(
'text_join',
'vp_eager_frontmatter_interpolation',
(state) => eagerFrontmatterInterpolation(md, state)
eagerFrontmatterInterpolation
)
// the finalize rule is pushed when this plugin is applied - which must be
// after anchor/title so it runs once their rules have read the plain text
md.core.ruler.push('vp_eager_frontmatter_finalize', finalize)
// resolved values render with their own escaping (see `escapeValue`);
// everything else keeps the existing text rule
const textRule = md.renderer.rules.text!
@ -47,20 +70,84 @@ export const eagerFrontmatterInterpolationPlugin = (md: MarkdownItAsync) => {
: textRule(tokens, idx, options, env, self)
}
function eagerFrontmatterInterpolation(
md: MarkdownItAsync,
state: StateCore
): void {
export function findStaleEagerInterpolations(
interpolations: EagerInterpolation[],
frontmatter: Record<string, unknown>
): string[] {
const resolve = createResolver(frontmatter)
const stale = interpolations
.filter(({ expression, value }) => resolve(expression) !== value)
.map(({ expression }) => expression)
return [...new Set(stale)]
}
function eagerFrontmatterInterpolation(state: StateCore): void {
const { frontmatter } = state.env as MarkdownEnv
if (!frontmatter || !state.src.includes('{{')) return
if (!frontmatter || !anyInterpolationRE.test(state.src)) return
const resolve = createResolver(frontmatter)
// an unclosed v-pre element opened by a raw html block scopes over the
// markdown after it until later raw html closes it
let blockScope: VPreScope | undefined
let skipLevel: number | null = null
for (const token of state.tokens) {
if (token.type === 'html_block') {
blockScope = scanRawHtml(token.content, blockScope)
continue
}
if (skipLevel !== null) {
if (token.nesting === -1 && token.level === skipLevel) skipLevel = null
continue
}
if (
token.nesting === 1 &&
(token.type === 'container_v-pre_open' || hasVPre(token))
) {
skipLevel = token.level
continue
}
if (token.type !== 'inline' || !token.children) continue
if (blockScope) {
// a stray closing tag inside a paragraph still ends the scope, but the
// paragraph itself stays with the runtime
for (const child of token.children) {
if (child.type === 'html_inline')
blockScope = scanRawHtml(child.content, blockScope)
}
continue
}
processInline(state, token, resolve)
}
}
// after anchor ids and the page title have been extracted from the plain
// text, retype values the shared `text` renderer rule - which user config
// may replace - could not safely emit: braces would compile as
// interpolations and entity look-alikes would decode. `html_inline` renders
// its content verbatim, so these carry their own escaping.
const unsafeAsTextRE = /[{}]|&[\w#]+;/
function finalize(state: StateCore): void {
if (!(state.env as MarkdownEnv).eagerInterpolations?.length) return
for (const token of state.tokens) {
if (token.type !== 'inline' || !token.children) continue
for (const child of token.children) {
if (child.meta?.frontmatterValue && unsafeAsTextRE.test(child.content)) {
child.type = 'html_inline'
child.content = escapeValue(child.content)
}
}
}
}
function createResolver(frontmatter: Record<string, unknown>): Resolve {
// 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) => {
return (rawExpr) => {
const expr = rawExpr.trim()
if (!expr.startsWith('$frontmatter')) return undefined
const path = expr.slice('$frontmatter'.length)
@ -91,29 +178,40 @@ function eagerFrontmatterInterpolation(
}
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
// scans a chunk of raw html, entering and leaving `v-pre` element scopes the
// way Vue's parser would: quoted attribute values may contain `>`, tag names
// match case-insensitively, self-closing and void tags open no scope, and
// comments and raw-text elements (script/style/...) are not markup
function scanRawHtml(
html: string,
scope: VPreScope | undefined
): VPreScope | undefined {
const src = html.replace(htmlCommentRE, '').replace(rawTextElementRE, '')
htmlTagRE.lastIndex = 0
let m: RegExpExecArray | null
while ((m = htmlTagRE.exec(src))) {
const [, closing, rawTag, attrs, selfClosing] = m
const tag = rawTag.toLowerCase()
if (scope) {
if (tag === scope.tag && !selfClosing) {
scope.depth += closing ? -1 : 1
if (!scope.depth) scope = undefined
}
} else if (
token.nesting === 1 &&
(token.type === 'container_v-pre_open' || hasVPre(token))
!closing &&
!selfClosing &&
!voidTagRE.test(tag) &&
vPreAttrRE.test(attrs)
) {
skipLevel = token.level
} else if (token.type === 'inline' && token.children) {
processInline(md, state, token, resolve)
scope = { tag, depth: 1 }
}
}
return scope
}
function processInline(
md: MarkdownItAsync,
state: StateCore,
inline: Token,
resolve: Resolve
@ -122,35 +220,30 @@ function processInline(
let out: Token[] | undefined
let skipLevel: number | null = null
let preTag: string | undefined
let preDepth = 0
// scopes opened by raw inline tags end with the paragraph - Vue closes
// unclosed inline elements at the enclosing block's end tag
let scope: VPreScope | undefined
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) {
if (child.type === 'html_inline') {
scope = scanRawHtml(child.content, scope)
} else if (scope) {
// inside a raw v-pre element - leave everything to the runtime
} else if (skipLevel !== null) {
if (child.nesting === -1 && child.level === skipLevel) skipLevel = null
} else if (child.nesting === 1 && hasVPre(child)) {
skipLevel = child.level
} else if (child.type === 'text' && child.content.includes('{{')) {
replacement = replaceInterpolations(state, child, resolve)
replacement = replaceInterpolations(state, children, i, resolve)
} else if (child.type === 'link_open' || child.type === 'image') {
resolveDest(md, child, child.type === 'image' ? 'src' : 'href', resolve)
resolveDest(
state,
child,
child.type === 'image' ? 'src' : 'href',
resolve
)
}
if (replacement && !out) out = children.slice(0, i)
if (out) out.push(...(replacement ?? [child]))
@ -158,22 +251,67 @@ function processInline(
if (out) inline.children = out
}
// how the neighbor beyond any whitespace looks from a text token: entering a
// raw inline element, leaving one, or neither
function rawTagBoundary(
children: Token[],
i: number,
dir: -1 | 1
): { kind: 'open' | 'close' | null; sawBreak: boolean } {
let sawBreak = false
for (let j = i + dir; j >= 0 && j < children.length; j += dir) {
const t = children[j]
if (t.type === 'softbreak') {
sawBreak = true
continue
}
if (t.type !== 'html_inline') break
htmlTagRE.lastIndex = 0
const m = htmlTagRE.exec(t.content)
if (!m) break
const [, closing, tag, , selfClosing] = m
if (selfClosing || voidTagRE.test(tag.toLowerCase())) break
return { kind: closing ? 'close' : 'open', sawBreak }
}
return { kind: null, sawBreak }
}
// 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,
children: Token[],
index: number,
resolve: Resolve
): Token[] | undefined {
const token = children[index]
const src = token.content
const left = rawTagBoundary(children, index, -1)
const right = rawTagBoundary(children, index, 1)
let out: Token[] | undefined
let lastIndex = 0
for (const m of src.matchAll(interpolationRE)) {
const value = resolve(m[1])
if (value === undefined) continue
// an inlined value merges with adjacent whitespace into one text node,
// which Vue's whitespace condensing keeps - while the runtime's
// whitespace-only text nodes at raw inline element edges are removed.
// `<code> {{ x }} </code>` must stay with the runtime to render the same.
const before = src.slice(0, m.index)
const after = src.slice(m.index + m[0].length)
if (
(left.kind === 'open' &&
!before.trim() &&
(before !== '' || left.sawBreak)) ||
(right.kind === 'close' &&
!after.trim() &&
(after !== '' || right.sawBreak))
) {
continue
}
out ??= []
if (m.index > lastIndex) {
out.push(textToken(state, token, src.slice(lastIndex, m.index)))
@ -181,6 +319,7 @@ function replaceInterpolations(
const valueToken = textToken(state, token, value)
valueToken.meta = { frontmatterValue: true }
out.push(valueToken)
record(state, m[1], value)
lastIndex = m.index + m[0].length
}
@ -195,7 +334,7 @@ function replaceInterpolations(
// destination goes through the same normalization and validation a literal
// one would
function resolveDest(
md: MarkdownItAsync,
state: StateCore,
token: Token,
attr: string,
resolve: Resolve
@ -203,21 +342,34 @@ function resolveDest(
const url = token.attrGet(attr)
if (!url) return
let changed = false
const resolved = url.replace(destInterpolationRE, (match, rawExpr) => {
const resolved: EagerInterpolation[] = []
const next = 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
resolved.push({ expression: expr, value })
return value
})
if (!changed) return
if (!resolved.length) return
const normalized = md.normalizeLink(resolved)
if (md.validateLink(normalized)) token.attrSet(attr, normalized)
const normalized = state.md.normalizeLink(next)
if (!state.md.validateLink(normalized)) return
token.attrSet(attr, normalized)
// the value belongs to the page whose frontmatter it came from - the
// include plugin must not rebase it against an included file's directory
token.meta = { ...token.meta, frontmatterDest: true }
for (const r of resolved) record(state, r.expression, r.value)
}
function record(state: StateCore, expression: string, value: string): void {
const env = state.env as MarkdownEnv
;(env.eagerInterpolations ??= []).push({
expression: expression.trim(),
value
})
}
function textToken(state: StateCore, from: Token, content: string): Token {
@ -234,22 +386,26 @@ 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
// compiler's whitespace condensing and cannot smuggle markup into extracted
// titles and headers. 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
return text === '' || text.includes('<') || 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
// this text: `{` must never reach the compiler as a possible interpolation
// start, and entity look-alikes must survive the compiler's decoding. `&` is
// encoded numerically so downstream passes that undo `&amp;`
// double-encoding (the toc `format` hook) leave it alone.
function escapeValue(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/&/g, '&#38;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\{/g, '&#123;')

@ -269,7 +269,9 @@ function registerRebaseRules(md: MarkdownItAsync) {
const token = tokens[idx]
const attr = rule === 'image' ? 'src' : 'href'
const url = token.attrGet(attr)
if (url?.[0] === '.') {
// a destination resolved from `$frontmatter` belongs to the page the
// frontmatter came from, not to the included file
if (url?.[0] === '.' && !token.meta?.frontmatterDest) {
const rebased = slash(
path.join(path.relative(path.dirname(file), dir), url)
)

@ -13,6 +13,7 @@ import {
type MarkdownOptions,
type MarkdownRenderer
} from './markdown/markdown'
import { findStaleEagerInterpolations } from './markdown/plugins/eagerFrontmatterInterpolation'
import { getPageDataTransformer } from './plugins/dynamicRoutesPlugin'
import {
EXTERNAL_URL_RE,
@ -284,6 +285,24 @@ export async function createMarkdownToVueRenderFn(
}
}
// interpolations were inlined from the frontmatter as rendered - values
// rewritten by `transformPageData` afterwards would silently diverge
if (transformPageData.length && env.eagerInterpolations?.length) {
const stale = findStaleEagerInterpolations(
env.eagerInterpolations,
(pageData.frontmatter ?? {}) as Record<string, unknown>
)
if (stale.length) {
siteConfig?.logger?.warn(
`${relativePath}: ${stale.map((e) => `{{ ${e} }}`).join(', ')} ` +
`resolved while rendering markdown, but transformPageData changed ` +
`the underlying frontmatter afterwards - the rendered content ` +
`keeps the old value. Avoid rewriting interpolated keys, or set ` +
`markdown.eagerFrontmatterInterpolation: false.`
)
}
}
const vueSrc = [
...injectPageDataCode(
sfcBlocks?.scripts.map((item) => item.content) ?? [],

7
types/shared.d.ts vendored

@ -616,4 +616,11 @@ export interface MarkdownEnv {
* The key of the locale the page belongs to.
*/
localeIndex?: string
/**
* The expressions inlined by eager frontmatter interpolation while
* rendering, with the value each resolved to - used to detect values that
* `transformPageData` changes after the fact.
* @internal
*/
eagerInterpolations?: { expression: string; value: string }[]
}

Loading…
Cancel
Save