diff --git a/__tests__/e2e/local-search/local-search.test.ts b/__tests__/e2e/local-search/local-search.test.ts index 8248b46a..4a21622c 100644 --- a/__tests__/e2e/local-search/local-search.test.ts +++ b/__tests__/e2e/local-search/local-search.test.ts @@ -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' diff --git a/__tests__/unit/node/contentLoader.test.ts b/__tests__/unit/node/contentLoader.test.ts index 9d0ce2ac..387a58c8 100644 --- a/__tests__/unit/node/contentLoader.test.ts +++ b/__tests__/unit/node/contentLoader.test.ts @@ -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.') + }) }) diff --git a/__tests__/unit/node/markdown/plugins/eagerFrontmatterInterpolation.test.ts b/__tests__/unit/node/markdown/plugins/eagerFrontmatterInterpolation.test.ts index e38885f5..afdf0eb0 100644 --- a/__tests__/unit/node/markdown/plugins/eagerFrontmatterInterpolation.test.ts +++ b/__tests__/unit/node/markdown/plugins/eagerFrontmatterInterpolation.test.ts @@ -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: '& {{ hi }}' +html: 'bold' mustache: '{{ x }}' +amp: 'a < 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 = {}) { + 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 + title: A & B count: 2 done: false --- {{ $frontmatter.meta.title }} / {{$frontmatter.count}} / {{ $frontmatter.done }} `) - expect(html).toContain('

A <b>& B / 2 / false

') + expect(html).toContain('

A & B / 2 / false

') }) 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( - '

<b>&amp; {{ hi }}</b>

' - ) // 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 &lt; b

' + ) expect( await renderBody( '© {{ $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 () => { + 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 abc {{ $frontmatter.title }} {{ $frontmatter.title }}' + ) + ).toContain('c {{ $frontmatter.title }} Hello World') + // a self-closing same-name tag does not affect the scope + expect( + await renderBody('ab {{ $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( - '{{ $frontmatter.title }}\n\n
\n\n{{ $frontmatter.title }}\n\n
' + '
\n\n{{ $frontmatter.title }}\n\n
\n\n{{ $frontmatter.title }}' ) - // content before the block cannot be inside its scope - expect(html).toContain('

Hello World

') 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 }}\nb') + ).toContain('{{ $frontmatter.title }}') + // non-whitespace neighbors and closing-tag adjacency are safe + expect(await renderBody('ax {{ $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 &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(' { @@ -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 = {} + 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('

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}
` }) @@ -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 {{ $frontmatter.title }} b', + 'a\n{{ $frontmatter.title }}\nb', + '{{ $frontmatter.title }}', + '{{ $frontmatter.html }}', + '{{ $frontmatter.spaced }}' + ].join('\n\n') + ) }) }) }) diff --git a/__tests__/unit/node/markdown/plugins/include.test.ts b/__tests__/unit/node/markdown/plugins/include.test.ts index 3053459b..0a8aa757 100644 --- a/__tests__/unit/node/markdown/plugins/include.test.ts +++ b/__tests__/unit/node/markdown/plugins/include.test.ts @@ -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', + {}, + { + 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"') + }) }) diff --git a/__tests__/unit/node/markdownToVue.test.ts b/__tests__/unit/node/markdownToVue.test.ts index dd0d23e8..f0f12bb1 100644 --- a/__tests__/unit/node/markdownToVue.test.ts +++ b/__tests__/unit/node/markdownToVue.test.ts @@ -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) + }) }) diff --git a/src/node/contentLoader.ts b/src/node/contentLoader.ts index 937aa718..b9f9d3e6 100644 --- a/src/node/contentLoader.ts +++ b/src/node/contentLoader.ts @@ -150,7 +150,10 @@ export function createContentLoader( 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 diff --git a/src/node/markdown/markdown.ts b/src/node/markdown/markdown.ts index 45d84171..eeb90ced 100644 --- a/src/node/markdown/markdown.ts +++ b/src/node/markdown/markdown.ts @@ -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) { diff --git a/src/node/markdown/plugins/containers.ts b/src/node/markdown/plugins/containers.ts index 690890ba..88c7c87b 100644 --- a/src/node/markdown/plugins/containers.ts +++ b/src/node/markdown/plugins/containers.ts @@ -138,7 +138,7 @@ function createOpenRender( if (noTitle) return `
\n` const title = md.renderInline( info || titlesFor(titles, env.localeIndex)[name], - { references: env.references } + { references: env.references, frontmatter: env.frontmatter } ) if (name === 'details') return `
${title}\n` diff --git a/src/node/markdown/plugins/eagerFrontmatterInterpolation.ts b/src/node/markdown/plugins/eagerFrontmatterInterpolation.ts index 79ac04db..a0be946e 100644 --- a/src/node/markdown/plugins/eagerFrontmatterInterpolation.ts +++ b/src/node/markdown/plugins/eagerFrontmatterInterpolation.ts @@ -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`, // `[]`, `['']` or `[""]` 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. ``, 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[] { + 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): 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. + // ` {{ x }} ` 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 `&` +// double-encoding (the toc `format` hook) leave it alone. function escapeValue(value: string): string { return value - .replace(/&/g, '&') + .replace(/&/g, '&') .replace(//g, '>') .replace(/\{/g, '{') diff --git a/src/node/markdown/plugins/include.ts b/src/node/markdown/plugins/include.ts index 0e4ee896..ec258e3b 100644 --- a/src/node/markdown/plugins/include.ts +++ b/src/node/markdown/plugins/include.ts @@ -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) ) diff --git a/src/node/markdownToVue.ts b/src/node/markdownToVue.ts index 22e5cb4b..661af795 100644 --- a/src/node/markdownToVue.ts +++ b/src/node/markdownToVue.ts @@ -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 + ) + 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) ?? [], diff --git a/types/shared.d.ts b/types/shared.d.ts index ff82c58a..a35663f9 100644 --- a/types/shared.d.ts +++ b/types/shared.d.ts @@ -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 }[] }