Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ This project is pre-1.0. Breaking changes may appear in minor or patch releases
- Added a condition to data-row loops so a list can show a subset of a table rather than always its newest rows — pick one of the table's own fields and require it to be checked, unchecked, equal to a value, or to have any value at all. A relation field offers its rows by name instead of asking for an id. The condition applies on the canvas, on published pages, and in the "load more" endpoint, and the item count follows it so pagination never advertises rows the page drops.
- Added the table's own fields to a data-row loop's "Order by" list, so a list can follow a real date, title, or rank stored in the row instead of only the row's built-in columns. Values compare as text, which sorts ISO dates chronologically.

### Content and publishing

- Fixed the SEO title and SEO description fields on a post so they reach the published page. Both were editable in the Content settings panel but never emitted anything: an entry's `<title>` always showed its plain title, and no description tag was written at all. An authored value now drives `<title>` / `<meta name="description">` and outranks the site-wide Meta Title and Meta Description, on the published page and in the Content editor's Live preview alike. The on-page `{page.title}` and `{currentEntry.title}` bindings keep rendering the entry's real title.

## 0.0.16 - 2026-08-11

### Media and integrations
Expand Down
3 changes: 3 additions & 0 deletions docs/e2e/feature-matrix.md
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,7 @@ MEDIA-007 note: unsafe SVG upload sanitization and public `/uploads` serving are
| CONTENT-007 | P2 | partial | AI | Use the content AI assistant panel | AI chat permission | Content AI panel | No-provider guidance or chat flow is understandable | provider failure, write-tool permission leaks |
| CONTENT-008 | P1 | ✅ | Custom fields | Edit custom post fields and use them in Site templates | Logged in | Data Posts fields → Content settings → Site binding picker → publish post route | Custom field appears in the Content entry settings panel, persists after reload, is offered as a scoped currentEntry binding, resolves in canvas preview, and renders on the public post route | stale table metadata, unresolved custom tokens, save/publish ordering |
| CONTENT-009 | P1 | ✅ | Entries | Keep the stored title in the sidebar when an entries list load resolves after a create | Logged in | Content page → New post while the entries list request is still in flight | Sidebar row shows the stored "Untitled" while the editor keeps its blank title field | request ordering, editor-only row views leaking into the list |
| CONTENT-010 | P1 | ✅ | Entries | Publish an entry whose SEO title and SEO description differ from its title | Posts entry template published | Content settings panel → SEO title / SEO description → publish post → public `/posts/<slug>` | Served `<head>` carries the authored `<title>` and `<meta name="description">` while the on-page H1 keeps the entry's own title | site-level Meta Title shadowing the entry, SEO override leaking into `{page.title}`, blank SEO field overriding with an empty value |

CONTENT-003 note: slash-menu Heading 2 and Data token placeholder insertion with save/reload persistence are automated in `content.e2e.ts`; media picker insertion and sanitization edge cases remain lower-level or future browser coverage.

Expand All @@ -267,6 +268,8 @@ CONTENT-008 note: `content.e2e.ts` adds a custom text field to the system Posts

CONTENT-009 note: `content-create-race.e2e.ts` holds the first entries-list GET until the create POST has landed, forcing the ordering that previously merged the editor's blank-title view into the sidebar and rendered a nameless row. Verified to fail without the fix and pass with it; `contentAdmin.test.tsx` pins the same path at unit level.

CONTENT-010 note: `content-seo-meta.e2e.ts` authors and publishes a Posts entry template, fills the Content settings panel's SEO title and SEO description with values distinct from the entry title, publishes the post, and reads the served HTML on the anonymous public route. Verified to fail on the pre-fix build (`<title>` showed the plain entry title and no description tag was emitted). Entry-vs-site-setting precedence and the `{page.title}` binding guard are pinned at render level in `publicRendering.test.ts`.

## AI Workspace

