From 6b2d7a25905dc550da4d924c11071de7019f93e0 Mon Sep 17 00:00:00 2001 From: Mostafa Sadeghi <205455727+mostafasadeghidev@users.noreply.github.com> Date: Mon, 3 Aug 2026 05:50:53 +0200 Subject: [PATCH] fix(publish): interpolate entry tokens in page meta title and description {currentEntry.*} / {page.*} / {site.*} tokens in site metaTitle, metaDescription, and the page-title fallback were published verbatim (and pre-escape, effectively static), so every CMS entry route rendered the template page's static instead of a per-entry SEO title. buildDocumentMetaTags now receives the composed TemplateRenderDataContext (the same frames dynamic text bindings resolve against) and runs the title + description through interpolateTokens before escapeHtml. Entry routes resolve {currentEntry.name} from the entryStack seeded by renderPublishedDataRowTemplate; plain pages resolve entry tokens to '' (or the token's own |fallback) with no leaked placeholder syntax, and token-free strings are untouched via the containsTokens fast path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- docs/features/publisher.md | 4 +- docs/features/templates.md | 2 + src/__tests__/publisher/render.test.ts | 70 ++++++++++++++++++++++++++ src/core/publisher/render.ts | 30 +++++++++-- 4 files changed, 99 insertions(+), 7 deletions(-) diff --git a/docs/features/publisher.md b/docs/features/publisher.md index c87e8eb12..a8b32f0dd 100644 --- a/docs/features/publisher.md +++ b/docs/features/publisher.md @@ -327,8 +327,8 @@ The publisher emits `<head>` in this order: 1. `<meta charset="utf-8">` 2. `<meta name="viewport" content="width=device-width, initial-scale=1">` -3. `<title>` from `page.title` -4. `<meta name="description">` if present in page settings +3. `<title>` — `site.settings.metaTitle` → `page.title` → `site.name`, token-interpolated against the render context before escaping, so `{currentEntry.*}` resolves per-entry on entry routes (e.g. `{currentEntry.name} | Acme`) and `{page.*}` / `{site.*}` / `{route.*}` work everywhere +4. `<meta name="description">` if `site.settings.metaDescription` is set — same token interpolation 5. `<link rel="icon">` if a favicon is configured 6. `<script type="importmap">` mapping bare specifiers (e.g. `three`) to `/_instatic/runtime/cache/<hash>/...` URLs 7. Runtime asset `<script>` tags (`scriptTagsForRuntimeAssets`) diff --git a/docs/features/templates.md b/docs/features/templates.md index f65f6c3b4..b339aa53b 100644 --- a/docs/features/templates.md +++ b/docs/features/templates.md @@ -230,6 +230,8 @@ Text props mix literal text + tokens: `parseTokenString(input)` returns `TokenSegmentNode[]`; `interpolateTokens(input, ctx)` evaluates and concatenates. Tokens that resolve to `undefined` render as the empty string. +Interpolation applies to string-typed props during the tree walk **and** to the document `<title>` + `<meta name="description">` (`buildDocumentMetaTags` in `src/core/publisher/render.ts`), so a site-wide `metaTitle` like `{currentEntry.name} | Acme` publishes per-entry SEO titles on entry routes. + Source: `src/core/templates/tokenInterpolation.ts`. --- diff --git a/src/__tests__/publisher/render.test.ts b/src/__tests__/publisher/render.test.ts index b01c86c01..7f7fddf3d 100644 --- a/src/__tests__/publisher/render.test.ts +++ b/src/__tests__/publisher/render.test.ts @@ -1030,4 +1030,74 @@ describe('publishPage', () => { expect(html).not.toContain('<script>') expect(html).toContain('<script>') }) + + // Token interpolation in <head> meta — entry routes carry the published row + // on the context's entryStack (seeded by renderPublishedDataRowTemplate), so + // `{currentEntry.*}` resolves to per-entry SEO titles/descriptions through + // the same engine dynamic text bindings use. + it('interpolates {currentEntry.*} tokens in metaTitle on an entry route', () => { + const proj = makeSite({ + settings: { ...makeSite().settings, metaTitle: '{currentEntry.name} | Acme Agency' }, + }) + const page = makePage({ root: { moduleId: 'base.text', props: { text: 'Hi' } } }) + const { html } = publishPage(page, proj, registry, { + templateContext: { + entryStack: [{ id: 'row-1', fields: { name: 'Our Discovery Process' } }], + }, + }) + expect(html).toContain('<title>Our Discovery Process | Acme Agency') + }) + + it('interpolates {currentEntry.*} tokens in the meta description', () => { + const proj = makeSite({ + settings: { ...makeSite().settings, metaDescription: '{currentEntry.summary}' }, + }) + const page = makePage({ root: { moduleId: 'base.text', props: { text: 'Hi' } } }) + const { html } = publishPage(page, proj, registry, { + templateContext: { + entryStack: [{ id: 'row-1', fields: { summary: 'How we run discovery.' } }], + }, + }) + expect(html).toContain('') + }) + + it('entry tokens in metaTitle degrade gracefully on a plain page (no leaked placeholders)', () => { + const proj = makeSite({ + settings: { ...makeSite().settings, metaTitle: '{currentEntry.name|Home} | Acme Agency' }, + }) + const page = makePage({ root: { moduleId: 'base.text', props: { text: 'Hi' } } }) + // No templateContext → empty entry stack → the token's own |fallback applies. + const { html } = publishPage(page, proj, registry) + expect(html).toContain('Home | Acme Agency') + expect(html).not.toContain('{currentEntry') + }) + + it('interpolates tokens in the page.title fallback of the chain', () => { + // No metaTitle set → the chain picks page.title, which may itself carry + // tokens (entry templates title themselves per-entry this way). + const page = makePage({ root: { moduleId: 'base.text', props: { text: 'Hi' } } }) + page.title = '{currentEntry.name} — {site.name}' + const { html } = publishPage(page, makeSite(), registry, { + templateContext: { entryStack: [{ id: 'row-1', fields: { name: 'Launch Week' } }] }, + }) + expect(html).toContain('<title>Launch Week — Test SiteDocument') + }) + + it('XSS: interpolated token values are escaped in and description', () => { + const proj = makeSite({ + settings: { + ...makeSite().settings, + metaTitle: '{currentEntry.name}', + metaDescription: '{currentEntry.name}', + }, + }) + const page = makePage({ root: { moduleId: 'base.text', props: { text: 'Hi' } } }) + const { html } = publishPage(page, proj, registry, { + templateContext: { + entryStack: [{ id: 'row-1', fields: { name: '<script>alert(1)</script>"' } }], + }, + }) + expect(html).not.toContain('<script>alert(1)') + expect(html).toContain('<title><script>alert(1)</script>"') + }) }) diff --git a/src/core/publisher/render.ts b/src/core/publisher/render.ts index c7ead0b2a..c46502786 100644 --- a/src/core/publisher/render.ts +++ b/src/core/publisher/render.ts @@ -24,6 +24,7 @@ import type { Page, SiteDocument } from '@core/page-tree' import type { IModuleRegistry } from '@core/module-engine' import type { TemplateRenderDataContext } from '@core/templates/dynamicBindings' import { buildPageFrame, buildSiteFrame, buildRouteFrame } from '@core/templates/contextFrames' +import { interpolateTokens } from '@core/templates/tokenInterpolation' import { classNamesForClassIds } from '@core/page-tree' import { normalizeHtmlAttributeName, @@ -300,6 +301,14 @@ function bodyHtmlAttributes(value: unknown): string { * `` metadata tags derived from site settings + page. * * - `title` falls back through metaTitle → page.title → site.name. + * - Title and description are token-interpolated against the render + * context before escaping, so `{currentEntry.*}` / `{page.*}` / + * `{site.*}` resolve per-entry on entry routes (SEO titles like + * `{currentEntry.name} | Acme`) instead of publishing the template + * page's static text. The `??` chain picks the raw value first; + * a token that resolves empty does NOT re-trigger the fallback — + * authors opt into fallbacks with the token's own `{...|fallback}` + * syntax. * - URL-typed settings (faviconUrl) are validated by * isSafeUrl() (blocks `javascript:` / `vbscript:` schemes) and then * escapeHtml()'d for safe attribute interpolation. @@ -313,17 +322,23 @@ interface DocumentMetaTags { langAttr: string } -function buildDocumentMetaTags(site: SiteDocument, page: Page): DocumentMetaTags { +function buildDocumentMetaTags( + site: SiteDocument, + page: Page, + context: TemplateRenderDataContext, +): DocumentMetaTags { const { settings } = site const metaDesc = settings.metaDescription - ? `\n ` + ? `\n ` : '' const favicon = settings.faviconUrl && isSafeUrl(settings.faviconUrl) ? `\n ` : '' return { - pageTitle: escapeHtml(settings.metaTitle ?? page.title ?? site.name), + pageTitle: escapeHtml( + interpolateTokens(settings.metaTitle ?? page.title ?? site.name, context), + ), metaDesc, favicon, langAttr: escapeHtml(settings.language ?? 'en'), @@ -499,6 +514,11 @@ export function publishPage( // emit placeholders instead of recursing. const dynamicNodeIds = findDynamicNodeIds(page, site, registry) + // Composed once per page render: the walker reads it through the config, + // and the builder interpolates {source.field} tokens in the + // title/description against the same frames. + const templateContext = composeTemplateContext(page, site, options.templateContext) + // Read-only inputs of this render pass. A renderer that needs a different // page (VC ref) or template frame (loop iteration) derives a child config — // it never mutates this one. @@ -507,7 +527,7 @@ export function publishPage( site, registry, breakpointId: options.breakpointId, - templateContext: composeTemplateContext(page, site, options.templateContext), + templateContext, loopData: options.loopData, mediaAssets: options.mediaAssets, dynamicNodeIds: dynamicNodeIds.size > 0 ? dynamicNodeIds : undefined, @@ -555,7 +575,7 @@ export function publishPage( acc.cssMap, ) - const meta = buildDocumentMetaTags(site, page) + const meta = buildDocumentMetaTags(site, page, templateContext) const runtime = buildRuntimeAssetsBlock(options, acc) const csp = buildContentSecurityPolicy(runtime.anyScriptTag, runtime.importmap, acc.cspSources)