diff --git a/docs/README.md b/docs/README.md index 25ca21208..5d480d421 100644 --- a/docs/README.md +++ b/docs/README.md @@ -138,6 +138,7 @@ Three categories, three voices: |------------------------------------------------------------------|----------------------------------------------------------------------| | [features/plugin-system.md](features/plugin-system.md) | The plugin system end-to-end: package shape, lifecycle, sandbox, SDK, permissions, CLI | | [features/publisher.md](features/publisher.md) | The page-tree-to-HTML/CSS renderer + server-side publishing wrappers | +| [features/seo.md](features/seo.md) | SEO & AEO: metadata model, JSON-LD, robots/sitemap, the SEO workspace | | [features/visual-components.md](features/visual-components.md) | VCs, slots, params, instantiation, recursion guard | | [features/content-storage.md](features/content-storage.md) | `data_tables` + `data_rows` — the universal content store | | [features/content-workspace.md](features/content-workspace.md) | Content workspace UI: collections, entries, body editor, settings panel | diff --git a/docs/editor.md b/docs/editor.md index 086012952..7851b9644 100644 --- a/docs/editor.md +++ b/docs/editor.md @@ -242,7 +242,7 @@ src/admin/ ### Cross-page primitives - **`SpotlightRoot`** — Cmd+K command palette. Owns its own command registry (`spotlight/commands/`), provider runner (`providers/`), scopes, keybindings, recents, telemetry. Available from every workspace. -- **`AdminSectionNavigation`** — top-of-screen workspace switcher. +- **`AdminSectionNavigation`** — top-of-screen workspace switcher. Ends with the **Tools** dropdown: first-party utility screens (SEO at `/admin/tools/seo`) plus plugin admin pages (their `/admin/plugins/:pluginId/:pageId` routes are unchanged — only the nav grouping moved). - **`AccountMenuButton`** — top-right avatar / account menu. - **`Panel`, `PanelHeader`, `SidebarResizeHandle`** — generic floating-panel chrome reused across the editor, content, and data workspaces. - **`StepUp`** — re-auth dialog gating sensitive actions. @@ -586,7 +586,7 @@ The sidebar shell expands/collapses by animating `--*-panel-width`. The panel sl | Section | What it contains | |---------------|------------------------------------------------------------------------------| -| General | Site name, meta title, meta description, language, favicon | +| General | Site name, language, favicon (site-wide SEO copy moved to `/admin/tools/seo`) | | Shortcuts | Auto-rendered keyboard shortcut reference from the keybindings registry | | Publishing | Self-hosted runtime info + framework CSS tree-shaking toggle | | Preferences | Catalog-driven editor preferences (auto-rendered from `PREFERENCE_CATALOG`) | diff --git a/docs/features/auth-and-access.md b/docs/features/auth-and-access.md index 934bd1443..89c631772 100644 --- a/docs/features/auth-and-access.md +++ b/docs/features/auth-and-access.md @@ -117,7 +117,7 @@ Users can list active sessions and revoke them individually. `revokeOtherSession ## Capabilities -36 core capabilities. The canonical list is in `src/core/capabilities.ts` (`@core/capabilities`) as an `as const` array; `CoreCapability` is derived from it via `typeof CORE_CAPABILITIES[number]`: +40 core capabilities. The canonical list is in `src/core/capabilities.ts` (`@core/capabilities`) as an `as const` array; `CoreCapability` is derived from it via `typeof CORE_CAPABILITIES[number]`: ```ts // src/core/capabilities.ts — source of truth @@ -131,8 +131,11 @@ export const CORE_CAPABILITIES = [ 'runtime.dependencies', 'storage.elect', 'storage.migrate', 'plugins.read', 'plugins.configure', 'plugins.install', 'plugins.lifecycle', 'users.manage', 'roles.manage', 'audit.read', - 'data.tables.read', 'data.tables.manage', 'data.rows.move', 'data.export', 'data.import', + 'data.custom.tables.read', 'data.custom.tables.manage', + 'data.system.tables.read', 'data.system.tables.manage', + 'data.rows.move', 'data.export', 'data.import', 'ai.chat', 'ai.tools.write', 'ai.providers.manage', 'ai.audit.read', + 'seo.read', 'seo.manage', ] as const export type CoreCapability = typeof CORE_CAPABILITIES[number] @@ -175,7 +178,7 @@ Four system roles, defined in `SYSTEM_ROLES`: |---------|-----------|------------------------------------------------------------------------------|-------------| | Owner | `owner` | All `CORE_CAPABILITIES` | Owner-only `roles.manage`. Resyncs on every boot via `syncSystemRoles(db)`. | | Admin | `admin` | All except `roles.manage` | Force-resynced on every boot. Hand-edits restored at next boot. | -| Client | `client` | `dashboard.read`, `site.read`, `site.content.edit`, `media.read`, `data.tables.read` | Editable | +| Client | `client` | `dashboard.read`, `site.read`, `site.content.edit`, `media.read`, `data.custom.tables.read` | Editable | | Member | `member` | (none) | Editable | `listRoles(db)` returns the built-ins in rank order (`owner`, `admin`, `client`, `member`), followed by custom roles alphabetized by name. Custom roles can be created via `roles.manage` (Owner-only). Roles are persisted in the `roles` table with `capabilities_json: CoreCapability[]`. diff --git a/docs/features/content-storage.md b/docs/features/content-storage.md index 611ef004f..98099a9e5 100644 --- a/docs/features/content-storage.md +++ b/docs/features/content-storage.md @@ -80,7 +80,7 @@ Used to render the **currently-published** page (vs. the in-progress draft on th | `kind` | Authored in | Built-in fields | Workflow | Notes | |--------------|-----------------------------------|-----------------|----------|------------------------------------------------| -| `postType` | Content workspace (`/admin/content`) | `title`, `slug`, `body` (text), `featuredMedia`, `seoTitle`, `seoDescription` | `draft / published / unpublished / scheduled` + versions | Built-in fields cannot be renamed or deleted, only enabled / disabled. | +| `postType` | Content workspace (`/admin/content`) | `title`, `slug`, `body` (text), `featuredMedia`, `seo` (structured `SeoMetadata`) | `draft / published / unpublished / scheduled` + versions | Built-in fields cannot be renamed or deleted, only enabled / disabled. | | `data` | Data workspace grid (`/admin/data`) | none | none | Pure user-defined fields. Like a database table.| | `page` | Site workspace (`/admin/site`) | `title`, `slug`, `body` (pageTree) | same as `postType` | Each row is a CMS page. `body` cell holds the `NodeTree`. | | `component` | Site workspace, VC mode | `name`, `tree` (pageTree), `params` (fieldSchema), `description` | none | Each row is a Visual Component. See [docs/features/visual-components.md](visual-components.md). | @@ -249,7 +249,7 @@ The field appears in the postType's edit form and is queryable from loops. 1. Open the Data workspace. 2. Create a new `data_table` with `kind: 'postType'`. -3. The system seeds the built-in fields (`title`, `slug`, `body`, `featuredMedia`, `seoTitle`, `seoDescription`). +3. The system seeds the built-in fields (`title`, `slug`, `body`, `featuredMedia`, `seo`). 4. Add custom fields as needed. 5. Add posts via the Content workspace. 6. Create a post-type template in the Site workspace if the collection needs public detail pages. @@ -320,8 +320,9 @@ There's also one filter — `content.entry.cells` — that runs over the cell ba api.cms.hooks.filter('content.entry.cells', (cells, { tableSlug, entryId, actor }) => { if (tableSlug !== 'pages') return cells if (actor.kind === 'plugin' && actor.pluginId === api.plugin.id) return cells - if (!cells.metaDescription && typeof cells.body === 'string') { - return { ...cells, metaDescription: cells.body.slice(0, 160) } + const seo = (cells.seo ?? {}) as { description?: string } + if (!seo.description && typeof cells.body === 'string') { + return { ...cells, seo: { ...seo, description: cells.body.slice(0, 160) } } } return cells }) diff --git a/docs/features/content-workspace.md b/docs/features/content-workspace.md index dc75053c1..ee8633a18 100644 --- a/docs/features/content-workspace.md +++ b/docs/features/content-workspace.md @@ -139,7 +139,7 @@ The mode switch is client-only. The markdown body is the source of truth in both | Hook | Source | Owns | |------|--------|------| | `useContentWorkspace` | `hooks/useContentWorkspace.ts` | Collection list, entry list, selection, CRUD operations, error state | -| `useContentEntryDraft` | `hooks/useContentEntryDraft.ts` | In-memory field state (`title`, `body`, `slug`, `featuredMediaId`, `seoTitle`, `seoDescription`), save / publish / status-change handlers | +| `useContentEntryDraft` | `hooks/useContentEntryDraft.ts` | In-memory field state (`title`, `body`, `slug`, `featuredMediaId`, `seoTitle`/`seoDescription` — merged into the structured `seo` cell on save), save / publish / status-change handlers | | `useContentMediaPicker` | `hooks/useContentMediaPicker.ts` | Media picker modal open/close, featured media asset hydration, body media insert | --- diff --git a/docs/features/dashboard.md b/docs/features/dashboard.md index 7da186e31..99c7f28ab 100644 --- a/docs/features/dashboard.md +++ b/docs/features/dashboard.md @@ -30,10 +30,9 @@ src/admin/pages/dashboard/ │ ├── DashboardGrid.module.css — the 1px-gap pattern + customize-mode transitions │ ├── BlockLibrary.tsx — bottom-docked dock of unused widgets in customize mode │ ├── BlockLibrary.module.css -│ ├── OnboardingPanel.tsx — first-run setup checklist -│ ├── OnboardingPanel.module.css -│ ├── LiquidProgressRing.tsx — animated liquid-filled ring (onboarding completion) -│ └── LiquidProgressRing.module.css +│ ├── OnboardingPanel.tsx — first-run setup checklist (completion ring: +│ │ the shared @ui LiquidProgressRing primitive) +│ └── OnboardingPanel.module.css ├── hooks/ │ ├── useDashboardLayout.ts — layout state (positions / sizes) + DnD + resize math │ ├── useDashboardStats.ts — fetches /admin/api/cms/dashboard diff --git a/docs/features/data-workspace.md b/docs/features/data-workspace.md index 62b921fce..b39343ff0 100644 --- a/docs/features/data-workspace.md +++ b/docs/features/data-workspace.md @@ -91,7 +91,7 @@ Tiers enforced by the guard functions: | Tier | Field IDs | Edit affordance | Delete affordance | |------|-----------|-----------------|-------------------| | Mandatory built-in (postType) | `title`, `slug` | None — locked row, no edit/delete buttons | Blocked | -| Optional built-in (postType) | `body`, `featuredMedia`, `seoTitle`, `seoDescription` | Description + required only; label locked | Allowed | +| Optional built-in (postType) | `body`, `featuredMedia`, `seo` | Description + required only; label locked | Allowed | | Built-in on a **system table** | every `builtIn` field | None — fully locked row | Blocked | | Custom | all others | Fully editable | Allowed if not the primary field | diff --git a/docs/features/plugin-system.md b/docs/features/plugin-system.md index 2e3d96aca..8c5220653 100644 --- a/docs/features/plugin-system.md +++ b/docs/features/plugin-system.md @@ -542,7 +542,7 @@ const pagesTable = await api.cms.content.tables.get('pages') const pages = api.cms.content.table('pages') const result = await pages.list({ status: 'published', limit: 50 }) const entry = await pages.get(entryId) -await pages.update(entryId, { cells: { seoTitle: 'New title' } }) +await pages.update(entryId, { cells: { seo: { title: 'New title' } } }) await pages.publish(entryId) await pages.delete(entryId) @@ -585,8 +585,9 @@ Filter that runs before persistence — validate, normalize, auto-fill: ```js api.cms.hooks.filter('content.entry.cells', (cells, { tableSlug, entryId, actor }) => { if (tableSlug !== 'pages') return cells - if (!cells.metaDescription && typeof cells.body === 'string') { - return { ...cells, metaDescription: cells.body.slice(0, 160) } + const seo = (cells.seo ?? {}) as { description?: string } + if (!seo.description && typeof cells.body === 'string') { + return { ...cells, seo: { ...seo, description: cells.body.slice(0, 160) } } } return cells }) diff --git a/docs/features/publisher.md b/docs/features/publisher.md index 3f130b28d..41498b102 100644 --- a/docs/features/publisher.md +++ b/docs/features/publisher.md @@ -299,14 +299,18 @@ The publisher emits `` in this order: 1. `` 2. `` -3. `` from `page.title` -4. `<meta name="description">` if present in page settings -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`) -8. `<link rel="stylesheet" href="/_instatic/css/<bundle>-<hash>.css">` per bundle -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 +3. The resolved SEO block (`src/core/publisher/seoHead.ts`): `<title>`, + description, canonical, robots, Open Graph + X card tags, and one + `<script type="application/ld+json">` per JSON-LD entity. Values come from + the shared `@core/seo` resolver — the server pre-resolves page/row SEO + (incl. the configured public origin); previews/exports use `publishPage`'s + internal fallback. See [docs/features/seo.md](seo.md). +4. `<link rel="icon">` if a favicon is configured +5. `<script type="importmap">` mapping bare specifiers (e.g. `three`) to `/_instatic/runtime/cache/<hash>/...` URLs +6. Runtime asset `<script>` tags (`scriptTagsForRuntimeAssets`) +7. `<link rel="stylesheet" href="/_instatic/css/<bundle>-<hash>.css">` per bundle +8. **`head` placement** plugin-injected tags (after the publisher's own head, before custom user head content) +9. `<meta http-equiv="Content-Security-Policy" content="...">` — assembled based on what's actually in the page 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. diff --git a/docs/features/seo.md b/docs/features/seo.md new file mode 100644 index 000000000..ee5eb167c --- /dev/null +++ b/docs/features/seo.md @@ -0,0 +1,200 @@ +# SEO & AEO + +Search and answer-engine optimization is a core publishing capability: +structured metadata per page/post, site-wide defaults with title patterns, +Open Graph + X cards, schema.org JSON-LD, generated `robots.txt` with +AI-crawler controls, `sitemap.xml`, and an admin workspace at +`/admin/tools/seo` with AI-assisted copy suggestions. + +Spec history: `docs/superpowers/specs/2026-06-12-seo-workspace-design.md`. + +--- + +## The model + +One engine module owns everything: **`src/core/seo/`** (barrel-gated). + +| File | Responsibility | +| --- | --- | +| `schema.ts` | `SeoMetadataSchema` (per-target object stored in `cells_json.seo`), `SiteSeoSettingsSchema` (`site.settings.seo`: title pattern, description, default social image, X handle/card, organization, robots, sitemap) | +| `resolve.ts` | `resolveSeoMetadata` — the single fallback engine (see below) | +| `jsonLd.ts` | `buildJsonLdEntities` (`WebSite`, `Organization`, `Article`, `BreadcrumbList`) + `serializeJsonLd` (escapes `</script`, `<!--`) | +| `robots.ts` | `generateRobotsTxt` — serves the stored body verbatim (`SeoRobotsSettings.content`), falling back to `DEFAULT_ROBOTS_TEMPLATE` when empty and appending the origin-resolved `Sitemap:` line unless the body already has one. `SYSTEM_DISALLOW_PATHS` seeds the default template + the tab's "block system paths" shortcut. `blockAll` serves a bare `Disallow: /` for preview hosts. | +| `robotsAnalysis.ts` | `lintRobotsTxt` (flags unknown directives, rules before any `User-agent`, malformed values) + `matchRobots` (is a path crawlable for a UA — Google longest-match, `Allow` tie-break, `*`/`$` support). Powers the Robots tab's issue list and URL tester, plus the contextual recommendations. | +| `aiCrawlers.ts` | The AI-crawler user-agent lists the Robots tab's "block AI crawlers" shortcuts insert (training + answer groups) | +| `health.ts` | `computeSeoReport` — per-target weighted checks + 0–100 score (`aggregateSeoScore`, `seoScoreTier` for the site-wide rollup and good/fair/poor tiers) | +| `lengthMeter.ts` | Approximate pixel-width metering with an ideal band (~580px title / ~990px description budgets; "ok" only between the too-short minimum and the budget) | + +### Storage + +- **Targets** (`page` + `postType` rows): one built-in `seoMetadata` field with + id `seo`; the structured object lives in `cells_json.seo`. Not offered as a + user-created custom field type; not bindable; not form-submittable. +- **Site defaults**: `site.settings.seo` (replaced the legacy + `metaTitle`/`metaDescription` settings). +- **Templates**: an entry template page row's `seo.title`/`seo.description` + act as token patterns for every matching post. Only entry templates + (postTypes targets) are SEO targets — `everywhere` layout templates have + no route or content of their own, so the target index excludes them (the + wrapped pages own the metadata). + +### Resolution — two-stage title + +`resolveSeoMetadata` is shared by the publisher, the admin previews, and the +score reports — what the editor shows IS what gets emitted. + +``` +baseTitle = target.seo.title ?? row/page title +pattern = template.seo.title ?? site.seo.titlePattern // {source.field} tokens +title = explicit target title (pattern skipped) + | interpolate(pattern) // shared token engine + | baseTitle ?? site.name +``` + +Patterns use the existing `{source.field}` token engine +(`src/core/templates/tokenInterpolation.ts`) — `{page.title}`, `{site.name}`, +`{currentEntry.title}`. There is no second token grammar. + +Social fields fall back search → OG → X. `noindex` emits `noindex` only +(never a silent `nofollow`). + +### Absolute URLs + +Canonical, `og:url`, sitemap `<loc>`, and origin-dependent JSON-LD use the +configured public origin (`PUBLIC_ORIGINS` env → `canonicalPublicOrigin()` in +`server/auth/security.ts`). Static HTML is baked at publish time, so with no +origin configured those tags are **omitted** — never a guessed host. The +dynamic `robots.txt`/`sitemap.xml` endpoints fall back to the request origin. + +## Published output + +`src/core/publisher/seoHead.ts` builds the head: title, description, +canonical, robots, OG (incl. `og:locale`, `og:site_name`, `article:*_time`), +X cards (`twitter:*` tag names), and one +`<script type="application/ld+json">` per entity. The server resolves the +payload per route in `server/publish/publicRenderer.ts` (page SEO from the +snapshot, row SEO from the published version's cells + entry-template +patterns); `publishPage` has an internal fallback so previews/exports run the +same resolver. + +JSON-LD (zero-config in v1): `WebSite` + `Organization` on the homepage, +`Article` on row routes, `BreadcrumbList` on routes deeper than one segment. +Noindex targets emit none. + +## robots.txt and sitemap.xml + +`server/publish/seoEndpoints.ts`, dispatched by `server/router.ts` BEFORE +static assets and public rendering. Both generate from the **published +snapshot** and cache keyed by `publishVersion` — SEO follows the publish +lifecycle (edit → publish → live). + +- `robots.txt` (`text/plain`): the admin-authored body (or the default + template — allow all + system-path disallows for `/admin` + `/_instatic/`), + with a `Sitemap:` line appended unless the body already declares one. The + Robots tab's shortcuts compose common bodies (block AI crawlers from + `AI_TRAINING_CRAWLERS` / `AI_ANSWER_CRAWLERS`, block system paths, block + everything). +- `sitemap.xml` (`application/xml`): published routable pages + post rows, + excluding templates, `noindex` targets, and + `site.settings.seo.sitemap.excludedTargets`; `<lastmod>` from publish + timestamps. Disabled ⇒ 404. + +### Environment protection + +`requestHostIsCanonical(req)` (in `server/auth/security.ts`) compares the +request `Host` (trusted; never `X-Forwarded-*`) against the configured +`PUBLIC_ORIGINS`. On a non-canonical host — a preview/staging deploy whose +host isn't a configured origin — `robots.txt` serves a blanket `Disallow: /` +(uncached) and the static-asset, upload, and public-page handlers stamp +`X-Robots-Tag: noindex, nofollow`, so a non-production deploy can't be +indexed even via a direct asset URL. With no public origin configured the +predicate returns `null` and behavior is unchanged (local dev / unconfigured +installs are unaffected). + +## Admin workspace — `/admin/tools/seo` + +`src/admin/pages/seo/`. Reached from the **Tools** nav dropdown +(`AdminSectionNavigation`), which also hosts plugin admin pages (their +`/admin/plugins/:pluginId/:pageId` routes are unchanged — only nav grouping +moved). + +Save/publish lives in the **toolbar**, like the Site and Content workspaces: +the active editor (target editor, site defaults, robots, sitemap) registers +itself on a save bridge (`hooks/useSeoSaveBridge.ts`) and `SeoToolbar` +renders the shared `PublishActionGroup` (status dot, split "Save & publish" +button, Save draft + Open live URL menu). Posts publish through the +incremental row endpoint; everything else runs the step-up-gated full site +publish — the same machinery as the Site toolbar, not a parallel path. + +- **Meta tab** — one flat three-column grid. Left: site-wide SEO score in a + `LiquidProgressRing` (tier-toned liquid, computed from one shared + `indexSeoTargets` pass) over the target index (search, + All/Pages/Posts/Templates/Issues filters, clickable issues line, tiered + score pills per row, ↑/↓/Enter + `/` keyboard nav, pinned Site defaults + row). Middle: the editor form with a live score chip and a clickable + improvements list (each row focuses the field it describes). Right: the + sticky Search / Open Graph / X / Schema preview rail — the editors render + form + rail as fragment siblings so the grid stays flat. + Controlled `Input`/`Textarea` primitives + (no contentEditable); empty fields show their RESOLVED fallback as + placeholder; title/description carry ideal-band pixel meters (green only + inside the band; inherited values render muted with an Inherited/Missing + tag instead of character counts); images pick from the media library; X + fields hide behind "Customize X preview" until set; switching targets + while dirty asks via an in-app dialog. The Schema view pretty-prints the + exact JSON-LD the publisher will emit. +- **Robots.txt tab** — the file is edited directly: an editable CodeMirror + surface (`SeoCodeEditor`, saving `seo.robots.content`) is the main column, + with an assistant rail on the left — contextual recommendations (block AI + crawlers / system paths, "all crawlers blocked" warning) that double as + one-click document edits, quick-insert shortcuts, a lint issue list + (`lintRobotsTxt`), and a "test a URL" checker (`matchRobots`). No public- + origin notices (production always sets `PUBLIC_ORIGINS`). +- **Sitemap tab** — same shape as Robots: an assistant rail (heading, + the Generate toggle + inclusion count) beside the per-target + include/exclude list as the main column (noindex targets shown disabled + with the reason). Off ⇒ a quiet disabled state. + +All workspace forms render rows through the shared +`components/SeoFormRow.tsx` (`SeoFormRow` / `SeoSwitchRow`): a two-column +grid with muted labels left and the control stack right. + +### AI suggestions + +The sparkle on title/description/OG/X inputs calls +`POST /admin/api/cms/seo/generate` (`server/handlers/cms/seoGenerate.ts`): +one tool-less driver call through the existing `server/ai` stack (provider + +model from the `content`, falling back to `site`, scope default), returning +three suggestions rendered as tappable bubbles with **More options** +(exclude-aware regenerate) and **Reject**. Length budgets ride the prompt. +Suggestions fill the input through the normal dirty/save flow — nothing +auto-saves. + +## API and permissions + +`server/handlers/cms/seo.ts` — `/admin/api/cms/seo/*`: + +| Route | Capability | Notes | +| --- | --- | --- | +| `GET /seo/targets` | `seo.read` | Full target index + site SEO + configured origin (draft cells) | +| `PUT /seo/targets/:kind/:id` | `seo.manage` + target ownership | `pages.edit` for page/template rows; `content.edit.any`/`content.manage` for post rows | +| `PUT /seo/site` | `seo.manage` | Site defaults + robots + sitemap settings (one object) | +| `POST /seo/generate` | `seo.manage` + `ai.chat` | AI suggestions | + +`seo.read` gates the workspace; `seo.manage` gates writes. Publishing +additionally requires the real publish capability for the scope +(`content.publish.*` for post rows, `pages.publish` for the site publish). +Score reports are computed client-side with the shared core resolver, so the +scoreboard, the index, the editor, and the published output agree by +construction. + +## Tests + +- `src/core/seo/__tests__/` — resolver fallback chains, JSON-LD building + + escaping, robots generation, score reports. +- `src/__tests__/publisher/render.test.ts` — emitted head tags. +- `src/__tests__/server/seoEndpoints.test.ts` — robots/sitemap endpoints + + version caching. +- `src/__tests__/server/seoHandler.test.ts` / `seoGenerate.test.ts` — + API + capability gates (real SQLite via the capability harness). +- `src/__tests__/admin/seoWorkspace.test.tsx` — Meta/Robots tabs + Tools nav. diff --git a/docs/features/site-shell.md b/docs/features/site-shell.md index e1e15ea2c..cf4314203 100644 --- a/docs/features/site-shell.md +++ b/docs/features/site-shell.md @@ -121,12 +121,11 @@ CRUD actions on `classSlice`: `addCondition`, `updateCondition`, `removeConditio ```ts type SiteSettings = { - metaTitle?: string - metaDescription?: string faviconUrl?: string language?: string framework?: FrameworkSettings // colors, typography, spacing, preferences — absent when disabled fonts?: SiteFontsSettings // installed font library + editable font tokens + seo?: SiteSeoSettings // site-wide SEO defaults — see docs/features/seo.md shortcuts: Record<string, string> // keyboard shortcut overrides } ``` diff --git a/docs/reference/capabilities.md b/docs/reference/capabilities.md index 0dd7fcdb4..247e5d486 100644 --- a/docs/reference/capabilities.md +++ b/docs/reference/capabilities.md @@ -8,7 +8,7 @@ For the broader auth flow (sessions, MFA, step-up), see [docs/features/auth-and- ## TL;DR -- Defined as a `const` array in `src/core/capabilities.ts` (`@core/capabilities`); `CoreCapability` is derived via `typeof CORE_CAPABILITIES[number]`. **36 capabilities.** +- Defined as a `const` array in `src/core/capabilities.ts` (`@core/capabilities`); `CoreCapability` is derived via `typeof CORE_CAPABILITIES[number]`. **40 capabilities.** - Handlers gate on capability, not on role: `requireCapability(req, db, 'site.read')`. - The **Owner AND Admin** roles get their capability lists force-resynced from `SYSTEM_ROLES` on every server boot. Hand-edits to either built-in role through the admin UI are restored at next boot — they are code-level decisions, not runtime ones. - Adding a capability: append the literal to `CORE_CAPABILITIES` in `src/core/capabilities.ts` (one place — server imports it), add it to the relevant `SYSTEM_ROLES` entries, wire `requireCapability(...)` at the gate point, and add picker meta + groups for the role-edit dialog. The two architecture tests (`capability-picker-coverage.test.ts`, `cms-handlers-capability-gated.test.ts`) catch missing pieces. @@ -16,7 +16,7 @@ For the broader auth flow (sessions, MFA, step-up), see [docs/features/auth-and- --- -## The 36 core capabilities +## The 40 core capabilities ### Read @@ -125,6 +125,13 @@ Was a single `ai.use`. Split so a Client persona can have chat assistance withou | `ai.providers.manage` | Create / update / delete AI provider credentials + per-scope defaults | Owner, Admin | | `ai.audit.read` | Read site-wide AI usage, cost, and error events across all users | Owner, Admin | +### SEO + +| Capability | Grants | Roles | +|---------------|------------------------------------------------------------------------|--------------| +| `seo.read` | Open the SEO workspace (`/admin/tools/seo`); read metadata, robots, sitemap settings | Owner, Admin | +| `seo.manage` | Edit target metadata, site SEO defaults, robots.txt, sitemap settings. Target writes additionally require the owning persona (`pages.edit` / content edit). `POST /seo/generate` additionally requires `ai.chat`. | Owner, Admin | + --- ## Roles @@ -133,8 +140,8 @@ Four built-in `SYSTEM_ROLES`: | Role | id | Capabilities | Boot behaviour | |----------|-----------|------------------------------------------------------------------------------|----------------| -| Owner | `owner` | All 36 (`CORE_CAPABILITIES`) | Force-resynced on every boot. Owner-only `roles.manage`. | -| Admin | `admin` | All 36 except `roles.manage` | **Force-resynced on every boot** (changed from previous "seeded once"). Hand-edits restored at boot. | +| Owner | `owner` | All 40 (`CORE_CAPABILITIES`) | Force-resynced on every boot. Owner-only `roles.manage`. | +| Admin | `admin` | All 40 except `roles.manage` | **Force-resynced on every boot** (changed from previous "seeded once"). Hand-edits restored at boot. | | Client | `client` | `dashboard.read`, `site.read`, `site.content.edit`, `media.read`, `data.custom.tables.read` | Seeded once; freely editable. Sees custom tables only — never the system tables. | | Member | `member` | (none) | Seeded once; freely editable. | diff --git a/docs/reference/ui-primitives.md b/docs/reference/ui-primitives.md index dcb44707c..62439ff92 100644 --- a/docs/reference/ui-primitives.md +++ b/docs/reference/ui-primitives.md @@ -73,6 +73,7 @@ Every interactive control in `src/admin/` goes through one of these. Bare `<butt | `Image` | Image with built-in blurhash fallback | `src`, `blurhash`, `alt`, `width`, `height` | | `CanvasModulePlaceholder` | Diagonal-stripe placeholder for empty modules on the canvas | `label` | | `Kbd` | Single keyboard keycap. Use anywhere a key name appears as a hint. | `children`, `className` | +| `LiquidProgressRing` | Animated liquid-filled progress ring — onboarding completion, the SEO site score. Tier the liquid with `tone`; override the centered fraction with `label`. | `value`, `total`, `size?`, `tone?: 'mint' \| 'amber' \| 'danger'`, `label?`, `ariaLabel?` | | `ShortcutKeys` | Full shortcut sequence ("⌘K", "Ctrl+Shift+P") — splits the label into individual `Kbd` spans. | `label`, `aria-hidden`, `className` | ### Loading / skeleton diff --git a/server/ai/tools/content/systemPrompt.ts b/server/ai/tools/content/systemPrompt.ts index ba56acfb3..bd8661027 100644 --- a/server/ai/tools/content/systemPrompt.ts +++ b/server/ai/tools/content/systemPrompt.ts @@ -13,7 +13,7 @@ import type { ContentSnapshot, ActiveDocument } from './snapshot' const STATIC_PROMPT_PREFIX = `You manage the user's website content (posts, pages, custom collections) by calling tools. No filesystem or shell. Bias toward action — execute the prompt, don't ask scoping questions. Scope: -- Each collection is a typed table of documents (posts, pages, or custom). Documents have a fixed schema: built-in fields (title, slug, body, featuredMedia, seoTitle, seoDescription) plus any custom fields. +- Each collection is a typed table of documents (posts, pages, or custom). Documents have a fixed schema: built-in fields (title, slug, body, featuredMedia, seo) plus any custom fields. - The active document is the one currently open in the editor. Most edits target it; call set_active_document to switch the user's view before editing another doc. - Body content is exchanged as **markdown**. Use standard markdown (headings, paragraphs, lists, links, bold/italic, code, blockquotes) — the bridge converts to the editor's internal format on write. @@ -50,7 +50,7 @@ Media + users: - list_users to look up an author id before set_document_author. Other: -- Field ids are stable (title, slug, body, featuredMedia, seoTitle, seoDescription, plus custom). Use them verbatim; case-sensitive. +- Field ids are stable (title, slug, body, featuredMedia, seo, plus custom). Use them verbatim; case-sensitive. The seo field holds a structured object; set seo.title / seo.description rather than replacing the whole object. - Don't invent option ids for select fields — read the schema first. - create_document success data includes the new id as documentId. - On tool error: read the message and retry with corrected input. diff --git a/server/auth/capabilities.ts b/server/auth/capabilities.ts index a91f84387..452555f90 100644 --- a/server/auth/capabilities.ts +++ b/server/auth/capabilities.ts @@ -78,6 +78,8 @@ const adminCapabilities: CoreCapability[] = [ 'ai.tools.write', 'ai.providers.manage', 'ai.audit.read', + 'seo.read', + 'seo.manage', ] const clientCapabilities: CoreCapability[] = [ diff --git a/server/auth/security.ts b/server/auth/security.ts index dea2bdd1f..5faf62549 100644 --- a/server/auth/security.ts +++ b/server/auth/security.ts @@ -94,12 +94,54 @@ export function expectedOrigin(req: Request): string { return `${proto}://${host}` } +/** + * The canonical configured public origin (`publicOrigins[0]`), or null when + * none is configured. SEO consumers (canonical URLs, og:url, sitemap <loc>, + * JSON-LD) use this: published static HTML must NEVER bake a guessed host, + * so callers that bake artefacts omit absolute URLs when this is null, while + * the dynamic robots/sitemap endpoints fall back to the request origin. + */ +export function canonicalPublicOrigin(): string | null { + return publicOrigins[0] ?? null +} + /** True when the canonical configured public origin uses https. */ export function publicOriginIsHttps(): boolean { const configured = publicOrigins[0] return configured?.startsWith('https://') ?? false } +/** + * Whether this request is arriving on the configured production host. + * + * - `null` — no public origin configured; the caller can't tell, so it + * should leave behavior unchanged (local dev / unconfigured installs). + * - `true` — the request `Host` matches a configured public origin. + * - `false` — a public origin IS configured but this host isn't one of + * them (a preview/staging deploy, a platform domain not in the set). + * + * Compares by host, not full origin: a TLS-terminating edge hands the app + * plain HTTP with the real `Host` header, so the scheme would differ even on + * the canonical domain. Reads only the trusted `Host` header — never + * `X-Forwarded-*` — matching `expectedOrigin`'s threat model. + * + * Used by the robots.txt endpoint and the static/upload/page handlers to + * keep non-production deploys out of search (Disallow + `X-Robots-Tag`). + */ +export function requestHostIsCanonical(req: Request): boolean | null { + if (publicOrigins.length === 0) return null + const host = (req.headers.get('host') ?? new URL(req.url).host).toLowerCase() + if (host === '') return false + for (const origin of publicOrigins) { + try { + if (new URL(origin).host.toLowerCase() === host) return true + } catch { + // Skip malformed configured origins. + } + } + return false +} + /** * True when the request's `Origin` header is acceptable for a state-changing * action. The check is a CSRF defense-in-depth on top of `SameSite=Lax`: diff --git a/server/db/migrations-pg.ts b/server/db/migrations-pg.ts index 0c5d96205..ded847d63 100644 --- a/server/db/migrations-pg.ts +++ b/server/db/migrations-pg.ts @@ -61,8 +61,8 @@ export const pgMigrations: Migration[] = [ -- UI are preserved. insert into roles (id, slug, name, description, is_system, capabilities_json) values - ('owner', 'owner', 'Owner', 'Permanent installation owner with full system access.', true, '["dashboard.read","site.read","site.structure.edit","site.content.edit","site.style.edit","pages.edit","pages.publish","content.create","content.edit.own","content.edit.any","content.publish.own","content.publish.any","content.manage","media.read","media.write","media.replace","media.delete","runtime.dependencies","storage.elect","storage.migrate","plugins.read","plugins.configure","plugins.install","plugins.lifecycle","users.manage","roles.manage","audit.read","data.custom.tables.read","data.custom.tables.manage","data.system.tables.read","data.system.tables.manage","data.rows.move","data.export","data.import","ai.chat","ai.tools.write","ai.providers.manage","ai.audit.read"]'::jsonb), - ('admin', 'admin', 'Admin', 'Full admin access (cannot manage roles).', true, '["dashboard.read","site.read","site.structure.edit","site.content.edit","site.style.edit","pages.edit","pages.publish","content.create","content.edit.own","content.edit.any","content.publish.own","content.publish.any","content.manage","media.read","media.write","media.replace","media.delete","runtime.dependencies","storage.elect","storage.migrate","plugins.read","plugins.configure","plugins.install","plugins.lifecycle","users.manage","audit.read","data.custom.tables.read","data.custom.tables.manage","data.system.tables.read","data.system.tables.manage","data.rows.move","data.export","data.import","ai.chat","ai.tools.write","ai.providers.manage","ai.audit.read"]'::jsonb), + ('owner', 'owner', 'Owner', 'Permanent installation owner with full system access.', true, '["dashboard.read","site.read","site.structure.edit","site.content.edit","site.style.edit","pages.edit","pages.publish","content.create","content.edit.own","content.edit.any","content.publish.own","content.publish.any","content.manage","media.read","media.write","media.replace","media.delete","runtime.dependencies","storage.elect","storage.migrate","plugins.read","plugins.configure","plugins.install","plugins.lifecycle","users.manage","roles.manage","audit.read","data.custom.tables.read","data.custom.tables.manage","data.system.tables.read","data.system.tables.manage","data.rows.move","data.export","data.import","ai.chat","ai.tools.write","ai.providers.manage","ai.audit.read","seo.read","seo.manage"]'::jsonb), + ('admin', 'admin', 'Admin', 'Full admin access (cannot manage roles).', true, '["dashboard.read","site.read","site.structure.edit","site.content.edit","site.style.edit","pages.edit","pages.publish","content.create","content.edit.own","content.edit.any","content.publish.own","content.publish.any","content.manage","media.read","media.write","media.replace","media.delete","runtime.dependencies","storage.elect","storage.migrate","plugins.read","plugins.configure","plugins.install","plugins.lifecycle","users.manage","audit.read","data.custom.tables.read","data.custom.tables.manage","data.system.tables.read","data.system.tables.manage","data.rows.move","data.export","data.import","ai.chat","ai.tools.write","ai.providers.manage","ai.audit.read","seo.read","seo.manage"]'::jsonb), ('client', 'client', 'Client', 'Can edit page copy (text, images, links) but not structure or styles.', true, '["dashboard.read","site.read","site.content.edit","media.read","data.custom.tables.read"]'::jsonb), ('member', 'member', 'Member', 'Public-facing member account — no admin access by default.', true, '[]'::jsonb) on conflict (id) do update @@ -243,7 +243,7 @@ export const pgMigrations: Migration[] = [ insert into data_tables (id, name, slug, kind, route_base, singular_label, plural_label, primary_field_id, system, fields_json) values ('posts', 'Posts', 'posts', 'postType', '/posts', 'Post', 'Posts', 'title', true, - '[{"type":"text","id":"title","label":"Title","required":true,"builtIn":true},{"type":"text","id":"slug","label":"Slug","required":true,"builtIn":true},{"type":"richText","id":"body","label":"Body","format":"markdown","builtIn":true},{"type":"media","id":"featuredMedia","label":"Featured media","mediaKind":"image","builtIn":true},{"type":"text","id":"seoTitle","label":"SEO title","builtIn":true},{"type":"longText","id":"seoDescription","label":"SEO description","builtIn":true}]'::jsonb) + '[{"type":"text","id":"title","label":"Title","required":true,"builtIn":true},{"type":"text","id":"slug","label":"Slug","required":true,"builtIn":true},{"type":"richText","id":"body","label":"Body","format":"markdown","builtIn":true},{"type":"media","id":"featuredMedia","label":"Featured media","mediaKind":"image","builtIn":true},{"type":"seoMetadata","id":"seo","label":"SEO","builtIn":true}]'::jsonb) on conflict (id) do update set name = excluded.name, slug = excluded.slug, @@ -259,7 +259,7 @@ export const pgMigrations: Migration[] = [ insert into data_tables (id, name, slug, kind, route_base, singular_label, plural_label, primary_field_id, system, fields_json) values ('pages', 'Pages', 'pages', 'page', '', 'Page', 'Pages', 'title', true, - '[{"type":"text","id":"title","label":"Title","required":true,"builtIn":true},{"type":"text","id":"slug","label":"Slug","required":true,"builtIn":true},{"type":"pageTree","id":"body","label":"Body","required":true,"builtIn":true},{"type":"text","id":"seoTitle","label":"SEO title","builtIn":true},{"type":"longText","id":"seoDescription","label":"SEO description","builtIn":true},{"type":"boolean","id":"templateEnabled","label":"Template","builtIn":true},{"type":"longText","id":"templateTarget","label":"Template target","builtIn":true},{"type":"number","id":"templatePriority","label":"Template priority","integer":true,"builtIn":true}]'::jsonb) + '[{"type":"text","id":"title","label":"Title","required":true,"builtIn":true},{"type":"text","id":"slug","label":"Slug","required":true,"builtIn":true},{"type":"pageTree","id":"body","label":"Body","required":true,"builtIn":true},{"type":"seoMetadata","id":"seo","label":"SEO","builtIn":true},{"type":"boolean","id":"templateEnabled","label":"Template","builtIn":true},{"type":"longText","id":"templateTarget","label":"Template target","builtIn":true},{"type":"number","id":"templatePriority","label":"Template priority","integer":true,"builtIn":true}]'::jsonb) on conflict (id) do update set name = excluded.name, slug = excluded.slug, diff --git a/server/db/migrations-sqlite.ts b/server/db/migrations-sqlite.ts index b5a5162b5..2b28321ba 100644 --- a/server/db/migrations-sqlite.ts +++ b/server/db/migrations-sqlite.ts @@ -56,8 +56,8 @@ export const sqliteMigrations: Migration[] = [ -- UI are preserved. insert into roles (id, slug, name, description, is_system, capabilities_json) values - ('owner', 'owner', 'Owner', 'Permanent installation owner with full system access.', 1, '["dashboard.read","site.read","site.structure.edit","site.content.edit","site.style.edit","pages.edit","pages.publish","content.create","content.edit.own","content.edit.any","content.publish.own","content.publish.any","content.manage","media.read","media.write","media.replace","media.delete","runtime.dependencies","storage.elect","storage.migrate","plugins.read","plugins.configure","plugins.install","plugins.lifecycle","users.manage","roles.manage","audit.read","data.custom.tables.read","data.custom.tables.manage","data.system.tables.read","data.system.tables.manage","data.rows.move","data.export","data.import","ai.chat","ai.tools.write","ai.providers.manage","ai.audit.read"]'), - ('admin', 'admin', 'Admin', 'Full admin access (cannot manage roles).', 1, '["dashboard.read","site.read","site.structure.edit","site.content.edit","site.style.edit","pages.edit","pages.publish","content.create","content.edit.own","content.edit.any","content.publish.own","content.publish.any","content.manage","media.read","media.write","media.replace","media.delete","runtime.dependencies","storage.elect","storage.migrate","plugins.read","plugins.configure","plugins.install","plugins.lifecycle","users.manage","audit.read","data.custom.tables.read","data.custom.tables.manage","data.system.tables.read","data.system.tables.manage","data.rows.move","data.export","data.import","ai.chat","ai.tools.write","ai.providers.manage","ai.audit.read"]'), + ('owner', 'owner', 'Owner', 'Permanent installation owner with full system access.', 1, '["dashboard.read","site.read","site.structure.edit","site.content.edit","site.style.edit","pages.edit","pages.publish","content.create","content.edit.own","content.edit.any","content.publish.own","content.publish.any","content.manage","media.read","media.write","media.replace","media.delete","runtime.dependencies","storage.elect","storage.migrate","plugins.read","plugins.configure","plugins.install","plugins.lifecycle","users.manage","roles.manage","audit.read","data.custom.tables.read","data.custom.tables.manage","data.system.tables.read","data.system.tables.manage","data.rows.move","data.export","data.import","ai.chat","ai.tools.write","ai.providers.manage","ai.audit.read","seo.read","seo.manage"]'), + ('admin', 'admin', 'Admin', 'Full admin access (cannot manage roles).', 1, '["dashboard.read","site.read","site.structure.edit","site.content.edit","site.style.edit","pages.edit","pages.publish","content.create","content.edit.own","content.edit.any","content.publish.own","content.publish.any","content.manage","media.read","media.write","media.replace","media.delete","runtime.dependencies","storage.elect","storage.migrate","plugins.read","plugins.configure","plugins.install","plugins.lifecycle","users.manage","audit.read","data.custom.tables.read","data.custom.tables.manage","data.system.tables.read","data.system.tables.manage","data.rows.move","data.export","data.import","ai.chat","ai.tools.write","ai.providers.manage","ai.audit.read","seo.read","seo.manage"]'), ('client', 'client', 'Client', 'Can edit page copy (text, images, links) but not structure or styles.', 1, '["dashboard.read","site.read","site.content.edit","media.read","data.custom.tables.read"]'), ('member', 'member', 'Member', 'Public-facing member account — no admin access by default.', 1, '[]') on conflict (id) do update @@ -223,7 +223,7 @@ export const sqliteMigrations: Migration[] = [ insert into data_tables (id, name, slug, kind, route_base, singular_label, plural_label, primary_field_id, system, fields_json) values ('posts', 'Posts', 'posts', 'postType', '/posts', 'Post', 'Posts', 'title', 1, - '[{"type":"text","id":"title","label":"Title","required":true,"builtIn":true},{"type":"text","id":"slug","label":"Slug","required":true,"builtIn":true},{"type":"richText","id":"body","label":"Body","format":"markdown","builtIn":true},{"type":"media","id":"featuredMedia","label":"Featured media","mediaKind":"image","builtIn":true},{"type":"text","id":"seoTitle","label":"SEO title","builtIn":true},{"type":"longText","id":"seoDescription","label":"SEO description","builtIn":true}]') + '[{"type":"text","id":"title","label":"Title","required":true,"builtIn":true},{"type":"text","id":"slug","label":"Slug","required":true,"builtIn":true},{"type":"richText","id":"body","label":"Body","format":"markdown","builtIn":true},{"type":"media","id":"featuredMedia","label":"Featured media","mediaKind":"image","builtIn":true},{"type":"seoMetadata","id":"seo","label":"SEO","builtIn":true}]') on conflict (id) do update set name = excluded.name, slug = excluded.slug, @@ -239,7 +239,7 @@ export const sqliteMigrations: Migration[] = [ insert into data_tables (id, name, slug, kind, route_base, singular_label, plural_label, primary_field_id, system, fields_json) values ('pages', 'Pages', 'pages', 'page', '', 'Page', 'Pages', 'title', 1, - '[{"type":"text","id":"title","label":"Title","required":true,"builtIn":true},{"type":"text","id":"slug","label":"Slug","required":true,"builtIn":true},{"type":"pageTree","id":"body","label":"Body","required":true,"builtIn":true},{"type":"text","id":"seoTitle","label":"SEO title","builtIn":true},{"type":"longText","id":"seoDescription","label":"SEO description","builtIn":true},{"type":"boolean","id":"templateEnabled","label":"Template","builtIn":true},{"type":"longText","id":"templateTarget","label":"Template target","builtIn":true},{"type":"number","id":"templatePriority","label":"Template priority","integer":true,"builtIn":true}]') + '[{"type":"text","id":"title","label":"Title","required":true,"builtIn":true},{"type":"text","id":"slug","label":"Slug","required":true,"builtIn":true},{"type":"pageTree","id":"body","label":"Body","required":true,"builtIn":true},{"type":"seoMetadata","id":"seo","label":"SEO","builtIn":true},{"type":"boolean","id":"templateEnabled","label":"Template","builtIn":true},{"type":"longText","id":"templateTarget","label":"Template target","builtIn":true},{"type":"number","id":"templatePriority","label":"Template priority","integer":true,"builtIn":true}]') on conflict (id) do update set name = excluded.name, slug = excluded.slug, diff --git a/server/handlers/cms/index.ts b/server/handlers/cms/index.ts index cf35a7683..9e5b5ccf5 100644 --- a/server/handlers/cms/index.ts +++ b/server/handlers/cms/index.ts @@ -47,6 +47,7 @@ import { handleMediaFolderRoutes } from './mediaFolders' import { handleMediaStorageAdminRoutes } from './mediaStorageAdmin' import { handlePluginsRoutes } from './plugins' import { handleDataRoutes } from './data' +import { handleSeoRoutes } from './seo' import { handleDashboardRoutes } from './dashboard' import { handleFontsRoutes } from './fonts' import { handlePublishRoutes } from './publish' @@ -101,6 +102,7 @@ export async function handleCmsRequest( ?? (await handleMediaRoutes(req, db)) ?? (await handlePluginsRoutes(req, db, options)) ?? (await handleDataRoutes(req, db, options)) + ?? (await handleSeoRoutes(req, db, options)) // Dashboard stats — read-only aggregate counts used by the admin // dashboard widgets. Lives after data routes so future routes // under `/data/...` can never accidentally shadow it. diff --git a/server/handlers/cms/seo.ts b/server/handlers/cms/seo.ts new file mode 100644 index 000000000..c55de8a1a --- /dev/null +++ b/server/handlers/cms/seo.ts @@ -0,0 +1,245 @@ +/** + * SEO workspace API — `/admin/api/cms/seo/*`. + * + * GET /seo/targets — the complete target index (pages, templates, + * post rows) plus site SEO defaults and the + * configured public origin. Draft cells: the + * workspace edits drafts, publish takes them live. + * PUT /seo/targets/:kind/:id — write one target's structured `seo` cell. + * PUT /seo/site — write `site.settings.seo` (defaults + robots + * + sitemap settings live in one object; the + * Robots/Sitemap tabs save through here too). + * + * Capabilities: `seo.read` to read, `seo.manage` to write. Target writes + * additionally require the persona that owns the underlying target — + * `pages.edit` for page/template rows, a content-edit capability for post + * rows — so the SEO workspace can't smuggle edits past the content gates. + */ +import { Type } from '@core/utils/typeboxHelpers' +import type { DataRow, DataTable } from '@core/data/schemas' +import { readSeoCell, readTitleCell } from '@core/data/cells' +import { SeoMetadataSchema, SiteSeoSettingsSchema, parseSiteSeoSettings } from '@core/seo' +import { normalizeRouteBase } from '@core/templates/templateMatching' +import { parsePageTemplate } from '@core/page-tree' +import type { DbClient } from '../../db/client' +import { badRequest, jsonResponse, readValidatedBody } from '../../http' +import { requireCapability, userHasAnyCapability, userHasCapability } from '../../auth/authz' +import { canonicalPublicOrigin } from '../../auth/security' +import { getDraftSite, saveDraftSite } from '../../repositories/site' +import { listDataTables } from '../../repositories/data/tables' +import { getDataRow, listDataRows, saveDataRowDraft } from '../../repositories/data' +import { CMS_API_PREFIX } from './shared' +import type { CmsHandlerOptions } from './shared' +import { runRouteTable, type Route, type RouteParams } from './routeTable' +import { handleSeoGenerate } from './seoGenerate' + +// --------------------------------------------------------------------------- +// Body schemas +// --------------------------------------------------------------------------- + +const SeoTargetPutBodySchema = Type.Object({ + seo: SeoMetadataSchema, +}) + +const SiteSeoPutBodySchema = Type.Object({ + seo: SiteSeoSettingsSchema, +}) + +// --------------------------------------------------------------------------- +// Target index +// --------------------------------------------------------------------------- + +export type SeoTargetKind = 'page' | 'template' | 'post' + +interface SeoTargetPayload { + kind: SeoTargetKind + id: string + title: string + /** Public route path; null for templates (not directly routable). */ + route: string | null + tableSlug?: string + tableLabel?: string + /** + * Templates only: the postType table slugs this entry template applies + * to. Lets the admin preview resolve a post's template title pattern + * exactly like the publisher does. (`everywhere` layout templates are + * not SEO targets at all — the pages they wrap own the metadata.) + */ + templateTableSlugs?: string[] + seo: ReturnType<typeof readSeoCell> | null + status: string + updatedAt: string + publishedAt: string | null +} + +/** + * Map a pages-table row to a target — or null for `everywhere` layout + * templates: they have no route and no content of their own, so per-target + * metadata is meaningless (the wrapped pages own their SEO). Only entry + * templates (postTypes targets) are SEO targets, as token-pattern sources + * for their posts. + */ +function pageRowToTarget(row: DataRow): SeoTargetPayload | null { + const template = row.cells.templateEnabled === true + ? parsePageTemplate({ + enabled: true, + target: row.cells.templateTarget, + priority: row.cells.templatePriority, + }) + : null + // Mirrors isTemplatePage(page): parsePageTemplate only returns a config + // for enabled templates, so a non-null config means "template page". + const isTemplate = template !== null + const entryTarget = template !== null && template.target.kind === 'postTypes' ? template.target : null + if (isTemplate && entryTarget === null) return null + const slug = row.slug.replace(/^\/+/, '') + return { + kind: isTemplate ? 'template' : 'page', + id: row.id, + title: readTitleCell(row.cells) || row.slug, + route: isTemplate ? null : slug === 'index' || slug === '' ? '/' : `/${slug}`, + ...(entryTarget !== null ? { templateTableSlugs: entryTarget.tableSlugs } : {}), + seo: readSeoCell(row.cells) ?? null, + status: row.status, + updatedAt: row.updatedAt, + publishedAt: row.publishedAt, + } +} + +function postRowToTarget(row: DataRow, table: DataTable): SeoTargetPayload { + const routeBase = normalizeRouteBase(table.routeBase ?? '') + return { + kind: 'post', + id: row.id, + title: readTitleCell(row.cells) || row.slug, + route: routeBase ? `${routeBase}/${row.slug}` : null, + tableSlug: table.slug, + tableLabel: table.pluralLabel, + seo: readSeoCell(row.cells) ?? null, + status: row.status, + updatedAt: row.updatedAt, + publishedAt: row.publishedAt, + } +} + +async function handleGetTargets(req: Request, db: DbClient): Promise<Response> { + const user = await requireCapability(req, db, 'seo.read') + if (user instanceof Response) return user + + const [site, tables, pageRows] = await Promise.all([ + getDraftSite(db), + listDataTables(db), + listDataRows(db, 'pages'), + ]) + + const postTables = tables.filter((table) => table.kind === 'postType') + const postTargets: SeoTargetPayload[] = [] + for (const table of postTables) { + const rows = await listDataRows(db, table.id) + for (const row of rows) postTargets.push(postRowToTarget(row, table)) + } + + return jsonResponse({ + siteName: site?.name ?? '', + language: site?.settings.language ?? null, + publicOrigin: canonicalPublicOrigin(), + faviconUrl: site?.settings.faviconUrl ?? null, + siteSeo: site?.settings.seo ?? null, + targets: [ + ...pageRows.map(pageRowToTarget).filter((target) => target !== null), + ...postTargets, + ], + }) +} + +// --------------------------------------------------------------------------- +// Target write +// --------------------------------------------------------------------------- + +async function handlePutTarget( + req: Request, + db: DbClient, + params: RouteParams, +): Promise<Response> { + const user = await requireCapability(req, db, 'seo.manage') + if (user instanceof Response) return user + + const body = await readValidatedBody(req, SeoTargetPutBodySchema) + if (!body) return badRequest('Invalid SEO metadata payload') + + const row = await getDataRow(db, params.id) + if (!row) return jsonResponse({ error: 'Target not found' }, { status: 404 }) + + const isPageRow = row.tableId === 'pages' + const kindMatchesRow = isPageRow + ? params.kind === 'page' || params.kind === 'template' + : params.kind === 'post' + if (!kindMatchesRow) return badRequest('Target kind does not match the row') + + // Target-level ownership: the SEO workspace must not smuggle writes past + // the personas that own the underlying rows. + if (isPageRow) { + if (!userHasCapability(user, 'pages.edit')) { + return jsonResponse({ error: 'Forbidden' }, { status: 403 }) + } + } else if (!userHasAnyCapability(user, ['content.edit.any', 'content.manage'])) { + return jsonResponse({ error: 'Forbidden' }, { status: 403 }) + } + + const updated = await saveDataRowDraft( + db, + row.id, + { cells: { ...row.cells, seo: body.seo }, slug: row.slug }, + user.id, + ) + if (!updated) return jsonResponse({ error: 'Target not found' }, { status: 404 }) + + return jsonResponse({ + target: row.tableId === 'pages' + ? pageRowToTarget(updated) + : postRowToTarget(updated, (await listDataTables(db)).find((t) => t.id === row.tableId)!), + }) +} + +// --------------------------------------------------------------------------- +// Site SEO settings (defaults + robots + sitemap) +// --------------------------------------------------------------------------- + +async function handlePutSiteSeo(req: Request, db: DbClient): Promise<Response> { + const user = await requireCapability(req, db, 'seo.manage') + if (user instanceof Response) return user + + const body = await readValidatedBody(req, SiteSeoPutBodySchema) + if (!body) return badRequest('Invalid site SEO payload') + + const site = await getDraftSite(db) + if (!site) return jsonResponse({ error: 'Site not found' }, { status: 404 }) + + const seo = parseSiteSeoSettings(body.seo) + await saveDraftSite(db, { ...site, settings: { ...site.settings, seo } }, user.id) + + return jsonResponse({ seo: seo ?? null }) +} + +// --------------------------------------------------------------------------- +// Route table + dispatcher +// --------------------------------------------------------------------------- + +const SEO_ROUTES: readonly Route<[CmsHandlerOptions]>[] = [ + { method: 'GET', pattern: `${CMS_API_PREFIX}/seo/targets`, handler: handleGetTargets }, + { + method: 'PUT', + pattern: new RegExp(`^${CMS_API_PREFIX}/seo/targets/(?<kind>page|template|post)/(?<id>[^/]+)$`), + handler: handlePutTarget, + }, + { method: 'PUT', pattern: `${CMS_API_PREFIX}/seo/site`, handler: handlePutSiteSeo }, + { method: 'POST', pattern: `${CMS_API_PREFIX}/seo/generate`, handler: handleSeoGenerate }, +] + +export async function handleSeoRoutes( + req: Request, + db: DbClient, + options: CmsHandlerOptions, +): Promise<Response | null> { + return runRouteTable(req, db, SEO_ROUTES, options) +} diff --git a/server/handlers/cms/seoGenerate.ts b/server/handlers/cms/seoGenerate.ts new file mode 100644 index 000000000..3383d6176 --- /dev/null +++ b/server/handlers/cms/seoGenerate.ts @@ -0,0 +1,217 @@ +/** + * AI metadata suggestions — `POST /admin/api/cms/seo/generate`. + * + * One driver call, three suggestions. Rides the existing `server/ai` stack: + * the scope default ('content', falling back to 'site') picks the provider + * credential + model; the call runs tool-less with a no-op bridge and the + * response text is parsed as a JSON string array. + * + * Capabilities: `seo.manage` (it feeds the SEO editor) plus `ai.chat` + * (it spends provider tokens). Length budgets ride the prompt so the + * suggestions fit the editor's meters without truncation. + */ +import { Type, type Static } from '@core/utils/typeboxHelpers' +import { safeParseJson } from '@core/utils/jsonValidate' +import { readBodyCell, readSeoCell, readTitleCell } from '@core/data/cells' +import type { DbClient } from '../../db/client' +import { badRequest, jsonResponse, readValidatedBody } from '../../http' +import { requireCapability, userHasCapability } from '../../auth/authz' +import { getDataRow } from '../../repositories/data' +import { getDraftSite } from '../../repositories/site' +import { listDefaults } from '../../ai/defaults/store' +import { + readCredentialForUser, + resolveCredentialForDriver, +} from '../../ai/credentials/store' +import { resolveDriver } from '../../ai/drivers' +import type { AiBrowserBridge } from '../../ai/runtime/types' + +// --------------------------------------------------------------------------- +// Request / response shapes +// --------------------------------------------------------------------------- + +const GENERATABLE_FIELDS = [ + 'title', + 'description', + 'ogTitle', + 'ogDescription', + 'xTitle', + 'xDescription', +] as const + +const GenerateBodySchema = Type.Object({ + kind: Type.Union([Type.Literal('page'), Type.Literal('template'), Type.Literal('post')]), + id: Type.String({ minLength: 1 }), + field: Type.Union(GENERATABLE_FIELDS.map((field) => Type.Literal(field))), + exclude: Type.Optional(Type.Array(Type.String())), +}) + +type GenerateBody = Static<typeof GenerateBodySchema> + +const SuggestionsSchema = Type.Array(Type.String(), { minItems: 1 }) + +const SUGGESTION_COUNT = 3 + +// --------------------------------------------------------------------------- +// Prompt assembly +// --------------------------------------------------------------------------- + +const FIELD_SPECS: Record<GenerateBody['field'], { label: string; budget: string }> = { + title: { label: 'SEO title', budget: 'at most 60 characters (~580px in search results)' }, + description: { label: 'meta description', budget: 'at most 160 characters (~990px in search results)' }, + ogTitle: { label: 'Open Graph title', budget: 'at most 60 characters' }, + ogDescription: { label: 'Open Graph description', budget: 'at most 160 characters' }, + xTitle: { label: 'X card title', budget: 'at most 60 characters' }, + xDescription: { label: 'X card description', budget: 'at most 160 characters' }, +} + +const SYSTEM_PROMPT = [ + 'You are an expert SEO copywriter inside a CMS.', + `Respond with ONLY a JSON array of exactly ${SUGGESTION_COUNT} strings — no prose, no markdown fences, no keys.`, + 'Each string is one complete suggestion. Vary the angle across suggestions (benefit-led, descriptive, curiosity).', + 'Never stuff keywords; write for humans first. Match the language of the source content.', +].join(' ') + +interface SuggestionContext { + field: GenerateBody['field'] + pageTitle: string + bodyExcerpt: string + existingValue: string | undefined + siteName: string + siteDescription: string | undefined + exclude: string[] +} + +function buildUserPrompt(ctx: SuggestionContext): string { + const spec = FIELD_SPECS[ctx.field] + const lines = [ + `Write ${SUGGESTION_COUNT} ${spec.label} suggestions for the content below. Each must be ${spec.budget}.`, + '', + `Site: ${ctx.siteName}`, + ...(ctx.siteDescription ? [`Site description: ${ctx.siteDescription}`] : []), + `Content title: ${ctx.pageTitle}`, + ...(ctx.existingValue ? [`Current ${spec.label}: ${ctx.existingValue}`] : []), + ...(ctx.bodyExcerpt ? ['', 'Content excerpt:', ctx.bodyExcerpt] : []), + ] + if (ctx.exclude.length > 0) { + lines.push('', 'Do NOT repeat any of these already-shown suggestions:') + for (const item of ctx.exclude) lines.push(`- ${item}`) + } + return lines.join('\n') +} + +// --------------------------------------------------------------------------- +// Response parsing — tolerant of stray prose around the JSON array +// --------------------------------------------------------------------------- + +export function parseSuggestionsText(text: string): string[] | null { + const start = text.indexOf('[') + const end = text.lastIndexOf(']') + if (start === -1 || end <= start) return null + const parsed = safeParseJson(text.slice(start, end + 1), SuggestionsSchema) + if (!parsed.ok) return null + const unique = [...new Set(parsed.value.map((item) => item.trim()).filter((item) => item !== ''))] + return unique.length > 0 ? unique.slice(0, SUGGESTION_COUNT) : null +} + +// --------------------------------------------------------------------------- +// Handler +// --------------------------------------------------------------------------- + +/** Bridge for tool-less calls — never invoked because `tools` is empty. */ +const NOOP_BRIDGE: AiBrowserBridge = { + callBrowser: () => Promise.reject(new Error('seo/generate runs without tools')), +} + +export async function handleSeoGenerate(req: Request, db: DbClient): Promise<Response> { + const user = await requireCapability(req, db, 'seo.manage') + if (user instanceof Response) return user + if (!userHasCapability(user, 'ai.chat')) { + return jsonResponse({ error: 'Forbidden' }, { status: 403 }) + } + + const body = await readValidatedBody(req, GenerateBodySchema) + if (!body) return badRequest('Invalid generate payload') + + const row = await getDataRow(db, body.id) + if (!row) return jsonResponse({ error: 'Target not found' }, { status: 404 }) + + // Provider + model from the scope defaults — content first (SEO copy is + // content work), site as fallback. No default → actionable 409. + const defaults = await listDefaults(db) + const aiDefault = + defaults.find((record) => record.scope === 'content') ?? + defaults.find((record) => record.scope === 'site') + if (!aiDefault) { + return jsonResponse( + { error: 'No AI provider configured. Set a content or site default in the AI workspace.' }, + { status: 409 }, + ) + } + const credential = await readCredentialForUser(db, user.id, aiDefault.credentialId) + if (!credential) { + return jsonResponse( + { error: 'The default AI credential is not accessible to your account. Configure one in the AI workspace.' }, + { status: 409 }, + ) + } + let resolvedCredential + try { + resolvedCredential = await resolveCredentialForDriver(credential) + } catch (err) { + console.error('[seo-generate] credential resolution failed:', err) + return jsonResponse({ error: 'AI credential could not be resolved.' }, { status: 409 }) + } + + const site = await getDraftSite(db) + const prompt = buildUserPrompt({ + field: body.field, + pageTitle: readTitleCell(row.cells) || row.slug, + bodyExcerpt: readBodyCell(row.cells).slice(0, 2000), + existingValue: readSeoCell(row.cells)?.[body.field], + siteName: site?.name ?? '', + siteDescription: site?.settings.seo?.description, + exclude: body.exclude ?? [], + }) + + const driver = resolveDriver(credential.providerId) + let text = '' + try { + const stream = driver.stream({ + systemPrompt: [SYSTEM_PROMPT], + messages: [{ role: 'user', content: [{ kind: 'text', text: prompt }] }], + tools: [], + modelId: aiDefault.modelId, + modelCapabilities: driver.capabilities(aiDefault.modelId), + credentials: resolvedCredential, + signal: req.signal, + bridge: NOOP_BRIDGE, + toolContextBase: { + db, + userId: user.id, + capabilities: user.capabilities, + scope: 'content', + conversationId: `seo-generate:${row.id}`, + snapshot: null, + }, + }) + for await (const event of stream) { + if (event.type === 'text') text += event.text + if (event.type === 'error') { + console.error('[seo-generate] driver error:', event.message) + return jsonResponse({ error: 'AI generation failed. Try again.' }, { status: 502 }) + } + } + } catch (err) { + console.error('[seo-generate] driver call failed:', err) + return jsonResponse({ error: 'AI generation failed. Try again.' }, { status: 502 }) + } + + const suggestions = parseSuggestionsText(text) + if (!suggestions) { + console.error('[seo-generate] unparsable model output:', text.slice(0, 200)) + return jsonResponse({ error: 'The model returned an unexpected format. Try again.' }, { status: 502 }) + } + + return jsonResponse({ suggestions }) +} diff --git a/server/handlers/cms/siteDiff.ts b/server/handlers/cms/siteDiff.ts index c8671f032..16b2915f4 100644 --- a/server/handlers/cms/siteDiff.ts +++ b/server/handlers/cms/siteDiff.ts @@ -10,7 +10,7 @@ * structure — adding / removing / reordering breakpoints, managing files, * toggling fileTypes, modifying packageJson/runtime, * changing site id / name. - * content — settings.metaTitle / settings.metaDescription (authored text + * content — settings.seo (authored site-wide SEO copy * the "client / copy editor" persona owns). * style — classes registry contents, settings.framework, settings.fonts, * file contents. @@ -114,7 +114,7 @@ export function validateSiteWriteDiff( } // Settings — split into chromatic-style fields (framework/fonts) and - // structural fields (metaTitle/metaDescription/favicon/language/shortcuts). + // content (seo) and structural fields (favicon/language/shortcuts). diffSettings(ctx, previous.settings, next.settings) // breakpoints — adding / removing / reordering is style infra. @@ -162,8 +162,7 @@ function diffSettings( // Content fields — site-wide SEO copy that the copy-editor persona owns. const contentKeys: Array<keyof SiteShell['settings']> = [ - 'metaTitle', - 'metaDescription', + 'seo', ] for (const key of contentKeys) { if (!deepEqual(prev[key], next[key])) { diff --git a/server/publish/publicRenderer.ts b/server/publish/publicRenderer.ts index e798504f4..843494d66 100644 --- a/server/publish/publicRenderer.ts +++ b/server/publish/publicRenderer.ts @@ -1,8 +1,12 @@ import '../../src/modules/base' import '@core/loops/sources' import { registry } from '@core/module-engine' -import { publishPage } from '@core/publisher' -import { buildRouteFrame } from '@core/templates/contextFrames' +import { publishPage, type PublishedSeo } from '@core/publisher' +import { buildPageFrame, buildRouteFrame, buildSiteFrame } from '@core/templates/contextFrames' +import { interpolateTokens } from '@core/templates/tokenInterpolation' +import { readSeoCell, readTitleCell } from '@core/data/cells' +import { resolveSeoMetadata, buildJsonLdEntities } from '@core/seo' +import { canonicalPublicOrigin } from '../auth/security' import { buildPublishedSiteCssBundle } from './siteCssBundle' import { buildPublishedSiteModuleJsMap } from './moduleJsBundle' import { resolveTemplateChain, resolveNotFoundTemplate, composeTemplateChain } from '@core/templates' @@ -10,7 +14,7 @@ import type { TemplateRenderDataContext } from '@core/templates/dynamicBindings' import { prefetchLoopData, publishedDataRowToLoopItem } from './loopPrefetch' import { prefetchMediaAssets } from './mediaPrefetch' import { getPublishVersion } from './publishState' -import type { Page } from '@core/page-tree' +import type { Page, SiteDocument } from '@core/page-tree' import type { SiteCssBundle } from '@core/publisher' import type { PublishedDataRow } from '@core/data/schemas' import type { DbClient } from '../db/client' @@ -77,6 +81,86 @@ interface RenderPublishedSnapshotContext { publishVersion?: number } +/** + * Resolve the SEO payload for a published PAGE route. The configured public + * origin (when present) yields absolute canonical/og:url values that are + * safe to bake into static artefacts; with no origin configured those tags + * are omitted rather than guessed. + */ +function buildPageRenderSeo(page: Page, site: SiteDocument): PublishedSeo { + const origin = canonicalPublicOrigin() ?? undefined + const pageFrame = buildPageFrame(page) + const context: TemplateRenderDataContext = { + entryStack: [], + page: pageFrame, + site: buildSiteFrame(site), + route: buildRouteFrame(pageFrame.permalink), + } + const resolved = resolveSeoMetadata({ + target: page.seo, + siteSeo: site.settings.seo, + siteName: site.name, + baseTitle: page.title, + routeKind: 'page', + routePath: pageFrame.permalink, + origin, + language: site.settings.language, + interpolate: (pattern) => interpolateTokens(pattern, context), + }) + const jsonLd = buildJsonLdEntities(resolved, { + kind: 'page', + routePath: pageFrame.permalink, + origin, + siteName: site.name, + organization: site.settings.seo?.organization, + }) + return { resolved, jsonLd } +} + +/** + * Resolve the SEO payload for a published ROW route. The row's own + * `cells.seo` wins; the entry template's `seo` (innermost chain element — + * broadest → narrowest ordering) provides token-bearing title/description + * patterns; site defaults close the chain. `article:*_time` comes from the + * active version's publish timestamp. + */ +function buildRowRenderSeo( + row: PublishedDataRow, + site: SiteDocument, + entryTemplate: Page | undefined, + templateContext: TemplateRenderDataContext, +): PublishedSeo { + const origin = canonicalPublicOrigin() ?? undefined + const routePath = `${row.tableRouteBase}/${row.slug}` + const context: TemplateRenderDataContext = { + ...templateContext, + site: templateContext.site ?? buildSiteFrame(site), + route: templateContext.route ?? buildRouteFrame(routePath), + } + const resolved = resolveSeoMetadata({ + target: readSeoCell(row.cells), + templateSeo: entryTemplate?.seo, + siteSeo: site.settings.seo, + siteName: site.name, + baseTitle: readTitleCell(row.cells), + routeKind: 'row', + routePath, + origin, + language: site.settings.language, + publishedAt: row.publishedAt, + updatedAt: row.publishedAt, + interpolate: (pattern) => interpolateTokens(pattern, context), + }) + const jsonLd = buildJsonLdEntities(resolved, { + kind: 'row', + routePath, + origin, + siteName: site.name, + organization: site.settings.seo?.organization, + }) + return { resolved, jsonLd } +} + /** * Shared render tail for both public paths. Given an already-resolved, * composed `merged` tree and its seed `templateContext`, this owns the @@ -90,6 +174,7 @@ async function renderMergedTemplate( snapshot: PublishedPageSnapshot, templateContext: TemplateRenderDataContext | undefined, ctx: RenderPublishedSnapshotContext, + seo: PublishedSeo, ): Promise<{ html: string; jsModuleIds: string[]; publishVersion: number; cssBundle: SiteCssBundle }> { const publishVersion = ctx.publishVersion ?? getPublishVersion() const cssBundle = buildPublishedSiteCssBundle(snapshot.site, registry, merged, publishVersion) @@ -100,6 +185,7 @@ async function renderMergedTemplate( ]) const published = publishPage(merged, snapshot.site, registry, { templateContext, + seo, runtimeAssets: snapshot.runtimeAssets, runtimePackageImportmap: snapshot.runtimePackageImportmap, cssEmission: 'external', @@ -137,7 +223,8 @@ export async function renderPublishedSnapshot( ? { entryStack: [], route: buildRouteFrame(ctx.url.toString()) } : undefined - const rendered = await renderMergedTemplate(merged, snapshot, templateContext, ctx) + const seo = buildPageRenderSeo(page, snapshot.site) + const rendered = await renderMergedTemplate(merged, snapshot, templateContext, ctx, seo) return { ...rendered, pageId: snapshot.pageRowId, slug: page.slug, siteId: snapshot.site.id } } @@ -162,7 +249,8 @@ export async function renderPublishedNotFound( ? { entryStack: [], route: buildRouteFrame(ctx.url.toString()) } : undefined - const rendered = await renderMergedTemplate(merged, snapshot, templateContext, ctx) + const seo = buildPageRenderSeo(page, snapshot.site) + const rendered = await renderMergedTemplate(merged, snapshot, templateContext, ctx, seo) return { ...rendered, pageId: page.id, slug: page.slug, siteId: snapshot.site.id } } @@ -186,6 +274,9 @@ export async function renderPublishedDataRowTemplate( ...(ctx.url ? { route: buildRouteFrame(ctx.url.toString()) } : {}), } - const rendered = await renderMergedTemplate(merged, snapshot, templateContext, ctx) + // The entry template is the narrowest chain element (broadest → narrowest). + const entryTemplate = chain.at(-1) + const seo = buildRowRenderSeo(row, snapshot.site, entryTemplate, templateContext) + const rendered = await renderMergedTemplate(merged, snapshot, templateContext, ctx, seo) return { ...rendered, pageId: merged.id, slug: merged.slug, siteId: snapshot.site.id } } diff --git a/server/publish/seoEndpoints.ts b/server/publish/seoEndpoints.ts new file mode 100644 index 000000000..fa0148e91 --- /dev/null +++ b/server/publish/seoEndpoints.ts @@ -0,0 +1,179 @@ +/** + * First-party `GET /robots.txt` and `GET /sitemap.xml`. + * + * Both are generated from the PUBLISHED snapshot (SEO follows the publish + * lifecycle — draft edits appear after the next publish) and cached keyed by + * `publishVersion`, the same invalidation discipline as the Layer B render + * cache: first request after a publish regenerates, `bumpPublishVersion()` + * turns the cached body stale. + * + * Origin resolution: the configured canonical public origin wins; these + * endpoints are dynamic (never baked to disk), so with nothing configured + * they fall back to the request origin. Because the body embeds the origin, + * the cache only serves a hit when the origin matches the cached one. + * + * Dispatched by `server/router.ts` BEFORE static assets and public page + * rendering — `/robots.txt` must never fall through to an HTML response. + */ + +import type { DbClient } from '../db/client' +import { isTemplatePage } from '@core/templates' +import { + generateRobotsTxt, + parseSeoMetadata, + absoluteUrl, + type SiteSeoSettings, +} from '@core/seo' +import { canonicalPublicOrigin, requestHostIsCanonical } from '../auth/security' +import { listPublishedRowsForSitemap } from '../repositories/data/publish' +import { getLatestSnapshotForVersion } from './publishedSnapshotCache' +import { getPublishVersion } from './publishState' + +// --------------------------------------------------------------------------- +// publishVersion-keyed cache +// --------------------------------------------------------------------------- + +interface CachedSeoFile { + version: number + origin: string + body: string +} + +let robotsCache: CachedSeoFile | null = null +let sitemapCache: CachedSeoFile | null = null + +/** Test seam — drop both cached bodies. */ +export function resetSeoEndpointCachesForTests(): void { + robotsCache = null + sitemapCache = null +} + +function escapeXml(value: string): string { + return value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", ''') +} + +/** + * Resolve the origin these files embed: configured canonical origin first, + * request origin as the dynamic-endpoint fallback. + */ +function resolveOrigin(url: URL): string { + return canonicalPublicOrigin() ?? url.origin +} + +async function publishedSeoSettings(db: DbClient): Promise<SiteSeoSettings | undefined> { + const snapshot = await getLatestSnapshotForVersion(db, getPublishVersion()) + return snapshot?.site.settings.seo +} + +// --------------------------------------------------------------------------- +// robots.txt +// --------------------------------------------------------------------------- + +export async function serveRobotsTxt(db: DbClient, url: URL, req: Request): Promise<Response> { + // Non-canonical host (preview/staging) → blanket Disallow, uncached: the + // body doesn't depend on settings and must never be confused with the + // production body (the cache keys on the resolved canonical origin, which + // is identical for every host). + if (requestHostIsCanonical(req) === false) { + return new Response(generateRobotsTxt({ sitemapEnabled: false, blockAll: true }), { + headers: { 'content-type': 'text/plain; charset=utf-8', 'x-robots-tag': 'noindex' }, + }) + } + + const version = getPublishVersion() + const origin = resolveOrigin(url) + + if (!robotsCache || robotsCache.version !== version || robotsCache.origin !== origin) { + const seo = await publishedSeoSettings(db) + const body = generateRobotsTxt({ + robots: seo?.robots, + sitemapEnabled: seo?.sitemap?.enabled !== false, + origin, + }) + robotsCache = { version, origin, body } + } + + return new Response(robotsCache.body, { + headers: { 'content-type': 'text/plain; charset=utf-8' }, + }) +} + +// --------------------------------------------------------------------------- +// sitemap.xml +// --------------------------------------------------------------------------- + +interface SitemapEntry { + loc: string + lastmod?: string +} + +async function collectSitemapEntries(db: DbClient, origin: string): Promise<SitemapEntry[]> { + const snapshot = await getLatestSnapshotForVersion(db, getPublishVersion()) + if (!snapshot) return [] + + const seo = snapshot.site.settings.seo + const excluded = new Set(seo?.sitemap?.excludedTargets ?? []) + const entries: SitemapEntry[] = [] + + // Published routable pages — templates are never directly routable, and + // noindex pages exclude themselves. + for (const page of snapshot.site.pages) { + if (isTemplatePage(page)) continue + if (page.seo?.noindex === true) continue + if (excluded.has(`page:${page.id}`)) continue + const slug = page.slug.replace(/^\/+/, '') + const routePath = slug === 'index' || slug === '' ? '/' : `/${slug}` + entries.push({ loc: absoluteUrl(origin, routePath) }) + } + + // Published post-type rows with route bases. + const rows = await listPublishedRowsForSitemap(db) + for (const row of rows) { + const rowSeo = parseSeoMetadata(row.cells.seo) + if (rowSeo?.noindex === true) continue + if (excluded.has(`row:${row.rowId}`)) continue + entries.push({ + loc: absoluteUrl(origin, `${row.tableRouteBase}/${row.rowSlug}`), + lastmod: row.publishedAt, + }) + } + + return entries +} + +export async function serveSitemapXml(db: DbClient, url: URL): Promise<Response> { + const version = getPublishVersion() + const origin = resolveOrigin(url) + + const seo = await publishedSeoSettings(db) + if (seo?.sitemap?.enabled === false) { + return new Response('Not found', { + status: 404, + headers: { 'content-type': 'text/plain; charset=utf-8' }, + }) + } + + if (!sitemapCache || sitemapCache.version !== version || sitemapCache.origin !== origin) { + const entries = await collectSitemapEntries(db, origin) + const urls = entries + .map((entry) => { + const lastmod = entry.lastmod ? `\n <lastmod>${escapeXml(entry.lastmod)}</lastmod>` : '' + return ` <url>\n <loc>${escapeXml(entry.loc)}</loc>${lastmod}\n </url>` + }) + .join('\n') + const body = + '<?xml version="1.0" encoding="UTF-8"?>\n' + + '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n' + + `${urls}${urls ? '\n' : ''}</urlset>\n` + sitemapCache = { version, origin, body } + } + + return new Response(sitemapCache.body, { + headers: { 'content-type': 'application/xml; charset=utf-8' }, + }) +} diff --git a/server/repositories/data/publish.ts b/server/repositories/data/publish.ts index 9575e9566..744053ba1 100644 --- a/server/repositories/data/publish.ts +++ b/server/repositories/data/publish.ts @@ -16,6 +16,7 @@ * when the URL belongs to a * previously-published slug * listPublishedRowRoutes — every published row route (for the bake) + * listPublishedRowsForSitemap — row routes + cells/publish time (sitemap) * getRowTableRouteInfo — route base + table slug for one row * getRowTableRouteBase — route base only, ignoring soft deletes * @@ -331,6 +332,55 @@ export async function listPublishedRowRoutes(db: DbClient): Promise<PublishedRow })) } +export interface PublishedRowSitemapEntry { + rowId: string + rowSlug: string + tableRouteBase: string + /** Active published version's cells — read `cells.seo` for noindex. */ + cells: Record<string, unknown> + /** ISO datetime the active version was published — sitemap <lastmod>. */ + publishedAt: string +} + +/** + * Every published, routable data row with the cells and publish timestamp + * the sitemap generator needs. Same row set as `listPublishedRowRoutes`, + * plus `cells_json` (for the structured `seo.noindex` flag) and the active + * version's publish time (for `<lastmod>`). + */ +export async function listPublishedRowsForSitemap( + db: DbClient, +): Promise<PublishedRowSitemapEntry[]> { + const { rows } = await db<{ + row_id: string + row_slug: string + table_route_base: string + cells_json: Record<string, unknown> + published_at: string + }>` + select data_rows.id as row_id, + data_row_versions.slug as row_slug, + data_tables.route_base as table_route_base, + data_row_versions.cells_json, + data_row_versions.published_at + from data_rows + join data_tables on data_tables.id = data_rows.table_id + join data_row_versions on data_row_versions.id = data_rows.active_version_id + where data_rows.table_id <> 'pages' + and data_rows.status = 'published' + and data_rows.deleted_at is null + and data_tables.deleted_at is null + order by data_rows.created_at asc + ` + return rows.map((row) => ({ + rowId: row.row_id, + rowSlug: row.row_slug, + tableRouteBase: normalizeRouteBase(row.table_route_base), + cells: row.cells_json, + publishedAt: row.published_at, + })) +} + /** * Resolve a public URL (tableRouteBase + rowSlug) to the active published * version of a data row. diff --git a/server/router.ts b/server/router.ts index 74ff147a7..cba634fe5 100644 --- a/server/router.ts +++ b/server/router.ts @@ -11,10 +11,12 @@ import { handleLoopRequest, isLoopRuntimeAssetPath, serveLoopRuntimeAsset } from import { handleHoleRequest, isHoleRuntimeAssetPath, serveHoleRuntimeAsset } from './handlers/cms/hole' import { handleModuleJsAssetRequest, isModuleJsAssetPath } from './handlers/cms/moduleJs' import { handlePublicFormRequest } from './forms/handler' +import { serveRobotsTxt, serveSitemapXml } from './publish/seoEndpoints' import { isRuntimePackagePath, tryServeRuntimePackage } from './publish/runtime/packageServer' import { jsonResponse } from './http' import { binaryResponse, toArrayBuffer } from './binary' import { hardenUploadResponse, serveAdminApp, serveStaticFile } from './static' +import { requestHostIsCanonical } from './auth/security' import { registry } from '@core/module-engine' import type { CssBundleFile, SiteCssBundleId } from '@core/publisher' import { buildPublishedSiteCssBundle } from './publish/siteCssBundle' @@ -76,6 +78,10 @@ const routes: readonly RouteHandler[] = [ tryServeRuntimePackageNamespace, tryServeSiteCssNamespace, tryServeMediaRedirect, + // robots.txt + sitemap.xml are first-party generated files. They dispatch + // BEFORE static assets and public page rendering so neither path can be + // shadowed by an uploaded file or fall through to an HTML response. + tryServeSeoFiles, tryServeStaticAsset, tryServeUpload, tryServeAdminApp, @@ -356,7 +362,8 @@ async function tryServeStaticAsset( ): Promise<Response | null> { if (!runtime.staticDir) return null if (pathname === '/' || pathname === '/index.html') return null - return await serveStaticFile(runtime.staticDir, pathname, _req) + const asset = await serveStaticFile(runtime.staticDir, pathname, _req) + return asset && withPreviewNoindex(_req, asset) } async function tryServeUpload( @@ -385,13 +392,13 @@ async function tryServeUpload( const headers = new Headers(hardened.headers) headers.set('access-control-allow-origin', '*') headers.set('cross-origin-resource-policy', 'cross-origin') - return new Response(hardened.body, { + return withPreviewNoindex(req, new Response(hardened.body, { status: hardened.status, statusText: hardened.statusText, headers, - }) + })) } - return hardened + return withPreviewNoindex(req, hardened) } async function tryServeAdminApp( @@ -422,9 +429,26 @@ async function tryServeAdminApp( * fast-path (pre-rendered static artefacts via `readArtefact`), then * `resolvePublicRoute`, then the live renderer + `applyPublishedHtmlPipeline`. */ +/** + * First-party robots.txt / sitemap.xml — generated from the published + * snapshot with publishVersion-keyed caching (`server/publish/seoEndpoints.ts`). + */ +function tryServeSeoFiles( + req: Request, + runtime: ServerRuntime, + url: URL, + pathname: string, +): Promise<Response> | null { + if (req.method !== 'GET' && req.method !== 'HEAD') return null + if (pathname === '/robots.txt') return serveRobotsTxt(runtime.db, url, req) + if (pathname === '/sitemap.xml') return serveSitemapXml(runtime.db, url) + return null +} + async function tryServePublicRoute(req: Request, runtime: ServerRuntime, url: URL, _pathname: string): Promise<Response | null> { if (req.method !== 'GET') return null - return await renderPublicResolution(runtime.db, url, runtime.uploadsDir) + const rendered = await renderPublicResolution(runtime.db, url, runtime.uploadsDir) + return rendered && withPreviewNoindex(req, rendered) } /** @@ -458,6 +482,24 @@ async function tryServeNotFoundPage(req: Request, runtime: ServerRuntime, url: U // Helpers // --------------------------------------------------------------------------- +/** + * On a non-canonical host (a preview/staging deploy whose `Host` isn't the + * configured production origin), stamp `X-Robots-Tag: noindex, nofollow` on + * served content so the preview can't be indexed even via a direct asset URL + * — the hard guarantee robots.txt's Disallow can't give. No-op when the host + * is canonical or when no public origin is configured (local dev). + */ +function withPreviewNoindex(req: Request, response: Response): Response { + if (requestHostIsCanonical(req) !== false) return response + const headers = new Headers(response.headers) + headers.set('x-robots-tag', 'noindex, nofollow') + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }) +} + function adminUiNotBuiltResponse(pathname: string): Response { const targetUrl = `${VITE_DEV_URL}${pathname}` const html = `<!doctype html> diff --git a/src/__tests__/admin/capabilityAwareAdmin.test.tsx b/src/__tests__/admin/capabilityAwareAdmin.test.tsx index e4b47f0f5..5ea7d76d3 100644 --- a/src/__tests__/admin/capabilityAwareAdmin.test.tsx +++ b/src/__tests__/admin/capabilityAwareAdmin.test.tsx @@ -71,8 +71,7 @@ function makeTable(id: string, name: string, slug: string) { { type: 'text', id: 'slug', label: 'Slug', required: true, builtIn: true }, { type: 'richText', id: 'body', label: 'Body', format: 'markdown', builtIn: true }, { type: 'media', id: 'featuredMedia', label: 'Featured media', mediaKind: 'image', builtIn: true }, - { type: 'text', id: 'seoTitle', label: 'SEO title', builtIn: true }, - { type: 'longText', id: 'seoDescription', label: 'SEO description', builtIn: true }, + { type: 'seoMetadata', id: 'seo', label: 'SEO', builtIn: true }, ], createdByUserId: null, updatedByUserId: null, diff --git a/src/__tests__/admin/seoWorkspace.test.tsx b/src/__tests__/admin/seoWorkspace.test.tsx new file mode 100644 index 000000000..cf87a49b2 --- /dev/null +++ b/src/__tests__/admin/seoWorkspace.test.tsx @@ -0,0 +1,480 @@ +/** + * SEO workspace UI tests — /admin/tools/seo. + * + * Renders the Meta/Robots/Sitemap tabs against a mocked fetch: + * - tab switching through the Tabs primitive + * - target search / filter / selection driving the preview editor + * - snippet edits writing the STRUCTURED seo object on save + * - inherited values rendering as placeholders (X falls back to OG/search) + * - the Customize X gate + * - robots toggles updating the generated preview + * - the Tools nav dropdown exposing the SEO link + */ +import { afterEach, describe, expect, it } from 'bun:test' +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { MemoryRouter } from '@admin/lib/routing' +import { AdminSessionProvider } from '@admin/session' +import { StepUpProvider } from '@admin/shared/StepUp' +import { AdminSectionNavigation } from '@admin/shared/AdminSectionNavigation' +import { MetaTab } from '@admin/pages/seo/tabs/MetaTab' +import { RobotsTab } from '@admin/pages/seo/tabs/RobotsTab' +import { SitemapTab } from '@admin/pages/seo/tabs/SitemapTab' +import { SeoToolbar } from '@admin/pages/seo/components/SeoToolbar' +import { useSeoWorkspace } from '@admin/pages/seo/hooks/useSeoWorkspace' +import { useSeoSaveBridge } from '@admin/pages/seo/hooks/useSeoSaveBridge' +import type { CmsCurrentUser } from '@core/persistence' + +const originalFetch = globalThis.fetch +const now = '2026-06-12T10:00:00.000Z' + +function currentUser(capabilities: string[]): CmsCurrentUser { + return { + id: 'user_1', + email: 'seo@example.com', + displayName: 'SEO Editor', + status: 'active', + role: { + id: 'test-role', + slug: 'test-role', + name: 'Test Role', + description: '', + isSystem: false, + capabilities, + }, + capabilities, + lastLoginAt: null, + failedLoginCount: 0, + lockedUntil: null, + passwordUpdatedAt: null, + mfaEnabled: false, + mfaEnabledAt: null, + mfaRecoveryCodesRemaining: 0, + stepUpAuthMode: 'required', + stepUpWindowMinutes: 15, + avatarMediaId: null, + avatarUrl: null, + gravatarHash: '', + createdAt: now, + updatedAt: now, + } +} + +function json(body: unknown, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }) +} + +interface FetchCall { + input: RequestInfo | URL + init?: RequestInit +} + +const TARGETS_PAYLOAD = { + siteName: 'Acme', + language: 'en', + publicOrigin: 'https://acme.com', + faviconUrl: null, + siteSeo: { + titlePattern: '{page.title} — {site.name}', + description: 'Site default description', + }, + targets: [ + { + kind: 'page', + id: 'page_home', + title: 'Home', + route: '/', + seo: null, + status: 'published', + updatedAt: now, + publishedAt: now, + }, + { + kind: 'page', + id: 'page_about', + title: 'About', + route: '/about', + seo: { title: 'About Acme', description: 'Who we are.' }, + status: 'published', + updatedAt: now, + publishedAt: now, + }, + { + kind: 'template', + id: 'page_tpl', + title: 'Post template', + route: null, + templateTableSlugs: ['posts'], + seo: { title: '{currentEntry.title} — {site.name}' }, + status: 'published', + updatedAt: now, + publishedAt: null, + }, + { + kind: 'post', + id: 'row_hello', + title: 'Hello world', + route: '/posts/hello-world', + tableSlug: 'posts', + tableLabel: 'Posts', + seo: null, + status: 'published', + updatedAt: now, + publishedAt: now, + }, + ], +} + +function mockSeoFetch(): FetchCall[] { + const calls: FetchCall[] = [] + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + calls.push({ input, init }) + const url = String(input) + if (url === '/admin/api/cms/seo/targets') return json(TARGETS_PAYLOAD) + if (url.startsWith('/admin/api/cms/seo/targets/') && init?.method === 'PUT') { + const body = JSON.parse(String(init.body)) as { seo: Record<string, unknown> } + const id = url.split('/').at(-1) + const stored = TARGETS_PAYLOAD.targets.find((t) => t.id === id)! + return json({ target: { ...stored, seo: body.seo } }) + } + if (url.match(/^\/admin\/api\/cms\/data\/rows\/[^/]+\/publish$/) && init?.method === 'POST') { + const id = url.split('/').at(-2)! + // RowEnvelope validates a full DataRow shape — return one. + return json({ + row: { + id, + tableId: 'posts', + cells: { title: 'Hello world', slug: 'hello-world' }, + slug: 'hello-world', + status: 'published', + authorUserId: null, + createdByUserId: null, + updatedByUserId: null, + publishedByUserId: null, + author: null, + createdBy: null, + updatedBy: null, + publishedBy: null, + createdAt: now, + updatedAt: now, + publishedAt: now, + scheduledPublishAt: null, + deletedAt: null, + }, + }) + } + if (url === '/admin/api/cms/media') { + // SeoImageField resolves picked-tile thumbnails from the asset list. + return json({ assets: [] }) + } + if (url === '/admin/api/cms/seo/site' && init?.method === 'PUT') { + const body = JSON.parse(String(init.body)) as { seo: Record<string, unknown> } + return json({ seo: body.seo }) + } + return json({ error: `Unhandled ${url}` }, 404) + }) as typeof fetch + return calls +} + +/** + * Mirrors SeoPage's wiring: the tab registers on the save bridge and the + * toolbar's PublishActionGroup drives it — without the AdminPageLayout + * chrome the page itself adds. + */ +function MetaHarness({ canManage = true }: { canManage?: boolean }) { + const workspace = useSeoWorkspace() + const bridge = useSeoSaveBridge() + if (workspace.loading) return <p>Loading…</p> + return ( + <> + <SeoToolbar status={bridge.status} onSave={bridge.save} onPublish={bridge.publish} /> + <MetaTab workspace={workspace} canManage={canManage} bridge={bridge} /> + </> + ) +} + +function RobotsHarness() { + const workspace = useSeoWorkspace() + const bridge = useSeoSaveBridge() + if (workspace.loading) return <p>Loading…</p> + return <RobotsTab workspace={workspace} canManage bridge={bridge} /> +} + +function SitemapHarness() { + const workspace = useSeoWorkspace() + const bridge = useSeoSaveBridge() + if (workspace.loading) return <p>Loading…</p> + return <SitemapTab workspace={workspace} canManage bridge={bridge} /> +} + +function renderWithSession(node: React.ReactElement, capabilities: string[] = ['seo.read', 'seo.manage', 'ai.chat', 'pages.publish', 'content.publish.any']) { + return render( + <MemoryRouter initialEntries={['/admin/tools/seo']}> + <AdminSessionProvider user={currentUser(capabilities)}> + {/* The real app provides StepUpProvider in AuthenticatedAdmin — the + SEO editors consume useStepUp for the publish action. */} + <StepUpProvider> + {node} + </StepUpProvider> + </AdminSessionProvider> + </MemoryRouter>, + ) +} + +afterEach(() => { + cleanup() + globalThis.fetch = originalFetch +}) + +describe('SEO Meta tab', () => { + it('lists targets, filters by search, and selects into the editor', async () => { + mockSeoFetch() + renderWithSession(<MetaHarness />) + + const aboutRow = await screen.findByTestId('seo-target-page_about') + expect(screen.getByTestId('seo-target-site-defaults')).toBeDefined() + expect(screen.getByTestId('seo-target-row_hello')).toBeDefined() + + // Search narrows the list. + fireEvent.change(screen.getByTestId('seo-target-search'), { target: { value: 'about' } }) + expect(screen.queryByTestId('seo-target-row_hello')).toBeNull() + + // Selecting a row activates the preview editor for that target. + fireEvent.click(aboutRow) + const editor = await screen.findByRole('region', { name: 'SEO for About' }) + expect(within(editor).getByDisplayValue('About Acme')).toBeDefined() + }) + + it('shows inherited values as placeholders and saves the structured object', async () => { + const calls = mockSeoFetch() + renderWithSession(<MetaHarness />) + + fireEvent.click(await screen.findByTestId('seo-target-page_home')) + const editor = await screen.findByRole('region', { name: 'SEO for Home' }) + + // No explicit title — the placeholder shows the interpolated site pattern. + const titleInput = within(editor).getByLabelText('Title') as HTMLInputElement + expect(titleInput.value).toBe('') + expect(titleInput.placeholder).toBe('Home — Acme') + + fireEvent.change(titleInput, { target: { value: 'Welcome to Acme' } }) + fireEvent.change(within(editor).getByLabelText('Description'), { + target: { value: 'The Acme homepage.' }, + }) + // Save draft lives in the toolbar's publish-actions menu, like Content. + fireEvent.click(screen.getByTestId('toolbar-publish-actions-trigger')) + fireEvent.click(screen.getByTestId('toolbar-seo-save-draft')) + + await waitFor(() => { + const put = calls.find( + (call) => + String(call.input) === '/admin/api/cms/seo/targets/page/page_home' && + call.init?.method === 'PUT', + ) + expect(put).toBeDefined() + expect(JSON.parse(String(put!.init!.body))).toEqual({ + seo: { title: 'Welcome to Acme', description: 'The Acme homepage.' }, + }) + }) + }) + + it('keeps X fields behind the customize gate until they differ', async () => { + mockSeoFetch() + renderWithSession(<MetaHarness />) + + fireEvent.click(await screen.findByTestId('seo-target-page_about')) + const editor = await screen.findByRole('region', { name: 'SEO for About' }) + + // The X section starts collapsed behind the customize gate. + expect(within(editor).queryByLabelText('X title')).toBeNull() + + fireEvent.click(screen.getByTestId('seo-customize-x')) + const xTitle = within(editor).getByLabelText('X title') as HTMLInputElement + // Inherits the search title through the OG fallback chain. + expect(xTitle.placeholder).toBe('About Acme') + }) + + it('saves then publishes a post through the row publish endpoint', async () => { + const calls = mockSeoFetch() + renderWithSession(<MetaHarness />) + + fireEvent.click(await screen.findByTestId('seo-target-row_hello')) + const editor = await screen.findByRole('region', { name: 'SEO for Hello world' }) + fireEvent.change(within(editor).getByLabelText('Title'), { target: { value: 'Published title' } }) + + fireEvent.click(screen.getByTestId('toolbar-publish-btn')) + await screen.findByText('Published — live') + + const put = calls.find( + (call) => String(call.input) === '/admin/api/cms/seo/targets/post/row_hello' && call.init?.method === 'PUT', + ) + expect(put).toBeDefined() + const publish = calls.find( + (call) => String(call.input) === '/admin/api/cms/data/rows/row_hello/publish' && call.init?.method === 'POST', + ) + expect(publish).toBeDefined() + }) + + it('renders the sidebar score summary and jumps the index to the issues filter', async () => { + mockSeoFetch() + renderWithSession(<MetaHarness />) + + // Site-wide score ring + live per-target score chip in the editor. + await screen.findByLabelText(/Site SEO score: \d+ out of 100/) + expect(screen.getByTestId('seo-score-chip')).toBeDefined() + + // Every seeded target has at least one open check, so the issues line + // is present; clicking it narrows the index to issue targets only. + fireEvent.click(screen.getByTestId('seo-issues-line')) + expect(screen.queryByTestId('seo-issues-line')).toBeNull() + expect(screen.getByTestId('seo-target-page_home')).toBeDefined() + }) + + it('clicking an improvement focuses the field it describes', async () => { + mockSeoFetch() + renderWithSession(<MetaHarness />) + + fireEvent.click(await screen.findByTestId('seo-target-page_about')) + const editor = await screen.findByRole('region', { name: 'SEO for About' }) + + // "About Acme" has no social image — the improvement row points at the + // OG image field (a tabIndex=-1 container, since the Library mode has no + // single input to land on). + fireEvent.click(within(editor).getByTestId('seo-improvement-socialImage')) + expect((document.activeElement as HTMLElement).id.endsWith('-ogImage')).toBe(true) + }) + + it('guards target switching while the editor is dirty', async () => { + mockSeoFetch() + renderWithSession(<MetaHarness />) + + fireEvent.click(await screen.findByTestId('seo-target-page_home')) + const editor = await screen.findByRole('region', { name: 'SEO for Home' }) + fireEvent.change(within(editor).getByLabelText('Title'), { target: { value: 'Dirty' } }) + + // Switching opens the in-app confirm dialog instead of discarding. + fireEvent.click(screen.getByTestId('seo-target-page_about')) + expect(await screen.findByText('Discard unsaved changes?')).toBeDefined() + + fireEvent.click(screen.getByTestId('seo-discard-switch')) + expect(await screen.findByRole('region', { name: 'SEO for About' })).toBeDefined() + }) +}) + +describe('SEO Robots tab', () => { + it('renders the editable robots.txt seeded with the default template', async () => { + mockSeoFetch() + renderWithSession(<RobotsHarness />) + + // The container mirrors the live content in data-content (the lazy CM6 + // editor paints only measured-visible lines in a test DOM). + const editor = await screen.findByTestId('seo-robots-editor') + expect(editor.getAttribute('data-content')).toContain('User-agent: *') + expect(editor.getAttribute('data-content')).toContain('Disallow: /admin') + }) + + it('a quick-insert shortcut edits the document', async () => { + mockSeoFetch() + renderWithSession(<RobotsHarness />) + await screen.findByTestId('seo-robots-editor') + + fireEvent.click(screen.getByTestId('seo-robots-block-ai')) + const content = screen.getByTestId('seo-robots-editor').getAttribute('data-content') ?? '' + expect(content).toContain('User-agent: GPTBot') + expect(content).toContain('User-agent: PerplexityBot') + }) + + it('surfaces an AI-crawler recommendation and applies it on click', async () => { + mockSeoFetch() + renderWithSession(<RobotsHarness />) + await screen.findByTestId('seo-robots-editor') + + // Default template leaves AI crawlers allowed → the recommendation shows. + const rec = await screen.findByTestId('seo-robots-rec-ai-training') + fireEvent.click(rec) + + expect(screen.getByTestId('seo-robots-editor').getAttribute('data-content')).toContain('User-agent: GPTBot') + // Applied → recommendation gone. + expect(screen.queryByTestId('seo-robots-rec-ai-training')).toBeNull() + }) + + it('URL tester reports a blocked system path and an allowed content path', async () => { + mockSeoFetch() + renderWithSession(<RobotsHarness />) + await screen.findByTestId('seo-robots-editor') + + fireEvent.change(screen.getByTestId('seo-robots-test-ua'), { target: { value: 'Googlebot' } }) + fireEvent.change(screen.getByTestId('seo-robots-test-path'), { target: { value: '/admin/users' } }) + expect(screen.getByTestId('seo-robots-test-result').textContent).toContain('Blocked') + + fireEvent.change(screen.getByTestId('seo-robots-test-path'), { target: { value: '/about' } }) + expect(screen.getByTestId('seo-robots-test-result').textContent).toContain('Allowed') + }) +}) + +describe('SEO Sitemap tab', () => { + it('lists routable targets with include switches and a count', async () => { + mockSeoFetch() + renderWithSession(<SitemapHarness />) + + // Routable targets: Home (/), About (/about), Hello world (/posts/…). + // The template has no route and is excluded. + expect(await screen.findByText('Hello world')).toBeDefined() + expect(screen.getByTestId('seo-sitemap-counts').textContent).toContain('3 of 3') + expect(screen.queryByText('Post template')).toBeNull() + }) + + it('turning generation off replaces the list with the disabled state', async () => { + mockSeoFetch() + renderWithSession(<SitemapHarness />) + await screen.findByText('Hello world') + + fireEvent.click(screen.getByTestId('seo-sitemap-enabled')) + expect(screen.queryByText('Hello world')).toBeNull() + expect(screen.getByTestId('seo-sitemap-counts').textContent).toContain('off') + }) +}) + +describe('Tools navigation', () => { + it('exposes the SEO link inside the Tools dropdown', async () => { + mockSeoFetch() + render( + <MemoryRouter initialEntries={['/admin/dashboard']}> + <AdminSessionProvider user={currentUser(['dashboard.read', 'seo.read'])}> + <AdminSectionNavigation section="dashboard" /> + </AdminSessionProvider> + </MemoryRouter>, + ) + + fireEvent.click(await screen.findByTestId('tools-nav-trigger')) + expect(await screen.findByTestId('tools-nav-seo')).toBeDefined() + }) + + it('opens the Tools dropdown on hover', async () => { + mockSeoFetch() + render( + <MemoryRouter initialEntries={['/admin/dashboard']}> + <AdminSessionProvider user={currentUser(['dashboard.read', 'seo.read'])}> + <AdminSectionNavigation section="dashboard" /> + </AdminSessionProvider> + </MemoryRouter>, + ) + + fireEvent.mouseEnter(await screen.findByTestId('tools-nav-trigger')) + expect(await screen.findByTestId('tools-nav-seo')).toBeDefined() + }) + + it('hides the Tools dropdown without seo.read or plugin pages', () => { + mockSeoFetch() + render( + <MemoryRouter initialEntries={['/admin/dashboard']}> + <AdminSessionProvider user={currentUser(['dashboard.read'])}> + <AdminSectionNavigation section="dashboard" /> + </AdminSessionProvider> + </MemoryRouter>, + ) + expect(screen.queryByTestId('tools-nav-trigger')).toBeNull() + }) +}) diff --git a/src/__tests__/architecture/button-primitive-usage.test.ts b/src/__tests__/architecture/button-primitive-usage.test.ts index cd4a767b2..45ce88f71 100644 --- a/src/__tests__/architecture/button-primitive-usage.test.ts +++ b/src/__tests__/architecture/button-primitive-usage.test.ts @@ -100,6 +100,23 @@ const ALLOWLIST = new Set([ // mode toggle's segmented pill pattern and shares the same constraints. 'admin/pages/content/components/ContentModeToggle/ContentModeToggle.tsx', + // ── §8.8 SEO target index rows ────────────────────────────────────────── + // role="option" rows inside the SEO workspace's role="listbox" target + // index: full-width two-line rows (title + route, score pill right) and + // the pinned Site defaults card. Button's fixed per-size heights and + // white-space: nowrap crush the two-line layout — same rationale as the + // §8.1 nav rows and §8.7 listbox options. + 'admin/pages/seo/components/SeoTargetIndex.tsx', + + // ── §8.11 SEO advice rows (recommendations + improvements) ────────────── + // The Meta editor's improvements list and the Robots tab's recommendation + // list render full-width rows (status dot + wrapping two-line advice text) + // that apply a fix / focus a field on click. Button's fixed heights and + // white-space: nowrap cannot host wrapping multi-line row content — same + // pattern class as the §8.8 index rows. + 'admin/pages/seo/components/SeoPreviewEditor.tsx', + 'admin/pages/seo/tabs/RobotsTab.tsx', + // ── §8.7 Full-width row disclosure / listbox option custom layouts ────── // ColorTokenCard row toggle is a full-width structured row (title + meta, // expand caret pattern) — same pattern as §8.2 disclosures but on a diff --git a/src/__tests__/architecture/data-tables-system-flag.test.ts b/src/__tests__/architecture/data-tables-system-flag.test.ts index a728d51b0..07c68e101 100644 --- a/src/__tests__/architecture/data-tables-system-flag.test.ts +++ b/src/__tests__/architecture/data-tables-system-flag.test.ts @@ -75,8 +75,7 @@ describe('data_tables system seeds — four tables present after fresh boot', () expect(builtInIds).toContain('slug') expect(builtInIds).toContain('body') expect(builtInIds).toContain('featuredMedia') - expect(builtInIds).toContain('seoTitle') - expect(builtInIds).toContain('seoDescription') + expect(builtInIds).toContain('seo') }) test('pages fields_json parses and contains expected builtIn field ids', () => { @@ -88,8 +87,7 @@ describe('data_tables system seeds — four tables present after fresh boot', () expect(builtInIds).toContain('title') expect(builtInIds).toContain('slug') expect(builtInIds).toContain('body') - expect(builtInIds).toContain('seoTitle') - expect(builtInIds).toContain('seoDescription') + expect(builtInIds).toContain('seo') expect(builtInIds).toContain('templateEnabled') expect(builtInIds).toContain('templateTarget') expect(builtInIds).toContain('templatePriority') diff --git a/src/__tests__/architecture/no-core-barrel-deep-imports.test.ts b/src/__tests__/architecture/no-core-barrel-deep-imports.test.ts index 6ee37600c..8cf047c6d 100644 --- a/src/__tests__/architecture/no-core-barrel-deep-imports.test.ts +++ b/src/__tests__/architecture/no-core-barrel-deep-imports.test.ts @@ -10,6 +10,7 @@ * - `@core/framework` * - `@core/framework-schema` * - `@core/fonts` + * - `@core/seo` * * Per the barrel convention (CLAUDE.md → "Barrel imports"): everything OUTSIDE * a module imports through its barrel; files INSIDE the module import each @@ -36,6 +37,7 @@ const BARRELLED_MODULES = [ 'framework', 'framework-schema', 'fonts', + 'seo', ] // Scan production + test sources in both the app and the server. diff --git a/src/__tests__/architecture/no-plugin-tab-shells.test.ts b/src/__tests__/architecture/no-plugin-tab-shells.test.ts index 026866f00..f1480a376 100644 --- a/src/__tests__/architecture/no-plugin-tab-shells.test.ts +++ b/src/__tests__/architecture/no-plugin-tab-shells.test.ts @@ -74,7 +74,7 @@ function collectAllFiles(): string[] { /** §T.0 — every file under the Tabs primitive directory is allowed. */ const TABS_PRIMITIVE_DIR = join(PROJECT_ROOT, 'src/ui/components/Tabs') -/** §T.1–§T.5 — pre-existing custom tablist implementations. */ +/** §T.1–§T.6 — Button-row / compact tablist implementations. */ const EXACT_ALLOWLIST = new Set<string>([ // §T.1 — capability-gated Button row in UsersPage predates the Tabs primitive. join(PROJECT_ROOT, 'src/admin/pages/users/UsersPage.tsx'), @@ -89,6 +89,10 @@ const EXACT_ALLOWLIST = new Set<string>([ // pattern: icon-only, compact fixed layout that the full-width Tabs // style cannot represent. join(PROJECT_ROOT, 'src/admin/pages/content/components/ContentModeToggle/ContentModeToggle.tsx'), + // §T.6 — SeoPage matches the §T.1/§T.4 Button-row pattern so the SEO + // workspace's header tabs look identical to the sibling AI/Users/Account + // pages it sits next to in the admin nav. + join(PROJECT_ROOT, 'src/admin/pages/seo/SeoPage.tsx'), ]) function isAllowlisted(file: string): boolean { diff --git a/src/__tests__/data/contentAdmin.test.tsx b/src/__tests__/data/contentAdmin.test.tsx index 17b0ab8ff..c5e682d79 100644 --- a/src/__tests__/data/contentAdmin.test.tsx +++ b/src/__tests__/data/contentAdmin.test.tsx @@ -51,8 +51,7 @@ const allBuiltInFields = [ { type: 'text', id: 'slug', label: 'Slug', required: true, builtIn: true }, { type: 'richText', id: 'body', label: 'Body', format: 'markdown', builtIn: true }, { type: 'media', id: 'featuredMedia', label: 'Featured media', mediaKind: 'image', builtIn: true }, - { type: 'text', id: 'seoTitle', label: 'SEO title', builtIn: true }, - { type: 'longText', id: 'seoDescription', label: 'SEO description', builtIn: true }, + { type: 'seoMetadata', id: 'seo', label: 'SEO', builtIn: true }, ] const titleOnlyFields = [ @@ -123,8 +122,7 @@ function makeRow( slug: 'untitled', body: '', featuredMedia: null, - seoTitle: '', - seoDescription: '', + seo: {}, ...cells, } return { @@ -378,7 +376,7 @@ beforeEach(() => { if (url === '/admin/api/cms/data/rows/entry_1/publish' && init?.method === 'POST') { return json({ row: { - ...makeRow('entry_1', 'posts', { title: 'My first post', slug: 'untitled', body: '## Intro', featuredMedia: null, seoTitle: '', seoDescription: '' }), + ...makeRow('entry_1', 'posts', { title: 'My first post', slug: 'untitled', body: '## Intro', featuredMedia: null, seo: {} }), status: 'published', updatedAt: '2026-05-01T10:02:00.000Z', publishedAt: '2026-05-01T10:02:00.000Z', @@ -390,7 +388,7 @@ beforeEach(() => { const body = JSON.parse(String(init.body)) return json({ row: { - ...makeRow('entry_1', 'posts', { title: 'My first post', slug: 'updated-slug', body: '', featuredMedia: imageAsset.id, seoTitle: '', seoDescription: '' }), + ...makeRow('entry_1', 'posts', { title: 'My first post', slug: 'updated-slug', body: '', featuredMedia: imageAsset.id, seo: {} }), status: body.status, updatedAt: '2026-05-01T10:03:00.000Z', }, @@ -776,8 +774,7 @@ describe('ContentPage', () => { slug: 'authored-post', body: 'Body', featuredMedia: null, - seoTitle: '', - seoDescription: '', + seo: {}, }, { authorUserId: editorAuthor.id, author: editorAuthor, @@ -792,8 +789,7 @@ describe('ContentPage', () => { slug: 'authored-post', body: 'Body', featuredMedia: null, - seoTitle: '', - seoDescription: '', + seo: {}, }, { authorUserId: adminAuthor.id, author: adminAuthor, @@ -932,8 +928,7 @@ describe('ContentPage', () => { slug: 'untitled', body: '', featuredMedia: null, - seoTitle: '', - seoDescription: '', + seo: {}, }, })) expect(calls.some((call) => @@ -1080,8 +1075,7 @@ describe('ContentPage', () => { slug: 'portable-lamp', body: 'A compact lamp', featuredMedia: imageAsset.id, - seoTitle: 'SEO lamp', - seoDescription: 'Lamp description', + seo: { title: 'SEO lamp', description: 'Lamp description' }, }, { updatedAt: '2026-05-01T10:01:00.000Z' })], }) } @@ -1093,8 +1087,7 @@ describe('ContentPage', () => { slug: 'portable-lamp', body: 'A compact lamp', featuredMedia: imageAsset.id, - seoTitle: 'SEO lamp', - seoDescription: 'Lamp description', + seo: { title: 'SEO lamp', description: 'Lamp description' }, }, { updatedAt: '2026-05-01T10:05:00.000Z' }), }) } @@ -1106,8 +1099,7 @@ describe('ContentPage', () => { slug: 'portable-lamp', body: 'A compact lamp', featuredMedia: imageAsset.id, - seoTitle: 'SEO lamp', - seoDescription: 'Lamp description', + seo: { title: 'SEO lamp', description: 'Lamp description' }, }, { updatedAt: '2026-05-01T10:05:00.000Z' })], }) } @@ -1166,8 +1158,8 @@ describe('ContentPage', () => { if (url === '/admin/api/cms/data/tables/posts/rows' && init?.method === 'GET') { return json({ rows: [ - makeRow('entry_1', 'posts', { title: 'Summer sale', slug: 'summer-sale', body: 'Sale copy', featuredMedia: null, seoTitle: '', seoDescription: '' }, { updatedAt: '2026-05-01T10:01:00.000Z' }), - makeRow('entry_2', 'posts', { title: 'Published story', slug: 'published-story', body: 'Published copy', featuredMedia: null, seoTitle: '', seoDescription: '' }, { status: 'published', updatedAt: '2026-05-01T10:02:00.000Z', publishedAt: '2026-05-01T10:02:00.000Z' }), + makeRow('entry_1', 'posts', { title: 'Summer sale', slug: 'summer-sale', body: 'Sale copy', featuredMedia: null, seo: {} }, { updatedAt: '2026-05-01T10:01:00.000Z' }), + makeRow('entry_2', 'posts', { title: 'Published story', slug: 'published-story', body: 'Published copy', featuredMedia: null, seo: {} }, { status: 'published', updatedAt: '2026-05-01T10:02:00.000Z', publishedAt: '2026-05-01T10:02:00.000Z' }), ], }) } @@ -1188,20 +1180,20 @@ describe('ContentPage', () => { if (url === '/admin/api/cms/data/rows/entry_1/publish' && init?.method === 'POST') { return json({ - row: makeRow('entry_1', 'posts', { title: 'Summer sale', slug: 'summer-sale', body: 'Sale copy', featuredMedia: null, seoTitle: '', seoDescription: '' }, { status: 'published', updatedAt: '2026-05-01T10:03:00.000Z', publishedAt: '2026-05-01T10:03:00.000Z' }), + row: makeRow('entry_1', 'posts', { title: 'Summer sale', slug: 'summer-sale', body: 'Sale copy', featuredMedia: null, seo: {} }, { status: 'published', updatedAt: '2026-05-01T10:03:00.000Z', publishedAt: '2026-05-01T10:03:00.000Z' }), }) } if (url === '/admin/api/cms/data/rows/entry_2/status' && init?.method === 'PATCH') { const body = JSON.parse(String(init.body)) return json({ - row: makeRow('entry_2', 'posts', { title: 'Published story', slug: 'published-story', body: 'Published copy', featuredMedia: null, seoTitle: '', seoDescription: '' }, { status: body.status, updatedAt: '2026-05-01T10:04:00.000Z' }), + row: makeRow('entry_2', 'posts', { title: 'Published story', slug: 'published-story', body: 'Published copy', featuredMedia: null, seo: {} }, { status: body.status, updatedAt: '2026-05-01T10:04:00.000Z' }), }) } if (url === '/admin/api/cms/data/rows/entry_1' && init?.method === 'DELETE') { return json({ - row: makeRow('entry_1', 'posts', { title: 'Winter sale', slug: 'winter-sale', body: 'Sale copy', featuredMedia: null, seoTitle: '', seoDescription: '' }, { updatedAt: '2026-05-01T10:06:00.000Z', deletedAt: '2026-05-01T10:06:00.000Z' }), + row: makeRow('entry_1', 'posts', { title: 'Winter sale', slug: 'winter-sale', body: 'Sale copy', featuredMedia: null, seo: {} }, { updatedAt: '2026-05-01T10:06:00.000Z', deletedAt: '2026-05-01T10:06:00.000Z' }), }) } @@ -1290,8 +1282,7 @@ describe('ContentPage', () => { slug: 'winter-sale', body: 'Sale copy', featuredMedia: null, - seoTitle: '', - seoDescription: '', + seo: {}, }, }) )).toBe(true) @@ -1452,8 +1443,7 @@ describe('ContentPage', () => { slug: 'untitled', body: '![hero.png](/uploads/hero.png)', featuredMedia: null, - seoTitle: '', - seoDescription: '', + seo: {}, }, })) }) @@ -1507,8 +1497,7 @@ describe('ContentPage', () => { slug: 'updated-slug', body: '', featuredMedia: imageAsset.id, - seoTitle: '', - seoDescription: '', + seo: {}, }, })) expect(calls.some((call) => @@ -1529,8 +1518,7 @@ describe('ContentPage', () => { slug: 'first-post', body: '', featuredMedia: imageAsset.id, - seoTitle: '', - seoDescription: '', + seo: {}, }, { status: 'published', updatedAt: '2026-05-01T10:01:00.000Z', diff --git a/src/__tests__/persistence.test.ts b/src/__tests__/persistence.test.ts index 78570807a..ff85c9e9b 100644 --- a/src/__tests__/persistence.test.ts +++ b/src/__tests__/persistence.test.ts @@ -112,10 +112,10 @@ describe('validateSite — happy path', () => { it('accepts settings with all optional fields', () => { const p = validSite() p.settings.language = 'fr' - p.settings.metaTitle = 'My Site' + p.settings.seo = { titlePattern: '{page.title} — My Site' } const result = validateSite(p) expect(result.settings.language).toBe('fr') - expect(result.settings.metaTitle).toBe('My Site') + expect(result.settings.seo?.titlePattern).toBe('{page.title} — My Site') }) it('fills defaults for missing settings sub-fields', () => { diff --git a/src/__tests__/persistence/roundTripFixture.json b/src/__tests__/persistence/roundTripFixture.json index 399ce05b6..c96c8df5a 100644 --- a/src/__tests__/persistence/roundTripFixture.json +++ b/src/__tests__/persistence/roundTripFixture.json @@ -204,8 +204,10 @@ } }, "settings": { - "metaTitle": "My Round-Trip Site", - "metaDescription": "Testing round-trips", + "seo": { + "titlePattern": "{page.title} — My Round-Trip Site", + "description": "Testing round-trips" + }, "language": "en", "shortcuts": { "save": "ctrl+s" }, "framework": { diff --git a/src/__tests__/publisher/render.test.ts b/src/__tests__/publisher/render.test.ts index b01c86c01..fbc7f1352 100644 --- a/src/__tests__/publisher/render.test.ts +++ b/src/__tests__/publisher/render.test.ts @@ -1009,25 +1009,50 @@ describe('publishPage', () => { expect(html).not.toContain('zustand') }) - it('uses site metaTitle for <title> when set', () => { - const proj = makeSite({ - settings: { ...makeSite().settings, metaTitle: 'My Site — Home' }, - }) + it('uses the page seo title for <title> when set', () => { const page = makePage({ root: { moduleId: 'base.text', props: { text: 'Hi' } } }) - const { html } = publishPage(page, proj, registry) + page.seo = { title: 'My Site — Home', description: 'A fine page.' } + const { html } = publishPage(page, makeSite(), registry) expect(html).toContain('<title>My Site — Home') + expect(html).toContain('') }) - it('XSS: escapes metaTitle with special chars', () => { + it('interpolates the site title pattern around the page title', () => { const proj = makeSite({ settings: { ...makeSite().settings, - metaTitle: '', + seo: { titlePattern: '{page.title} — {site.name}' }, }, }) const page = makePage({ root: { moduleId: 'base.text', props: { text: 'Hi' } } }) const { html } = publishPage(page, proj, registry) - expect(html).not.toContain('' } + const { html } = publishPage(page, makeSite(), registry) + expect(html).not.toContain('', note: '