| ID | Priority | Auto | Area | User Goal | Setup | Path | Expected Outcome | Watch For |
Expand Down
1 change: 1 addition & 0 deletions docs/e2e/feature-validation.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -146,3 +146,4 @@ SPOT-011 Keyboard-only Spotlight execution As a keyboard user, I want to open, s
SPOT-012 Spotlight reduced-motion behaviour As a user who prefers reduced motion, I want Spotlight open, close, and nested-scope transitions to avoid slide animation so the palette respects my OS accessibility setting. With reduced motion emulated, opening Spotlight and drilling into a nested scope avoids slide or translateY animation and uses opacity-only or instant transition behavior. Reduced-motion preference ignored; slide animation still plays; nested scope animates horizontally; page jumps on open; tests miss animation due timing. CSS and state transitions branch on prefers-reduced-motion while preserving palette functionality. tests/e2e/command-palette.e2e.ts; src/admin/spotlight; spotlight CSS modules The regression uses browser media emulation and style assertions; it does not visually diff every animation frame. Happy: reduced-motion palette opens and drills into scope without slide animation. Error: transform animation present. Boundary: nested scope transition. Invalid: normal motion mode still allowed separately. Permission: unaffected by capabilities. Performance: no delay introduced. Mobile: reduced-motion media emulation only. Focused capability/responsive/spotlight Playwright regression passed 2026-06-23; canonical spreadsheet row added after matrix gap discovery 0 None Verification: command-palette.e2e.ts passed inside the combined focused run. SPOT-012 covers reduced-motion behavior for palette open and nested scope transitions. 2026-06-23
SPOT-013 Spotlight high-contrast highlight visibility As a high-contrast user, I want the active Spotlight row and matched text marks to stay visually distinct so search results remain readable. With high-contrast media emulated, opening Spotlight, searching, and arrowing through results shows a visible active-row outline and readable match marks. Highlight invisible; outline too faint; match marks blend into background; keyboard active row differs from visual row; contrast mode breaks selection styling. Spotlight CSS uses tokenized high-contrast styles for active rows and marks while preserving keyboard active-state semantics. tests/e2e/command-palette.e2e.ts; src/admin/spotlight; spotlight CSS modules The regression uses browser media emulation and computed-style checks; full manual visual contrast audit remains future review. Happy: active row and match marks remain legible. Error: no visible outline or unreadable mark. Boundary: one searched result with arrow navigation. Invalid: high contrast should not hide text. Permission: unaffected by capabilities. Performance: no render delay. Mobile: media emulation only. Focused capability/responsive/spotlight Playwright regression passed 2026-06-23; canonical spreadsheet row added after matrix gap discovery 0 None Verification: command-palette.e2e.ts passed inside the combined focused run. SPOT-013 covers high-contrast active-row outline and match-mark legibility. 2026-06-23
CONTENT-009 Freshly created entry keeps its stored title in the sidebar As a content author, I want a post I just created to show its name in the sidebar so I can find it. createUntitledEntry stores "Untitled" on the server and hands the editor a blank-title view; the sidebar list renders the stored row even when an in-flight entries list load resolves after the create. List load resolving after the create; an MCP save landing during a list load; the server list omitting the new row. Rows loaded through the cmsData client with schema validation; the selection may hold an editor-local view that must never reach the list. src/admin/pages/content/hooks/useContentWorkspace.ts; src/__tests__/data/contentAdmin.test.tsx; tests/e2e/content-create-race.e2e.ts The posts rows endpoint is stateful, so a row created by POST is visible to every later GET. Happy: create a post with the list load held and the sidebar shows Untitled. Regression: without the fix the row renders nameless. Boundary: when the server list omits the row the selection is still merged in. Permission/Performance/Mobile: not applicable. Automated Playwright E2E passed; verified to fail without the fix 0 None Pins an ordering that previously decided the outcome at random. 2026-08-18
CONTENT-010 Entry SEO title and description reach the published <head> As a content author, I want the SEO title and SEO description I fill in for a post to appear in the published page’s <head> so search engines show what I wrote. renderPublishedDataRowTemplate and handleRowPreview pass the row’s seoTitle/seoDescription to publishPage as documentMeta; buildDocumentMetaTags prefers them over the site-level metaTitle/metaDescription, and page.title keeps the entry’s own title for the {page.title} binding. Blank or whitespace-only SEO field; site-level Meta Title configured; entry with no SEO fields at all; collection with SEO fields disabled; Live preview vs published parity. seoTitle/seoDescription are optional post-type built-ins read through readEntrySeoOverride; a blank value is not an override; the override reaches the <head> only, never a binding frame. server/publish/publicRenderer.ts; server/handlers/cms/data/preview.ts; src/core/publisher/render.ts; src/core/data/cells.ts; tests/e2e/content-seo-meta.e2e.ts The public entry route needs a published Posts entry template; the spec authors and publishes its own. Happy: authored SEO title and description appear in <head>. Regression: pre-fix build emits the plain title and no description. Boundary: blank SEO field falls through to site settings. Invalid: non-string cell ignored. Permission: publishing requires step-up. Performance: n/a. Mobile: n/a. Automated Playwright E2E passed; verified to fail without the fix 0 None Closes the gap that let both SEO fields ship inert: nothing checked the published <head>. 2026-08-20
23 changes: 20 additions & 3 deletions docs/features/publisher.md
Original file line number Diff line number Diff line change
Expand Up @@ -327,15 +327,31 @@ The publisher emits `<head>` in this order:

