Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/features/publisher.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand Down
2 changes: 2 additions & 0 deletions docs/features/templates.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

---
Expand Down
70 changes: 70 additions & 0 deletions src/__tests__/publisher/render.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1030,4 +1030,74 @@ describe('publishPage', () => {
expect(html).not.toContain('<script>')
expect(html).toContain('&lt;script&gt;')
})

// 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</title>')
})

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('<meta name="description" content="How we run discovery.">')
})

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('<title>Home | Acme Agency</title>')
expect(html).not.toContain('{currentEntry')
})

it('interpolates tokens in the page.title fallback of the <title> 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</title>')
})

it('XSS: interpolated token values are escaped in <title> 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>&lt;script&gt;alert(1)&lt;/script&gt;&quot;</title>')
})
})
30 changes: 25 additions & 5 deletions src/core/publisher/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -300,6 +301,14 @@ function bodyHtmlAttributes(value: unknown): string {
* `<head>` 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.
Expand All @@ -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 <meta name="description" content="${escapeHtml(settings.metaDescription)}">`
? `\n <meta name="description" content="${escapeHtml(interpolateTokens(settings.metaDescription, context))}">`
: ''
const favicon =
settings.faviconUrl && isSafeUrl(settings.faviconUrl)
? `\n <link rel="icon" href="${escapeHtml(settings.faviconUrl)}">`
: ''
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'),
Expand Down Expand Up @@ -499,6 +514,11 @@ export function publishPage(
// emit <instatic-hole> placeholders instead of recursing.
const dynamicNodeIds = findDynamicNodeIds(page, site, registry)

// Composed once per page render: the walker reads it through the config,
// and the <head> 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.
Expand All @@ -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,
Expand Down Expand Up @@ -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)

Expand Down