From c16f1992e80036cd399616ac52e5d46ad44911da Mon Sep 17 00:00:00 2001 From: DavidBabinec Date: Fri, 21 Aug 2026 09:33:09 +0200 Subject: [PATCH] fix(publish): wire entry SEO title and description into the published `seoTitle` and `seoDescription` are fully-built post-type fields, declared in the default field set, seeded by both migration paths, and editable in the Content settings panel. Neither reached the published page. An entry's `` always showed its plain title, and no `<meta name="description">` was emitted at all. Give `publishPage` a `documentMeta` override consumed by `buildDocumentMetaTags`. Both entry render paths read the row's authored values with `readEntrySeoOverride` and pass them there, so publish and the Content editor's Live mode stay in parity. The override outranks the site-level metaTitle / metaDescription, because a per-entry value is the more specific choice. A blank field is not an override and still falls through. The override deliberately never touches `page.title`. `publishPage` feeds that to `buildPageFrame`, so it is also the `{page.title}` binding, and an SEO value assigned there renders inside the page body. `page.title` keeps the entry's own title for both title bindings. Co-authored-by: Aaron Sachs <898627+asachs01@users.noreply.github.com> --- CHANGELOG.md | 4 + docs/e2e/feature-matrix.md | 3 + docs/e2e/feature-validation.tsv | 1 + docs/features/publisher.md | 23 ++- server/handlers/cms/data/preview.ts | 6 +- server/publish/publicRenderer.ts | 18 +- src/__tests__/server/publicRendering.test.ts | 170 +++++++++++++++++++ src/core/data/__tests__/cells.test.ts | 29 +++- src/core/data/cells.ts | 25 +++ src/core/publisher/index.ts | 2 +- src/core/publisher/render.ts | 44 ++++- tests/e2e/content-seo-meta.e2e.ts | 124 ++++++++++++++ 12 files changed, 433 insertions(+), 16 deletions(-) create mode 100644 tests/e2e/content-seo-meta.e2e.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 24dfccaf4..9669b585a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,10 @@ This project is pre-1.0. Breaking changes may appear in minor or patch releases - Added a condition to data-row loops so a list can show a subset of a table rather than always its newest rows — pick one of the table's own fields and require it to be checked, unchecked, equal to a value, or to have any value at all. A relation field offers its rows by name instead of asking for an id. The condition applies on the canvas, on published pages, and in the "load more" endpoint, and the item count follows it so pagination never advertises rows the page drops. - Added the table's own fields to a data-row loop's "Order by" list, so a list can follow a real date, title, or rank stored in the row instead of only the row's built-in columns. Values compare as text, which sorts ISO dates chronologically. +### Content and publishing + +- Fixed the SEO title and SEO description fields on a post so they reach the published page. Both were editable in the Content settings panel but never emitted anything: an entry's `<title>` always showed its plain title, and no description tag was written at all. An authored value now drives `<title>` / `<meta name="description">` and outranks the site-wide Meta Title and Meta Description, on the published page and in the Content editor's Live preview alike. The on-page `{page.title}` and `{currentEntry.title}` bindings keep rendering the entry's real title. + ## 0.0.16 - 2026-08-11 ### Media and integrations diff --git a/docs/e2e/feature-matrix.md b/docs/e2e/feature-matrix.md index 407196479..130281443 100644 --- a/docs/e2e/feature-matrix.md +++ b/docs/e2e/feature-matrix.md @@ -254,6 +254,7 @@ MEDIA-007 note: unsafe SVG upload sanitization and public `/uploads` serving are | CONTENT-007 | P2 | partial | AI | Use the content AI assistant panel | AI chat permission | Content AI panel | No-provider guidance or chat flow is understandable | provider failure, write-tool permission leaks | | CONTENT-008 | P1 | ✅ | Custom fields | Edit custom post fields and use them in Site templates | Logged in | Data Posts fields → Content settings → Site binding picker → publish post route | Custom field appears in the Content entry settings panel, persists after reload, is offered as a scoped currentEntry binding, resolves in canvas preview, and renders on the public post route | stale table metadata, unresolved custom tokens, save/publish ordering | | CONTENT-009 | P1 | ✅ | Entries | Keep the stored title in the sidebar when an entries list load resolves after a create | Logged in | Content page → New post while the entries list request is still in flight | Sidebar row shows the stored "Untitled" while the editor keeps its blank title field | request ordering, editor-only row views leaking into the list | +| CONTENT-010 | P1 | ✅ | Entries | Publish an entry whose SEO title and SEO description differ from its title | Posts entry template published | Content settings panel → SEO title / SEO description → publish post → public `/posts/<slug>` | Served `<head>` carries the authored `<title>` and `<meta name="description">` while the on-page H1 keeps the entry's own title | site-level Meta Title shadowing the entry, SEO override leaking into `{page.title}`, blank SEO field overriding with an empty value | CONTENT-003 note: slash-menu Heading 2 and Data token placeholder insertion with save/reload persistence are automated in `content.e2e.ts`; media picker insertion and sanitization edge cases remain lower-level or future browser coverage. @@ -267,6 +268,8 @@ CONTENT-008 note: `content.e2e.ts` adds a custom text field to the system Posts CONTENT-009 note: `content-create-race.e2e.ts` holds the first entries-list GET until the create POST has landed, forcing the ordering that previously merged the editor's blank-title view into the sidebar and rendered a nameless row. Verified to fail without the fix and pass with it; `contentAdmin.test.tsx` pins the same path at unit level. +CONTENT-010 note: `content-seo-meta.e2e.ts` authors and publishes a Posts entry template, fills the Content settings panel's SEO title and SEO description with values distinct from the entry title, publishes the post, and reads the served HTML on the anonymous public route. Verified to fail on the pre-fix build (`<title>` showed the plain entry title and no description tag was emitted). Entry-vs-site-setting precedence and the `{page.title}` binding guard are pinned at render level in `publicRendering.test.ts`. + ## AI Workspace | ID | Priority | Auto | Area | User Goal | Setup | Path | Expected Outcome | Watch For | diff --git a/docs/e2e/feature-validation.tsv b/docs/e2e/feature-validation.tsv index 3f1910e0a..551cde0ef 100644 --- a/docs/e2e/feature-validation.tsv +++ b/docs/e2e/feature-validation.tsv @@ -146,3 +146,4 @@ SPOT-011 Keyboard-only Spotlight execution As a keyboard user, I want to open, s SPOT-012 Spotlight reduced-motion behaviour As a user who prefers reduced motion, I want Spotlight open, close, and nested-scope transitions to avoid slide animation so the palette respects my OS accessibility setting. With reduced motion emulated, opening Spotlight and drilling into a nested scope avoids slide or translateY animation and uses opacity-only or instant transition behavior. Reduced-motion preference ignored; slide animation still plays; nested scope animates horizontally; page jumps on open; tests miss animation due timing. CSS and state transitions branch on prefers-reduced-motion while preserving palette functionality. tests/e2e/command-palette.e2e.ts; src/admin/spotlight; spotlight CSS modules The regression uses browser media emulation and style assertions; it does not visually diff every animation frame. Happy: reduced-motion palette opens and drills into scope without slide animation. Error: transform animation present. Boundary: nested scope transition. Invalid: normal motion mode still allowed separately. Permission: unaffected by capabilities. Performance: no delay introduced. Mobile: reduced-motion media emulation only. Focused capability/responsive/spotlight Playwright regression passed 2026-06-23; canonical spreadsheet row added after matrix gap discovery 0 None Verification: command-palette.e2e.ts passed inside the combined focused run. SPOT-012 covers reduced-motion behavior for palette open and nested scope transitions. 2026-06-23 SPOT-013 Spotlight high-contrast highlight visibility As a high-contrast user, I want the active Spotlight row and matched text marks to stay visually distinct so search results remain readable. With high-contrast media emulated, opening Spotlight, searching, and arrowing through results shows a visible active-row outline and readable match marks. Highlight invisible; outline too faint; match marks blend into background; keyboard active row differs from visual row; contrast mode breaks selection styling. Spotlight CSS uses tokenized high-contrast styles for active rows and marks while preserving keyboard active-state semantics. tests/e2e/command-palette.e2e.ts; src/admin/spotlight; spotlight CSS modules The regression uses browser media emulation and computed-style checks; full manual visual contrast audit remains future review. Happy: active row and match marks remain legible. Error: no visible outline or unreadable mark. Boundary: one searched result with arrow navigation. Invalid: high contrast should not hide text. Permission: unaffected by capabilities. Performance: no render delay. Mobile: media emulation only. Focused capability/responsive/spotlight Playwright regression passed 2026-06-23; canonical spreadsheet row added after matrix gap discovery 0 None Verification: command-palette.e2e.ts passed inside the combined focused run. SPOT-013 covers high-contrast active-row outline and match-mark legibility. 2026-06-23 CONTENT-009 Freshly created entry keeps its stored title in the sidebar As a content author, I want a post I just created to show its name in the sidebar so I can find it. createUntitledEntry stores "Untitled" on the server and hands the editor a blank-title view; the sidebar list renders the stored row even when an in-flight entries list load resolves after the create. List load resolving after the create; an MCP save landing during a list load; the server list omitting the new row. Rows loaded through the cmsData client with schema validation; the selection may hold an editor-local view that must never reach the list. src/admin/pages/content/hooks/useContentWorkspace.ts; src/__tests__/data/contentAdmin.test.tsx; tests/e2e/content-create-race.e2e.ts The posts rows endpoint is stateful, so a row created by POST is visible to every later GET. Happy: create a post with the list load held and the sidebar shows Untitled. Regression: without the fix the row renders nameless. Boundary: when the server list omits the row the selection is still merged in. Permission/Performance/Mobile: not applicable. Automated Playwright E2E passed; verified to fail without the fix 0 None Pins an ordering that previously decided the outcome at random. 2026-08-18 +CONTENT-010 Entry SEO title and description reach the published <head> As a content author, I want the SEO title and SEO description I fill in for a post to appear in the published page’s <head> so search engines show what I wrote. renderPublishedDataRowTemplate and handleRowPreview pass the row’s seoTitle/seoDescription to publishPage as documentMeta; buildDocumentMetaTags prefers them over the site-level metaTitle/metaDescription, and page.title keeps the entry’s own title for the {page.title} binding. Blank or whitespace-only SEO field; site-level Meta Title configured; entry with no SEO fields at all; collection with SEO fields disabled; Live preview vs published parity. seoTitle/seoDescription are optional post-type built-ins read through readEntrySeoOverride; a blank value is not an override; the override reaches the <head> only, never a binding frame. server/publish/publicRenderer.ts; server/handlers/cms/data/preview.ts; src/core/publisher/render.ts; src/core/data/cells.ts; tests/e2e/content-seo-meta.e2e.ts The public entry route needs a published Posts entry template; the spec authors and publishes its own. Happy: authored SEO title and description appear in <head>. Regression: pre-fix build emits the plain title and no description. Boundary: blank SEO field falls through to site settings. Invalid: non-string cell ignored. Permission: publishing requires step-up. Performance: n/a. Mobile: n/a. Automated Playwright E2E passed; verified to fail without the fix 0 None Closes the gap that let both SEO fields ship inert: nothing checked the published <head>. 2026-08-20 diff --git a/docs/features/publisher.md b/docs/features/publisher.md index 7a729b533..5084289de 100644 --- a/docs/features/publisher.md +++ b/docs/features/publisher.md @@ -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>` from `page.title` -4. `<meta name="description">` if present in page settings +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 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`) @@ -336,6 +336,22 @@ The publisher emits `<head>` in this order: 9. **`head` placement** plugin-injected tags (after the publisher's own head, before custom user head content) 10. `<meta http-equiv="Content-Security-Policy" content="...">` — assembled based on what's actually in the page +### `documentMeta` — per-render `<head>` overrides + +Rows 3 and 4 above take their most specific value from `PublishPageOptions.documentMeta`, a `{ title?, description? }` the caller supplies for this render only. A post-type entry's authored `seoTitle` / `seoDescription` are row cells, not fields of the composed template `Page`, so both entry render paths read them with `readEntrySeoOverride(cells)` (`src/core/data/cells.ts`) and pass them here: + +| Path | File | +|---|---| +| Publish / public route | `renderPublishedDataRowTemplate` in `server/publish/publicRenderer.ts` | +| Content editor Live mode | `handleRowPreview` in `server/handlers/cms/data/preview.ts` | + +Two invariants: + +- **Never write the SEO override onto `page.title`.** `publishPage` hands `page` to `buildPageFrame`, so `page.title` is also the `{page.title}` binding — an SEO value assigned there renders inside the page body. `page.title` stays the entry's own `title` cell; the override reaches `<head>` and nothing else. +- **A blank field is not an override.** `readEntrySeoOverride` omits an empty or whitespace-only cell, so it falls through to the site-level `metaTitle` / `metaDescription` exactly as an absent one does. + +Both call sites read through the one helper so publish and Live preview can't drift. + Installed fonts are emitted through the CSS bundle, not external `<link>` tags. The font CSS includes self-hosted `@font-face` rules for `site.settings.fonts.items` plus `:root` declarations for editable tokens such as `--font-primary`. A page rule can therefore keep `font-family: var(--font-primary)` while the token assignment changes site-wide. Plugins inject at four anchors. The order matters — see [docs/features/plugin-system.md](plugin-system.md) for the splicing rules. @@ -370,7 +386,7 @@ Because `serializeCsp` sorts, the same plugins + adapters always emit a **byte-i | `server/publish/renderCache.ts` | In-memory LRU keyed by `(urlPath, canonicalQuery)`, entries versioned. `getOrRender` (single-flight). Reads the version from `publishState`; version captured at render start — a publish landing mid-render discards the result rather than caching stale HTML. Layer B. | | `server/publish/publishState.ts` | Publish-time process state: `publishVersion` (`bumpPublishVersion`/`getPublishVersion`), `withPublishLock` (ISS-038 publish serializer), and `createVersionedSingleFlight` — the generalized version-keyed single-flight memo the hole endpoint reuses. Repositories import the version + lock from here (not from the cache). | | `server/publish/holeRuntime.ts` | Exports `runInstaticHoleRuntime` (the TypeScript source of the Layer C runtime) and `HOLE_RUNTIME_JS` (IIFE-serialized string, ~1.1 KB, served to browsers). Tests call `runInstaticHoleRuntime()` directly to avoid dynamic eval. | -| `server/publish/publicRenderer.ts` | `renderPublishedSnapshot`, `renderPublishedDataRowTemplate` — thin wrappers (resolve + compose the template chain, seed the context) over one shared `renderMergedTemplate` (CSS bundle + loop/media prefetch + `publishPage` + publish-version stamping). | +| `server/publish/publicRenderer.ts` | `renderPublishedSnapshot`, `renderPublishedDataRowTemplate` — thin wrappers (resolve + compose the template chain, seed the context) over one shared `renderMergedTemplate` (CSS bundle + loop/media prefetch + `publishPage` + publish-version stamping). The entry path also passes the row's `readEntrySeoOverride(...)` through as `documentMeta`. | | `server/publish/publishedHtmlPipeline.ts` | Post-process: DOMPurify the final HTML, run plugin `publish.html` filter, splice in declarative tags from plugin manifests, inject runtime assets. Runs at publish time only — never per-request. | | `server/publish/siteCssBundle.ts` | Hash the four CSS strings, write `uploads/css/...` files. The framework bundle's module-CSS half comes from the shared walk in `siteModuleAssets.ts`. | | `server/publish/siteModuleAssets.ts` | `collectSiteModuleAssets` — the one full-site render walk whose accumulators feed BOTH the framework CSS bundle (`cssMap`) and the published module-JS map (`jsMap`). | @@ -550,6 +566,7 @@ This is rare and requires architectural review — most "new behavior" fits with | Hand-writing `<picture>` / `<img srcset>` in a module | Set `props.<key>` to a media URL; `mediaPresentation.ts` materializes the markup. | | Adding `@import url(...)` to module CSS | The final document passes through DOMPurify in `publishedHtmlPipeline.ts`, which strips dangerous CSS constructs. Add it to the site's user stylesheets instead (where it is intentional). | | Editing the CSP meta tag string manually | Edit the CSP source list — the tag is derived. | +| Assigning an entry's `seoTitle` to `page.title` to reach `<title>` | Pass it as `documentMeta.title`. `page.title` is also the `{page.title}` binding, so an override written there renders in the page body. | --- diff --git a/server/handlers/cms/data/preview.ts b/server/handlers/cms/data/preview.ts index 01e0de87b..c6b0ccb7d 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 { readEntrySeoOverride } from '@core/data/cells' import { resolveTemplateChain, composeTemplateChain } from '@core/templates' import { buildRouteFrame } from '@core/templates/contextFrames' import { publishPage } from '@core/publisher' @@ -95,7 +96,9 @@ 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. + // can't know its title — the entry's own (draft) title is the real page + // title. The draft SEO override travels separately through + // `documentMeta` so it only ever reaches the `<head>`, mirroring publish. if (typeof draftCells.title === 'string') merged.title = draftCells.title // Build a synthetic PublishedDataRow with the draft cells merged in. @@ -118,6 +121,7 @@ export async function handleRowPreview( const published = publishPage(merged, snapshot.site, registry, { templateContext, + documentMeta: readEntrySeoOverride(draftCells), runtimeAssets: snapshot.runtimeAssets, runtimePackageImportmap: snapshot.runtimePackageImportmap, cssEmission: 'external', diff --git a/server/publish/publicRenderer.ts b/server/publish/publicRenderer.ts index 21058b07d..c8f41a48a 100644 --- a/server/publish/publicRenderer.ts +++ b/server/publish/publicRenderer.ts @@ -11,8 +11,9 @@ import { prefetchLoopData, publishedDataRowToLoopItem } from './loopPrefetch' import { prefetchMediaAssets } from './mediaPrefetch' import { getPublishVersion } from './publishState' import type { Page } from '@core/page-tree' -import type { SiteCssBundle } from '@core/publisher' +import type { DocumentMetaOverride, SiteCssBundle } from '@core/publisher' import type { PublishedDataRow } from '@core/data/schemas' +import { readEntrySeoOverride } from '@core/data/cells' import type { DbClient } from '../db/client' import type { PublishedPageSnapshot } from '../repositories/publish' @@ -90,6 +91,7 @@ async function renderMergedTemplate( snapshot: PublishedPageSnapshot, templateContext: TemplateRenderDataContext | undefined, ctx: RenderPublishedSnapshotContext, + documentMeta?: DocumentMetaOverride, ): Promise<{ html: string; jsModuleIds: string[]; publishVersion: number; cssBundle: SiteCssBundle }> { const publishVersion = ctx.publishVersion ?? getPublishVersion() const moduleJsMap = buildPublishedSiteModuleJsMap(snapshot.site, registry) @@ -101,6 +103,7 @@ async function renderMergedTemplate( const cssBundle = buildPublishedSiteCssBundle(snapshot.site, registry, merged, publishVersion, { mediaAssets }) const published = publishPage(merged, snapshot.site, registry, { templateContext, + ...(documentMeta ? { documentMeta } : {}), runtimeAssets: snapshot.runtimeAssets, runtimePackageImportmap: snapshot.runtimePackageImportmap, cssEmission: 'external', @@ -178,7 +181,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. + // can't know its title — the entry's own title is the real page title. + // It stays the plain `title` cell: `page.title` feeds the `{page.title}` + // binding as well as `<title>`, so the SEO override travels separately + // through `documentMeta` and only reaches the `<head>`. if (typeof row.cells.title === 'string') merged.title = row.cells.title // Seed the entry stack with the published row + route frame from the request @@ -190,6 +196,12 @@ export async function renderPublishedDataRowTemplate( ...(ctx.url ? { route: buildRouteFrame(ctx.url.toString()) } : {}), } - const rendered = await renderMergedTemplate(merged, snapshot, templateContext, ctx) + const rendered = await renderMergedTemplate( + merged, + snapshot, + templateContext, + ctx, + readEntrySeoOverride(row.cells), + ) return { ...rendered, pageId: merged.id, slug: merged.slug, siteId: snapshot.site.id } } diff --git a/src/__tests__/server/publicRendering.test.ts b/src/__tests__/server/publicRendering.test.ts index 0a35f8a3b..7844e6116 100644 --- a/src/__tests__/server/publicRendering.test.ts +++ b/src/__tests__/server/publicRendering.test.ts @@ -165,6 +165,176 @@ describe('public rendering', () => { expect(result).toBeNull() }) + const entrySnapshotWithSettings = ( + settings: PublishedPageSnapshot['site']['settings'], + ): 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', 'crumb'], + }, + heading: { + id: 'heading', + moduleId: 'base.text', + props: { text: '{currentEntry.title}', tag: 'h1' }, + breakpointOverrides: {}, + children: [], + }, + crumb: { + id: 'crumb', + moduleId: 'base.text', + props: { text: 'Breadcrumb: {page.title}', tag: 'p' }, + breakpointOverrides: {}, + children: [], + }, + }, + } as unknown as PublishedPageSnapshot['site']['pages'][number], + ], + files: [], + visualComponents: [], + breakpoints: [ + { id: 'desktop', label: 'Desktop', width: 1440, icon: 'monitor' }, + ], + settings, + styleRules: {}, + createdAt: 1000, + updatedAt: 2000, + }, + }) + + const entryBaseRow: 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', + } + + // Guards the split between the entry's SEO `<head>` overrides and its real + // title: `seoTitle` / `seoDescription` drive `<title>` and + // `<meta name="description">`, while both on-page title bindings + // (`{currentEntry.title}` and `{page.title}`) keep rendering the plain + // title. `page.title` feeds the binding frame as well as the meta tag, so + // writing the SEO override onto it would leak into visible page content. + it('routes seoTitle/seoDescription into <head> without touching the on-page title bindings', async () => { + const snap = entrySnapshotWithSettings({ shortcuts: {} }) + + const withSeo = await renderPublishedDataRowTemplate( + snap, + { + ...entryBaseRow, + cells: { + title: 'Plain H1 Title', + seoTitle: 'SEO Override Title', + seoDescription: 'SEO override description', + }, + }, + { db: makeFakeDb(snap) }, + ) + expect(withSeo?.html).toContain('<title>SEO Override Title') + expect(withSeo?.html).toContain( + '', + ) + expect(withSeo?.html).toContain('