1. `<meta charset="utf-8">`
2. `<meta name="viewport" content="width=device-width, initial-scale=1">`
3. `<title>` from `page.title`
4. `<meta name="description">` if present in page settings
3. `<title>` — the entry's `seoTitle` (post-type entries only) → `settings.metaTitle` → `page.title` → site name
4. `<meta name="description">` — the entry's `seoDescription` (post-type entries only) → `settings.metaDescription`; omitted when neither is set
5. `<link rel="icon">` if a favicon is configured
6. `<script type="importmap">` mapping bare specifiers (e.g. `three`) to `/_instatic/runtime/cache/<hash>/...` URLs
7. Runtime asset `<script>` tags (`scriptTagsForRuntimeAssets`)
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

### `documentMeta` — per-render `<head>` overrides

Rows 3 and 4 above take their most specific value from `PublishPageOptions.documentMeta`, a `{ title?, description? }` the caller supplies for this render only. A post-type entry's authored `seoTitle` / `seoDescription` are row cells, not fields of the composed template `Page`, so both entry render paths read them with `readEntrySeoOverride(cells)` (`src/core/data/cells.ts`) and pass them here:

| Path | File |
|---|---|
| Publish / public route | `renderPublishedDataRowTemplate` in `server/publish/publicRenderer.ts` |
| Content editor Live mode | `handleRowPreview` in `server/handlers/cms/data/preview.ts` |

Two invariants:

- **Never write the SEO override onto `page.title`.** `publishPage` hands `page` to `buildPageFrame`, so `page.title` is also the `{page.title}` binding — an SEO value assigned there renders inside the page body. `page.title` stays the entry's own `title` cell; the override reaches `<head>` and nothing else.
- **A blank field is not an override.** `readEntrySeoOverride` omits an empty or whitespace-only cell, so it falls through to the site-level `metaTitle` / `metaDescription` exactly as an absent one does.

Both call sites read through the one helper so publish and Live preview can't drift.

Installed fonts are emitted through the CSS bundle, not external `<link>` tags. The font CSS includes self-hosted `@font-face` rules for `site.settings.fonts.items` plus `:root` declarations for editable tokens such as `--font-primary`. A page rule can therefore keep `font-family: var(--font-primary)` while the token assignment changes site-wide.

