(
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 }[]
}