From 6f9bc232f36dd321d2d36661c3c051f24cbc6e3f Mon Sep 17 00:00:00 2001
From: Aaron Sachs <898627+asachs01@users.noreply.github.com>
Date: Fri, 14 Aug 2026 01:03:05 +0000
Subject: [PATCH] fix(publish): wire the existing seoTitle field into the
document
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
seoTitle is already a fully-built post-type field — declared in the
default field set, seeded by the DB migrations, and editable in the
Content Settings panel's SEO field — but the publish and preview render
paths never consumed it: `renderPublishedDataRowTemplate` and
`handleRowPreview` both set `merged.title` unconditionally from
`row.cells.title`, so the meta-tag `` always fell back to the
plain entry title even when an author filled in a distinct SEO title.
Add `resolveEntryDocumentTitle()` (prefers seoTitle, falls back to
title) and use it at both call sites. The on-page H1 binding
(`{currentEntry.title}`) is unaffected — it resolves from the loop
item's cells directly, never from `merged.title` — so this only
changes what feeds the `` tag / meta description pipeline.
---
server/handlers/cms/data/preview.ts | 7 +-
server/publish/publicRenderer.ts | 7 +-
src/__tests__/server/publicRendering.test.ts | 93 ++++++++++++++++++++
src/core/data/__tests__/cells.test.ts | 21 ++++-
src/core/data/cells.ts | 15 ++++
5 files changed, 138 insertions(+), 5 deletions(-)
diff --git a/server/handlers/cms/data/preview.ts b/server/handlers/cms/data/preview.ts
index 01e0de87b..1c9bfccc5 100644
--- a/server/handlers/cms/data/preview.ts
+++ b/server/handlers/cms/data/preview.ts
@@ -20,6 +20,7 @@
import { Type } from '@sinclair/typebox'
import type { DbClient } from '../../../db/client'
import type { DataRow, DataRowCells, PublishedDataRow } from '@core/data/schemas'
+import { resolveEntryDocumentTitle } from '@core/data/cells'
import { resolveTemplateChain, composeTemplateChain } from '@core/templates'
import { buildRouteFrame } from '@core/templates/contextFrames'
import { publishPage } from '@core/publisher'
@@ -95,8 +96,10 @@ export async function handleRowPreview(
}
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
+ // can't know its title — the entry's own (draft) title (SEO-title
+ // override, if set) is the real document title.
+ const documentTitle = resolveEntryDocumentTitle(draftCells)
+ if (documentTitle !== null) merged.title = documentTitle
// 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 21058b07d..8141e640b 100644
--- a/server/publish/publicRenderer.ts
+++ b/server/publish/publicRenderer.ts
@@ -13,6 +13,7 @@ import { getPublishVersion } from './publishState'
import type { Page } from '@core/page-tree'
import type { SiteCssBundle } from '@core/publisher'
import type { PublishedDataRow } from '@core/data/schemas'
+import { resolveEntryDocumentTitle } from '@core/data/cells'
import type { DbClient } from '../db/client'
import type { PublishedPageSnapshot } from '../repositories/publish'
@@ -178,8 +179,10 @@ export async function renderPublishedDataRowTemplate(
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
+ // can't know its title — the entry's own title (SEO-title override, if
+ // set) is the real document title.
+ const documentTitle = resolveEntryDocumentTitle(row.cells)
+ if (documentTitle !== null) merged.title = documentTitle
// 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/__tests__/server/publicRendering.test.ts b/src/__tests__/server/publicRendering.test.ts
index 0a35f8a3b..ec8444a11 100644
--- a/src/__tests__/server/publicRendering.test.ts
+++ b/src/__tests__/server/publicRendering.test.ts
@@ -165,6 +165,99 @@ describe('public rendering', () => {
expect(result).toBeNull()
})
+ // Guards the seoTitle/title split: the document `` prefers the
+ // row's seoTitle override when present, but the H1 (`{currentEntry.title}`
+ // binding) always renders the plain title, never the SEO override.
+ it('prefers seoTitle for the document while the H1 binding keeps the plain title', async () => {
+ const snap: PublishedPageSnapshot = {
+ cmsSnapshotVersion: 1,
+ pageRowId: 'page_home',
+ site: {
+ id: 'project_1',
+ name: 'Public Site',
+ pages: [
+ {
+ id: 'entry_template',
+ title: 'Entry Template',
+ slug: 'entry-template',
+ rootNodeId: 'root',
+ template: { enabled: true, target: { kind: 'postTypes', tableSlugs: ['posts'] }, priority: 0 },
+ nodes: {
+ root: {
+ id: 'root',
+ moduleId: 'base.body',
+ props: {},
+ breakpointOverrides: {},
+ children: ['heading'],
+ },
+ heading: {
+ id: 'heading',
+ moduleId: 'base.text',
+ props: { text: '{currentEntry.title}', tag: 'h1' },
+ breakpointOverrides: {},
+ children: [],
+ },
+ },
+ } as unknown as PublishedPageSnapshot['site']['pages'][number],
+ ],
+ files: [],
+ visualComponents: [],
+ breakpoints: [
+ { id: 'desktop', label: 'Desktop', width: 1440, icon: 'monitor' },
+ ],
+ // No settings.metaTitle — the entry's own title/seoTitle must drive
+ // ``, not a site-level override.
+ settings: {
+ shortcuts: {},
+ },
+ styleRules: {},
+ createdAt: 1000,
+ updatedAt: 2000,
+ },
+ }
+
+ const baseRow: Omit = {
+ id: 'ver_1',
+ rowId: 'row_1',
+ tableId: 'tbl_posts',
+ tableSlug: 'posts',
+ tableKind: 'posts',
+ tableRouteBase: '/blog',
+ versionNumber: 1,
+ slug: 'hello',
+ featuredMediaId: null,
+ featuredMediaPath: null,
+ authorUserId: null,
+ authorName: null,
+ authorRoleSlug: null,
+ authorRoleName: null,
+ publishedByUserId: null,
+ publishedByName: null,
+ publishedByRoleSlug: null,
+ publishedByRoleName: null,
+ publishedAt: '2024-01-01T00:00:00.000Z',
+ createdAt: '2024-01-01T00:00:00.000Z',
+ }
+
+ const withSeoTitle: PublishedDataRow = {
+ ...baseRow,
+ cells: { title: 'Plain H1 Title', seoTitle: 'SEO Override Title' },
+ }
+ const withSeo = await renderPublishedDataRowTemplate(snap, withSeoTitle, { db: makeFakeDb(snap) })
+ expect(withSeo?.html).toContain('SEO Override Title')
+ expect(withSeo?.html).not.toContain('Plain H1 Title')
+ expect(withSeo?.html).toContain('Plain H1 Title') // H1 binding unaffected
+
+ resetForTests()
+
+ const withoutSeoTitle: PublishedDataRow = {
+ ...baseRow,
+ cells: { title: 'Plain H1 Title' },
+ }
+ const withoutSeo = await renderPublishedDataRowTemplate(snap, withoutSeoTitle, { db: makeFakeDb(snap) })
+ expect(withoutSeo?.html).toContain('Plain H1 Title')
+ })
+
it('injects stored runtime asset manifests when rendering a published snapshot', async () => {
const published = snapshot('Runtime page')
published.runtimeAssets = {
diff --git a/src/core/data/__tests__/cells.test.ts b/src/core/data/__tests__/cells.test.ts
index bece8ab53..e4d9a2c83 100644
--- a/src/core/data/__tests__/cells.test.ts
+++ b/src/core/data/__tests__/cells.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'bun:test'
-import { stripPostTypeBuiltInCells } from '../cells'
+import { resolveEntryDocumentTitle, stripPostTypeBuiltInCells } from '../cells'
describe('stripPostTypeBuiltInCells', () => {
it('drops the six post-type built-in field ids and keeps custom cells', () => {
@@ -30,3 +30,22 @@ describe('stripPostTypeBuiltInCells', () => {
expect(stripPostTypeBuiltInCells({})).toEqual({})
})
})
+
+describe('resolveEntryDocumentTitle', () => {
+ it('prefers seoTitle when set', () => {
+ expect(resolveEntryDocumentTitle({ title: 'Plain Title', seoTitle: 'SEO Title' })).toBe('SEO Title')
+ })
+
+ it('falls back to title when seoTitle is unset', () => {
+ expect(resolveEntryDocumentTitle({ title: 'Plain Title' })).toBe('Plain Title')
+ })
+
+ it('falls back to title when seoTitle is an empty string', () => {
+ expect(resolveEntryDocumentTitle({ title: 'Plain Title', seoTitle: '' })).toBe('Plain Title')
+ })
+
+ it('returns null when neither title nor seoTitle is a string', () => {
+ expect(resolveEntryDocumentTitle({})).toBeNull()
+ expect(resolveEntryDocumentTitle({ title: 42 })).toBeNull()
+ })
+})
diff --git a/src/core/data/cells.ts b/src/core/data/cells.ts
index 1947218fd..45d25b2cf 100644
--- a/src/core/data/cells.ts
+++ b/src/core/data/cells.ts
@@ -107,6 +107,21 @@ export function readSeoTitleCell(cells: DataRowCells): string {
return readStringCell(cells, 'seoTitle')
}
+/**
+ * The document ``/meta-tag source for a post-type entry: the
+ * per-row SEO title override when set, else the entry's own title. Both
+ * `renderPublishedDataRowTemplate` (publish) and `handleRowPreview`
+ * (Content editor Live mode) feed this into `merged.title` so the two
+ * paths stay in parity. The on-page H1 (`{currentEntry.title}` binding)
+ * never goes through this — it reads `cells.title` directly, so this
+ * only ever affects meta-tag output, never the visible headline.
+ */
+export function resolveEntryDocumentTitle(cells: DataRowCells): string | null {
+ const seoTitle = readSeoTitleCell(cells)
+ if (seoTitle) return seoTitle
+ return typeof cells.title === 'string' ? cells.title : null
+}
+
export function readSeoDescriptionCell(cells: DataRowCells): string {
return readStringCell(cells, 'seoDescription')
}