diff --git a/server/ai/tools/content/writeTools.ts b/server/ai/tools/content/writeTools.ts index 25f560af4..c6e553aee 100644 --- a/server/ai/tools/content/writeTools.ts +++ b/server/ai/tools/content/writeTools.ts @@ -125,7 +125,7 @@ const setDocumentFieldTool: AiTool = { execution: 'browser', requiredCapabilities: DOCUMENT_EDIT_CAPS, description: - "Write one field on a document. `value` shape depends on the field type (read content_get_collection_schema first if unsure): text/longText/richText/url/email → string; number → number; boolean → boolean; date/dateTime → ISO string; select → option id; multiSelect → option id[]; media → { id } or { id }[]; relation → { rowId } or { rowId }[]; body → markdown string. Bridge converts markdown ↔ Tiptap automatically for body.", + "Write one field on a document. The document MUST be the active one — call content_set_active_document first, or the write is refused. (content_create_document leaves the new document active, so create-then-fill needs no extra call.) `value` shape depends on the field type (read content_get_collection_schema first if unsure): text/longText/richText/url/email → string; number → number; boolean → boolean; date/dateTime → ISO string; select → option id; multiSelect → option id[]; media → { id } or { id }[]; relation → { rowId } or { rowId }[]; body → markdown string. Bridge converts markdown ↔ Tiptap automatically for body.", inputSchema: SetDocumentFieldInput, } @@ -144,7 +144,7 @@ const setDocumentFieldsTool: AiTool = { execution: 'browser', requiredCapabilities: DOCUMENT_EDIT_CAPS, description: - 'Batch-write multiple fields on one document. `fields` is Record; same per-type shapes as content_set_document_field. Prefer this when generating a whole post (title + slug + body + seo* in one call).', + 'Batch-write multiple fields on one document. The document MUST be the active one — call content_set_active_document first, or the write is refused. `fields` is Record; same per-type shapes as content_set_document_field. Prefer this when generating a whole post (title + slug + body + seo* in one call), and when filling several documents in sequence set each one active before writing to it.', inputSchema: SetDocumentFieldsInput, } diff --git a/server/publish/bakeDataRows.ts b/server/publish/bakeDataRows.ts index 1ab0fe65b..146c96393 100644 --- a/server/publish/bakeDataRows.ts +++ b/server/publish/bakeDataRows.ts @@ -32,6 +32,7 @@ import { renderPublishedDataRowTemplate } from './publicRenderer' import { applyPublishedHtmlPipeline } from './publishedHtmlPipeline' import { writeArtefact } from './staticArtefact' import { getLatestSnapshotForVersion } from './publishedSnapshotCache' +import { snapshotForEntryRoute } from './entryTemplateSnapshot' interface DataRowBakeResult { /** Routes successfully baked into the slot. */ @@ -91,7 +92,10 @@ export async function bakePublishedDataRowArtefacts( const row = await getPublishedDataRowByRoute(db, route.tableRouteBase, route.rowSlug) if (!row) continue const syntheticUrl = new URL(`http://localhost${urlPath}`) - const rendered = await renderPublishedDataRowTemplate(siteSnapshot, row, { + // Runtime assets come from this table's entry template, not from the + // arbitrary page the site-wide snapshot happens to name. + const snapshot = await snapshotForEntryRoute(db, siteSnapshot, route.tableSlug) + const rendered = await renderPublishedDataRowTemplate(snapshot, row, { db, url: syntheticUrl, publishVersion, diff --git a/server/publish/entryTemplateSnapshot.ts b/server/publish/entryTemplateSnapshot.ts new file mode 100644 index 000000000..cd4eca058 --- /dev/null +++ b/server/publish/entryTemplateSnapshot.ts @@ -0,0 +1,72 @@ +/** + * Runtime assets for a route rendered through a template. + * + * Runtime assets are per-PAGE: `publishSite.ts` bundles them once per row in + * `site.pages`, honouring each script's `scope` through + * `assetScopeAppliesToPage`. Entry routes (`/rooms/`) are not pages — + * they are rendered per request from a template page plus a data row — so they + * have no manifest of their own and have to borrow their template's. + * + * They used to borrow whatever `getLatestPublishedSiteSnapshot` returned, which + * is `order by data_rows.created_at asc limit 1`: the first published page ever + * created. That getter exists to supply `site_json`; its `runtime_assets_json` + * rode along by accident. So every entry route on a site served one arbitrary + * page's scripts, and the scope predicate was never consulted on that path at + * all — a script scoped to two pages shipped on all of them, and a script the + * oldest page did not carry never shipped anywhere. + * + * `getLatestPublishedSiteSnapshot` no longer carries runtime assets, so the + * failure mode if this resolution misses is "no scripts", never "someone + * else's scripts". + */ +import type { DbClient } from '../db/client' +import type { PublishedPageSnapshot } from '../repositories/publish' +import { getPublishedPageSnapshotById } from '../repositories/publish' +import { resolveNotFoundTemplate, resolveTemplateChain } from '@core/templates/templateMatching' + +/** + * `siteSnapshot` with the runtime manifest of the page that will actually + * render `pageId`. The site document is kept from `siteSnapshot` so it stays + * the one the caller resolved its template chain against, even if a publish + * lands between the two reads. + */ +async function withRuntimeAssetsOfPage( + db: DbClient, + siteSnapshot: PublishedPageSnapshot, + pageId: string, +): Promise { + const own = await getPublishedPageSnapshotById(db, pageId) + // The rendering page's manifest is authoritative, INCLUDING when it has + // none. Merging only the present case would let a manifest that arrived on + // `siteSnapshot` from anywhere else survive onto a route it was never + // scoped to — which is the whole defect. + const { runtimeAssets: _discarded, ...withoutAssets } = siteSnapshot + return own?.runtimeAssets + ? { ...withoutAssets, runtimeAssets: own.runtimeAssets } + : withoutAssets +} + +/** + * Snapshot to render an entry route of `tableSlug` with — the innermost + * template in its chain supplies the runtime manifest. + */ +export async function snapshotForEntryRoute( + db: DbClient, + siteSnapshot: PublishedPageSnapshot, + tableSlug: string, +): Promise { + const chain = resolveTemplateChain(siteSnapshot.site, { kind: 'entry', tableSlug }) + const innermost = chain[chain.length - 1] + if (!innermost) return siteSnapshot + return await withRuntimeAssetsOfPage(db, siteSnapshot, innermost.id) +} + +/** Snapshot to render the 404 route with — same reasoning as entry routes. */ +export async function snapshotForNotFoundRoute( + db: DbClient, + siteSnapshot: PublishedPageSnapshot, +): Promise { + const template = resolveNotFoundTemplate(siteSnapshot.site) + if (!template) return siteSnapshot + return await withRuntimeAssetsOfPage(db, siteSnapshot, template.id) +} diff --git a/server/publish/publicRouter.ts b/server/publish/publicRouter.ts index 65e30e123..de02b0339 100644 --- a/server/publish/publicRouter.ts +++ b/server/publish/publicRouter.ts @@ -76,6 +76,7 @@ import { import { NOT_FOUND_ARTEFACT_URL_PATH, readArtefact } from './staticArtefact' import { getOrRender, peek } from './renderCache' import { getLatestSnapshotForVersion } from './publishedSnapshotCache' +import { snapshotForEntryRoute, snapshotForNotFoundRoute } from './entryTemplateSnapshot' import { getPublishVersion } from './publishState' import { canonicalRenderQuery } from './loopPrefetch' @@ -173,7 +174,10 @@ async function resolvePublicRoute( // full-site parse. const siteSnapshot = await getLatestSnapshotForVersion(db, getPublishVersion()) if (!siteSnapshot) return { kind: 'not-found' } - return { kind: 'row', snapshot: siteSnapshot, row } + // That snapshot carries no runtime manifest — an entry route takes the one + // belonging to the template that actually renders it. + const snapshot = await snapshotForEntryRoute(db, siteSnapshot, row.tableSlug) + return { kind: 'row', snapshot, row } } const redirect = await getDataRowRedirectByRoute(db, route.tableRouteBase, route.rowSlug) @@ -320,8 +324,10 @@ export async function renderNotFoundResponse( return new Response(warm.body, { headers: warm.headers, status: 404 }) } - const snapshot = await getLatestSnapshotForVersion(db, getPublishVersion()) - if (!snapshot || !resolveNotFoundTemplate(snapshot.site)) return null + const siteSnapshot = await getLatestSnapshotForVersion(db, getPublishVersion()) + if (!siteSnapshot || !resolveNotFoundTemplate(siteSnapshot.site)) return null + // Same as entry routes: the 404 template supplies its own runtime manifest. + const snapshot = await snapshotForNotFoundRoute(db, siteSnapshot) const syntheticUrl = new URL(NOT_FOUND_ARTEFACT_URL_PATH, url.origin) const cached = await getOrRender(cacheKey, async () => { diff --git a/server/publish/publishRow.ts b/server/publish/publishRow.ts index 5feb28ede..8e7e4155f 100644 --- a/server/publish/publishRow.ts +++ b/server/publish/publishRow.ts @@ -26,6 +26,7 @@ import { type PreviousPublishedRoute, } from '../repositories/data/publish' import { getLatestPublishedSiteSnapshot } from '../repositories/publish' +import { snapshotForEntryRoute } from './entryTemplateSnapshot' import { renderPublishedDataRowTemplate } from './publicRenderer' import { applyPublishedHtmlPipeline } from './publishedHtmlPipeline' import { removeArtefactInPlace, updateArtefactInPlace } from './staticArtefact' @@ -132,7 +133,10 @@ async function writeDataRowArtefact( const newPath = publicDataPath(tableInfo.tableRouteBase, publishedRow.slug) const syntheticUrl = new URL(`http://localhost${newPath}`) - const rendered = await renderPublishedDataRowTemplate(siteSnapshot, publishedDataRow, { + // Runtime assets come from this table's entry template, not from the + // arbitrary page the site-wide snapshot happens to name. + const snapshot = await snapshotForEntryRoute(db, siteSnapshot, tableInfo.tableSlug) + const rendered = await renderPublishedDataRowTemplate(snapshot, publishedDataRow, { db, url: syntheticUrl, publishVersion, diff --git a/server/repositories/publish.ts b/server/repositories/publish.ts index 244c52db5..5f386c1f0 100644 --- a/server/repositories/publish.ts +++ b/server/repositories/publish.ts @@ -323,13 +323,25 @@ export async function getPublishedPageSnapshotById( return rows[0] ? snapshotFromQueryRow(rows[0]) : null } +/** + * Any published page's snapshot, used purely as a carrier for `site_json` — + * routes that are not themselves pages (entry routes, the 404) need the site + * document to resolve their template chain. + * + * It deliberately does NOT carry runtime assets. Those are per-page, and the + * arbitrary page this returns (the first created, per the `order by`) is + * almost never the page that renders the request. Letting its + * `runtime_assets_json` ride along meant every entry route on a site served + * one unrelated page's scripts, with the scope predicate never consulted. + * Callers needing a manifest resolve the page that actually renders and take + * its own — see `server/publish/entryTemplateSnapshot.ts`. + */ export async function getLatestPublishedSiteSnapshot( db: DbClient, ): Promise { const { rows } = await db` select data_rows.id as row_id, site_snapshots.site_json, - data_row_versions.runtime_assets_json, site_snapshots.importmap_body, site_snapshots.importmap_sha256 from data_rows @@ -341,5 +353,6 @@ export async function getLatestPublishedSiteSnapshot( order by data_rows.created_at asc limit 1 ` - return rows[0] ? snapshotFromQueryRow(rows[0]) : null + const row = rows[0] + return row ? snapshotFromQueryRow({ ...row, runtime_assets_json: null }) : null } diff --git a/src/__tests__/data/contentAdmin.test.tsx b/src/__tests__/data/contentAdmin.test.tsx index dac0d1ea8..c7f4fc9c5 100644 --- a/src/__tests__/data/contentAdmin.test.tsx +++ b/src/__tests__/data/contentAdmin.test.tsx @@ -970,6 +970,78 @@ describe('ContentPage', () => { expect(params.get('row')).toBe('article_2') }) + it('publishing another document does not steal the active document', async () => { + // `applyStatus` used to call `updateSelectedEntry` for whatever row it + // published, active or not. That retargeted the workspace — discarding the + // author's unsaved draft — and left a tool loop of + // `set_document_fields → set_document_status` writing one document behind + // itself, so every field write after the first was refused. + const postA = makeRow('post_a', 'posts', { title: 'Open post', slug: 'open-post', seoTitle: '' }) + const postB = makeRow('post_b', 'posts', { title: 'Other post', slug: 'other-post', seoTitle: '' }) + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input) + const method = init?.method ?? 'GET' + + if (url === '/admin/api/cms/data/tables' && method === 'GET') { + return json({ tables: [makeTable('posts', 'Posts', 'posts', '/posts', 'Post', 'Posts')] }) + } + if (url === '/admin/api/cms/data/tables/posts/rows' && method === 'GET') { + return json({ rows: [postA, postB] }) + } + if (url === '/admin/api/cms/data/rows/post_a' && method === 'GET') return json({ row: postA }) + if (url === '/admin/api/cms/data/rows/post_b' && method === 'GET') return json({ row: postB }) + if (url === '/admin/api/cms/data/rows/post_a' && method === 'PATCH') { + const body = JSON.parse(String(init?.body)) + return json({ row: makeRow('post_a', 'posts', body.cells) }) + } + if (url === '/admin/api/cms/data/rows/post_b/publish' && method === 'POST') { + return json({ row: { ...postB, status: 'published', publishedAt: '2026-05-01T10:02:00.000Z' } }) + } + if (url === '/admin/api/cms/data/authors' && method === 'GET') return json({ authors: [] }) + if (url === '/admin/api/cms/media' && method === 'GET') return json({ assets: [] }) + + const ambient = ambientFetchFallback(url) + if (ambient) return ambient + return json({ error: `Unhandled ${method} ${url}` }, 500) + } + + render( + + + , + ) + expect(await screen.findByRole('region', { name: 'Posts' })).toBeDefined() + + // Each call gets its own act() so React commits in between and the bridge's + // workspace ref refreshes. Batching them hides the bug: the ref would still + // hold the pre-publish workspace and the last write would pass either way. + let statusResult: Awaited> | null = null + let writeResult: Awaited> | null = null + await act(async () => { + await executeContentTool('content_set_active_document', { documentId: 'post_a' }) + }) + await act(async () => { + // Publish the OTHER document… + statusResult = await executeContentTool('content_set_document_status', { + documentId: 'post_b', + status: 'published', + }) + }) + await act(async () => { + // …post_a must still be the active document, so this write must land. + writeResult = await executeContentTool('content_set_document_field', { + documentId: 'post_a', + fieldId: 'seoTitle', + value: 'Still mine', + }) + }) + + expect(statusResult?.ok).toBe(true) + expect(writeResult?.ok).toBe(true) + expect(String(writeResult?.error ?? '')).not.toContain('not the active doc') + }) + it('uses content-specific rail panels instead of editor-only panels', async () => { render( diff --git a/src/__tests__/publisher/entryRouteRuntimeScope.test.ts b/src/__tests__/publisher/entryRouteRuntimeScope.test.ts new file mode 100644 index 000000000..582ba0d6f --- /dev/null +++ b/src/__tests__/publisher/entryRouteRuntimeScope.test.ts @@ -0,0 +1,106 @@ +/** + * Entry routes take their runtime manifest from the template that renders + * them, not from whatever page the site-wide snapshot happens to name. + * + * `getLatestPublishedSiteSnapshot` is `order by data_rows.created_at asc limit + * 1` — the first published page ever created. It exists to carry `site_json`, + * and its `runtime_assets_json` used to ride along. Entry routes are rendered + * from a template plus a data row rather than from a page, so they inherited + * that manifest wholesale: every entry route on a site served one arbitrary + * page's scripts, and `assetScopeAppliesToPage` was never consulted on the + * path at all. + * + * Over-inclusion was what surfaced in practice — a script scoped to two pages + * shipping on ten entry routes — but the same bug under-includes just as + * easily: if the oldest page carries no scripts, a scoped script reaches + * nothing. + */ +import { describe, expect, it } from 'bun:test' +import type { DbClient } from '../../../server/db' +import type { PublishedPageSnapshot } from '../../../server/repositories/publish' +import { snapshotForEntryRoute } from '../../../server/publish/entryTemplateSnapshot' +import { makeSite } from '../fixtures' + +const postsTarget = { kind: 'postTypes' as const, tableSlugs: ['posts'] } + +function runtimeAssets(name: string) { + return { + scripts: [{ publicPath: `/_instatic/assets/${name}/classic/001-${name}.js`, placement: 'body-end' as const }], + } +} + +/** A site whose first page is unrelated and whose second is the entry template. */ +function siteSnapshot(): PublishedPageSnapshot { + const site = makeSite() + site.pages[0].id = 'oldest-page' + site.pages.push({ + ...structuredClone(site.pages[0]), + id: 'post-template', + slug: 'post-template', + template: { enabled: true, target: postsTarget, priority: 10 }, + }) + return { cmsSnapshotVersion: 1, pageRowId: 'oldest-page', site } +} + +/** Stands in for the DB: only `getPublishedPageSnapshotById` is reached. */ +function dbReturning(byPageId: Record): DbClient { + const fake = (async (_strings: TemplateStringsArray, ...params: unknown[]) => { + const pageId = String(params[0]) + const assets = byPageId[pageId] + return assets + ? { rows: [{ row_id: pageId, site_json: makeSite(), runtime_assets_json: assets }], rowCount: 1 } + : { rows: [], rowCount: 0 } + }) as unknown as DbClient + return fake +} + +describe('entry-route runtime manifest', () => { + it('uses the entry template\'s own manifest, not the site snapshot\'s', async () => { + const snapshot = siteSnapshot() + const db = dbReturning({ 'post-template': runtimeAssets('template') }) + + const resolved = await snapshotForEntryRoute(db, snapshot, 'posts') + + expect(resolved.runtimeAssets?.scripts[0]?.publicPath).toContain('001-template.js') + }) + + it('serves no scripts when the template has none, rather than another page\'s', async () => { + // Reproduce the old shape: a site snapshot already carrying the oldest + // page's manifest, and a template with none of its own. The old code + // passed that straight through, so every entry route on the site served + // `001-oldest.js`. + const snapshot = { ...siteSnapshot(), runtimeAssets: runtimeAssets('oldest') } + const db = dbReturning({}) + + const resolved = await snapshotForEntryRoute(db, snapshot, 'posts') + + expect(resolved.runtimeAssets?.scripts[0]?.publicPath ?? '').not.toContain('001-oldest.js') + }) + + it('overrides a stale manifest on the site snapshot with the template\'s', async () => { + const snapshot = { ...siteSnapshot(), runtimeAssets: runtimeAssets('oldest') } + const db = dbReturning({ 'post-template': runtimeAssets('template') }) + + const resolved = await snapshotForEntryRoute(db, snapshot, 'posts') + + expect(resolved.runtimeAssets?.scripts[0]?.publicPath).toContain('001-template.js') + }) + + it('leaves the site document untouched so the resolved chain still applies', async () => { + const snapshot = siteSnapshot() + const db = dbReturning({ 'post-template': runtimeAssets('template') }) + + const resolved = await snapshotForEntryRoute(db, snapshot, 'posts') + + expect(resolved.site).toBe(snapshot.site) + }) + + it('falls back to the site snapshot when the table has no entry template', async () => { + const snapshot = siteSnapshot() + const db = dbReturning({ 'post-template': runtimeAssets('template') }) + + const resolved = await snapshotForEntryRoute(db, snapshot, 'no-such-table') + + expect(resolved).toBe(snapshot) + }) +}) diff --git a/src/__tests__/templates/entryTemplateScope.test.ts b/src/__tests__/templates/entryTemplateScope.test.ts new file mode 100644 index 000000000..cbb18963c --- /dev/null +++ b/src/__tests__/templates/entryTemplateScope.test.ts @@ -0,0 +1,62 @@ +/** + * A composed entry template keeps its `template` config, so scoped assets + * reach the routes they target. + * + * `assetScopeAppliesToPage` gates its `templates` branch on + * `Boolean(page.template)`. `composeTemplateChain` returned a Page built from + * scratch — id, slug, title, rootNodeId, nodes — which dropped `template`, so + * a stylesheet scoped to an entry template never applied to that template's + * own entry routes. The one place it was meant to apply was the one place it + * could not. + */ +import { describe, expect, it } from 'bun:test' +import { makeSite } from '../fixtures' +import { composeTemplateChain, resolveTemplateChain } from '@core/templates' +import { assetScopeAppliesToPage } from '@core/site-runtime/runtimeConfig' + +const postsTarget = { kind: 'postTypes' as const, tableSlugs: ['posts'] } + +function siteWithEntryTemplate() { + const site = makeSite() + site.pages[0].id = 'post-template' + site.pages[0].template = { enabled: true, target: postsTarget, priority: 10 } + return site +} + +describe('composed entry template scope', () => { + it('carries the innermost template config onto the composed page', () => { + const site = siteWithEntryTemplate() + const chain = resolveTemplateChain(site, { kind: 'entry', tableSlug: 'posts' }) + const merged = composeTemplateChain(chain, { kind: 'entry' }) + + expect(merged.id).toBe('post-template') + expect(merged.template).toBeDefined() + expect(merged.template?.enabled).toBe(true) + }) + + it('lets a template-scoped asset match its own entry route', () => { + const site = siteWithEntryTemplate() + const chain = resolveTemplateChain(site, { kind: 'entry', tableSlug: 'posts' }) + const merged = composeTemplateChain(chain, { kind: 'entry' }) + + const scope = { type: 'templates' as const, templatePageIds: ['post-template'] } + expect(assetScopeAppliesToPage(scope, merged)).toBe(true) + }) + + it('still excludes a template-scoped asset from an unrelated template', () => { + const site = siteWithEntryTemplate() + const chain = resolveTemplateChain(site, { kind: 'entry', tableSlug: 'posts' }) + const merged = composeTemplateChain(chain, { kind: 'entry' }) + + const scope = { type: 'templates' as const, templatePageIds: ['some-other-template'] } + expect(assetScopeAppliesToPage(scope, merged)).toBe(false) + }) + + it('does not invent a template config for a plain page terminal', () => { + const site = makeSite() + const page = site.pages[0] + const merged = composeTemplateChain([], { kind: 'page', page }) + + expect(merged.template).toBeUndefined() + }) +}) diff --git a/src/admin/pages/content/agent/useContentToolBridge.ts b/src/admin/pages/content/agent/useContentToolBridge.ts index 953bc3010..ee5dbdfce 100644 --- a/src/admin/pages/content/agent/useContentToolBridge.ts +++ b/src/admin/pages/content/agent/useContentToolBridge.ts @@ -44,6 +44,8 @@ interface ContentToolWorkspaceSurface { updateEntryStatus(entry: DataRow, status: 'draft' | 'unpublished'): Promise updateEntryAuthor(entry: DataRow, userId: string): Promise updateSelectedEntry(entry: DataRow): void + /** Refresh a row, selecting it only if it is already the active document. */ + applyEntryUpdate(entry: DataRow): void } interface ContentToolDraftSurface { @@ -291,6 +293,22 @@ function applyFieldsToDraft( } } +/** + * Publish/schedule a row without moving the active document. + * + * `updateSelectedEntry` sets `selectedEntry`, so calling it for a row that is + * NOT the active one silently retargets the workspace. Two things went wrong + * with that. A caller looping `set_document_fields` → `set_document_status` + * over several documents left the active pointer one step behind itself, and + * the field writes — which require the active document — then failed on every + * document after the first. And it yanked whatever a human had open, taking + * their unsaved draft with it, because `useContentEntryDraft` re-applies on a + * `selectedEntry` id change. + * + * The non-agent paths already guard this way (`useContentWorkspace`'s + * `updateEntryStatus` / `updateEntryAuthor`); this brings the agent path in + * line rather than leaving the two asymmetric. + */ async function applyStatus( ws: ContentToolWorkspaceSurface, row: DataRow, @@ -299,13 +317,11 @@ async function applyStatus( ): Promise { if (status === 'scheduled') { if (!scheduledAt) throw new Error('scheduledAt is required for scheduled publishing.') - const scheduled = await scheduleCmsDataRowPublish(row.id, scheduledAt) - ws.updateSelectedEntry(scheduled) + ws.applyEntryUpdate(await scheduleCmsDataRowPublish(row.id, scheduledAt)) return } if (status === 'published') { - const published = await publishCmsDataRow(row.id) - ws.updateSelectedEntry(published) + ws.applyEntryUpdate(await publishCmsDataRow(row.id)) return } await ws.updateEntryStatus(row, status) diff --git a/src/admin/pages/content/hooks/useContentWorkspace.ts b/src/admin/pages/content/hooks/useContentWorkspace.ts index 2f2e7ea63..240fae53b 100644 --- a/src/admin/pages/content/hooks/useContentWorkspace.ts +++ b/src/admin/pages/content/hooks/useContentWorkspace.ts @@ -118,6 +118,20 @@ export function useContentWorkspace({ setEntries((current) => updateRowList(current, entry)) } + /** + * Refresh a row in the list, making it the active document ONLY if it + * already was. Use this for any update that is not itself a navigation — + * `updateSelectedEntry` retargets the workspace, which silently discards + * whatever the author had open and unsaved. + */ + const applyEntryUpdate = (entry: DataRow) => { + if (selectedEntryRef.current?.id === entry.id) { + updateSelectedEntry(entry) + return + } + setEntries((current) => updateRowList(current, entry)) + } + /** * Re-read the collection roster from the server and return it. * @@ -522,6 +536,7 @@ export function useContentWorkspace({ openEntry, selectEntry, updateSelectedEntry, + applyEntryUpdate, createUntitledEntry, duplicateEntry, createCollection, diff --git a/src/core/templates/templateCompose.ts b/src/core/templates/templateCompose.ts index 628c1f0e7..1189cfa4e 100644 --- a/src/core/templates/templateCompose.ts +++ b/src/core/templates/templateCompose.ts @@ -112,7 +112,14 @@ export function composeTemplateChain(chain: Page[], terminal: TerminalContent): // matched template as-is (chrome without a body). `chain` is non-empty here // because the renderer 404s an entry route with no matching template. const t = chain[chain.length - 1] - return { id: t.id, slug: t.slug, title: t.title, rootNodeId: t.rootNodeId, nodes: { ...t.nodes } } + return { + id: t.id, + slug: t.slug, + title: t.title, + ...(t.template ? { template: t.template } : {}), + rootNodeId: t.rootNodeId, + nodes: { ...t.nodes }, + } } // Build the merged tree from the INNERMOST effective template outward. @@ -145,6 +152,11 @@ export function composeTemplateChain(chain: Page[], terminal: TerminalContent): // terminals have no Page here; their caller overrides `title` from the // published row after composing. title: terminal.kind === 'page' ? terminal.page.title : innermost.title, + // Carry the innermost template's `template` config. `assetScopeAppliesToPage` + // gates its `templates` branch on `Boolean(page.template)`, so dropping it + // here meant a stylesheet scoped to an entry template never applied to that + // template's own routes — the one place it was meant to. + ...(innermost.template ? { template: innermost.template } : {}), rootNodeId: acc.rootId, nodes: acc.nodes, }