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
7 changes: 5 additions & 2 deletions server/handlers/cms/data/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down
7 changes: 5 additions & 2 deletions server/publish/publicRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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;
Expand Down
93 changes: 93 additions & 0 deletions src/__tests__/server/publicRendering.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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</title>')
expect(withSeo?.html).not.toContain('<title>Plain H1 Title</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('<title>Plain H1 Title</title>')
})

it('injects stored runtime asset manifests when rendering a published snapshot', async () => {
const published = snapshot('Runtime page')
published.runtimeAssets = {
Expand Down
21 changes: 20 additions & 1 deletion src/core/data/__tests__/cells.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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()
})
})
15 changes: 15 additions & 0 deletions src/core/data/cells.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,21 @@ export function readSeoTitleCell(cells: DataRowCells): string {
return readStringCell(cells, 'seoTitle')
}

/**
* The document `<title>`/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')
}
Expand Down