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 `<title>` 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 `<title>` 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 `<title>` 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 <title> 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 + // `<title>`, not a site-level override. + settings: { + shortcuts: {}, + }, + styleRules: {}, + createdAt: 1000, + updatedAt: 2000, + }, + } + + const baseRow: Omit<PublishedDataRow, 'cells'> = { + 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('<title>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') }