Plugins inject at four anchors. The order matters — see [docs/features/plugin-system.md](plugin-system.md) for the splicing rules.
Expand Down Expand Up @@ -370,7 +386,7 @@ Because `serializeCsp` sorts, the same plugins + adapters always emit a **byte-i
| `server/publish/renderCache.ts` | In-memory LRU keyed by `(urlPath, canonicalQuery)`, entries versioned. `getOrRender` (single-flight). Reads the version from `publishState`; version captured at render start — a publish landing mid-render discards the result rather than caching stale HTML. Layer B. |
| `server/publish/publishState.ts` | Publish-time process state: `publishVersion` (`bumpPublishVersion`/`getPublishVersion`), `withPublishLock` (ISS-038 publish serializer), and `createVersionedSingleFlight` — the generalized version-keyed single-flight memo the hole endpoint reuses. Repositories import the version + lock from here (not from the cache). |
| `server/publish/holeRuntime.ts` | Exports `runInstaticHoleRuntime` (the TypeScript source of the Layer C runtime) and `HOLE_RUNTIME_JS` (IIFE-serialized string, ~1.1 KB, served to browsers). Tests call `runInstaticHoleRuntime()` directly to avoid dynamic eval. |
| `server/publish/publicRenderer.ts` | `renderPublishedSnapshot`, `renderPublishedDataRowTemplate` — thin wrappers (resolve + compose the template chain, seed the context) over one shared `renderMergedTemplate` (CSS bundle + loop/media prefetch + `publishPage` + publish-version stamping). |
| `server/publish/publicRenderer.ts` | `renderPublishedSnapshot`, `renderPublishedDataRowTemplate` — thin wrappers (resolve + compose the template chain, seed the context) over one shared `renderMergedTemplate` (CSS bundle + loop/media prefetch + `publishPage` + publish-version stamping). The entry path also passes the row's `readEntrySeoOverride(...)` through as `documentMeta`. |
| `server/publish/publishedHtmlPipeline.ts` | Post-process: DOMPurify the final HTML, run plugin `publish.html` filter, splice in declarative tags from plugin manifests, inject runtime assets. Runs at publish time only — never per-request. |
| `server/publish/siteCssBundle.ts` | Hash the four CSS strings, write `uploads/css/...` files. The framework bundle's module-CSS half comes from the shared walk in `siteModuleAssets.ts`. |
| `server/publish/siteModuleAssets.ts` | `collectSiteModuleAssets` — the one full-site render walk whose accumulators feed BOTH the framework CSS bundle (`cssMap`) and the published module-JS map (`jsMap`). |
Expand Down Expand Up @@ -550,6 +566,7 @@ This is rare and requires architectural review — most "new behavior" fits with
| Hand-writing `<picture>` / `<img srcset>` in a module | Set `props.<key>` to a media URL; `mediaPresentation.ts` materializes the markup. |
| Adding `@import url(...)` to module CSS | The final document passes through DOMPurify in `publishedHtmlPipeline.ts`, which strips dangerous CSS constructs. Add it to the site's user stylesheets instead (where it is intentional). |
| Editing the CSP meta tag string manually | Edit the CSP source list — the tag is derived. |
| Assigning an entry's `seoTitle` to `page.title` to reach `<title>` | Pass it as `documentMeta.title`. `page.title` is also the `{page.title}` binding, so an override written there renders in the page body. |

---

Expand Down
6 changes: 5 additions & 1 deletion server/handlers/cms/data/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import { Type } from '@sinclair/typebox'
import type { DbClient } from '../../../db/client'
import type { DataRow, DataRowCells, PublishedDataRow } from '@core/data/schemas'
import { readEntrySeoOverride } from '@core/data/cells'
import { resolveTemplateChain, composeTemplateChain } from '@core/templates'
import { buildRouteFrame } from '@core/templates/contextFrames'
import { publishPage } from '@core/publisher'
Expand Down Expand Up @@ -95,7 +96,9 @@ export async function handleRowPreview(
}
const merged = composeTemplateChain(chain, { kind: 'entry' })
// The template chain has no Page for the entry, so composeTemplateChain
// can't know its title — the entry's own (draft) title is the real document title.
// can't know its title — the entry's own (draft) title is the real page
// title. The draft SEO override travels separately through
// `documentMeta` so it only ever reaches the `<head>`, mirroring publish.
if (typeof draftCells.title === 'string') merged.title = draftCells.title

// Build a synthetic PublishedDataRow with the draft cells merged in.
Expand All @@ -118,6 +121,7 @@ export async function handleRowPreview(

const published = publishPage(merged, snapshot.site, registry, {
templateContext,
documentMeta: readEntrySeoOverride(draftCells),
runtimeAssets: snapshot.runtimeAssets,
runtimePackageImportmap: snapshot.runtimePackageImportmap,
cssEmission: 'external',
Expand Down
Loading
Loading