From add3c80ef78365f7312ab11c1214fab2891b21fa Mon Sep 17 00:00:00 2001 From: DavidBabinec Date: Wed, 22 Jul 2026 23:07:57 +0200 Subject: [PATCH] fix(editor): render loops in page preview --- docs/e2e/feature-matrix.md | 2 +- docs/e2e/feature-validation.tsv | 2 +- docs/features/loops.md | 7 + .../persistence/cmsRuntimeClient.test.ts | 47 +++--- .../publisher/previewOverlay.test.tsx | 91 +++++++++--- .../server/cmsRuntimeHandlers.test.ts | 78 ++++++++++ .../AdminCanvasLayout/AdminCanvasLayout.tsx | 4 +- .../site/canvas/useRuntimeScriptBuild.ts | 2 +- .../site/preview/PreviewOverlay.module.css | 7 + .../pages/site/preview/PreviewOverlay.tsx | 136 +++++++++++++++--- .../pages/site/store/slices/sitePanelSlice.ts | 2 +- src/core/persistence/cmsRuntime.ts | 16 ++- src/core/persistence/index.ts | 2 + 13 files changed, 329 insertions(+), 67 deletions(-) diff --git a/docs/e2e/feature-matrix.md b/docs/e2e/feature-matrix.md index 191f608de..545466ede 100644 --- a/docs/e2e/feature-matrix.md +++ b/docs/e2e/feature-matrix.md @@ -174,7 +174,7 @@ SITE-019 note: `visual-builder.e2e.ts` saves a styled Container subtree as a lay SITE-014 note: focused Bun coverage spans the dependency panel, auto-resolve hook, client envelope validation, runtime handler normalization, module dependency/importmap filtering, script import analysis, runtime config, site runtime build, dependency resolver/cache, package importmap/server, and runtime asset publish injection. `tests/e2e/runtime-dependencies.e2e.ts` covers browser authoring of a site script import, Dependencies-panel missing package Add, live `canvas-confetti` registry/cache resolution, save/publish, public importmap emission, browser loading of the emitted `/_instatic/runtime/cache/...` package URL, and a 390px mobile path that authors a missing import, opens Dependencies, verifies no horizontal overflow, and confirms the Add action is reachable. Live registry/install failure UX permutations remain operator-run. -SITE-016 note: `tests/e2e/preview-live.e2e.ts` creates a disposable page, publishes version A, saves draft version B without publishing, verifies the Preview page overlay iframe renders draft B, verifies the toolbar Open live page popup still serves published version A without editor chrome, and repeats preview opening at 390px to confirm the overlay remains reachable without document overflow. Template-target and Content-entry live-path permutations remain lower-level or future browser coverage. +SITE-016 note: `tests/e2e/preview-live.e2e.ts` creates a disposable page, publishes version A, saves draft version B without publishing, verifies the Preview page overlay iframe renders draft B, verifies the toolbar Open live page popup still serves published version A without editor chrome, and repeats preview opening at 390px to confirm the overlay remains reachable without document overflow. Issue #234 additionally gates Preview page through the server runtime-preview path so loop and media prefetch matches public rendering. Template-target and Content-entry live-path permutations remain lower-level or future browser coverage. ## Public Serving diff --git a/docs/e2e/feature-validation.tsv b/docs/e2e/feature-validation.tsv index 50549ff98..176c1e3a1 100644 --- a/docs/e2e/feature-validation.tsv +++ b/docs/e2e/feature-validation.tsv @@ -43,7 +43,7 @@ SITE-012 Framework colors, typography, spacing, and fonts As a site editor, I wa SITE-013 Code editor for site files As a site editor, I want to create, preview, configure, edit, save, and publish site files so custom CSS, JavaScript, images, and binary assets can participate in the authored site without leaving the visual editor. CodeEditorPanel opens the active site file, lazy-loads CodeMirror for text/script/style/node-prop buffers, renders image previews for image files, renders a non-image binary placeholder for other binary assets, and exposes ScriptSettingsPane or StyleSettingsPane for runtime files. Site Explorer creates stylesheet files under src/styles/.css and script files under src/scripts/.ts, sets the new file active, and file mutations persist through site.files. Script settings control enabled state, module/classic format, canvas execution, placement, timing, priority, import diagnostics, and page/template scope. Stylesheet settings control enabled state, priority, and page/template scope. Deleting a runtime file clears its runtime config and active editor selection. Publisher emits enabled scoped user stylesheets into page-specific userStyles bundles and enabled scripts into published runtime assets. Empty text files remain editable and saveable; image files preview by URL while other binary files show a placeholder; invalid or unsafe paths are rejected; duplicate normalized paths are rejected; deleting an active file closes the editor; deleting scripts/styles removes runtime config; disabled or out-of-scope styles/scripts do not publish for the page; script import diagnostics do not run for classic scripts; CodeMirror content syncs through a short debounce before save. SiteFileSchema and SiteFileType validate persisted file shape; filesSlice normalizes paths, rejects traversal/empty/colliding paths, and routes content/blob updates by file kind; SiteRuntime schemas normalize script/style defaults and scope targeting; collectRuntimeScripts and collectAppliedStyles filter by enabled state, page/template scope, priority, and path order; collectUserStylesheetCss comment-wraps source paths and feeds the userStyles CSS bundle; public CSS links are emitted only for non-empty bundles. src/admin/pages/site/code-editor/CodeEditorPanel.tsx; src/admin/pages/site/code-editor/CodeMirrorEditor.tsx; src/admin/pages/site/code-editor/ScriptSettingsPane.tsx; src/admin/pages/site/code-editor/StyleSettingsPane.tsx; src/admin/pages/site/code-editor/AssetScopeControl.tsx; src/admin/pages/site/store/slices/filesSlice.ts; src/admin/pages/site/store/slices/sitePanelSlice.ts; src/core/files/schemas.ts; src/core/site-runtime/runtimeConfig.ts; src/core/site-runtime/schemas.ts; src/core/publisher/userStylesheets.ts; server/publish/siteCssBundle.ts CodeMirror is intentionally lazy-loaded; browser specs wait for the CodeMirror content element before typing; direct script authoring plus dependency resolution is tracked in SITE-014, while SITE-013 owns the generic file editor and stylesheet publish path; local E2E uses disposable SQLite data and can publish public pages without external services. Happy: create stylesheet from Site Explorer, type CSS in CodeMirror, save draft, publish, verify public page loads one /_instatic/css/userStyles-* link and applies the CSS. Error: invalid/duplicate/unsafe paths reject in files data-layer tests; resolve/import errors remain surfaced by the script settings/dependencies flows. Boundary: empty files, image preview, non-image binary placeholder, disabled runtime config, style/script priority ordering, page/template scope, and delete cleanup. Invalid: path traversal and unsafe normalized paths are rejected. Permission/security: runtime endpoints and site save/publish remain capability/step-up gated by surrounding flows; published output omits admin chrome and serves immutable hashed CSS. Performance: CodeMirror loads lazily and userStyles hashing changes only when relevant stylesheet content/config changes. Mobile/responsive: dependency/code-authoring mobile reachability is covered in SITE-014; broader standalone code-editor mobile exploration remains a residual manual check. SITE-013 automated audit passed 2026-06-23 with direct stylesheet browser coverage; no open defects; broader standalone mobile exploratory remains pending 0 None Added `tests/e2e/site-files.e2e.ts` for the missing direct stylesheet journey. Existing focused coverage maps script settings, style settings, CodeMirror changes, CodeMirror theme, file data-layer validation, runtime config normalization/scope/order, editor-store runtime cleanup, server userStyles bundle generation, publisher runtime assets, and agent code-asset tools. Verification this slice: TSV integrity guard passed; focused SITE-013 unit/integration `bun test` passed 101/101; focused `bun run test:e2e -- --project=e2e tests/e2e/site-files.e2e.ts -g "SITE-013"` passed 2/2 including setup; `bun run lint` passed; `bun run build` passed; full `bun test` passed 5697/5697. Run log: docs/e2e/runs/2026-06-23-site013-code-editor-stylesheet.md. 2026-06-23 SITE-014 Site dependencies and runtime package resolution As a site editor, I want to declare dependencies so plugin modules and site code can use runtime packages. Dependencies panel edits package metadata; POST /runtime/dependencies/resolve installs/resolves package import map; published pages serve cached runtime packages under /_instatic/runtime/cache. Invalid package.json; network/install failure; stale hash; package not found; runtime path 404 under namespace. Resolve body accepts unknown packageJson and normalizes to safe runtime dependencies only; devDependencies are not resolved into runtime importmaps; client validates dependencyLock/packageImportmap envelopes; runtime package paths require a 24-hex hash and reject traversal. src/admin/pages/site/panels/DependenciesPanel; src/admin/pages/site/hooks/useAutoResolveDependencies.ts; src/core/persistence/cmsRuntime.ts; server/handlers/cms/runtime.ts; server/publish/runtime/dependencyResolver.ts; server/publish/runtime/dependencyCache.ts; server/publish/runtime/packageImportmap.ts; server/publish/runtime/packageServer.ts Package installs may need network; deterministic tests use mocked registry/install/cache roots and UI fetch stubs. Happy: dependency panel detects imports, adds missing dependency, resolves lock/importmap, preview/build consumes runtime deps, package server serves cached package assets, public importmap points at hashed cache URLs, browser can load the emitted package asset, and mobile code authoring exposes the missing-dependency Add action without horizontal overflow. Error: resolve API failure surfaces in store; network/install timeout/cap/partial cache handled; missing package asset 404. Boundary: no dependencies, stale lock, importmap missing, concurrent resolves, cache sentinel, relative RUNTIME_CACHE_DIR. Invalid: unsafe package names, malformed lock envelope, bad runtime hash, traversal path. Permission: runtime.dependencies/site.read caps. Performance: install cache reuse and concurrent install dedupe. Mobile: Code Editor authoring and dependency panel controls stay reachable at 390px. Passed 2026-06-23: deterministic SITE-014 coverage (75 tests), cache layout regression (8 tests), Site Explorer layering invariant (33 tests), focused browser E2E (3 tests including setup), full bun test (5695 pass), lint, and build. 0 None Browser E2E now covers authoring a script import, Dependencies-panel missing package Add, live canvas-confetti registry/cache resolution, save/publish, public marker output, importmap emission, browser loading of /_instatic/runtime/cache package URLs, and a 390px mobile path for missing left-pad import analysis, dependency panel containment, and Add reachability. DEF-20260623-SITE014-01 closed: Code Editor floated above docked sidebars and intercepted Dependencies Add; fixed by raising site sidebars above floating editor panels and updating the layering invariant. DEF-20260623-SITE014-02 closed: relative RUNTIME_CACHE_DIR leaked relative paths to esbuild nodePaths and blocked publish with Could not resolve canvas-confetti despite an installed cache; fixed by absolute-normalizing cacheRootDir. DEF-20260623-SITE014-03 closed: mobile Code Editor authoring was blocked by fixed desktop panel sizing, a horizontal settings rail over the editor, and sidebar interception; fixed with responsive Code Editor viewport clamps, stacked settings panes on narrow screens, and a mobile overlay layer for the active Code Editor. Remaining: live registry/install failure UX permutations. 2026-06-23 SITE-015 Site editor media explorer and picker As a site editor, I want to browse, upload, reuse, inspect, edit, and apply media from inside the site editor so image, video, SVG, and background/media controls can use CMS assets without leaving the authoring workflow. MediaLibraryControl renders library and URL modes for image/video props, lazy-loads MediaPickerModal on Browse, filters by media kind, updates the prop with the picked asset publicPath, supports clearing, and opens MediaViewerWindow for the selected CMS asset. MediaExplorerPanel is a docked left-rail panel that lists CMS assets grouped as Images, Videos, and Other, supports search and list/grid view persistence, uploads assets through CMS media APIs, opens the shared viewer, exposes context menu actions for Copy URL, Rename, Delete, and conditionally Use in selected image/video when the selected canvas node matches the asset kind. Published pages render selected local media through /uploads URLs. Empty library shows bucket empty states; image/video/other assets are bucketed by MIME type; selected image/video actions only appear for matching module and asset kind; unsupported uploads return an alert through the media upload queue; deleted or missing selected paths fall back to saved-path labels; URL mode accepts local /uploads paths plus http/https URLs and rejects invalid image/video URLs; SVG uploads are sanitized before serving; viewer edits/removal update local asset state; upload queue and media viewer can be closed without leaving the editor. CMS media responses are validated by @core/persistence/cmsMedia; uploads are server magic-byte/type/size checked and routed through the media presentation pipeline; MediaLibraryControl validates URL mode before calling onChange; MediaExplorerPanel applies assets only to selected base.image src or base.video videoUrl props; media routes require the relevant media capabilities; publisher escapes/render-validates media URLs and visitor pages must not include admin chrome. src/admin/pages/site/panels/MediaExplorerPanel/MediaExplorerPanel.tsx; src/admin/pages/site/panels/MediaExplorerPanel/mediaExplorerUtils.ts; src/admin/pages/site/property-controls/MediaLibraryControl.tsx; src/admin/pages/site/property-controls/ImageControl.tsx; src/admin/pages/site/property-controls/BackgroundImageControl.tsx; src/admin/pages/media/components/MediaPickerModal/MediaPickerModal.tsx; src/admin/pages/media/components/MediaViewerWindow/MediaViewerWindow.tsx; src/admin/pages/media/hooks/useStandaloneMediaEditor.ts; src/core/persistence/cmsMedia.ts; server/handlers/cms/media.ts; server/handlers/cms/mediaUpload.ts; src/modules/base/image; src/modules/base/video The docked Media Explorer and the property-control picker intentionally share CMS media and viewer primitives with the Media workspace; direct background-image picker publishing remains covered by lower-level style/publisher tests rather than a dedicated browser journey; the new SITE-015 browser regression uses disposable SQLite/uploads and verifies public media output as a visitor. Happy: upload an image through the property picker, select it for an image module, save, publish, and verify public /uploads image decoding; reuse the same library asset on a second image without re-upload; upload an image through the docked Media Explorer, use its context menu to apply it to the selected image module, save, publish, and verify public /uploads image decoding. Error: unsupported upload shows specific rejection feedback; media API errors surface inline or restore optimistic state. Boundary: empty library, search filters, list/grid view, image/video/other grouping, selected image/video context action gating, metadata rename/edit/reload persistence, replace/delete/restore/purge lifecycle, and sanitized SVG serving. Invalid: bad URL-mode values are rejected before prop update; unsafe SVG script/event/style content is stripped. Permission/security: media APIs require media capabilities and public visitor pages show only uploaded media, not admin chrome. Performance: MediaPickerModal lazy-loads only after Browse; media panel fetches assets when opened. Mobile/responsive: media viewer, replace dialog, trash restore, and storage panel have mobile containment coverage; docked editor Media Explorer mobile remains a residual exploratory check. SITE-015 browser regression passed 2026-06-23 with direct docked Media Explorer apply-to-selected-image coverage; no open defects; docked Media Explorer mobile exploratory remains pending 0 None Added SITE-015 coverage to `tests/e2e/media.e2e.ts`: create a page, insert/select an image module, open the Media panel, upload an image through the docked panel, use the asset context menu `Use in selected image`, save, publish, and verify the public page serves/decodes the uploaded image. Existing coverage in `media.e2e.ts` covers picker upload/select/publish, asset reuse, unsupported upload rejection, metadata persistence, mobile metadata viewer containment, replace/delete/restore/purge lifecycle, mobile lifecycle containment, storage panel state/mobile containment, and SVG sanitization. Focused component coverage in `siteExplorerPanel.test.tsx` covers Media Explorer grouping, search/list/grid, copy URL, selected image/video apply actions, viewer opening, rename, and delete. Verification this slice: TSV integrity guard passed; focused SITE-015 unit/component/API `bun test` passed 102/102; focused `bun run test:e2e -- --project=e2e tests/e2e/media.e2e.ts -g "SITE-015"` passed 2/2 including setup; full `bun run test:e2e -- --project=e2e tests/e2e/media.e2e.ts` passed 12/12 including setup; `bun run lint` passed; `bun run build` passed; full `bun test` passed 5697/5697. Run log: docs/e2e/runs/2026-06-23-site015-media-explorer.md. 2026-06-23 -SITE-016 Preview overlay and live-page opening As a site editor, I want to preview the current draft and open the live route so I can compare draft and published output. Preview page is exposed from the publish-actions menu; PreviewOverlay mounts only when previewOpen, requires an active site and active page, renders publishPage(activePage, site, registry) into a sandboxed iframe srcDoc, shows the active page title, closes by Close button/Escape/backdrop, and restores focus. OpenLivePageButton is globally mounted in the toolbar, reads adminUi.activeLivePath, and opens that path in a new noopener/noreferrer tab or falls back to the site root. useActiveLivePath publishes regular page paths, template preview targets, post-type preview permalinks, or /404 for not-found templates. No active site/page renders no overlay; unpublished saved drafts can preview without changing public output; live route shows last published artefact until publish; activeLivePath null opens root; template pages are not directly routable and resolve to their preview target; popup uses the current dev/admin origin but Vite proxies public routes; narrow viewport must keep preview reachable and document width contained. Preview state is owned by uiSlice openPreview/closePreview; overlay generation trusts the validated in-memory site document and the publisher boundary sanitizes emitted HTML; iframe uses sandbox="" with no allow flags; OpenLivePageButton receives the already-resolved public path from adminUi; save/publish remain capability and step-up gated by surrounding toolbar flows. src/admin/pages/site/toolbar/PublishButton.tsx; src/admin/pages/site/toolbar/PublishActionGroup.tsx; src/admin/pages/site/preview/PreviewOverlay.tsx; src/admin/pages/site/store/slices/uiSlice.ts; src/admin/pages/site/hooks/useActiveLivePath.ts; src/admin/shared/OpenLivePageButton/OpenLivePageButton.tsx; src/core/publisher; src/core/page-tree/page.ts Public live routes intentionally show the last published version, not the saved draft; this slice covers regular pages, while template/content-entry live-path permutations remain covered lower-level or future browser coverage. Happy: create page, publish version A, save draft version B, open Preview page and verify iframe shows B, open live page and verify popup route shows A without admin chrome. Error: no-site/no-active-page overlay and close behaviours covered by component tests. Boundary: activeLivePath root fallback, home path, content entry path, template target resolver, and mobile 390px preview reachability. Invalid: missing runtime preview body remains covered by server runtime tests, not this client overlay. Permission/security: publish/save capability and step-up gates surround the flow; public popup has no admin chrome. Performance: preview lazy-loads publisher graph only when opened. Mobile: overlay opens at 390px without document overflow. SITE-016 automated browser audit passed 2026-06-23 with draft-vs-live comparison and mobile preview reachability; no open defects; template/content live-path browser permutations remain residual risk 0 None Added `tests/e2e/preview-live.e2e.ts`: publish a disposable page with text A, save draft text B without publishing, verify Preview page iframe shows B and not A, verify toolbar Open live page popup shows A and not B without editor chrome, then reopen Preview page at 390x844 and verify no document overflow. Initial focused run failed because the new spec selected a layer while the Site Explorer was open; root cause was a spec precondition, fixed by opening the Layers panel before selecting the Text node. Verification this slice: TSV integrity guard passed; focused preview/live unit suite passed 52/52; focused `bun run test:e2e -- --project=e2e tests/e2e/preview-live.e2e.ts -g "SITE-016"` passed 2/2 including setup; `bun run lint` passed; `bun run build` passed; full `bun test` passed 5697/5697. Run log: docs/e2e/runs/2026-06-23-site016-preview-live.md. 2026-06-23 +SITE-016 Preview overlay and live-page opening As a site editor, I want to preview the current draft and open the live route so I can compare draft and published output. Preview page is exposed from the publish-actions menu; PreviewOverlay mounts only when previewOpen, requires an active site and active page, posts the current in-memory site and active page to the CMS runtime-preview endpoint and renders the server-built document in a sandboxed iframe srcDoc, shows the active page title, closes by Close button/Escape/backdrop, and restores focus. OpenLivePageButton is globally mounted in the toolbar, reads adminUi.activeLivePath, and opens that path in a new noopener/noreferrer tab or falls back to the site root. useActiveLivePath publishes regular page paths, template preview targets, post-type preview permalinks, or /404 for not-found templates. No active site/page renders no overlay; unpublished saved drafts can preview without changing public output; live route shows last published artefact until publish; activeLivePath null opens root; template pages are not directly routable and resolve to their preview target; popup uses the current dev/admin origin but Vite proxies public routes; narrow viewport must keep preview reachable and document width contained. Preview state is owned by uiSlice openPreview/closePreview; PreviewOverlay sends the validated in-memory draft to the site.read-gated runtime-preview endpoint, which prefetches loop and media data before the publisher boundary sanitizes emitted HTML; iframe uses sandbox="" with no allow flags; OpenLivePageButton receives the already-resolved public path from adminUi; save/publish remain capability and step-up gated by surrounding toolbar flows. src/admin/pages/site/toolbar/PublishButton.tsx; src/admin/pages/site/toolbar/PublishActionGroup.tsx; src/admin/pages/site/preview/PreviewOverlay.tsx; src/admin/pages/site/store/slices/uiSlice.ts; src/admin/pages/site/hooks/useActiveLivePath.ts; src/admin/shared/OpenLivePageButton/OpenLivePageButton.tsx; src/core/persistence/cmsRuntime.ts; server/handlers/cms/runtime.ts; server/publish/runtime/previewRuntime.ts; src/core/publisher; src/core/page-tree/page.ts Public live routes intentionally show the last published version, not the saved draft; this slice covers regular pages, while template/content-entry live-path permutations remain covered lower-level or future browser coverage. Happy: create page, publish version A, save draft version B, open Preview page and verify iframe shows B, open live page and verify popup route shows A without admin chrome. Error: no-site/no-active-page overlay and close behaviours covered by component tests. Boundary: activeLivePath root fallback, home path, content entry path, template target resolver, and mobile 390px preview reachability. Invalid: missing runtime preview body remains covered by server runtime tests, not this client overlay. Permission/security: publish/save capability and step-up gates surround the flow; public popup has no admin chrome. Performance: preview lazy-loads the overlay and starts one abortable runtime-preview request only when opened. Mobile: overlay opens at 390px without document overflow. SITE-016 browser verification passed 2026-07-22 after issue #234 restored loop parity in Preview page; draft-vs-live comparison and mobile preview reachability remain covered; template/content live-path browser permutations remain residual risk 0 None Added `tests/e2e/preview-live.e2e.ts`: publish a disposable page with text A, save draft text B without publishing, verify Preview page iframe shows B and not A, verify toolbar Open live page popup shows A and not B without editor chrome, then reopen Preview page at 390x844 and verify no document overflow. Initial focused run failed because the new spec selected a layer while the Site Explorer was open; root cause was a spec precondition, fixed by opening the Layers panel before selecting the Text node. Verification this slice: TSV integrity guard passed; focused preview/live unit suite passed 52/52; focused `bun run test:e2e -- --project=e2e tests/e2e/preview-live.e2e.ts -g "SITE-016"` passed 2/2 including setup; `bun run lint` passed; `bun run build` passed; full `bun test` passed 5697/5697. Run logs: docs/e2e/runs/2026-06-23-site016-preview-live.md and docs/e2e/runs/2026-07-22-issue-234-preview-loop-retest.md. Issue #234 was reproduced with a `site.pages` loop visible in the canvas and public route but missing from Preview page. PreviewOverlay now delegates current-draft rendering to the server runtime-preview boundary, which prefetches loop and media data before publishing. Verification passed: same-flow Chromium retest plus live-route check, focused runtime/preview tests 44/44, full `bun test` 6237/6237, `bun run lint`, and `bun run build`. 2026-07-22 SITE-017 Visual components and slots As a site editor, I want to componentize authored page content, add reusable slots, fill those slots on a page, and publish the resulting page as clean visitor HTML. Componentize converts the selected page node into a Visual Component row with a base.body definition root, replaces the original page node with a base.visual-component-ref, and switches the editor into VC mode. Adding a base.slot-outlet to the VC definition creates a locked base.slot-instance child on the page ref when returning to the page. The locked slot row hides destructive structural actions but accepts inserted child content. Save writes component rows before page rows so newly-created component refs validate; publish inlines the component definition and slot fill into the public artefact without editor slot labels or component names. Duplicate or blank component names; conversion attempted from VC mode, body/root, or an existing component ref; recursive refs; unknown or missing componentId; slot outlet rename/reorder/delete; empty or default slots; nested refs; page save racing a new component save; save/publish/reload after slot fill insertion. Component names and recursion use typed VisualComponent errors; VC/page shapes are TypeBox-validated through component/page adapters; syncSlotInstances materializes locked slot instances from slot outlets; dirty tracking marks both edited page and created component; CmsAdapter writes components before pages; publisher renderVisualComponentRef resolves refs, expands slot-instance children at matching outlets, and sanitizes emitted HTML. src/admin/pages/site/panels/PropertiesPanel/ConvertToComponentButton.tsx; src/admin/pages/site/store/slices/visualComponentsSlice.ts; src/admin/pages/site/store/slices/site/dirtyTracking.ts; src/core/persistence/cms.ts; server/handlers/cms/pages.ts; server/handlers/cms/components.ts; src/core/visualComponents; src/modules/base/visualComponentRef; src/modules/base/slotOutlet; src/modules/base/slotInstance; src/core/publisher/renderVisualComponentRef.ts Visual Components persist as rows in the components system table; page rows can reference a component only after that component row is stored; E2E uses disposable local SQLite/uploads and fresh owner login because publish rotates the session. Happy: componentize Text, create a slot outlet, return to page, insert Text into the locked slot, save, publish, and verify anonymous public page contains component body plus slot fill. Error: adapter ordering regression prevented new component refs from validating during page save. Boundary: locked slot row remains operable while hiding Rename/Duplicate/Cut/Delete; empty/default/nested/unknown component behavior covered by lower-level suites. Invalid: blank/duplicate names and recursive refs rejected by focused tests. Permission: publish step-up exercised; fine-grained capability variants remain lower-level/future browser coverage. Performance: save ordering is sequential only where required, layouts remain independent. Mobile: VC publish journey desktop-covered; mobile VC editing remains residual. SITE-017 browser regression passed 2026-06-23 after DEF-20260623-SITE017-001 fix; no open high/critical defects for this feature slice; mobile and permission permutations remain residual 0 None DEF-20260623-SITE017-001 fixed: publishing a freshly componentized page could emit an empty body because CmsAdapter saved pages and components in parallel, and the pages endpoint stripped the new base.visual-component-ref as dangling when it validated before the component row committed. Fix: write /admin/api/cms/components before starting /admin/api/cms/pages. Added `tests/e2e/visual-builder.e2e.ts` SITE-017 public publish journey, `src/__tests__/persistence/cmsAdapter.test.ts` ordering regression, and `src/__tests__/editor-store/dirtyTracking.test.ts` page+component dirty-mark guard. Verification passed: TSV integrity guard, focused VC suite 243/243, cmsAdapter 11/11, dirtyTracking 29/29, Playwright SITE-017 2/2 including setup, `bun run lint`, `bun run build`, and full `bun test` 5699/5699. Run log: docs/e2e/runs/2026-06-23-site017-visual-components.md. 2026-06-23 SITE-018 Templates and dynamic bindings As a site editor/content author, I want page templates and bindings so data rows render through site pages. Site Explorer creates template pages through Template settings, stores enabled template target and priority on the page row, opens the template in canvas mode, and shows synthetic preview data for postType templates. DynamicBindingControl auto-scopes string props on postTypes templates to the targeted table, inserts `{currentEntry.title}` tokens into text props, and `base.outlet` implicitly binds currentEntry.body. Save Draft persists template state, Publish snapshots it, and public `/posts/:slug` routes render the highest-priority matching published template with the published row title and body. No matching template or row; competing template priority/tie order; deleted target table/field; no compatible fields; empty/null title/body values; unpublished template or row; duplicate template slug; invalid binding path; missing outlet; loops nested inside templates. TemplateSettingsDialog requires nonblank title, unique normalized slug, numeric priority, and at least one selected post type when target kind is postTypes. PageTemplateConfig is TypeBox-parsed from page rows; binding picker filters fields by control compatibility; token interpolation and dynamic prop resolution tolerate unknown/empty fields; `base.outlet` body HTML is sanitized at the publisher boundary; public entry routes use published rows and published site snapshots only. src/core/page-tree/pageTemplate.ts; src/core/templates/templateMatching.ts; src/core/templates/templatePreviewData.ts; src/core/templates/dynamicBindings.ts; src/admin/shared/dialogs/TemplateSettingsDialog/TemplateSettingsDialog.tsx; src/admin/pages/site/canvas/TemplateModeControl.tsx; src/admin/pages/site/property-controls/DynamicBindingControl; src/modules/base/outlet; tests/e2e/visual-builder.e2e.ts; src/__tests__/templates; src/__tests__/server/cmsTemplateRoutes.test.ts The Posts table is seeded but entry templates are author-owned; SITE-018 publishes its own preview row and creates a priority-300 Posts template so it wins over lower-priority content fixtures deterministically. Content row publishing is step-up gated and runs from a fresh session. Happy: publish a post, create a Posts template, select that row as Preview source, bind title/body, save/publish, and verify the public route. Error: no matching row/template falls back or 404s in lower-level route tests. Boundary: the explicitly selected real preview row wins over newer unrelated posts, and the priority-300 template wins over lower-priority fixtures. Invalid: empty postTypes selection, invalid priority/slug, incompatible binding fields. Permission: site edit plus content publish capabilities required; persona splits remain pending. Performance: focused E2E completes template preview and publish within Playwright timeouts. Mobile: template controls still need narrow-viewport authoring coverage. SITE-018 focused Playwright regression passed 2026-07-11; real-row preview selection and competing-template priority are deterministic; mobile authoring and permission personas remain residual risk 0 None The release-gate regression now publishes a disposable post first, creates a priority-300 Posts template, explicitly selects that post through Preview source, verifies its title/body in canvas, publishes the template, and verifies the anonymous route without unresolved tokens. The 2026-07-11 sequential focus run passed SITE-018 together with lower-priority content-template fixtures. 2026-07-11 SITE-019 Saved layouts As a site editor, I want to save and reuse layouts so common structures can be inserted quickly. Layouts are stored in the layouts system table; the Save as layout dialog rejects blank and duplicate names; the module inserter lists saved layouts under Layouts; inserting a saved layout clones the captured subtree and style rules with fresh node ids; renaming or deleting the saved layout does not affect already-inserted page content. Duplicate layout names; blank names; saved layout source on page root; stale selections after page creation; mobile/narrow inserter category labels; context-menu z-index inside the spotlight inserter; deleted modules/VC refs inside a layout; invalid tree; deleting a saved layout already used by a page has no page effect. Layout route validates layout document; clone remaps node ids/scoped classes; system table locked from rename/delete; addPage clears stale canvas selection; module inserter category buttons keep explicit accessible names when labels hide responsively; saved-layout manage menu renders above the spotlight layer. server/handlers/cms/layouts.ts; src/admin/pages/site/dialogs/LayoutNameDialog.tsx; src/admin/pages/site/module-picker/ModuleInserterDialog.tsx; src/admin/pages/site/module-picker/SavedLayoutManageMenu.tsx; src/admin/pages/site/store/slices/layoutsSlice.ts; src/admin/pages/site/store/slices/site/pageActions.ts; src/core/data/layoutFromRow.ts Layouts are not separate DB tables; selector selection can persist across page switches, but stale node selection must not. Happy: save styled container subtree as layout, insert it on a new page, save/reload/publish, verify public output. Error: blank and duplicate names render inline errors. Boundary: narrow 390px inserter exposes Layouts. Invalid: stale node selection is cleared on addPage. Permission/security: structure edit and system table gates remain server-covered. Performance: picker remains responsive with saved item. Manage: rename and delete saved layout; inserted content persists. SITE-019 Playwright browser regression passed 2026-06-23 after DEF-20260623-SITE019-001/002/003 fixes; store selection and toolbar category accessibility regressions passed; no open high/critical defects in this slice; plugin-pack grouping, VC-mode cycle blocking, invalid/dangling layout snapshots, permission personas, and real touch/long-press mobile management remain residual risk 0 None DEF-20260623-SITE019-001 fixed: creating a new page via addPage left selectedNodeId/hover/inline-edit state from the previous page, keeping the right inspector expanded with no valid selected element and blocking narrow-width canvas controls. Fix: addPage reuses clearCanvasSelectionDraft; regression `src/__tests__/editor-store/pageActionsSelection.test.ts`. DEF-20260623-SITE019-002 fixed: module inserter category buttons lost accessible names at <=720px because visible labels were display:none and icons were aria-hidden, leaving count-only buttons. Fix: explicit aria-label on category buttons; regression in `src/__tests__/toolbar/modulePickerDropdown.test.tsx`. DEF-20260623-SITE019-003 fixed: saved-layout Rename/Delete context menu rendered at z-index 1000 under the spotlight inserter at z-index 9000, making menu items visible to accessibility APIs but unclickable. Fix: saved-layout manage menu uses zIndex 10000. Added `visual-builder.e2e.ts` SITE-019 browser journey covering blank/duplicate validation, mobile Layouts category reachability, insert, style preservation, rename, delete, save/reload, publish, and anonymous public output. Verification: focused `bun test src/__tests__/editor-store/pageActionsSelection.test.ts`, `bun test src/__tests__/toolbar/modulePickerDropdown.test.tsx`, and `bun run test:e2e -- --project=e2e tests/e2e/visual-builder.e2e.ts -g "SITE-019"` passed. Run log: docs/e2e/runs/2026-06-23-site019-saved-layouts.md. 2026-06-23 diff --git a/docs/features/loops.md b/docs/features/loops.md index ad98920e1..63353ad56 100644 --- a/docs/features/loops.md +++ b/docs/features/loops.md @@ -266,6 +266,13 @@ async function prefetchLoops(page, site, db) { The map is passed into `RenderConfig.loopData`. The walker reads from it; no async at render time. +The public renderer and the editor's full-page **Preview page** overlay both +use this server-side prefetch path. Preview sends the current in-memory draft +to `/admin/api/cms/runtime/preview`; the server resolves loop and media data +before calling `publishPage`. Calling the pure publisher without `loopData` +is intentionally not a preview fallback — the loop emits its missing-data +marker instead. + --- ## Editor canvas preview diff --git a/src/__tests__/persistence/cmsRuntimeClient.test.ts b/src/__tests__/persistence/cmsRuntimeClient.test.ts index 1507e69ce..0eb135683 100644 --- a/src/__tests__/persistence/cmsRuntimeClient.test.ts +++ b/src/__tests__/persistence/cmsRuntimeClient.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'bun:test' import { ApiError } from '@core/http' -import { buildCmsRuntimePreview, resolveCmsRuntimeDependencies } from '@core/persistence/cmsRuntime' +import { buildCmsRuntimePreview, resolveCmsRuntimeDependencies } from '@core/persistence' describe('CMS runtime client', () => { it('posts dependency manifests to the runtime resolve endpoint', async () => { @@ -86,23 +86,26 @@ describe('CMS runtime client', () => { await expect( buildCmsRuntimePreview( { site: { id: 's' }, pageId: 'p' }, - async () => - new Response( - JSON.stringify({ - html: '', - assets: [], - // scripts must be an array of script-asset objects, not a string. - runtimeAssets: { scripts: 'nope' }, - diagnostics: [], - }), - { status: 200 }, - ), + { + fetchImpl: async () => + new Response( + JSON.stringify({ + html: '', + assets: [], + // scripts must be an array of script-asset objects, not a string. + runtimeAssets: { scripts: 'nope' }, + diagnostics: [], + }), + { status: 200 }, + ), + }, ), ).rejects.toThrow() }) it('posts site preview requests to the runtime preview endpoint', async () => { const calls: Array<{ input: RequestInfo | URL; init?: RequestInit }> = [] + const controller = new AbortController() const result = await buildCmsRuntimePreview( { site: { id: 'site_1' }, @@ -110,14 +113,17 @@ describe('CMS runtime client', () => { breakpointId: 'mobile', templateContext: { entryStack: [] }, }, - async (input, init) => { - calls.push({ input, init }) - return new Response(JSON.stringify({ - html: '', - assets: [], - runtimeAssets: { scripts: [] }, - diagnostics: [], - }), { status: 200 }) + { + signal: controller.signal, + fetchImpl: async (input, init) => { + calls.push({ input, init }) + return new Response(JSON.stringify({ + html: '', + assets: [], + runtimeAssets: { scripts: [] }, + diagnostics: [], + }), { status: 200 }) + }, }, ) @@ -126,6 +132,7 @@ describe('CMS runtime client', () => { input: '/admin/api/cms/runtime/preview', init: { method: 'POST', credentials: 'include' }, }) + expect(calls[0].init?.signal).toBe(controller.signal) expect(calls[0].init?.body).toBe(JSON.stringify({ site: { id: 'site_1' }, pageId: 'page_1', diff --git a/src/__tests__/publisher/previewOverlay.test.tsx b/src/__tests__/publisher/previewOverlay.test.tsx index de6f22389..4949aca8d 100644 --- a/src/__tests__/publisher/previewOverlay.test.tsx +++ b/src/__tests__/publisher/previewOverlay.test.tsx @@ -13,7 +13,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'bun:test' import React from 'react' -import { render, screen, cleanup, fireEvent } from '@testing-library/react' +import { render, screen, cleanup, fireEvent, act } from '@testing-library/react' import { readFileSync } from 'fs' import { PreviewOverlay } from '@site/preview/PreviewOverlay' import { useEditorStore } from '@site/store/store' @@ -78,8 +78,29 @@ function openPreviewWithSite() { } as Parameters[0]) } -beforeEach(resetStore) -afterEach(cleanup) +const originalFetch = globalThis.fetch +const runtimePreviewCalls: Array<{ input: RequestInfo | URL; init?: RequestInit }> = [] +let runtimePreviewHtml = 'Test Site

