From 3c900e5a73c32d93a0670795ece4573c2caaf876 Mon Sep 17 00:00:00 2001 From: Henrique Chiuchi Date: Sat, 11 Jul 2026 18:54:58 -0300 Subject: [PATCH] fix(templates): stop leaking the template's title into published pages composeTemplateChain always returned the innermost TEMPLATE's title as the merged page's title, never the actual page or entry being rendered. Every published page's and <head> metadata came from whichever template wrapped it: ordinary pages all showed the "everywhere" template's title, and post-type entries showed the postType template's title instead of the entry's own title. For a page terminal, terminal.page is already available inside composeTemplateChain, so use its title directly. For an entry terminal there is no Page object at that layer, so the two callers (renderPublishedDataRowTemplate and the row preview handler) set merged.title from the published/draft row's own title cell right after composing, before the page is rendered. --- server/handlers/cms/data/preview.ts | 3 +++ server/publish/publicRenderer.ts | 3 +++ src/core/templates/templateCompose.ts | 6 +++++- 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/server/handlers/cms/data/preview.ts b/server/handlers/cms/data/preview.ts index 7e2821b4..57644952 100644 --- a/server/handlers/cms/data/preview.ts +++ b/server/handlers/cms/data/preview.ts @@ -94,6 +94,9 @@ export async function handleRowPreview( return jsonResponse({ error: 'No entry template found for this collection' }, { status: 404 }) } const merged = composeTemplateChain(chain, { kind: 'entry' }) + // The template chain has no Page for the entry, so composeTemplateChain + // can't know its title — the entry's own (draft) title is the real document title. + if (typeof draftCells.title === 'string') merged.title = draftCells.title // Build a synthetic PublishedDataRow with the draft cells merged in. // Bindings inside the template (`{currentEntry.body}`, featured-media diff --git a/server/publish/publicRenderer.ts b/server/publish/publicRenderer.ts index 19792a7b..a35e0177 100644 --- a/server/publish/publicRenderer.ts +++ b/server/publish/publicRenderer.ts @@ -176,6 +176,9 @@ export async function renderPublishedDataRowTemplate( const chain = resolveTemplateChain(snapshot.site, { kind: 'entry', tableSlug: row.tableSlug }) if (chain.length === 0) return null // no entry template → 404 (unchanged behaviour) const merged = composeTemplateChain(chain, { kind: 'entry' }) + // The template chain has no Page for the entry, so composeTemplateChain + // can't know its title — the entry's own title is the real document title. + if (typeof row.cells.title === 'string') merged.title = row.cells.title // Seed the entry stack with the published row + route frame from the request // URL. Loop interceptors push/pop iteration items on top of this stack; diff --git a/src/core/templates/templateCompose.ts b/src/core/templates/templateCompose.ts index 95bda45a..628c1f0e 100644 --- a/src/core/templates/templateCompose.ts +++ b/src/core/templates/templateCompose.ts @@ -140,7 +140,11 @@ export function composeTemplateChain(chain: Page[], terminal: TerminalContent): return { id: innermost.id, // identifies "what was rendered" for the publish.html filter slug: innermost.slug, - title: innermost.title, + // The terminal page's own title is the document's real title — the + // wrapping template's title is chrome, never `<title>` content. Entry + // terminals have no Page here; their caller overrides `title` from the + // published row after composing. + title: terminal.kind === 'page' ? terminal.page.title : innermost.title, rootNodeId: acc.rootId, nodes: acc.nodes, }