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>` — the entry's `seoTitle` (post-type entries only) → `settings.metaTitle` → `page.title` → site name
4. `<meta name="description">` — the entry's `seoDescription` (post-type entries only) → `settings.metaDescription`; omitted when neither is set
3. `<title>` — the entry's `seoTitle` (post-type entries only) → `settings.metaTitle` → `page.title` → site name, then 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">` — the entry's `seoDescription` (post-type entries only) → `settings.metaDescription`; omitted when neither is set, and token-interpolated the same way
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>')
})
})
26 changes: 22 additions & 4 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 @@ -325,6 +326,14 @@ function bodyHtmlAttributes(value: unknown): string {
* site.name.
* - `description` falls back through `documentMeta.description` → the
* site-level metaDescription.
* - Whichever value wins is then token-interpolated against the render
* context before escaping, so `{currentEntry.*}` / `{page.*}` /
* `{site.*}` resolve per-entry on entry routes. That serves both ways
* of authoring a title: fill each row's SEO field by hand, or write one
* pattern like `{currentEntry.name} | Acme` on the template page. The
* fallback 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 @@ -341,19 +350,23 @@ interface DocumentMetaTags {
function buildDocumentMetaTags(
site: SiteDocument,
page: Page,
context: TemplateRenderDataContext,
override: DocumentMetaOverride = {},
): DocumentMetaTags {
const { settings } = site
const description = override.description || settings.metaDescription
const metaDesc = description
? `\n <meta name="description" content="${escapeHtml(description)}">`
? `
<meta name="description" content="${escapeHtml(interpolateTokens(description, context))}">`
: ''
const favicon =
settings.faviconUrl && isSafeUrl(settings.faviconUrl)
? `\n <link rel="icon" href="${escapeHtml(settings.faviconUrl)}">`
: ''
return {
pageTitle: escapeHtml(override.title || (settings.metaTitle ?? page.title ?? site.name)),
pageTitle: escapeHtml(
interpolateTokens(override.title || (settings.metaTitle ?? page.title ?? site.name), context),
),
metaDesc,
favicon,
langAttr: escapeHtml(settings.language ?? 'en'),
Expand Down Expand Up @@ -529,6 +542,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 @@ -537,7 +555,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 @@ -585,7 +603,7 @@ export function publishPage(
acc.cssMap,
)

const meta = buildDocumentMetaTags(site, page, options.documentMeta)
const meta = buildDocumentMetaTags(site, page, templateContext, options.documentMeta)
const runtime = buildRuntimeAssetsBlock(options, acc)
const csp = buildContentSecurityPolicy(runtime.anyScriptTag, runtime.importmap, acc.cspSources)

Expand Down
Loading