Welcome

' + +beforeEach(() => { + resetStore() + runtimePreviewCalls.length = 0 + runtimePreviewHtml = 'Test Site

Welcome

' + globalThis.fetch = async (input, init) => { + runtimePreviewCalls.push({ input, init }) + return new Response(JSON.stringify({ + html: runtimePreviewHtml, + assets: [], + runtimeAssets: { scripts: [] }, + diagnostics: [], + }), { status: 200 }) + } +}) + +afterEach(() => { + cleanup() + globalThis.fetch = originalFetch +}) // --------------------------------------------------------------------------- // 1 — uiSlice preview actions @@ -113,83 +134,107 @@ describe('uiSlice — preview state', () => { // --------------------------------------------------------------------------- describe('PreviewOverlay — DOM rendering', () => { - it('renders nothing when previewOpen is false', () => { + it('renders nothing when previewOpen is false', async () => { render() expect(document.querySelector('[data-testid="preview-overlay"]')).toBeNull() expect(document.querySelector('[data-testid="preview-iframe"]')).toBeNull() + await act(async () => {}) }) - it('renders nothing when previewOpen=true but no site is loaded', () => { + it('renders nothing when previewOpen=true but no site is loaded', async () => { useEditorStore.setState({ previewOpen: true } as Parameters[0]) render() expect(document.querySelector('[data-testid="preview-overlay"]')).toBeNull() + await act(async () => {}) }) - it('renders the dialog overlay when previewOpen=true with a site', () => { + it('renders the dialog overlay when previewOpen=true with a site', async () => { openPreviewWithSite() render() expect(document.querySelector('[data-testid="preview-overlay"]')).not.toBeNull() + await screen.findByTestId('preview-iframe') }) - it('overlay has role="dialog" and aria-modal="true"', () => { + it('overlay has role="dialog" and aria-modal="true"', async () => { openPreviewWithSite() render() const dialog = screen.getByRole('dialog') expect(dialog).toBeDefined() expect(dialog.getAttribute('aria-modal')).toBe('true') + await screen.findByTestId('preview-iframe') }) - it('renders the preview iframe inside the dialog', () => { + it('renders the preview iframe inside the dialog', async () => { openPreviewWithSite() render() - const iframe = document.querySelector('[data-testid="preview-iframe"]') as HTMLIFrameElement | null + const iframe = await screen.findByTestId('preview-iframe') expect(iframe).not.toBeNull() }) - it('iframe has a non-empty srcdoc attribute', () => { + it('iframe has a non-empty srcdoc attribute', async () => { openPreviewWithSite() render() - const iframe = document.querySelector('[data-testid="preview-iframe"]') as HTMLIFrameElement | null - const srcdoc = iframe?.getAttribute('srcdoc') ?? '' + const iframe = await screen.findByTestId('preview-iframe') + const srcdoc = iframe.getAttribute('srcdoc') ?? '' expect(srcdoc.length).toBeGreaterThan(0) expect(srcdoc).toContain('') }) - it('iframe srcdoc contains the page title', () => { + it('iframe srcdoc contains the page title', async () => { openPreviewWithSite() render() - const iframe = document.querySelector('[data-testid="preview-iframe"]') as HTMLIFrameElement | null - const srcdoc = iframe?.getAttribute('srcdoc') ?? '' + const iframe = await screen.findByTestId('preview-iframe') + const srcdoc = iframe.getAttribute('srcdoc') ?? '' // The site name "Test Site" should appear as the page title expect(srcdoc).toMatch(/[^<]*<\/title>/) }) - it('close button has aria-label="Close preview"', () => { + it('renders server-resolved loop content from the current draft (ISS-234)', async () => { + runtimePreviewHtml = '<!DOCTYPE html><html><body><p>ISS-234 LOOP ROW</p></body></html>' + openPreviewWithSite() + const currentSite = useEditorStore.getState().site + render(<PreviewOverlay />) + + const iframe = await screen.findByTestId('preview-iframe') + expect(iframe.getAttribute('srcdoc')).toContain('ISS-234 LOOP ROW') + expect(runtimePreviewCalls).toHaveLength(1) + expect(runtimePreviewCalls[0]?.input).toBe('/admin/api/cms/runtime/preview') + expect(JSON.parse(String(runtimePreviewCalls[0]?.init?.body))).toMatchObject({ + site: currentSite, + pageId: 'page-1', + }) + }) + + it('close button has aria-label="Close preview"', async () => { openPreviewWithSite() render(<PreviewOverlay />) const closeBtn = screen.getByLabelText('Close preview') expect(closeBtn).toBeDefined() + await screen.findByTestId('preview-iframe') }) - it('clicking the close button closes the overlay (sets previewOpen=false)', () => { + it('clicking the close button closes the overlay (sets previewOpen=false)', async () => { openPreviewWithSite() render(<PreviewOverlay />) + await screen.findByTestId('preview-iframe') const closeBtn = screen.getByLabelText('Close preview') fireEvent.click(closeBtn) expect(useEditorStore.getState().previewOpen).toBe(false) }) - it('pressing Escape closes the overlay', () => { + it('pressing Escape closes the overlay', async () => { openPreviewWithSite() render(<PreviewOverlay />) + await screen.findByTestId('preview-iframe') const dialog = screen.getByRole('dialog') fireEvent.keyDown(dialog, { key: 'Escape', code: 'Escape' }) expect(useEditorStore.getState().previewOpen).toBe(false) }) - it('clicking the backdrop closes the overlay', () => { + it('clicking the backdrop closes the overlay', async () => { openPreviewWithSite() render(<PreviewOverlay />) + await screen.findByTestId('preview-iframe') // Backdrop is the first aria-hidden element const backdrop = document.querySelector('[aria-hidden="true"]') as HTMLElement | null expect(backdrop).not.toBeNull() @@ -197,11 +242,12 @@ describe('PreviewOverlay — DOM rendering', () => { expect(useEditorStore.getState().previewOpen).toBe(false) }) - it('overlay header shows page title', () => { + it('overlay header shows page title', async () => { openPreviewWithSite() render(<PreviewOverlay />) // Header reads "Preview — {page.title}" expect(document.body.textContent).toContain('Preview — Home') + await screen.findByTestId('preview-iframe') }) }) @@ -258,8 +304,9 @@ describe('PreviewOverlay — source enforcement', () => { expect(overlaySrc).toContain('aria-hidden="true"') }) - it('calls publishPage() to generate iframe content', () => { - expect(overlaySrc).toContain('publishPage(') + it('uses the CMS runtime preview boundary instead of bypassing server prefetch', () => { + expect(overlaySrc).toContain('buildCmsRuntimePreview(') + expect(overlaySrc).not.toContain('publishPage(') }) }) diff --git a/src/__tests__/server/cmsRuntimeHandlers.test.ts b/src/__tests__/server/cmsRuntimeHandlers.test.ts index 0507182f0..b0367d015 100644 --- a/src/__tests__/server/cmsRuntimeHandlers.test.ts +++ b/src/__tests__/server/cmsRuntimeHandlers.test.ts @@ -3,6 +3,8 @@ import { SESSION_COOKIE_NAME } from '../../../server/auth/tokens' import type { DbClient, DbResult } from '../../../server/db' import { handleCmsRequest } from '../../../server/handlers/cms' import type { SiteDocument } from '@core/page-tree' +import '@core/loops/sources' +import '@modules/base' function makeFakeDb(): DbClient { const handle = async <Row extends Record<string, unknown> = Record<string, unknown>>( @@ -144,6 +146,70 @@ function siteWithVC(): SiteDocument { } } +function siteWithLoop(): SiteDocument { + const base = site() + return { + ...base, + pages: [ + { + ...base.pages[0], + nodes: { + root: { + id: 'root', + moduleId: 'base.body', + props: {}, + breakpointOverrides: {}, + children: ['loop'], + }, + loop: { + id: 'loop', + moduleId: 'base.loop', + props: { + sourceId: 'site.pages', + filters: {}, + orderBy: 'definition', + direction: 'asc', + limit: 10, + offset: 0, + pagination: 'none', + pageSize: 10, + tag: 'div', + customTag: '', + }, + breakpointOverrides: {}, + children: ['loop_text'], + }, + loop_text: { + id: 'loop_text', + moduleId: 'base.text', + props: { text: 'Fallback' }, + dynamicBindings: { + text: { source: 'currentEntry', field: 'title' }, + }, + breakpointOverrides: {}, + children: [], + }, + }, + }, + { + id: 'page_loop_item', + title: 'ISS-234 SERVER LOOP ROW', + slug: 'loop-row', + rootNodeId: 'loop_item_root', + nodes: { + loop_item_root: { + id: 'loop_item_root', + moduleId: 'base.body', + props: {}, + breakpointOverrides: {}, + children: [], + }, + }, + }, + ], + } +} + describe('CMS runtime handlers', () => { it('resolves an empty runtime dependency manifest', async () => { const res = await handleCmsRequest(runtimeRequest( @@ -200,6 +266,18 @@ describe('CMS runtime handlers', () => { }) }) + it('prefetches and renders loop rows in the runtime preview (ISS-234)', async () => { + const res = await handleCmsRequest(runtimeRequest( + 'http://localhost/admin/api/cms/runtime/preview', + { site: siteWithLoop(), pageId: 'page_1' }, + ), makeFakeDb()) + + expect(res.status).toBe(200) + const body = await res.text() + expect(body).toContain('ISS-234 SERVER LOOP ROW') + expect(body).not.toContain('instatic: loop data missing') + }) + it('builds a runtime preview from a VC virtual page id when the editor is in VC canvas mode', async () => { const res = await handleCmsRequest(runtimeRequest( 'http://localhost/admin/api/cms/runtime/preview', diff --git a/src/admin/layouts/AdminCanvasLayout/AdminCanvasLayout.tsx b/src/admin/layouts/AdminCanvasLayout/AdminCanvasLayout.tsx index 5b07caee7..381f862ac 100644 --- a/src/admin/layouts/AdminCanvasLayout/AdminCanvasLayout.tsx +++ b/src/admin/layouts/AdminCanvasLayout/AdminCanvasLayout.tsx @@ -95,8 +95,8 @@ const SettingsModal = lazy(() => // Editor-only toolbar surface: preview iframe. It self-gates on store state, // but we ALSO conditionally render it at the call site (below) so its chunk -// isn't fetched on first paint — the preview overlay drags in the entire -// publisher graph, which is large. +// isn't fetched on first paint. The overlay is an infrequent, modal surface +// whose server-built document should load only when the user asks for it. const PreviewOverlay = lazy(() => import('@admin/pages/site/preview/PreviewOverlay').then((m) => ({ default: m.PreviewOverlay, diff --git a/src/admin/pages/site/canvas/useRuntimeScriptBuild.ts b/src/admin/pages/site/canvas/useRuntimeScriptBuild.ts index f1d44f17f..6b3cfd81a 100644 --- a/src/admin/pages/site/canvas/useRuntimeScriptBuild.ts +++ b/src/admin/pages/site/canvas/useRuntimeScriptBuild.ts @@ -31,7 +31,7 @@ import { useEditorStore } from '@site/store/store' import { buildCmsRuntimePreview, type CmsRuntimePreviewResult, -} from '@core/persistence/cmsRuntime' +} from '@core/persistence' import type { SiteRuntimeDiagnostic, SiteScriptFormat, SiteScriptPlacement } from '@core/site-runtime' import { getErrorMessage } from '@core/utils/errorMessage' diff --git a/src/admin/pages/site/preview/PreviewOverlay.module.css b/src/admin/pages/site/preview/PreviewOverlay.module.css index cc22bf37a..2eafada3f 100644 --- a/src/admin/pages/site/preview/PreviewOverlay.module.css +++ b/src/admin/pages/site/preview/PreviewOverlay.module.css @@ -55,6 +55,13 @@ white-space: nowrap; } +.previewContent { + flex: 1; + min-height: 0; + display: flex; + background: var(--overlay); +} + .iframe { flex: 1; border: 0; diff --git a/src/admin/pages/site/preview/PreviewOverlay.tsx b/src/admin/pages/site/preview/PreviewOverlay.tsx index e160e1cd0..37d54a5b8 100644 --- a/src/admin/pages/site/preview/PreviewOverlay.tsx +++ b/src/admin/pages/site/preview/PreviewOverlay.tsx @@ -1,8 +1,10 @@ /** - * PreviewOverlay — full-screen in-browser preview of the published page. + * PreviewOverlay — full-screen in-browser preview of the current draft page. * - * Renders the active page via publishPage() into a sandboxed <iframe> so - * the user can see exactly what visitors will see before exporting. + * Builds the active in-memory draft through the authenticated runtime-preview + * endpoint, then renders the result into a sandboxed <iframe>. The server path + * owns request-time concerns such as loop and media prefetch, keeping Preview + * aligned with the public renderer without publishing the draft. * * Accessibility (Guideline #225 / WCAG 2.1 AA): * - role="dialog" + aria-modal="true" @@ -17,15 +19,119 @@ */ import { useEffect, useRef } from 'react' +import type { Page, SiteDocument } from '@core/page-tree' +import { isAbortError } from '@core/http' +import { buildCmsRuntimePreview } from '@core/persistence' +import type { TemplateRenderDataContext } from '@core/templates/dynamicBindings' +import { useAsyncResource } from '@admin/lib/useAsyncResource' import { useEditorStore, selectActivePage } from '@site/store/store' -import { publishPage } from '@core/publisher' -import { registry } from '@core/module-engine' import { useTemplatePreviewContext } from '@site/hooks/useTemplatePreviewContext' import { EyeSolidIcon } from 'pixel-art-icons/icons/eye-solid' import { CloseIcon } from 'pixel-art-icons/icons/close' import { Button } from '@ui/components/Button' +import { EmptyState } from '@ui/components/EmptyState' +import { pushToast } from '@ui/components/Toast' import styles from './PreviewOverlay.module.css' +interface PreviewDocumentProps { + site: SiteDocument + page: Page + templatePreviewContext: TemplateRenderDataContext | undefined +} + +interface LoadedPreviewDocument { + site: SiteDocument + pageId: string + contextKey: string + html: string +} + +function PreviewDocument({ site, page, templatePreviewContext }: PreviewDocumentProps) { + const contextKey = JSON.stringify(templatePreviewContext ?? null) + const reportedErrorRef = useRef<string | null>(null) + const { data, loading, error, refresh } = useAsyncResource<LoadedPreviewDocument>( + async (signal) => { + try { + const preview = await buildCmsRuntimePreview( + { + site, + pageId: page.id, + templateContext: templatePreviewContext, + }, + { signal }, + ) + return { + site, + pageId: page.id, + contextKey, + html: preview.html, + } + } catch (err) { + if (!signal.aborted && !isAbortError(err)) { + console.error('[PreviewOverlay] Failed to build preview:', err) + } + throw err + } + }, + [site, page.id, contextKey], + { fallbackError: 'Preview build failed' }, + ) + + useEffect(() => { + if (!error) { + reportedErrorRef.current = null + return + } + if (reportedErrorRef.current === error) return + reportedErrorRef.current = error + pushToast({ + kind: 'error', + title: "Couldn't build preview", + body: error, + location: 'preview-overlay', + }) + }, [error]) + + const currentHtml = + data?.site === site && data.pageId === page.id && data.contextKey === contextKey + ? data.html + : null + + if (error) { + return ( + <EmptyState + variant="centered" + title="Preview unavailable" + description={error} + action={<Button variant="secondary" onClick={refresh}>Retry preview</Button>} + role="alert" + data-testid="preview-error" + /> + ) + } + + if (loading || !currentHtml) { + return ( + <EmptyState + variant="centered" + title="Building preview…" + description="Resolving dynamic content and page assets." + data-testid="preview-loading" + /> + ) + } + + return ( + <iframe + srcDoc={currentHtml} + sandbox="" + title={`Preview: ${page.title}`} + data-testid="preview-iframe" + className={styles.iframe} + /> + ) +} + export function PreviewOverlay() { const open = useEditorStore((s) => s.previewOpen) const closePreview = useEditorStore((s) => s.closePreview) @@ -59,10 +165,6 @@ export function PreviewOverlay() { if (!open || !site || !activePage) return null - const { html } = publishPage(activePage, site, registry, { - templateContext: templatePreviewContext, - }) - return ( <> {/* Backdrop */} @@ -103,14 +205,14 @@ export function PreviewOverlay() { </Button> </div> - {/* ── Sandboxed iframe ───────────────────────────────────────── */} - <iframe - srcDoc={html} - sandbox="" - title={`Preview: ${activePage.title}`} - data-testid="preview-iframe" - className={styles.iframe} - /> + {/* ── Sandboxed server-built preview ─────────────────────────── */} + <div className={styles.previewContent}> + <PreviewDocument + site={site} + page={activePage} + templatePreviewContext={templatePreviewContext} + /> + </div> </div> </div> </> diff --git a/src/admin/pages/site/store/slices/sitePanelSlice.ts b/src/admin/pages/site/store/slices/sitePanelSlice.ts index 66f267fce..c573c0b69 100644 --- a/src/admin/pages/site/store/slices/sitePanelSlice.ts +++ b/src/admin/pages/site/store/slices/sitePanelSlice.ts @@ -40,7 +40,7 @@ import { normalizeStyleRuntimeConfig, normalizeSiteRuntimeConfig, } from '@core/site-runtime' -import { resolveCmsRuntimeDependencies } from '@core/persistence/cmsRuntime' +import { resolveCmsRuntimeDependencies } from '@core/persistence' import { buildSiteHelpers } from './site/helpers' import { getErrorMessage } from '@core/utils/errorMessage' diff --git a/src/core/persistence/cmsRuntime.ts b/src/core/persistence/cmsRuntime.ts index 95e8e4977..b69e50160 100644 --- a/src/core/persistence/cmsRuntime.ts +++ b/src/core/persistence/cmsRuntime.ts @@ -27,6 +27,13 @@ interface CmsRuntimePreviewInput { templateContext?: TemplateRenderDataContext } +interface CmsRuntimePreviewRequestOptions { + signal?: AbortSignal + /** Injectable fetch — test seam only; defaults to the global `fetch`. */ + fetchImpl?: FetchLike + basePath?: string +} + interface CmsRuntimeDependencyResolveResult { dependencyLock: SiteDependencyLock /** @@ -62,9 +69,13 @@ export async function resolveCmsRuntimeDependencies( export async function buildCmsRuntimePreview( input: CmsRuntimePreviewInput, - fetchImpl: FetchLike = globalThis.fetch.bind(globalThis), - basePath = '/admin/api/cms', + options: CmsRuntimePreviewRequestOptions = {}, ): Promise<CmsRuntimePreviewResult> { + const { + signal, + fetchImpl = globalThis.fetch.bind(globalThis), + basePath = '/admin/api/cms', + } = options // The envelope schema validates the assets, runtimeAssets, and diagnostics // shapes in full against the canonical @core/site-runtime schemas, so the // parsed body matches CmsRuntimePreviewResult directly — no cast needed. @@ -72,6 +83,7 @@ export async function buildCmsRuntimePreview( method: 'POST', body: input, schema: CmsRuntimePreviewResponseSchema, + signal, fetchImpl, fallbackMessage: 'Runtime preview build failed', }) diff --git a/src/core/persistence/index.ts b/src/core/persistence/index.ts index 743612b34..7b4fdeb94 100644 --- a/src/core/persistence/index.ts +++ b/src/core/persistence/index.ts @@ -1,5 +1,7 @@ export { cmsAdapter } from './cms' export { getCmsPublishStatus, publishCmsDraft } from './cmsPublish' +export { buildCmsRuntimePreview, resolveCmsRuntimeDependencies } from './cmsRuntime' +export type { CmsRuntimePreviewResult } from './cmsRuntime' export { listCmsMediaAssets } from './cmsMedia' export type { CmsMediaAsset } from './cmsMedia' export {