Plain H1 Title

') + expect(withSeo?.html).toContain('

Breadcrumb: Plain H1 Title

') + expect(withSeo?.html).not.toContain('Breadcrumb: SEO Override Title') + + resetForTests() + + // No SEO cells → byte-identical to the pre-SEO behaviour: entry title in + // ``, no description tag at all. + const withoutSeo = await renderPublishedDataRowTemplate( + snap, + { ...entryBaseRow, cells: { title: 'Plain H1 Title' } }, + { db: makeFakeDb(snap) }, + ) + expect(withoutSeo?.html).toContain('<title>Plain H1 Title') + expect(withoutSeo?.html).not.toContain('Plain H1 Title') + expect(blankSeo?.html).not.toContain(' { + const snap = entrySnapshotWithSettings({ + metaTitle: 'Site Wide Meta Title', + metaDescription: 'Site wide description', + shortcuts: {}, + }) + + const withSeo = await renderPublishedDataRowTemplate( + snap, + { + ...entryBaseRow, + cells: { + title: 'Plain H1 Title', + seoTitle: 'SEO Override Title', + seoDescription: 'SEO override description', + }, + }, + { db: makeFakeDb(snap) }, + ) + expect(withSeo?.html).toContain('SEO Override Title') + expect(withSeo?.html).toContain( + '', + ) + + resetForTests() + + // Without an entry override the site-level settings still win over the + // entry title, unchanged from before. + const withoutSeo = await renderPublishedDataRowTemplate( + snap, + { ...entryBaseRow, cells: { title: 'Plain H1 Title' } }, + { db: makeFakeDb(snap) }, + ) + expect(withoutSeo?.html).toContain('Site Wide Meta Title') + expect(withoutSeo?.html).toContain( + '', + ) + }) + 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..56f902d6a 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 { readEntrySeoOverride, stripPostTypeBuiltInCells } from '../cells' describe('stripPostTypeBuiltInCells', () => { it('drops the six post-type built-in field ids and keeps custom cells', () => { @@ -30,3 +30,30 @@ describe('stripPostTypeBuiltInCells', () => { expect(stripPostTypeBuiltInCells({})).toEqual({}) }) }) + +describe('readEntrySeoOverride', () => { + it('returns both authored SEO fields', () => { + expect( + readEntrySeoOverride({ title: 'Plain Title', seoTitle: 'SEO Title', seoDescription: 'SEO Desc' }), + ).toEqual({ title: 'SEO Title', description: 'SEO Desc' }) + }) + + it('omits a field the author left blank so it falls through to site settings', () => { + expect(readEntrySeoOverride({ title: 'Plain Title', seoTitle: 'SEO Title' })).toEqual({ + title: 'SEO Title', + }) + expect(readEntrySeoOverride({ seoTitle: ' ', seoDescription: '' })).toEqual({}) + }) + + it('never falls back to the plain title — that stays page.title', () => { + expect(readEntrySeoOverride({ title: 'Plain Title' })).toEqual({}) + }) + + it('ignores non-string cells', () => { + expect(readEntrySeoOverride({ seoTitle: 42, seoDescription: null })).toEqual({}) + }) + + it('trims surrounding whitespace', () => { + expect(readEntrySeoOverride({ seoTitle: ' SEO Title ' })).toEqual({ title: 'SEO Title' }) + }) +}) diff --git a/src/core/data/cells.ts b/src/core/data/cells.ts index 1947218fd..eeec570be 100644 --- a/src/core/data/cells.ts +++ b/src/core/data/cells.ts @@ -111,6 +111,31 @@ export function readSeoDescriptionCell(cells: DataRowCells): string { return readStringCell(cells, 'seoDescription') } +/** + * The author-set `` overrides for a post-type entry — its `seoTitle` + * and `seoDescription` built-in fields. Both entry render paths + * (`renderPublishedDataRowTemplate` for publish, `handleRowPreview` for the + * Content editor's Live mode) hand the result to `publishPage` as + * `documentMeta`, so publish and preview stay in parity. + * + * A blank field is omitted rather than returned empty, so it falls through + * to the site-level `metaTitle` / `metaDescription` exactly as before. + * + * This deliberately never writes to `page.title`: that also feeds the + * `{page.title}` binding, which must keep rendering the entry's real title. + */ +export function readEntrySeoOverride(cells: DataRowCells): { + title?: string + description?: string +} { + const title = readSeoTitleCell(cells).trim() + const description = readSeoDescriptionCell(cells).trim() + return { + ...(title ? { title } : {}), + ...(description ? { description } : {}), + } +} + /** * The subset of a row's cells that belongs to CUSTOM (non-built-in) fields. * The Content authoring UI edits the post-type built-ins through dedicated diff --git a/src/core/publisher/index.ts b/src/core/publisher/index.ts index b66205279..d7c845234 100644 --- a/src/core/publisher/index.ts +++ b/src/core/publisher/index.ts @@ -6,7 +6,7 @@ // relative paths and never through this barrel. export { publishPage } from './render' -export type { PublishedRuntimePackageImportmap } from './render' +export type { DocumentMetaOverride, PublishedRuntimePackageImportmap } from './render' export { renderNode, resolveSpecialRenderer, getSpecialRendererModuleIds } from './renderNode' diff --git a/src/core/publisher/render.ts b/src/core/publisher/render.ts index c7ead0b2a..ced8e5874 100644 --- a/src/core/publisher/render.ts +++ b/src/core/publisher/render.ts @@ -147,6 +147,26 @@ interface PublishPageOptions { * HTML representation the agent targets nodes through. */ annotateNodeIds?: boolean + /** + * Per-render `` overrides that outrank the site-level settings. + * + * Post-type entries carry their own authored SEO title / description + * (`seoTitle` / `seoDescription`). Those belong to the row, not to the + * composed template `Page`, and they must not be written onto + * `page.title` — that value also feeds the `{page.title}` binding and has + * to keep rendering the entry's real title. The entry render paths pass + * them here instead. + */ + documentMeta?: DocumentMetaOverride +} + +/** + * `` values supplied by the caller for this render only. An omitted + * (or blank) key falls through to the site settings. + */ +export interface DocumentMetaOverride { + title?: string + description?: string } /** @@ -297,9 +317,14 @@ function bodyHtmlAttributes(value: unknown): string { } /** - * `` metadata tags derived from site settings + page. + * `` metadata tags derived from the caller's overrides + site + * settings + page. * - * - `title` falls back through metaTitle → page.title → site.name. + * - `title` falls back through the caller's `documentMeta.title` (a + * post-type entry's authored SEO title) → metaTitle → page.title → + * site.name. + * - `description` falls back through `documentMeta.description` → the + * site-level metaDescription. * - URL-typed settings (faviconUrl) are validated by * isSafeUrl() (blocks `javascript:` / `vbscript:` schemes) and then * escapeHtml()'d for safe attribute interpolation. @@ -313,17 +338,22 @@ interface DocumentMetaTags { langAttr: string } -function buildDocumentMetaTags(site: SiteDocument, page: Page): DocumentMetaTags { +function buildDocumentMetaTags( + site: SiteDocument, + page: Page, + override: DocumentMetaOverride = {}, +): DocumentMetaTags { const { settings } = site - const metaDesc = settings.metaDescription - ? `\n ` + const description = override.description || settings.metaDescription + const metaDesc = description + ? `\n ` : '' const favicon = settings.faviconUrl && isSafeUrl(settings.faviconUrl) ? `\n ` : '' return { - pageTitle: escapeHtml(settings.metaTitle ?? page.title ?? site.name), + pageTitle: escapeHtml(override.title || (settings.metaTitle ?? page.title ?? site.name)), metaDesc, favicon, langAttr: escapeHtml(settings.language ?? 'en'), @@ -555,7 +585,7 @@ export function publishPage( acc.cssMap, ) - const meta = buildDocumentMetaTags(site, page) + const meta = buildDocumentMetaTags(site, page, options.documentMeta) const runtime = buildRuntimeAssetsBlock(options, acc) const csp = buildContentSecurityPolicy(runtime.anyScriptTag, runtime.importmap, acc.cspSources) diff --git a/tests/e2e/content-seo-meta.e2e.ts b/tests/e2e/content-seo-meta.e2e.ts new file mode 100644 index 000000000..f533802b2 --- /dev/null +++ b/tests/e2e/content-seo-meta.e2e.ts @@ -0,0 +1,124 @@ +import { expect, test } from '@playwright/test' +import { + ANONYMOUS_STATE, + OWNER, + PUBLIC_BASE_URL, + insertModuleViaPicker, + insertNotchModule, + login, + openSiteEditor, + openSitePanel, + publishDraft, + setPropValue, +} from './helpers' + +/** + * CONTENT-010 — the Content settings panel's "SEO title" and "SEO + * description" fields must reach the published entry's ``, and must not + * leak into the entry's visible on-page title. + */ +test.describe('entry SEO meta', () => { + test.use({ storageState: ANONYMOUS_STATE }) + + test('published post uses the SEO title and SEO description in (CONTENT-010)', async ({ + page, + browser, + }) => { + await login(page) + const suffix = Date.now().toString(36) + const title = `Plain Entry Title ${suffix}` + const slug = `seo-meta-${suffix}` + const seoTitle = `SEO Override Title ${suffix}` + const seoDescription = `SEO override description ${suffix}` + + await test.step('author and publish a Posts entry template', async () => { + await openSiteEditor(page) + await openSitePanel(page) + await page.getByRole('button', { name: 'New template', exact: true }).click() + const dialog = page.getByRole('dialog', { name: 'Template settings' }) + await expect(dialog).toBeVisible() + await dialog.getByLabel('Name').fill(`SEO Meta Template ${suffix}`) + await dialog.getByLabel('Slug').fill(`seo-meta-template-${suffix}`) + await dialog.getByLabel('Applies to').click() + await page.getByRole('option', { name: 'Post types' }).click() + await dialog.getByLabel('Posts').setChecked(true) + await dialog.getByLabel('Priority').fill('300') + await dialog.getByRole('button', { name: 'Save' }).click() + await expect(dialog).toBeHidden() + + await insertNotchModule(page, 'text') + await setPropValue(page, 'text', '{currentEntry.title}') + await page.locator('#ctrl-tag').click() + await page.getByRole('option', { name: 'Heading 1', exact: true }).click() + await expect(page.locator('#ctrl-tag')).toHaveValue('Heading 1') + await insertModuleViaPicker(page, 'base.outlet') + await publishDraft(page) + }) + + await test.step('create a post with a distinct SEO title and description', async () => { + await page.goto('/admin/content') + const newPost = page.getByRole('button', { name: 'New post', exact: true }) + await expect(newPost).toBeEnabled() + await newPost.click() + await expect(page.getByRole('textbox', { name: 'Title', exact: true })).toHaveValue('') + + await page.getByRole('textbox', { name: 'Title', exact: true }).fill(title) + await page.getByRole('textbox', { name: 'Slug' }).fill(slug) + await page.getByTestId('content-body-editor').click() + await page.keyboard.type('Body for the SEO meta test.') + + const settingsPanel = page.getByTestId('content-settings-panel') + await settingsPanel.getByLabel('SEO title').fill(seoTitle) + await settingsPanel.getByLabel('SEO description').fill(seoDescription) + + await page.getByRole('button', { name: 'More publishing actions' }).click() + const saveResponse = page.waitForResponse((response) => + /\/admin\/api\/cms\/data\/rows\/[^/]+$/.test(new URL(response.url()).pathname) && + response.request().method() === 'PATCH', + ) + await page.getByTestId('toolbar-content-save-draft-action').click() + expect((await saveResponse).ok()).toBe(true) + }) + + await test.step('publish the post', async () => { + await page.getByRole('button', { name: 'Publish post' }).click() + const stepUp = page.getByTestId('step-up-dialog') + if (await stepUp.waitFor({ state: 'visible', timeout: 10_000 }).then(() => true, () => false)) { + await page.getByTestId('step-up-password').fill(OWNER.password) + await page.getByTestId('step-up-confirm').click() + await expect(stepUp).toBeHidden({ timeout: 20_000 }) + } + await expect( + page.getByRole('button', { name: 'Published', exact: true }), + ).toBeDisabled({ timeout: 20_000 }) + }) + + await test.step('the public entry page carries the SEO meta in ', async () => { + const context = await browser.newContext() + const visitor = await context.newPage() + try { + // Assert on the served bytes: `` meta tags are not reachable + // through the accessibility tree the other public specs use. + const response = await visitor.goto(`${PUBLIC_BASE_URL}/posts/${slug}`) + const html = (await response?.text()) ?? '' + const titleTag = /([^<]*)<\/title>/.exec(html)?.[1] ?? '(no <title> tag)' + const descTag = + /<meta name="description" content="([^"]*)">/.exec(html)?.[1] ?? + '(no meta description tag)' + + expect(titleTag, 'document <title> must use the authored SEO title').toBe(seoTitle) + expect(descTag, 'meta description must use the authored SEO description').toBe( + seoDescription, + ) + // The SEO override belongs in `<head>` only — the entry's own title + // still headlines the page. + await expect(visitor.getByRole('heading', { name: title })).toBeVisible() + expect(html, 'the SEO title must not leak into the page body').not.toContain( + `<h1>${seoTitle}</h1>`, + ) + } finally { + await context.close() + } + }) + }) +})