From d0846914054f722e090a5bcc9c36900d431fac8d Mon Sep 17 00:00:00 2001 From: David Babinec <49069339+DavidBabinec@users.noreply.github.com> Date: Wed, 10 Jun 2026 20:54:56 +0200 Subject: [PATCH 01/42] ci/docs: standardize on GHCR, drop the Docker Hub mirror (#20) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Docker Hub mirror never published — the repo has no DOCKERHUB_USERNAME/ DOCKERHUB_TOKEN secrets, so the job always took its skip branch while reporting green, and docker.io/corebunch/instatic was never populated. GHCR is public, rate-limit-friendly, and already what the deployment docs reference, so make it the single registry. - Remove the 'Mirror To Docker Hub' job from release.yml and drop it from the release-bundle job's needs. - Remove the Docker Hub pull block from docker-image.md. - Rewrite the release-workflow.md mirror section as an 'Image Registry' note: GHCR-only, with a pointer on how to re-add a mirror if ever wanted. Re-enabling Docker Hub later is a small revert plus the two secrets. Co-authored-by: Claude Fable 5 --- .github/workflows/release.yml | 55 ----------------------------- docs/deployment/docker-image.md | 9 ----- docs/deployment/release-workflow.md | 15 +++----- 3 files changed, 4 insertions(+), 75 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 675424142..352baf304 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -86,67 +86,12 @@ jobs: INSTATIC_REVISION=${{ github.sha }} INSTATIC_CREATED=${{ steps.version.outputs.created }} - dockerhub: - name: Mirror To Docker Hub - runs-on: ubuntu-latest - needs: image - steps: - - name: Resolve release version - id: version - shell: bash - run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" - - - name: Check Docker Hub credentials - id: credentials - env: - DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} - DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} - shell: bash - run: | - if [ -n "$DOCKERHUB_USERNAME" ] && [ -n "$DOCKERHUB_TOKEN" ]; then - echo "enabled=true" >> "$GITHUB_OUTPUT" - else - echo "enabled=false" >> "$GITHUB_OUTPUT" - fi - - - name: Set up Docker Buildx - if: steps.credentials.outputs.enabled == 'true' - uses: docker/setup-buildx-action@v3 - - - name: Login to GHCR - if: steps.credentials.outputs.enabled == 'true' - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ github.token }} - - - name: Login to Docker Hub - if: steps.credentials.outputs.enabled == 'true' - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Mirror image - if: steps.credentials.outputs.enabled == 'true' - run: | - docker buildx imagetools create \ - --tag docker.io/corebunch/instatic:${{ steps.version.outputs.version }} \ - --tag docker.io/corebunch/instatic:latest \ - ghcr.io/corebunch/instatic:${{ steps.version.outputs.version }} - - - name: Report skipped mirror - if: steps.credentials.outputs.enabled != 'true' - run: echo "Docker Hub mirror skipped because DOCKERHUB_USERNAME or DOCKERHUB_TOKEN is not configured." - bundle: name: Publish Release Bundle runs-on: ubuntu-latest needs: - verify - image - - dockerhub steps: - name: Checkout uses: actions/checkout@v4 diff --git a/docs/deployment/docker-image.md b/docs/deployment/docker-image.md index ec54ac386..20df3927d 100644 --- a/docs/deployment/docker-image.md +++ b/docs/deployment/docker-image.md @@ -39,15 +39,6 @@ docker pull ghcr.io/corebunch/instatic:latest docker pull ghcr.io/corebunch/instatic:0.0.3 ``` -Docker Hub is a discoverability mirror: - -```sh -docker pull corebunch/instatic:latest -docker pull corebunch/instatic:0.0.3 -``` - -When both registries are available, prefer GHCR in Compose files because it is produced directly by the release workflow. - The v0.0.3 published image is built for `linux/amd64`. Use it on Railway and x86_64 VPS/container hosts. ARM64 hosts should build from source for now, or wait for the native arm64 release job before pulling GHCR images directly. ## Run With SQLite diff --git a/docs/deployment/release-workflow.md b/docs/deployment/release-workflow.md index a07ecb743..dcccf1c4d 100644 --- a/docs/deployment/release-workflow.md +++ b/docs/deployment/release-workflow.md @@ -23,9 +23,8 @@ Release flow: 3. Tag a version, e.g. `v0.0.1`. 4. GitHub Actions runs `bun run build`, `bun test`, and `bun run lint`. 5. GitHub Actions builds `Dockerfile`. -6. GitHub Actions pushes the semver image, minor image, and `latest`. -7. GitHub Actions mirrors to Docker Hub when Docker Hub secrets exist. -8. GitHub Actions creates the GitHub Release and uploads the release bundle. +6. GitHub Actions pushes the semver image, minor image, and `latest` to GHCR. +7. GitHub Actions creates the GitHub Release and uploads the release bundle. ## Pre-Tag Template Updates @@ -119,18 +118,12 @@ The release workflow should: - push `latest` for tagged releases - create a release bundle with the Compose files and deployment docs - include the Render Blueprint templates in the release bundle -- skip the Docker Hub mirror cleanly when `DOCKERHUB_USERNAME` or `DOCKERHUB_TOKEN` is missing The first release targets `linux/amd64` because QEMU-based arm64 publishing made the tagged workflow too slow to use as a release gate. Add arm64 as a separate native-runner build before advertising multi-arch images. -## Docker Hub Mirror +## Image Registry -The release workflow always publishes GHCR. It mirrors to Docker Hub only when these repository secrets exist: - -- `DOCKERHUB_USERNAME` -- `DOCKERHUB_TOKEN` - -The mirror target is `docker.io/corebunch/instatic:`. If the secrets are absent, the workflow prints a skip message and the GHCR release still completes. +GHCR (`ghcr.io/corebunch/instatic`) is the only published registry. It is produced directly by the release workflow, is public, and has no aggressive anonymous pull-rate limits — use it in every Compose file, template, and deployment guide. There is no Docker Hub mirror; if one is ever wanted, add a `Mirror To Docker Hub` job plus `DOCKERHUB_USERNAME` / `DOCKERHUB_TOKEN` repository secrets. ## GHCR Visibility From fcb05f15d6558480409dd7eb4135eb9217176da3 Mon Sep 17 00:00:00 2001 From: David Babinec <49069339+DavidBabinec@users.noreply.github.com> Date: Wed, 10 Jun 2026 20:56:39 +0200 Subject: [PATCH 02/42] Per-stylesheet import modes; remove generated import scope classes (#15) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix selector pill flicker via canonical canvas node resolution The Properties panel resolved the selected node's DOM element by querying the admin document before the canvas iframes — but DOM-panel tree rows, Import-HTML preview rows, and overlay rings all carry data-node-id there, and the layers tree auto-expands ancestors AFTER selection. Ambient selector matching therefore flickered between correct (first click, real canvas element) and empty (re-click, tree row) on the same node. - New canonical lookup (canvasNodeLookup.ts) resolves nodes ONLY inside canvas breakpoint-frame documents (body[data-breakpoint-id]); the admin-document path and its ring-exclusion band-aids are deleted. - Consolidate three duplicate CSS attribute-escape helpers into one. - Ambient rules that match only through a universal subject (`*`, `body.x *`, `*::before`) no longer surface as pills — they are page-wide resets, not an identity of the selected element. They stay reachable via the dropdown and Selectors panel. Co-Authored-By: Claude Fable 5 * Replace import scope classes with per-stylesheet import modes The importer's automatic CSS cascade isolation rewrote selectors the source never contained (body.instatic-import-scope-* prefixes, silent btn-2 suffix renames). That machinery is gone; control moves to the user. Each top-level linked stylesheet now imports in one of two modes, picked in the Review step (default 'convert'): - convert: parsed into editable style rules, merging CSS-natively into the one global cascade. When two page cascades define the same class differently, that is an explicit "Stylesheets disagree" conflict row (rename with suffix / keep first / overwrite) — applied scoped to the affected cascades, never silently. Repeated class fragments demote to ambient rules so the registry keeps one bindable rule per name. - file: the CSS imports verbatim as a SiteFile (type 'style') with a site.runtime.styles entry scoped to exactly the linking pages — page isolation via runtime scope instead of generated classes. @import graphs flatten in cascade order, Google-font imports still install as self-hosted fonts, url() assets still upload and rewrite. Node class tokens survive through the existing bare-class linking step. scopeClasses.ts is deleted; classCascades.ts (conflict detection + resolution + bindable normalization) and stylesheetPlan.ts (mode partition + flattening) replace it. The near-identical script/stylesheet SiteFile committers are deduplicated into importedSiteFiles.ts. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- docs/editor.md | 2 +- docs/features/site-import.md | 74 +- docs/reference/css-class-registry.md | 4 +- .../admin/siteImport/SiteImportModal.test.tsx | 20 +- src/__tests__/canvas/canvasNodeLookup.test.ts | 70 ++ src/__tests__/panels/classPicker.test.tsx | 25 +- .../panels/selectorPickerModel.test.ts | 98 ++- src/__tests__/siteImport/applyImport.test.ts | 74 +- src/__tests__/siteImport/scopeClasses.test.ts | 344 --------- .../siteImport/stylesheetModes.test.ts | 286 ++++++++ .../modals/SiteImport/SiteImportModal.tsx | 44 +- .../shared/createSiteImportAdapter.ts | 1 + .../SiteImport/shared/importPlanning.ts | 28 + .../modals/SiteImport/steps/AnalyzeStep.tsx | 21 +- .../modals/SiteImport/steps/ConflictsStep.tsx | 78 +- .../SiteImport/steps/StylesheetModeRows.tsx | 75 ++ .../canvas/BreakpointSelectionOverlay.tsx | 8 +- .../site/canvas/CanvasTreeLadderOverlay.tsx | 8 +- .../pages/site/canvas/canvasDomGeometry.ts | 7 +- .../pages/site/canvas/canvasNodeLookup.ts | 44 ++ .../site/canvas/canvasOverlayGeometry.ts | 7 - .../panels/PropertiesPanel/ClassPicker.tsx | 5 +- .../PropertiesPanel/selectorPickerModel.ts | 133 +++- .../useClassPickerDerivedState.ts | 39 +- .../pages/site/store/slices/site/helpers.ts | 90 +-- .../store/slices/site/importedSiteFiles.ts | 138 ++++ .../pages/site/store/slices/site/types.ts | 9 + src/core/siteImport/adapter.ts | 10 + src/core/siteImport/applyAssetRewrites.ts | 15 +- src/core/siteImport/applyImport.ts | 154 ++-- src/core/siteImport/assetPlan.ts | 62 +- src/core/siteImport/classCascades.ts | Bin 0 -> 18974 bytes src/core/siteImport/conflicts.ts | 19 +- src/core/siteImport/index.ts | 14 +- src/core/siteImport/scopeClasses.ts | 679 ------------------ src/core/siteImport/stylesheetPlan.ts | 96 +++ src/core/siteImport/types.ts | 103 ++- 37 files changed, 1533 insertions(+), 1351 deletions(-) create mode 100644 src/__tests__/canvas/canvasNodeLookup.test.ts delete mode 100644 src/__tests__/siteImport/scopeClasses.test.ts create mode 100644 src/__tests__/siteImport/stylesheetModes.test.ts create mode 100644 src/admin/modals/SiteImport/steps/StylesheetModeRows.tsx create mode 100644 src/admin/pages/site/canvas/canvasNodeLookup.ts create mode 100644 src/admin/pages/site/store/slices/site/importedSiteFiles.ts create mode 100644 src/core/siteImport/classCascades.ts delete mode 100644 src/core/siteImport/scopeClasses.ts create mode 100644 src/core/siteImport/stylesheetPlan.ts diff --git a/docs/editor.md b/docs/editor.md index 11b143c6c..34bd18ce7 100644 --- a/docs/editor.md +++ b/docs/editor.md @@ -530,7 +530,7 @@ Opens the rail-selected panel: Property controls are driven by the selected node's module schema (`src/core/module-engine/`). -At the top of the Properties Panel, the selector picker is the single entry point for CSS rules that affect the selected element. Assigned class rules render as removable `TagPill` chips and are stored on `node.classIds`; matching ambient rules render as non-removable `TagPill` chips because they apply by selector matching, not assignment. The dropdown searches both class rules and ambient selectors, is capped to the picker width budget, and ellipsizes long selector labels instead of expanding across the editor. Ambient rows that do not match the selected canvas element stay visible but disabled with the mismatch reason, and selector-shaped input such as `.hero .title`, `h1`, or `a:hover` creates an ambient rule instead of a class. +At the top of the Properties Panel, the selector picker is the single entry point for CSS rules that affect the selected element. Assigned class rules render as removable `TagPill` chips and are stored on `node.classIds`; matching ambient rules render as non-removable `TagPill` chips because they apply by selector matching, not assignment. Ambient rules that match only through a universal subject (`*`, `body.x *`, `*::before`) never render as pills — they style every element in their scope and stay reachable through the dropdown and the Selectors panel instead. The dropdown searches both class rules and ambient selectors, is capped to the picker width budget, and ellipsizes long selector labels instead of expanding across the editor. Ambient rows that do not match the selected canvas element stay visible but disabled with the mismatch reason, and selector-shaped input such as `.hero .title`, `h1`, or `a:hover` creates an ambient rule instead of a class. The Typography panel stores Google/custom font assets and editable font tokens together under `site.settings.fonts`. Installed font assets own the self-hosted `@font-face` files; font tokens own the builder-facing variables such as `--font-primary`, the assigned font asset, and the fallback stack. The property-panel `font-family` control is a rich picker: token rows write `var(--font-primary)` so the selected node or class keeps following future token swaps, direct font rows write a concrete family stack as an escape hatch, and the text input still accepts manual values. diff --git a/docs/features/site-import.md b/docs/features/site-import.md index 9e2df4866..db90cba12 100644 --- a/docs/features/site-import.md +++ b/docs/features/site-import.md @@ -9,11 +9,12 @@ The static-site pipeline has two parts: a pure analysis function (`buildImportPl ## TL;DR - Entry: global admin-shell modal, opened from Spotlight or workspace actions. Drop files, a folder, a `.zip`, or a CMS-exported `.json` bundle. Static files use the four-stage modal (Drop → Review → Conflicts → Import, with completion shown inside the Import stage). CMS bundles use Drop → Review bundle → Import. -- `buildImportPlan({ fileMap, currentSite })` — pure, synchronous — produces an `ImportPlan` with pages, style rules, media, color tokens, custom fonts, Google font install requests, font tokens, and scripts. +- `buildImportPlan({ fileMap, currentSite, options })` — pure, synchronous — produces an `ImportPlan` with pages, style rules, kept stylesheet files, media, color tokens, custom fonts, Google font install requests, font tokens, and scripts. +- **Per-stylesheet import modes:** each top-level linked stylesheet either converts to editable style rules (default) or imports verbatim as a page-scoped `SiteFile` stylesheet (`options.stylesheetModes`, picked in the Review step). There are no generated scope classes — page isolation comes from the kept file's runtime scope. - `commitImportPlan(plan, adapter)` — uploads assets, then wraps all store writes in a single `adapter.commit` call → one Cmd+Z reverts the whole import. - Static imports load the current CMS draft into the editor store on demand when launched outside `/admin/site`; if no draft exists, the modal creates an empty site before analysis. -- Conflict resolution: rename with a numeric suffix (default), overwrite, skip, or custom-rename — per page slug, per class name, and per design token (colour / font CSS variable), with category-level bulk actions for rename / skip / overwrite. Token renames rewrite `var(--x)` references so imports stay faithful. -- What imports: pages, linked CSS plus unconditional local CSS `@import` graphs, `kind:'class'` and `kind:'ambient'` style rules, `@keyframes`, uploadable media/font files, root CSS color tokens, root CSS font tokens, `@font-face` families, known external font stylesheet imports, safe extra HTML attributes on base modules, body-level classes/attributes/style metadata, bare DOM text nodes in mixed content, and executable HTML scripts as page-scoped runtime scripts. +- Conflict resolution: rename with a numeric suffix (default), overwrite, skip, or custom-rename — per page slug, per class name, per design token (colour / font CSS variable), and per divergent cross-stylesheet class definition, with category-level bulk actions. Token renames rewrite `var(--x)` references so imports stay faithful. +- What imports: pages, linked CSS plus unconditional local CSS `@import` graphs, `kind:'class'` and `kind:'ambient'` style rules, stylesheets kept as page-scoped files, `@keyframes`, uploadable media/font files, root CSS color tokens, root CSS font tokens, `@font-face` families, known external font stylesheet imports, safe extra HTML attributes on base modules, body-level classes/attributes/style metadata, bare DOM text nodes in mixed content, and executable HTML scripts as page-scoped runtime scripts. - CMS bundle import preserves exported tables, rows, optional site shell, and embedded media using the same merge strategies as site transfer (`replace`, `merge-add`, `merge-overwrite`). - HTML forms import through the shared HTML importer as first-class form primitives (`base.form`, controls, labels, submit buttons), not as custom containers. - What cannot be modeled: `@layer`, conditional local CSS `@import`, and arbitrary external `@import` — surfaced as warnings when the CSS engine exposes them, never silently dropped. @@ -35,7 +36,7 @@ src/core/siteImport/ ├── fontTokens.ts — extract root --font-* custom properties as ImportFontToken[] from :root/html/body rules ├── fontImports.ts — resolve trusted Google CSS2 @import rules into installed-font requests ├── cssImports.ts — expand unconditional local CSS @import graphs while preserving each source path -├── scopeClasses.ts — scope colliding class names and ambient selectors across per-page stylesheet cascades +├── classCascades.ts — cross-sheet class semantics: detect divergent class definitions as explicit conflicts; apply rename / keep-first / overwrite; enforce one bindable class rule per name ├── mimeTypes.ts — extension → MIME fallback for FileMap entries that carry no MIME type (e.g. ZIP) ├── assetPlan.ts — normalise URL props/HTML attributes in node fragments + CSS/@keyframes url(); resolve @font-face; collect assets ├── applyAssetRewrites.ts — patch fragment props + CSS/@keyframes url() with new media URLs (post-upload) @@ -104,12 +105,16 @@ User drops files / folder / .zip / CMS bundle JSON │ → rules (minus :root --font-* props) + ImportFontToken[] │ └────────────────────────────────────────────────────────────────┘ │ - scopeCollidingClasses(pagePlans, cssFileResults) - │ renames divergent same-named classes per stylesheet + detectCrossSheetClassConflicts(pagePlans, cssFileResults, existingClassNames) + │ flags DIVERGENT same-named class definitions across page cascades + │ as explicit conflicts (default: rename with suffix) — applied later + │ by applyConflictResolutions, never silently ▼ - buildAssetPlan(pagePlans, cssFileResults, fileMap) - │ normalizes url() in node props, HTML attributes, CSS values, and raw @keyframes CSS to FileMap keys + buildAssetPlan(pagePlans, cssFileResults, fileMap, rawStylesheetSources) + │ normalizes url() in node props, HTML attributes, CSS values, raw @keyframes + │ CSS, and kept-stylesheet text to FileMap keys │ resolves @font-face → ImportFontFamily[] + │ flattens kept stylesheets (mode 'file') → ImportStylesheet[] │ collects deduplicated asset list ▼ detectConflicts(currentSite, pagePlans, styleRules, colorTokens, fontTokens) @@ -125,7 +130,7 @@ User drops files / folder / .zip / CMS bundle JSON tx.addConditions / tx.addColorTokens / tx.overwriteColorTokens / tx.addScripts tx.addFonts / tx.addFontTokens / tx.overwriteFontTokens tx.addStyleRule / tx.overwriteStyleRule - tx.addPage / tx.overwritePage + tx.addPage / tx.overwritePage / tx.addStylesheets │ ▼ ImportResult → ImportStep complete state (summary + per-category counts) @@ -147,7 +152,14 @@ interface ImportPlan { assets: { sourcePath: string; mimeType: string; bytes: Uint8Array }[] colors: ImportColorToken[] scripts: ImportScript[] - conflicts: { pages: PageConflict[]; rules: RuleConflict[]; tokens: TokenConflict[] } + linkedStylesheets: LinkedStylesheet[] // every top-level linked sheet + its import mode + stylesheets: ImportStylesheet[] // sheets kept as files (mode 'file') + conflicts: { + pages: PageConflict[] + rules: RuleConflict[] + tokens: TokenConflict[] + crossSheetClasses: CrossSheetClassConflict[] + } warnings: ImportWarning[] droppedAtRules: string[] // source text of un-modelable @-rules unusedCss: string[] // CSS files present but not linked by any page @@ -170,6 +182,7 @@ All URL-shaped values inside `pages[].nodeFragment` props, imported `htmlAttribu | **Fonts** | Self-hosted `@font-face` families with at least one bundled file, plus trusted Google CSS2 imports | `buildFontFamilies` in `assetPlan.ts` picks the best bundled format (woff2 → woff → ttf → otf); `extractGoogleFontImports` turns Google CSS2 `@import` rules into install requests. Commit uploads custom files via `tx.addFonts`, installs Google families through the CMS Google-font installer, then merges those returned `FontEntry` records via `tx.addInstalledFonts` | | **Font tokens** | Root `--font-*` variables with font-family stacks | `extractRootFontTokens` pulls them into `ImportFontToken[]`; committed via `tx.addFontTokens` after fonts so matching imported families can be assigned. A `--font-*` that collides with an existing font token surfaces as a `TokenConflict` (rename / skip / overwrite) | | **Scripts** | Executable inline scripts and JS files linked by imported HTML | Preserved in source order and committed via `tx.addScripts` with page scope from the source HTML. Classic scripts remain plain `'], + ['real newlines and tabs', 'line one\nline two\r\n\ttabbed'], + ['line/paragraph separators', 'before\u2028between\u2029after'], + ['unicode (emoji, CJK, RTL)', '🎉 日本語 العربية ñ é ü'], + ['null byte and control chars', 'a\u0000b\u0001c\u001fd'], + ['lone high surrogate', 'broken \ud83d surrogate'], + ['lone low surrogate', 'broken \ude00 surrogate'], + ['surrogate pair split across content', '😀 then lone \ud83d end'], +] + +describe('plugin VM payload marshaling', () => { + it('round-trips tricky strings byte-identically through runHookFilter', async () => { + const vm = await vmPromise + for (const [label, value] of TRICKY_STRINGS) { + const result = await vm.runHookFilter('test.identity', value) + expect(result as string, label).toBe(value) + } + }) + + it('round-trips a structured payload with tricky keys and values through runHookFilter', async () => { + const vm = await vmPromise + const payload = { + 'key "with" quotes': TRICKY_STRINGS.map(([, v]) => v), + 'key\\with\\backslashes': { nested: 'va`lue ${x}', n: 12.5, flag: true, nil: null }, + 'sep\u2028key\u2029tail': ['', '\ud83d'], + } + const result = await vm.runHookFilter('test.identity', payload) + expect(result).toEqual(payload) + }) + + it('round-trips a large payload with every tricky string embedded', async () => { + const vm = await vmPromise + const unit = TRICKY_STRINGS.map(([, v]) => v).join(' | ') + const value = unit.repeat(Math.ceil(200_000 / unit.length)) + const result = await vm.runHookFilter('test.identity', value) + expect(result).toBe(value) + }) + + it('carries tricky route bodies through runRoute verbatim', async () => { + const vm = await vmPromise + const body = TRICKY_STRINGS.map(([, v]) => v).join('\n') + const result = await vm.runRoute('GET /echo', { + request: { + url: 'https://example.test/echo?q="quoted"&t=`tick`', + method: 'GET', + headers: { 'content-type': 'text/plain' }, + body, + bodyEncoding: 'utf8', + }, + body: {}, + user: null, + }) + expect(result).toEqual({ + echoedBody: body, + echoedUrl: 'https://example.test/echo?q="quoted"&t=`tick`', + }) + }) + + it('dispatches through handles grabbed before plugin code runs — reassigning the global cannot hijack', async () => { + const vm = await createPluginVm({ + pluginSource: ` + ;(function () { + const __plugin_exports = (globalThis.__plugin_exports = {}); + __plugin_exports.activate = async function activate() { /* noop */ }; + globalThis.__plugin_handlers.filters['test.identity'] = function (value) { return value; }; + // A malicious bundle replacing the dispatcher global must not be + // able to intercept host dispatch. + globalThis.__runHookFilter = async function () { return '"hijacked"'; }; + })(); + `, + env: { + pluginId: 'test.hijack', + manifestVersion: '1.0.0', + grantedPermissions: [], + assetBasePath: '/uploads/plugins/test.hijack/1.0.0', + settings: {}, + hostCall: async () => null, + log: () => {}, + }, + }) + try { + const result = await vm.runHookFilter('test.identity', 'original') + expect(result).toBe('original') + } finally { + vm.dispose() + } + }) +}) From 11317b3ed2cf0e9231d97c638b38e55e8cf63c46 Mon Sep 17 00:00:00 2001 From: David Babinec <49069339+DavidBabinec@users.noreply.github.com> Date: Thu, 11 Jun 2026 13:40:07 +0200 Subject: [PATCH 11/42] Content Outlet availability + toast layering; close outlet invariant holes (#23) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Toasts now render above modal layers: new --toast-z-index token (10000, above the spotlight backdrop at 9000, below tooltips) replaces the stack's hardcoded 1500, which left toasts hidden behind the Add-to-canvas backdrop. - Module pickers gain a three-state availability model (moduleAvailability: insertable / hidden / disabled-with-reason). The Content Outlet tile stays visible but disabled — with the reason as tooltip/description — on regular pages, in VC mode, and on templates that already hold an outlet. Shared by the inserter dialog (click/Enter/drag all inert), the context-menu picker, and the notch favorites via useModuleInsertionContext. - The one-outlet-per-document invariant is now airtight: duplicateNode(s) and pasteNode could mint a second outlet, bypassing the insertNode guard. All store chokepoints now block and surface the warning toast (moved from useInsertModule into the store; the toast bus is designed for store-side producers). - Consolidated four copies of outlet detection into treeHasOutlet / subtreeHasOutlet / firstOutletId in @core/templates. Co-authored-by: Claude Opus 4.8 --- docs/features/templates.md | 9 +- .../editor-store/outletInvariant.test.ts | 137 ++++++++++++++++++ .../toolbar/moduleInserterModel.test.ts | 43 +++++- src/admin/pages/site/canvas/CanvasNotch.tsx | 18 ++- .../pages/site/canvas/canvasComposition.ts | 16 +- src/admin/pages/site/hooks/useInsertModule.ts | 30 +--- .../ModuleInserterDialog.module.css | 30 ++++ .../module-picker/ModuleInserterDialog.tsx | 13 +- .../ModuleInserterItemButton.tsx | 9 +- .../pages/site/module-picker/ModulePicker.tsx | 60 ++++---- .../site/module-picker/moduleInserterModel.ts | 120 +++++++++++---- .../useModuleInsertionContext.ts | 19 +++ .../pages/site/store/slices/clipboardSlice.ts | 16 ++ .../site/store/slices/site/nodeActions.ts | 54 ++++++- src/core/templates/index.ts | 1 + src/core/templates/outlet.ts | 44 ++++++ src/core/templates/templateCompose.ts | 22 +-- src/styles/globals.css | 7 + src/ui/components/Toast/Toast.module.css | 7 +- 19 files changed, 509 insertions(+), 146 deletions(-) create mode 100644 src/__tests__/editor-store/outletInvariant.test.ts create mode 100644 src/admin/pages/site/module-picker/useModuleInsertionContext.ts create mode 100644 src/core/templates/outlet.ts diff --git a/docs/features/templates.md b/docs/features/templates.md index 286feaaee..2fffbf675 100644 --- a/docs/features/templates.md +++ b/docs/features/templates.md @@ -11,7 +11,7 @@ A template is an ordinary `pages` row carrying a `target` (everywhere or one/mor - A template declares `target: { kind: 'everywhere' } | { kind: 'postTypes', tableSlugs }` and a `priority`. - **Chain resolver:** `resolveTemplateChain(site, ctx)` in `src/core/templates/templateMatching.ts` → `Page[]` ordered outer → inner. At most one template per breadth level (highest priority wins, document order breaks ties). Two breadth levels today: `everywhere` (outermost) → `postTypes` (innermost). - **Chain composer:** `composeTemplateChain(chain, terminal)` in `src/core/templates/templateCompose.ts` → one merged `Page` ready for `publishPage`. -- **`base.outlet`** is the polymorphic outlet content flows into. A template *should* contain one. Having NO outlet is not blocked (you add it after converting the page to a template — requiring it first would be circular). The editor enforces a one-outlet-per-document invariant for insertion: `useInsertModule` shows a warning toast and aborts, and `insertNode` in the store blocks the second outlet at the mutation chokepoint for all paths (drag-drop, programmatic, etc.). The composer remains defensive for data pre-dating the guard: no outlet → template skipped; multiple → first wins. +- **`base.outlet`** is the polymorphic outlet content flows into. A template *should* contain one. Having NO outlet is not blocked (you add it after converting the page to a template — requiring it first would be circular). The editor enforces a one-outlet-per-document invariant at the store's mutation chokepoints (`insertNode`, `duplicateNode(s)`, `pasteNode`), each surfacing a warning toast when blocked; the module pickers additionally render the outlet as a disabled tile with the reason (non-template page, VC mode, or outlet already placed) so authors rarely hit the block at all. The composer remains defensive for data pre-dating the guard: no outlet → template skipped; multiple → first wins. - Template pages are never served at their own slug; the live router and the static bake both skip them. - Dynamic bindings and token interpolation work exactly as before — the merged tree is a plain page tree. - **`templateTargetLabel(page)`** returns a short human-readable string for a template's target (e.g. `"Everywhere"` or `"posts, news"`); import from `@core/templates`. @@ -122,7 +122,12 @@ Result: one merged `Page` consumed by `publishPage` unchanged — one CSS bundle - **Splice (page route):** `composeTemplateChain` removes the `base.outlet` node and inserts the page's content in its place before `publishPage` is called. No outlet node reaches the renderer on page routes. - **Canvas preview:** `OutletEditor` renders the matched content READ-ONLY so the author sees what flows in — the first non-template page (`everywhere` target) via `ReadOnlyNodeTree`, or the entry body (`postTypes` target, resolved into `props.html`). It carries the editor wrapper bag so the outlet has a proper selection overlay; an empty match falls back to the shared placeholder. -A template normally contains exactly one `base.outlet`. Having **no outlet** is not blocked — you set a page as a template first and add the outlet afterward (the outlet block is only meaningful on templates, so requiring it before save would be circular). Inserting a **second outlet** IS blocked by the editor: `useInsertModule` (`src/admin/pages/site/hooks/useInsertModule.ts`) shows a warning toast and returns null, and `insertNode` in the store (`nodeActions.ts`) enforces the same invariant as a chokepoint for every insertion path (module picker, drag-drop, programmatic callers). The composer remains defensive for data that pre-dates or bypasses the guard: no outlet → the template is skipped; multiple → the first wins. +A template normally contains exactly one `base.outlet`. Having **no outlet** is not blocked — you set a page as a template first and add the outlet afterward (the outlet block is only meaningful on templates, so requiring it before save would be circular). A **second outlet** is prevented in two layers: + +- **Picker availability (UX):** `moduleAvailability` (`src/admin/pages/site/module-picker/moduleInserterModel.ts`) renders the Content Outlet tile disabled — with the reason as tooltip/description — on non-template pages, in VC mode, and on templates that already hold an outlet. All picker surfaces (inserter dialog, context-menu `ModulePicker`, notch favorites) share this via `useModuleInsertionContext`. +- **Store invariant (structural):** every mutation path that could mint a second outlet is guarded at the store chokepoint and surfaces a warning toast when blocked — `insertNode`, `duplicateNode` / `duplicateNodes` (a duplicated subtree carrying the outlet is refused/skipped), and `pasteNode` (a copied payload containing an outlet won't paste into a document that already has one). All in `nodeActions.ts` / `clipboardSlice.ts`, backed by the shared `treeHasOutlet` / `subtreeHasOutlet` helpers in `@core/templates`. + +The composer remains defensive for data that pre-dates or bypasses the guard: no outlet → the template is skipped; multiple → the first wins. --- diff --git a/src/__tests__/editor-store/outletInvariant.test.ts b/src/__tests__/editor-store/outletInvariant.test.ts new file mode 100644 index 000000000..75e86420a --- /dev/null +++ b/src/__tests__/editor-store/outletInvariant.test.ts @@ -0,0 +1,137 @@ +/** + * One-outlet-per-document invariant — store-level guards. + * + * `insertNode` blocking a second `base.outlet` is covered by + * `useInsertModule.test.tsx`. These tests cover the OTHER mutation paths that + * could mint a second outlet without going through `insertNode`: + * 1. duplicateNode of the outlet itself, and of an ancestor section that + * contains it. + * 2. duplicateNodes (multi-select) — outlet-carrying subtrees are skipped, + * the rest still duplicate. + * 3. pasteNode of a copied payload containing an outlet into a document that + * already has one (cross-page copy is the realistic route; same-document + * copy + paste reproduces it equally well). + */ + +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import { useEditorStore } from '@site/store/store' +import '@modules/base/index' + +function freshStore() { + localStorage.clear() + useEditorStore.setState({ + site: null, + activePageId: null, + selectedNodeId: null, + selectedNodeIds: [], + hoveredNodeId: null, + activeDocument: null, + clipboardEntry: null, + _historyPast: [], + _historyFuture: [], + canUndo: false, + canRedo: false, + hasUnsavedChanges: false, + } as Parameters[0]) +} + +beforeEach(freshStore) +afterEach(() => { + localStorage.clear() +}) + +function countOutlets(): number { + const state = useEditorStore.getState() + const page = state.site!.pages.find((p) => p.id === state.activePageId)! + return Object.values(page.nodes).filter((n) => n.moduleId === 'base.outlet').length +} + +describe('outlet invariant — duplicateNode', () => { + it('refuses to duplicate the outlet node itself', () => { + const store = useEditorStore.getState() + const site = store.createSite('Outlet Duplicate Test') + const rootId = site.pages[0].rootNodeId + const outletId = useEditorStore.getState().insertNode('base.outlet', {}, rootId) + expect(countOutlets()).toBe(1) + + const newId = useEditorStore.getState().duplicateNode(outletId) + expect(newId).toBe('') + expect(countOutlets()).toBe(1) + }) + + it('refuses to duplicate a section whose subtree contains the outlet', () => { + const store = useEditorStore.getState() + const site = store.createSite('Outlet Section Duplicate Test') + const rootId = site.pages[0].rootNodeId + const sectionId = useEditorStore.getState().insertNode('base.container', {}, rootId) + useEditorStore.getState().insertNode('base.outlet', {}, sectionId) + expect(countOutlets()).toBe(1) + + const newId = useEditorStore.getState().duplicateNode(sectionId) + expect(newId).toBe('') + expect(countOutlets()).toBe(1) + }) + + it('still duplicates outlet-free nodes normally', () => { + const store = useEditorStore.getState() + const site = store.createSite('Plain Duplicate Test') + const rootId = site.pages[0].rootNodeId + useEditorStore.getState().insertNode('base.outlet', {}, rootId) + const textId = useEditorStore.getState().insertNode('base.text', {}, rootId) + + const newId = useEditorStore.getState().duplicateNode(textId) + expect(newId).toBeTruthy() + expect(countOutlets()).toBe(1) + }) +}) + +describe('outlet invariant — duplicateNodes', () => { + it('skips outlet-carrying subtrees but duplicates the rest of the selection', () => { + const store = useEditorStore.getState() + const site = store.createSite('Outlet Multi Duplicate Test') + const rootId = site.pages[0].rootNodeId + const outletId = useEditorStore.getState().insertNode('base.outlet', {}, rootId) + const textId = useEditorStore.getState().insertNode('base.text', {}, rootId) + + const newIds = useEditorStore.getState().duplicateNodes([outletId, textId]) + expect(newIds).toHaveLength(1) + expect(countOutlets()).toBe(1) + + const page = useEditorStore.getState().site!.pages[0] + expect(page.nodes[newIds[0]].moduleId).toBe('base.text') + }) +}) + +describe('outlet invariant — pasteNode', () => { + it('refuses to paste a payload containing an outlet into a document that already has one', () => { + const store = useEditorStore.getState() + const site = store.createSite('Outlet Paste Test') + const rootId = site.pages[0].rootNodeId + const sectionId = useEditorStore.getState().insertNode('base.container', {}, rootId) + useEditorStore.getState().insertNode('base.outlet', {}, sectionId) + expect(countOutlets()).toBe(1) + + expect(useEditorStore.getState().copyNode(sectionId)).toBe(true) + const pasted = useEditorStore.getState().pasteNode(rootId) + expect(pasted).toBeNull() + expect(countOutlets()).toBe(1) + }) + + it('pastes an outlet payload into a document with no outlet', () => { + const store = useEditorStore.getState() + const site = store.createSite('Outlet Paste OK Test') + const rootId = site.pages[0].rootNodeId + const sectionId = useEditorStore.getState().insertNode('base.container', {}, rootId) + const outletId = useEditorStore.getState().insertNode('base.outlet', {}, sectionId) + expect(useEditorStore.getState().copyNode(sectionId)).toBe(true) + + // Remove the original — the document no longer holds an outlet. + useEditorStore.getState().deleteNode(sectionId) + expect(countOutlets()).toBe(0) + expect(useEditorStore.getState().site!.pages[0].nodes[outletId]).toBeUndefined() + + const pasted = useEditorStore.getState().pasteNode(rootId) + expect(pasted).toHaveLength(1) + expect(countOutlets()).toBe(1) + }) +}) diff --git a/src/__tests__/toolbar/moduleInserterModel.test.ts b/src/__tests__/toolbar/moduleInserterModel.test.ts index 24099a3e4..65efbcebf 100644 --- a/src/__tests__/toolbar/moduleInserterModel.test.ts +++ b/src/__tests__/toolbar/moduleInserterModel.test.ts @@ -2,9 +2,11 @@ import { beforeEach, describe, expect, it } from 'bun:test' import { DEFAULT_MODULE_INSERTER_FAVORITES, dedupeModuleInserterRefs, - getInsertableModuleItems, + getVisibleModuleItems, moduleAccentForCategory, + moduleAvailability, resolveInserterRefs, + type ModuleInsertionContext, type RegistryModuleForInserter, } from '@site/module-picker/moduleInserterModel' import { findCanvasViewportAtPoint } from '@site/module-picker/moduleInserterDropTarget' @@ -20,6 +22,10 @@ function mod(id: string, category: string, name = id): RegistryModuleForInserter return { id, category, name, description: `${name} description` } } +const PAGE_CTX: ModuleInsertionContext = { isVCMode: false, isTemplate: false, hasOutlet: false } +const TEMPLATE_CTX: ModuleInsertionContext = { isVCMode: false, isTemplate: true, hasOutlet: false } +const VC_CTX: ModuleInsertionContext = { isVCMode: true, isTemplate: false, hasOutlet: false } + beforeEach(() => { localStorage.clear() document.body.replaceChildren() @@ -36,13 +42,40 @@ describe('module inserter model', () => { mod('base.text', 'Typography', 'Text'), ] - const pageModeIds = getInsertableModuleItems(modules, false).map((item) => item.id) + const pageModeIds = getVisibleModuleItems(modules, PAGE_CTX).map((item) => item.id) expect(pageModeIds).toEqual(['base.container', 'base.text']) - const vcModeIds = getInsertableModuleItems(modules, true).map((item) => item.id) + const vcModeIds = getVisibleModuleItems(modules, VC_CTX).map((item) => item.id) expect(vcModeIds).toEqual(['base.container', 'base.slot-outlet', 'base.text']) }) + it('keeps the content outlet visible but disabled outside an insertable template context', () => { + const outlet = mod('base.outlet', 'CMS', 'Content Outlet') + + // Regular page: visible, disabled, reason explains the template requirement. + const onPage = moduleAvailability(outlet, PAGE_CTX) + expect(onPage.kind).toBe('disabled') + + // VC definition tree: disabled too — no matched content inside a component. + const inVC = moduleAvailability(outlet, VC_CTX) + expect(inVC.kind).toBe('disabled') + + // Template without an outlet: insertable. + expect(moduleAvailability(outlet, TEMPLATE_CTX)).toEqual({ kind: 'insertable' }) + + // Template that already has its outlet: disabled (one per document). + const alreadyPlaced = moduleAvailability(outlet, { ...TEMPLATE_CTX, hasOutlet: true }) + expect(alreadyPlaced.kind).toBe('disabled') + + // Disabled items still appear in the item list, carrying the reason. + const items = getVisibleModuleItems([outlet], PAGE_CTX) + expect(items).toHaveLength(1) + expect(items[0].disabledReason).toBeTruthy() + + // …and insertable contexts produce no disabledReason at all. + expect(getVisibleModuleItems([outlet], TEMPLATE_CTX)[0].disabledReason).toBeUndefined() + }) + it('maps module categories to the rail-tint accent set', () => { expect(moduleAccentForCategory('Layout')).toBe('lilac') expect(moduleAccentForCategory('Forms')).toBe('mint') @@ -67,11 +100,11 @@ describe('module inserter model', () => { }) it('resolves favorite refs against insertable items and skips missing refs', () => { - const items = getInsertableModuleItems([ + const items = getVisibleModuleItems([ mod('base.container', 'Layout', 'Container'), mod('base.text', 'Typography', 'Text'), mod('base.image', 'Media', 'Image'), - ], false) + ], PAGE_CTX) const resolved = resolveInserterRefs([ ...DEFAULT_MODULE_INSERTER_FAVORITES, diff --git a/src/admin/pages/site/canvas/CanvasNotch.tsx b/src/admin/pages/site/canvas/CanvasNotch.tsx index 171ed3e6b..edcc86d0c 100644 --- a/src/admin/pages/site/canvas/CanvasNotch.tsx +++ b/src/admin/pages/site/canvas/CanvasNotch.tsx @@ -14,6 +14,7 @@ import { } from "@site/module-picker/moduleInserterModel"; import { LAYOUT_PRESETS } from "@site/module-picker"; import { useModuleInserterPreference } from "@site/module-picker/useModuleInserterPreference"; +import { useModuleInsertionContext } from "@site/module-picker/useModuleInsertionContext"; import { resolveInsertLocation } from "@site/store/insertLocation"; import { selectActiveCanvasPage, useEditorStore } from "@site/store/store"; import { ModulePickerDropdown } from "@site/toolbar/ModulePickerDropdown"; @@ -48,6 +49,8 @@ export type CanvasNotchAction = { id: string; label: string; onClick: () => void; + /** Renders the action disabled, with this string as the tooltip. */ + disabledReason?: string; } & ( | { moduleId: string; icon?: never } | { icon: IconComponent; moduleId?: never } @@ -137,24 +140,23 @@ function FavoriteNotchActions() { const insertModule = useInsertModule(); const insertPreset = useInsertPreset(); const { favorites, setFavorites, toggleFavorite } = useModuleInserterPreference(); - const activeDocument = useEditorStore((s) => s.activeDocument); + const insertionContext = useModuleInsertionContext(); const visualComponents = useEditorStore((s) => s.site?.visualComponents ?? EMPTY_COMPONENTS); const canvasPage = useEditorStore(selectActiveCanvasPage); const selectedNodeId = useEditorStore((s) => s.selectedNodeId); const insertComponentRef = useEditorStore((s) => s.insertComponentRef); const [menu, setMenu] = useState(null); - const isVCMode = activeDocument?.kind === "visualComponent"; - const { allInsertableItems } = buildModuleInserterItems({ + const { allItems } = buildModuleInserterItems({ modules: registry.list(), - isVCMode, + context: insertionContext, layoutPresets: LAYOUT_PRESETS, visualComponents, }); - const resolvedFavorites = resolveInserterRefs(favorites, allInsertableItems); + const resolvedFavorites = resolveInserterRefs(favorites, allItems); const favoriteItems = favorites.length > 0 && resolvedFavorites.length === 0 - ? resolveInserterRefs(DEFAULT_MODULE_INSERTER_FAVORITES, allInsertableItems) + ? resolveInserterRefs(DEFAULT_MODULE_INSERTER_FAVORITES, allItems) : resolvedFavorites; function insertComponent(componentId: string) { @@ -275,6 +277,7 @@ function actionForItem( label: item.name, moduleId: item.id, onClick: () => handlers.insertModule(item.module), + disabledReason: item.disabledReason, }; } if (item.kind === "layout") { @@ -310,8 +313,9 @@ function renderActionButton( className={styles.quickButton} onClick={action.onClick} onContextMenu={options?.onContextMenu} + disabled={Boolean(action.disabledReason)} aria-label={`Add ${action.label}`} - tooltip={`Add ${action.label}`} + tooltip={action.disabledReason ?? `Add ${action.label}`} data-testid={`canvas-notch-${testIdPart(action.label)}-btn`} > {ActionIcon ? ( diff --git a/src/admin/pages/site/canvas/canvasComposition.ts b/src/admin/pages/site/canvas/canvasComposition.ts index 17fd5388a..31b3a8368 100644 --- a/src/admin/pages/site/canvas/canvasComposition.ts +++ b/src/admin/pages/site/canvas/canvasComposition.ts @@ -18,7 +18,11 @@ */ import type { Page, SiteDocument } from '@core/page-tree' -import { resolveTemplateChain, type RouteResolutionContext } from '@core/templates' +import { + resolveTemplateChain, + treeHasOutlet, + type RouteResolutionContext, +} from '@core/templates' /** Breadth rank: lower wraps higher. Non-template pages are the innermost. */ function levelRank(page: Page): number { @@ -27,14 +31,6 @@ function levelRank(page: Page): number { return target.kind === 'everywhere' ? 0 : 1 } -/** Whether a template tree contains a `base.outlet` to host the wrapped content. */ -function hasOutlet(page: Page): boolean { - for (const id in page.nodes) { - if (page.nodes[id].moduleId === 'base.outlet') return true - } - return false -} - /** * The templates that wrap `activeDoc` at publish time, ordered outermost-first. * Empty when nothing wraps it (the active doc is an `everywhere` layout, or no @@ -59,6 +55,6 @@ export function resolveEditorWrapperTemplates(site: SiteDocument, activeDoc: Pag // postTypes winner for the same route never wraps another postTypes template) // that actually have an outlet to host the wrapped content. return resolveTemplateChain(site, ctx).filter( - (page) => page.id !== activeDoc.id && levelRank(page) < myRank && hasOutlet(page), + (page) => page.id !== activeDoc.id && levelRank(page) < myRank && treeHasOutlet(page), ) } diff --git a/src/admin/pages/site/hooks/useInsertModule.ts b/src/admin/pages/site/hooks/useInsertModule.ts index 0e1648253..6826e4824 100644 --- a/src/admin/pages/site/hooks/useInsertModule.ts +++ b/src/admin/pages/site/hooks/useInsertModule.ts @@ -2,16 +2,6 @@ import { selectActiveCanvasPage, useEditorStore } from '@site/store/store' import { resolveInsertLocation, type InsertLocation } from '@site/store/insertLocation' import { getMissingModuleDependencies } from '@core/module-engine' import type { AnyModuleDefinition } from '@core/module-engine' -import type { Page } from '@core/page-tree' -import { pushToast } from '@ui/components/Toast' - -/** Whether a document tree already contains a `base.outlet` node. */ -function hasOutletNode(page: Page): boolean { - for (const id in page.nodes) { - if (page.nodes[id].moduleId === 'base.outlet') return true - } - return false -} /** * Insert a module into the active canvas document (page or Visual Component). @@ -41,20 +31,6 @@ export function useInsertModule() { return (mod: AnyModuleDefinition, explicitTarget?: string | InsertLocation) => { if (!canvasPage) return null - // A document hosts matched content in a SINGLE `base.outlet`: the template - // composer and the canvas fill only the first outlet, so a second one would - // render as a dead, empty "Content outlet" placeholder. Block it with a - // clear message instead of letting the author create a confusing duplicate. - if (mod.id === 'base.outlet' && hasOutletNode(canvasPage)) { - pushToast({ - kind: 'warning', - title: 'Only one content outlet', - body: 'This template already has a content outlet — matched content can flow into just one.', - location: 'module-inserter', - }) - return null - } - const location = typeof explicitTarget === 'object' ? explicitTarget @@ -66,11 +42,15 @@ export function useInsertModule() { ) if (!location) return null + const nodeId = insertNode(mod.id, mod.defaults, location.parentId, location.index) + // The store refuses invariant-breaking inserts (e.g. a second base.outlet) + // and surfaces its own toast — an empty id means nothing was inserted. + if (!nodeId) return null + for (const dependency of getMissingModuleDependencies(mod, packageJson)) { setDependency(dependency.name, dependency.version, dependency.dev) } - const nodeId = insertNode(mod.id, mod.defaults, location.parentId, location.index) selectNode(nodeId) return nodeId } diff --git a/src/admin/pages/site/module-picker/ModuleInserterDialog.module.css b/src/admin/pages/site/module-picker/ModuleInserterDialog.module.css index a68a216fb..3ae95fc9c 100644 --- a/src/admin/pages/site/module-picker/ModuleInserterDialog.module.css +++ b/src/admin/pages/site/module-picker/ModuleInserterDialog.module.css @@ -498,6 +498,36 @@ top: var(--moduleInserterTileControlInset); } +/* ── Disabled items ────────────────────────────────────────────────────────── + * Context-disabled modules (e.g. Content Outlet outside a template) stay + * visible but inert: dimmed preview/title, no insert/drag affordances, no + * hover lift. The description line carries the reason and stays readable. + */ + +.itemShellDisabled .tileItem, +.itemShellDisabled .rowItem { + cursor: not-allowed; +} + +.itemShellDisabled .tileItem:hover, +.itemShellDisabled .rowItem:hover, +.itemShellDisabled .tileItem:active, +.itemShellDisabled .rowItem:active { + background: var(--editor-surface-3); +} + +.itemShellDisabled .tileStage, +.itemShellDisabled .rowThumb, +.itemShellDisabled .itemTitle { + opacity: 0.45; +} + +.itemShellDisabled .dragGrip, +.itemShellDisabled .tileAdd, +.itemShellDisabled .rowGrip { + display: none; +} + .favoriteButton.favoriteButton { position: absolute; z-index: 4; diff --git a/src/admin/pages/site/module-picker/ModuleInserterDialog.tsx b/src/admin/pages/site/module-picker/ModuleInserterDialog.tsx index 74b48fc47..97006c1c8 100644 --- a/src/admin/pages/site/module-picker/ModuleInserterDialog.tsx +++ b/src/admin/pages/site/module-picker/ModuleInserterDialog.tsx @@ -68,6 +68,7 @@ import { } from './ModuleInserterItemButton' import { ModuleInserterShortcuts } from './ModuleInserterShortcuts' import { ModuleWireframe } from './ModuleWireframe' +import { useModuleInsertionContext } from './useModuleInsertionContext' import { useModuleInserterPreference } from './useModuleInserterPreference' import styles from './ModuleInserterDialog.module.css' @@ -114,28 +115,27 @@ export function ModuleInserterDialog({ const suppressClickRef = useRef(false) const selectionSourceRef = useRef('pointer') - const activeDocument = useEditorStore((s) => s.activeDocument) const visualComponents = useEditorStore((s) => s.site?.visualComponents ?? EMPTY_COMPONENTS) const setActiveBreakpoint = useEditorStore((s) => s.setActiveBreakpoint) const canvasPage = useEditorStore(selectActiveCanvasPage) + const insertionContext = useModuleInsertionContext() const { isFavorite, toggleFavorite, } = useModuleInserterPreference() - const isVCMode = activeDocument?.kind === 'visualComponent' const { moduleItems, layoutItems, componentItems, - allInsertableItems, + allItems, } = buildModuleInserterItems({ modules: registry.list(), - isVCMode, + context: insertionContext, layoutPresets: LAYOUT_PRESETS, visualComponents, }) - const recentItems = resolveRecentItems(recentRefs, allInsertableItems) + const recentItems = resolveRecentItems(recentRefs, allItems) const filteredModules = filterInserterItems(moduleItems, query) const filteredLayouts = filterInserterItems(layoutItems, query) @@ -191,6 +191,7 @@ export function ModuleInserterDialog({ target: InsertLocation | undefined, mode: 'click' | 'drop', ): boolean => { + if (item.disabledReason) return false const inserted = onInsertItem(item, target, mode) if (!inserted) return false trackModuleInserterRecent(recentRefForItem(item)) @@ -327,6 +328,8 @@ export function ModuleInserterDialog({ event: ReactPointerEvent, ) { if (event.button !== 0) return + // Disabled items can't be dragged to the canvas either. + if (item.disabledReason) return const startX = event.clientX const startY = event.clientY let started = false diff --git a/src/admin/pages/site/module-picker/ModuleInserterItemButton.tsx b/src/admin/pages/site/module-picker/ModuleInserterItemButton.tsx index c59949f29..60dff8ec3 100644 --- a/src/admin/pages/site/module-picker/ModuleInserterItemButton.tsx +++ b/src/admin/pages/site/module-picker/ModuleInserterItemButton.tsx @@ -10,6 +10,7 @@ import { PlusIcon } from 'pixel-art-icons/icons/plus' import { StarSolidIcon } from 'pixel-art-icons/icons/star-solid' import { ModuleIcon } from '@site/ui/ModuleIcon' import { Button } from '@ui/components/Button' +import { cn } from '@ui/cn' import { itemDescription, type ModuleInserterAccent, @@ -57,6 +58,7 @@ export function ModuleInserterItemButton({ onPointerDown, }: InserterItemButtonProps) { const isList = view === 'list' + const disabled = Boolean(item.disabledReason) const favoriteLabel = favorite ? `Remove ${item.name} from notch favorites` : `Add ${item.name} to notch favorites` @@ -68,7 +70,10 @@ export function ModuleInserterItemButton({ } return ( -
+

@@ -104,14 +83,17 @@ describe('published form runtime', () => { await flushRuntime() expect(calls.map((call) => call.path)).toEqual(['/_instatic/form/challenge']) + expect(calls[0].payload.pageId).toBe('page-home') const form = document.querySelector('form') expect(form).not.toBeNull() + // No per-form listener — submit is intercepted at document level. form?.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })) await waitForCalls(calls, 2) expect(calls[0].path).toBe('/_instatic/form/challenge') expect(calls[1].path).toBe('/_instatic/form/submit') + expect(calls[1].payload.pageId).toBe('page-home') expect(calls[1].payload.token).toBe('prefetched-token') expect(calls[1].payload.challenge).toBe('prefetched-challenge') } finally { diff --git a/src/__tests__/publisher/helpers.ts b/src/__tests__/publisher/helpers.ts index 5fa3e991b..6dcfc5b73 100644 --- a/src/__tests__/publisher/helpers.ts +++ b/src/__tests__/publisher/helpers.ts @@ -20,6 +20,7 @@ export { DEFAULT_SITE_SETTINGS } export function makeAccumulators(): RenderAccumulators { return { cssMap: new Map(), + jsMap: new Map(), infiniteLoopIds: new Set(), holeNodeIds: new Set(), } diff --git a/src/__tests__/publisher/moduleJsCollection.test.ts b/src/__tests__/publisher/moduleJsCollection.test.ts new file mode 100644 index 000000000..dae6668f9 --- /dev/null +++ b/src/__tests__/publisher/moduleJsCollection.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'bun:test' +import { renderNode } from '@core/publisher' +import type { RenderConfig } from '@core/publisher' +import { makeAccumulators, makeModule, makePage, makeRegistry, makeSite } from './helpers' + +describe('renderNode module-JS collection', () => { + it('collects render() js once per moduleId (deduped like CSS)', () => { + const registry = makeRegistry({ + 'base.body': makeModule('base.body', { + canHaveChildren: true, + render: (_p, children) => ({ html: children.join('') }), + }), + 'test.jsy': makeModule('test.jsy', { + render: () => ({ html: '
', js: 'JS_BODY' }), + }), + }) + const page = makePage({ + root: { moduleId: 'base.body', children: ['a', 'b'] }, + a: { moduleId: 'test.jsy' }, + b: { moduleId: 'test.jsy' }, + }) + const site = makeSite({ pages: [page] }) + const config: RenderConfig = { page, site, registry, breakpointId: undefined } + const acc = makeAccumulators() + + renderNode('root', config, acc) + + expect([...acc.jsMap.entries()]).toEqual([['test.jsy', 'JS_BODY']]) + }) + + it('leaves jsMap empty when no module emits js', () => { + const registry = makeRegistry({ + 'test.plain': makeModule('test.plain'), + }) + const page = makePage({ root: { moduleId: 'test.plain' } }) + const site = makeSite({ pages: [page] }) + const config: RenderConfig = { page, site, registry, breakpointId: undefined } + const acc = makeAccumulators() + + renderNode('root', config, acc) + + expect(acc.jsMap.size).toBe(0) + }) +}) diff --git a/src/__tests__/publisher/moduleJsPage.test.ts b/src/__tests__/publisher/moduleJsPage.test.ts new file mode 100644 index 000000000..7c5f2efd2 --- /dev/null +++ b/src/__tests__/publisher/moduleJsPage.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'bun:test' +import type { SiteDocument } from '@core/page-tree' +import { publishPage, collectHoleSubtreeModuleIds } from '@core/publisher' +import { makeModule, makePage, makeRegistry, makeSite } from './helpers' + +const registry = makeRegistry({ + 'base.body': makeModule('base.body', { + canHaveChildren: true, + render: (_p, children) => ({ html: `
${children.join('')}
` }), + }), + 'test.jsy': makeModule('test.jsy', { + render: () => ({ html: '
', js: 'JS_BODY' }), + }), + 'test.live': makeModule('test.live', { + canHaveChildren: true, + dynamic: true, + render: (_p, children) => ({ html: `
${children.join('')}
` }), + }), +}) + +describe('publishPage jsModuleIds', () => { + it('reports modules that emitted js during the render, sorted', () => { + const page = makePage({ + root: { moduleId: 'base.body', children: ['a'] }, + a: { moduleId: 'test.jsy' }, + }) + const site = makeSite({ pages: [page] }) + const { jsModuleIds } = publishPage(page, site, registry) + expect(jsModuleIds).toEqual(['test.jsy']) + }) + + it('includes moduleIds inside hole subtrees even though they never rendered', () => { + const page = makePage({ + root: { moduleId: 'base.body', children: ['live'] }, + live: { moduleId: 'test.live', children: ['inner'] }, + inner: { moduleId: 'test.jsy' }, + }) + const site = makeSite({ pages: [page] }) + const { html, jsModuleIds } = publishPage(page, site, registry) + expect(html).toContain(' { + const page = makePage({ root: { moduleId: 'base.body', children: [] } }) + const site = makeSite({ pages: [page] }) + expect(publishPage(page, site, registry).jsModuleIds).toEqual([]) + }) +}) + +describe('collectHoleSubtreeModuleIds', () => { + it('descends into Visual Component definition trees (cycle-guarded)', () => { + const page = makePage({ + root: { moduleId: 'base.body', children: ['ref'] }, + ref: { moduleId: 'base.visual-component-ref', props: { componentId: 'vc-1' } }, + }) + const site = makeSite({ + pages: [page], + visualComponents: [ + { + id: 'vc-1', + name: 'Test VC', + tree: { + rootNodeId: 'vc-root', + nodes: { + 'vc-root': { + id: 'vc-root', + moduleId: 'test.jsy', + props: {}, + children: [], + breakpointOverrides: {}, + classIds: [], + }, + }, + }, + } as unknown as SiteDocument['visualComponents'][number], + ], + }) + const ids = collectHoleSubtreeModuleIds(page, site, new Set(['ref'])) + expect(ids.has('base.visual-component-ref')).toBe(true) + expect(ids.has('test.jsy')).toBe(true) + }) + + it('returns an empty set when there are no dynamic nodes', () => { + const page = makePage({ root: { moduleId: 'base.body' } }) + const site = makeSite({ pages: [page] }) + expect(collectHoleSubtreeModuleIds(page, site, new Set()).size).toBe(0) + }) +}) diff --git a/src/__tests__/server/holeRouteHandler.test.ts b/src/__tests__/server/holeRouteHandler.test.ts index bcb332248..b042e7b14 100644 --- a/src/__tests__/server/holeRouteHandler.test.ts +++ b/src/__tests__/server/holeRouteHandler.test.ts @@ -402,3 +402,30 @@ describe('handleHoleRequest — snapshot single-flight', () => { expect(count()).toBe(2) }) }) + +// --------------------------------------------------------------------------- +// handleHoleRequest — CMS form token stamping +// --------------------------------------------------------------------------- + +describe('hole fragments and CMS forms', () => { + it('stamps form page tokens + page id onto CMS-native forms inside fragments', async () => { + registry.registerOrReplace( + makeModule('test.cmsform', { + render: () => ({ + html: '', + }), + }), + ) + const snapshot = makeSnapshot() + snapshot.site.pages[0].nodes['text-node'].moduleId = 'test.cmsform' + + const version = getPublishVersion() + const url = new URL(`http://localhost/_instatic/hole/text-node?v=${version}`) + const res = await handleHoleRequest(new Request(url), url, { db: makeFakeDb(snapshot) }) + + expect(res.status).toBe(200) + const html = await res.text() + expect(html).toContain('data-instatic-page-token=') + expect(html).toContain('data-instatic-page-id="page_1"') + }) +}) diff --git a/src/__tests__/server/moduleJsBundle.test.ts b/src/__tests__/server/moduleJsBundle.test.ts new file mode 100644 index 000000000..b1a9882aa --- /dev/null +++ b/src/__tests__/server/moduleJsBundle.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'bun:test' +import { collectSiteModuleAssets } from '../../../server/publish/siteModuleAssets' +import { + buildSiteModuleJsMap, + injectModuleScripts, +} from '../../../server/publish/moduleJsBundle' +import { makeModule, makePage, makeRegistry, makeSite } from '../publisher/helpers' + +const registry = makeRegistry({ + 'base.body': makeModule('base.body', { + canHaveChildren: true, + render: (_p, children) => ({ html: `
${children.join('')}
` }), + }), + 'test.jsy': makeModule('test.jsy', { + render: () => ({ html: '
', js: 'JS_BODY' }), + }), + 'test.plain': makeModule('test.plain'), +}) + +function makeTwoPageSite() { + const pageA = makePage({ + root: { moduleId: 'base.body', children: ['a'] }, + a: { moduleId: 'test.jsy' }, + }) + const pageB = makePage({ + root: { moduleId: 'base.body', children: ['b'] }, + b: { moduleId: 'test.jsy' }, + }) + pageB.id = 'page-2' + pageB.slug = 'two' + return makeSite({ pages: [pageA, pageB] }) +} + +const HTML_DOC = ` + + + + + +
+ +` + +describe('site module-JS map', () => { + it('walks every page and dedupes js per moduleId', () => { + const site = makeTwoPageSite() + const acc = collectSiteModuleAssets(site, registry) + expect([...acc.jsMap.entries()]).toEqual([['test.jsy', 'JS_BODY']]) + const map = buildSiteModuleJsMap(site, registry) + expect([...map.keys()]).toEqual(['test.jsy']) + }) + + it('excludes modules that emit no js', () => { + const page = makePage({ + root: { moduleId: 'base.body', children: ['p'] }, + p: { moduleId: 'test.plain' }, + }) + const map = buildSiteModuleJsMap(makeSite({ pages: [page] }), registry) + expect(map.size).toBe(0) + }) +}) + +describe('injectModuleScripts', () => { + it('appends sorted, versioned, deferred script tags before and relaxes CSP', () => { + const html = injectModuleScripts(HTML_DOC, ['z.widget', 'a.widget'], 7) + const aIdx = html.indexOf('data-instatic-module-js="a.widget"') + const zIdx = html.indexOf('data-instatic-module-js="z.widget"') + expect(aIdx).toBeGreaterThan(-1) + expect(zIdx).toBeGreaterThan(aIdx) + expect(html).toContain('') + expect(zIdx).toBeLessThan(html.indexOf('')) + expect(html).toContain("script-src 'self';") + expect(html).not.toContain("script-src 'none';") + }) + + it('does nothing (and keeps CSP locked) for an empty id list', () => { + const html = injectModuleScripts(HTML_DOC, [], 7) + expect(html).toBe(HTML_DOC) + expect(html).toContain("script-src 'none';") + }) + + it('is idempotent', () => { + const once = injectModuleScripts(HTML_DOC, ['a.widget'], 7) + const twice = injectModuleScripts(once, ['a.widget'], 7) + expect(twice).toBe(once) + }) +}) diff --git a/src/__tests__/server/moduleJsRoute.test.ts b/src/__tests__/server/moduleJsRoute.test.ts new file mode 100644 index 000000000..69d57dc29 --- /dev/null +++ b/src/__tests__/server/moduleJsRoute.test.ts @@ -0,0 +1,165 @@ +/** + * Tests for the `/_instatic/module-js/.js` asset endpoint. + * Fake DbClient intercepts the published-snapshot query — same pattern as + * holeRouteHandler.test.ts. + */ +import { beforeEach, describe, expect, it } from 'bun:test' +import type { DbClient, DbResult } from '../../../server/db' +import { + handleModuleJsAssetRequest, + isModuleJsAssetPath, +} from '../../../server/handlers/cms/moduleJs' +import { resetForTests } from '../../../server/publish/renderCache' +import { makeModule } from '../publisher/helpers' +import { registry } from '../../core/module-engine/registry' + +function makeSnapshot() { + return { + cmsSnapshotVersion: 1 as const, + pageRowId: 'page_1', + site: { + id: 'site_1', + name: 'Test Site', + pages: [ + { + id: 'page_1', + title: 'Test Page', + slug: 'test', + rootNodeId: 'root', + nodes: { + root: { + id: 'root', + moduleId: 'test.body', + props: {}, + breakpointOverrides: {}, + children: ['widget'], + classIds: [], + }, + widget: { + id: 'widget', + moduleId: 'test.jsy', + props: {}, + breakpointOverrides: {}, + children: [], + classIds: [], + }, + }, + }, + ], + files: [], + visualComponents: [], + breakpoints: [{ id: 'desktop', label: 'Desktop', width: 1440, icon: 'monitor' }], + settings: { metaTitle: 'Test', shortcuts: {} }, + styleRules: {}, + createdAt: 1000, + updatedAt: 2000, + packageJson: { dependencies: {}, devDependencies: {} }, + runtime: { + dependencyLock: { version: 1, packages: {}, updatedAt: 0 }, + scripts: {}, + }, + }, + } +} + +function makeFakeDb(snapshot: ReturnType | null): DbClient { + const handle = async = Record>( + strings: TemplateStringsArray, + ..._values: unknown[] + ): Promise> => { + const sql = strings.reduce((acc, str, i) => (i === 0 ? str : `${acc}$${i}${str}`), '') + const normalized = sql.replace(/\s+/g, ' ').trim().toLowerCase() + // The snapshot getters join site_snapshots and reassemble the + // PublishedPageSnapshot shape from this row (see repositories/publish.ts). + if (normalized.includes('site_snapshots.site_json')) { + return { + rows: snapshot + ? [{ + row_id: snapshot.pageRowId, + site_json: snapshot.site, + runtime_assets_json: null, + importmap_body: null, + importmap_sha256: null, + } as unknown as Row] + : [], + rowCount: snapshot ? 1 : 0, + } + } + return { rows: [], rowCount: 0 } + } + handle.transaction = async (cb: (tx: DbClient) => Promise): Promise => + cb(handle as unknown as DbClient) + return handle as DbClient +} + +function moduleJsRequest(path: string, method = 'GET'): [Request, URL] { + const url = new URL(`http://localhost${path}`) + return [new Request(url, { method }), url] +} + +beforeEach(() => { + resetForTests() + registry.registerOrReplace( + makeModule('test.body', { + canHaveChildren: true, + render: (_p, children) => ({ html: `
${children.join('')}
` }), + }), + ) + registry.registerOrReplace( + makeModule('test.jsy', { + render: () => ({ html: '
', js: '(function(){/* test runtime */})();' }), + }), + ) +}) + +describe('isModuleJsAssetPath', () => { + it('matches the namespace prefix only', () => { + expect(isModuleJsAssetPath('/_instatic/module-js/test.jsy.js')).toBe(true) + expect(isModuleJsAssetPath('/_instatic/module-js/')).toBe(true) + expect(isModuleJsAssetPath('/_instatic/module-js')).toBe(false) + expect(isModuleJsAssetPath('/_instatic/hole-runtime.js')).toBe(false) + }) +}) + +describe('handleModuleJsAssetRequest', () => { + it('serves a known module with text/javascript and a 1h public cache', async () => { + const [req, url] = moduleJsRequest('/_instatic/module-js/test.jsy.js?v=0') + const res = await handleModuleJsAssetRequest(req, url, { db: makeFakeDb(makeSnapshot()) }) + expect(res.status).toBe(200) + expect(res.headers.get('content-type')).toBe('text/javascript; charset=utf-8') + expect(res.headers.get('cache-control')).toBe('public, max-age=3600') + expect(await res.text()).toContain('test runtime') + }) + + it('404s for a moduleId with no published js', async () => { + const [req, url] = moduleJsRequest('/_instatic/module-js/test.body.js') + const res = await handleModuleJsAssetRequest(req, url, { db: makeFakeDb(makeSnapshot()) }) + expect(res.status).toBe(404) + }) + + it('404s for malformed / traversal-shaped ids without touching the map', async () => { + for (const path of [ + '/_instatic/module-js/..%2F..%2Fetc%2Fpasswd.js', + '/_instatic/module-js/UPPER.Case.js', + '/_instatic/module-js/no-namespace.js', + '/_instatic/module-js/test.jsy', // missing .js extension + '/_instatic/module-js/', + ]) { + const [req, url] = moduleJsRequest(path) + const res = await handleModuleJsAssetRequest(req, url, { db: makeFakeDb(makeSnapshot()) }) + expect(res.status).toBe(404) + } + }) + + it('404s when the site has never been published', async () => { + const [req, url] = moduleJsRequest('/_instatic/module-js/test.jsy.js') + const res = await handleModuleJsAssetRequest(req, url, { db: makeFakeDb(null) }) + expect(res.status).toBe(404) + }) + + it('405s non-GET methods', async () => { + const [req, url] = moduleJsRequest('/_instatic/module-js/test.jsy.js', 'POST') + const res = await handleModuleJsAssetRequest(req, url, { db: makeFakeDb(makeSnapshot()) }) + expect(res.status).toBe(405) + }) +}) diff --git a/src/__tests__/server/modulePackVm.test.ts b/src/__tests__/server/modulePackVm.test.ts index 37e039ddc..f4fd6c381 100644 --- a/src/__tests__/server/modulePackVm.test.ts +++ b/src/__tests__/server/modulePackVm.test.ts @@ -138,3 +138,47 @@ describe('modulePackVm — shared sandbox limits', () => { } }, 10_000) }) + +describe('modulePackVm — render js boundary', () => { + const JS_PACK = ` +const widget = { + id: 'acme.canvas.widget', + name: 'Widget', + category: 'Acme', + version: '1.0.0', + defaults: {}, + schema: {}, + render: () => ({ html: '
', js: '(function(){})();' }), +}; +const badJs = { + id: 'acme.canvas.badjs', + name: 'BadJs', + category: 'Acme', + version: '1.0.0', + defaults: {}, + schema: {}, + render: () => ({ html: '
', js: 42 }), +}; +export default [widget, badJs]; +` + + it('passes string render() js through the VM boundary', async () => { + const vm = await createModulePackVm({ pluginId: 'acme.canvas', packSource: JS_PACK }) + try { + const out = vm.render('acme.canvas.widget', {}, []) + expect(out.html).toBe('
') + expect(out.js).toBe('(function(){})();') + } finally { + vm.dispose() + } + }) + + it('drops non-string js at the VM boundary', async () => { + const vm = await createModulePackVm({ pluginId: 'acme.canvas', packSource: JS_PACK }) + try { + expect(vm.render('acme.canvas.badjs', {}, []).js).toBeUndefined() + } finally { + vm.dispose() + } + }) +}) diff --git a/src/admin/pages/site/agent/executor.ts b/src/admin/pages/site/agent/executor.ts index e29deab16..6b07c50ca 100644 --- a/src/admin/pages/site/agent/executor.ts +++ b/src/admin/pages/site/agent/executor.ts @@ -65,8 +65,7 @@ import { importHtml } from '@core/htmlImport' import { cssToStyleRules } from '@core/siteImport' import type { NewStyleRule } from '@core/siteImport' import type { BaseNode, ConditionDef, Page, PageTemplateConfig } from '@core/page-tree' -import { renderNode } from '@core/publisher' -import type { RenderConfig, RenderAccumulators } from '@core/publisher' +import { renderNode, type RenderConfig, type RenderAccumulators } from '@core/publisher' import { getAgentStoreApi } from './storeRef' import { captureAgentRenderSnapshot, SnapshotNodeNotFoundError } from './renderEvidence' import type { AgentRenderSnapshotPayload } from './types' @@ -359,6 +358,7 @@ function runGetNodeHtml(input: GetNodeHtmlInput): AiToolOutput { } const acc: RenderAccumulators = { cssMap: new Map(), + jsMap: new Map(), infiniteLoopIds: new Set(), holeNodeIds: new Set(), } diff --git a/src/core/module-engine/types.ts b/src/core/module-engine/types.ts index 9f9c1f322..a77be0143 100644 --- a/src/core/module-engine/types.ts +++ b/src/core/module-engine/types.ts @@ -56,6 +56,17 @@ export interface RenderOutput { * The publisher deduplicates across all instances (one CSS block per module type). */ css?: string + /** + * Optional vanilla-JS runtime for this module TYPE, deduplicated per + * moduleId exactly like `css` and served as an external per-module asset + * (`/_instatic/module-js/.js`) on published pages — never inlined, + * so no `` escaping is needed. Authoring contract: a self-contained + * IIFE; bind via document-level event delegation (hole fragments insert into + * the DOM after load); idempotent; no load-order assumptions; no framework + * runtimes. Never executed in the admin canvas (the canvas renders editor + * React components, not published render() output). + */ + js?: string } // --------------------------------------------------------------------------- diff --git a/src/core/plugin-sdk/modules.ts b/src/core/plugin-sdk/modules.ts index 5ae19383a..c91f30b51 100644 --- a/src/core/plugin-sdk/modules.ts +++ b/src/core/plugin-sdk/modules.ts @@ -46,6 +46,15 @@ export type PluginPropertySchema = Record export interface PluginRenderOutput { html: string css?: string + /** + * Optional vanilla-JS runtime for this module TYPE — deduped per moduleId + * and served as an external per-module asset on published pages + * (`/_instatic/module-js/.js`). Requires the plugin's GRANTED + * `frontend.assets` permission; without the grant the host drops it (one + * console warning per module). Must be a self-contained IIFE binding via + * document-level event delegation; never executed in the admin canvas. + */ + js?: string } export type PluginRenderFn = ( diff --git a/src/core/plugins/moduleAdapter.ts b/src/core/plugins/moduleAdapter.ts index 298672485..bb18a3af9 100644 --- a/src/core/plugins/moduleAdapter.ts +++ b/src/core/plugins/moduleAdapter.ts @@ -120,6 +120,11 @@ export function pluginModuleToHostModule( pluginId: string, definition: PluginModuleDefinition, componentFactory: PluginModuleComponentFactory, + /** + * The plugin's GRANTED permissions (never the declared `permissions` + * array) — authority for the `frontend.assets` gate on module JS. + */ + grantedPermissions: readonly string[], ): ModuleDefinition> { validatePluginModuleId(pluginId, definition.id) @@ -130,6 +135,14 @@ export function pluginModuleToHostModule( ) } + // `frontend.assets` is the existing permission meaning "may put script tags + // on published pages" (enforced against grantedPermissions the same way in + // server/publish/frontendInjections.ts). Module render() `js` rides the + // same authority; without the grant it is dropped with ONE warning per + // module so a publish over hundreds of nodes doesn't spam the log. + const allowModuleJs = grantedPermissions.includes('frontend.assets') + let warnedDroppedJs = false + return { id: definition.id, name: definition.name, @@ -171,7 +184,16 @@ export function pluginModuleToHostModule( render: (props, children) => { try { const out = definition.render(props, children) - return { html: out.html, css: out.css } + if (out.js !== undefined && !allowModuleJs) { + if (!warnedDroppedJs) { + warnedDroppedJs = true + console.warn( + `[plugin-module:${definition.id}] render() emitted js but plugin "${pluginId}" was not granted "frontend.assets" — module JS dropped.`, + ) + } + return { html: out.html, css: out.css } + } + return { html: out.html, css: out.css, js: out.js } } catch (err) { console.error(`[plugin-module:${definition.id}] render() threw:`, err) return { html: `` } diff --git a/src/core/plugins/modulePackLoader.ts b/src/core/plugins/modulePackLoader.ts index 8466d88f5..ca580b7ef 100644 --- a/src/core/plugins/modulePackLoader.ts +++ b/src/core/plugins/modulePackLoader.ts @@ -89,7 +89,12 @@ export function activatePluginModulePack( deactivatePluginModulePack(manifest.id) const ids = new Set() for (const definition of definitions) { - const hostModule = pluginModuleToHostModule(manifest.id, definition, componentFactory) + const hostModule = pluginModuleToHostModule( + manifest.id, + definition, + componentFactory, + manifest.grantedPermissions ?? [], + ) registry.registerOrReplace(hostModule) ids.add(hostModule.id) } @@ -159,8 +164,8 @@ export interface SandboxedModulePack { /** Optional iframe-backed editor preview source. */ editorRuntime?: PluginEditorRuntime }> - render(moduleId: string, props: Record, children: string[]): { html: string; css?: string } - preview(moduleId: string, props: Record, children: string[]): { html: string; css?: string } + render(moduleId: string, props: Record, children: string[]): { html: string; css?: string; js?: string } + preview(moduleId: string, props: Record, children: string[]): { html: string; css?: string; js?: string } dispose(): void } @@ -205,7 +210,12 @@ export function activateSandboxedPluginModulePack( ? (props, children) => pack.preview(meta.id, props, children) : undefined, } - const hostModule = pluginModuleToHostModule(manifest.id, definition, STUB_COMPONENT_FACTORY) + const hostModule = pluginModuleToHostModule( + manifest.id, + definition, + STUB_COMPONENT_FACTORY, + manifest.grantedPermissions ?? [], + ) registry.registerOrReplace(hostModule) ids.add(hostModule.id) } diff --git a/src/core/publisher/holeSubtreeModules.ts b/src/core/publisher/holeSubtreeModules.ts new file mode 100644 index 000000000..263eae374 --- /dev/null +++ b/src/core/publisher/holeSubtreeModules.ts @@ -0,0 +1,65 @@ +/** + * Static hole-subtree module census. + * + * A `` placeholder defers its subtree to request time, so the + * page render never executes those modules' render() — their `js` cannot land + * in `RenderAccumulators.jsMap`. This walker statically gathers every moduleId + * reachable inside the page's hole subtrees (descending through page-tree + * children AND into referenced Visual Component definition trees, + * cycle-guarded) so `publishPage` can report them as module-JS candidates. + * + * Over-inclusion is deliberate and cheap: the server intersects candidates + * with the site-wide module-JS map before emitting any `` sanitisation is applied on store. + */ + readonly jsMap: Map /** * Set of loop nodeIds on the page that requested the infinite-scroll * runtime. The publisher reads `.size` after rendering to decide whether diff --git a/src/core/publisher/renderNode.ts b/src/core/publisher/renderNode.ts index 284ffe48d..75b91b028 100644 --- a/src/core/publisher/renderNode.ts +++ b/src/core/publisher/renderNode.ts @@ -172,6 +172,13 @@ function renderStandardNode( acc.cssMap.set(node.moduleId, sanitizeModuleCSS(output.css)) } + // JS dedup — one entry per moduleId, mirroring CSS. No escaping needed: + // module JS is served as an external file (`/_instatic/module-js/.js`), + // never inlined into the document. + if (output.js && !acc.jsMap.has(node.moduleId)) { + acc.jsMap.set(node.moduleId, output.js) + } + // base.body has no wrapper element — its classIds + inline styles go on // in publishPage. if (node.moduleId === 'base.body') return output.html diff --git a/src/modules/base/forms/formRuntimeJs.ts b/src/modules/base/forms/formRuntimeJs.ts new file mode 100644 index 000000000..b47a3e717 --- /dev/null +++ b/src/modules/base/forms/formRuntimeJs.ts @@ -0,0 +1,260 @@ +/** + * Browser runtime for CMS-native forms — shipped through the module-JS + * channel: `base.form`'s render() returns this string as `js` when + * `mode === 'cms'`, the publisher dedupes it per moduleId, and published + * pages load it from `/_instatic/module-js/base.form.js`. + * + * Channel authoring contract (see RenderOutput.js): + * - self-contained vanilla IIFE, no framework runtime; + * - document-level event delegation, because hole fragments insert CMS + * forms into the DOM after load (forms present at load are attached + * eagerly; late-inserted forms attach on first focus or submit); + * - idempotent (window.__instaticFormRuntimeLoaded guard); + * - per-form identity: `data-instatic-form-id`, `data-instatic-page-id`, + * and `data-instatic-page-token` are stamped onto each
tag by + * `stampFormPageTokens` (server/forms/formRuntime.ts) for baked pages + * AND hole fragments. + */ +export const FORM_RUNTIME_JS = `(() => { + if (window.__instaticFormRuntimeLoaded) return; + window.__instaticFormRuntimeLoaded = true; + + const CMS_FORM_SELECTOR = 'form[data-instatic-form-mode="cms"][data-instatic-form-id]'; + + for (const form of document.querySelectorAll(CMS_FORM_SELECTOR)) attachForm(form); + + document.addEventListener('submit', (event) => { + const form = event.target; + if (!isCmsForm(form)) return; + event.preventDefault(); + attachForm(form); + submitForm(form); + }); + + // Hole fragments insert forms after load — attach on first interaction so + // labels/messages/challenge are prepared before the visitor submits. + document.addEventListener('focusin', (event) => { + const target = event.target; + const form = target && target.closest ? target.closest(CMS_FORM_SELECTOR) : null; + if (form) attachForm(form); + }); + + function isCmsForm(el) { + return !!el && el.tagName === 'FORM' + && el.getAttribute('data-instatic-form-mode') === 'cms' + && !!el.getAttribute('data-instatic-form-id'); + } + + function attachForm(form) { + if (form.__instaticFormRuntimeAttached) return; + form.__instaticFormRuntimeAttached = true; + connectLabels(form); + prepareMessages(form); + prefetchChallenge(form); + } + + async function submitForm(form) { + const formId = form.getAttribute('data-instatic-form-id') || ''; + const pageId = form.getAttribute('data-instatic-page-id') || ''; + const pageToken = form.getAttribute('data-instatic-page-token') || ''; + if (!formId || !pageId || !pageToken) { + setState(form, 'error', 'This form is missing its published form link.'); + return; + } + + setBusy(form, true); + setState(form, 'pending', 'Sending...'); + + try { + const challenge = await takeChallenge(form); + await postJson('/_instatic/form/submit', { + pageId, + formId, + token: challenge.token, + challenge: challenge.challenge, + values: collectValues(form), + }); + + const redirectUrl = form.getAttribute('data-instatic-success-redirect') || ''; + if (redirectUrl) { + window.location.assign(redirectUrl); + return; + } + + setState(form, 'success', form.getAttribute('data-instatic-success-message') || 'Thanks. Your submission was received.'); + if (form.getAttribute('data-instatic-reset-on-success') !== 'false') form.reset(); + } catch (err) { + const message = err instanceof Error && err.message ? err.message : 'Form submission failed.'; + setState(form, 'error', message); + } finally { + setBusy(form, false); + if (form.isConnected) prefetchChallenge(form); + } + } + + function prefetchChallenge(form) { + if (form.__instaticFormChallenge || form.__instaticFormChallengePromise) return form.__instaticFormChallengePromise; + const request = requestChallenge(form) + .then((challenge) => { + form.__instaticFormChallenge = challenge; + form.__instaticFormChallengePromise = null; + return challenge; + }) + .catch((err) => { + form.__instaticFormChallenge = null; + form.__instaticFormChallengePromise = null; + throw err; + }); + form.__instaticFormChallengePromise = request; + request.catch(() => {}); + return request; + } + + async function takeChallenge(form) { + const existing = form.__instaticFormChallenge; + if (existing && challengeIsFresh(existing)) { + form.__instaticFormChallenge = null; + return existing; + } + form.__instaticFormChallenge = null; + const challenge = await prefetchChallenge(form); + form.__instaticFormChallenge = null; + return challenge; + } + + function requestChallenge(form) { + const formId = form.getAttribute('data-instatic-form-id') || ''; + const pageId = form.getAttribute('data-instatic-page-id') || ''; + const pageToken = form.getAttribute('data-instatic-page-token') || ''; + if (!formId || !pageId || !pageToken) { + return Promise.reject(new Error('This form is missing its published form link.')); + } + return postJson('/_instatic/form/challenge', { pageId, formId, pageToken }); + } + + function challengeIsFresh(challenge) { + const expiresAt = Date.parse(challenge && challenge.expiresAt ? challenge.expiresAt : ''); + return !Number.isFinite(expiresAt) || Date.now() < expiresAt - 10000; + } + + async function postJson(path, payload) { + const response = await fetch(path, { + method: 'POST', + credentials: 'same-origin', + headers: { + 'accept': 'application/json', + 'content-type': 'application/json', + }, + body: JSON.stringify(payload), + }); + const body = await readJson(response); + if (!response.ok) throw new Error(errorMessage(body)); + return body; + } + + async function readJson(response) { + const text = await response.text(); + if (!text) return {}; + try { + return JSON.parse(text); + } catch (_err) { + return { error: 'Form submission failed.' }; + } + } + + function errorMessage(body) { + if (Array.isArray(body.errors) && body.errors.length > 0) { + return body.errors.map((entry) => entry && entry.message ? entry.message : '').filter(Boolean).join('\\n') || 'Invalid form values.'; + } + return typeof body.error === 'string' && body.error ? body.error : 'Form submission failed.'; + } + + function collectValues(form) { + const values = {}; + const data = new FormData(form); + for (const [name, value] of data.entries()) { + const normalized = typeof value === 'string' ? value : value.name; + if (values[name] === undefined) { + values[name] = normalized; + } else if (Array.isArray(values[name])) { + values[name].push(normalized); + } else { + values[name] = [values[name], normalized]; + } + } + return values; + } + + function connectLabels(form) { + const elements = Array.from(form.querySelectorAll('label[data-instatic-label-target="auto"], input:not([type="hidden"]):not([data-instatic-honeypot]), textarea, select')); + let counter = 0; + for (const element of elements) { + if (element.tagName.toLowerCase() !== 'label') continue; + const index = elements.indexOf(element); + const control = elements.slice(index + 1).find((candidate) => candidate.tagName.toLowerCase() !== 'label'); + if (!control) continue; + if (!control.id) { + counter += 1; + control.id = 'instatic-form-' + safeToken(form.getAttribute('data-instatic-form-id') || 'form') + '-' + counter; + } + element.setAttribute('for', control.id); + } + } + + function safeToken(value) { + return String(value).replace(/[^a-zA-Z0-9_-]+/g, '-').replace(/^-+|-+$/g, '') || 'form'; + } + + function setBusy(form, busy) { + form.setAttribute('aria-busy', busy ? 'true' : 'false'); + const buttons = form.querySelectorAll('button, input[type="submit"], input[type="button"]'); + for (const button of buttons) { + if (busy) { + if (button.disabled) button.setAttribute('data-instatic-was-disabled', 'true'); + button.disabled = true; + } else if (!button.hasAttribute('data-instatic-was-disabled')) { + button.disabled = false; + } else { + button.removeAttribute('data-instatic-was-disabled'); + } + } + } + + function prepareMessages(form) { + for (const message of formMessages(form)) { + if (!message.hasAttribute('data-instatic-default-text')) { + message.setAttribute('data-instatic-default-text', message.textContent || ''); + } + const kind = message.getAttribute('data-instatic-form-message') || 'status'; + if (kind === 'success' || kind === 'error') message.hidden = true; + } + } + + function setState(form, state, text) { + form.setAttribute('data-instatic-form-state', state); + const messages = formMessages(form); + const messageKind = state === 'error' ? 'error' : state === 'success' ? 'success' : 'status'; + const hasExactMessage = messages.some((message) => (message.getAttribute('data-instatic-form-message') || 'status') === messageKind); + + for (const message of messages) { + if (!message.hasAttribute('data-instatic-default-text')) { + message.setAttribute('data-instatic-default-text', message.textContent || ''); + } + const kind = message.getAttribute('data-instatic-form-message') || 'status'; + const shouldShow = kind === messageKind || (!hasExactMessage && kind === 'status'); + if (!shouldShow) { + message.hidden = true; + continue; + } + message.textContent = text || message.getAttribute('data-instatic-default-text') || ''; + message.hidden = !message.textContent; + } + } + + function formMessages(form) { + const formId = form.getAttribute('data-instatic-form-id') || ''; + return Array.from(document.querySelectorAll('[data-instatic-form-message]')).filter((message) => { + return form.contains(message) || (formId && message.getAttribute('data-instatic-form-id') === formId); + }); + } +})();` diff --git a/src/modules/base/forms/index.ts b/src/modules/base/forms/index.ts index 9ceb332ef..60585d8c6 100644 --- a/src/modules/base/forms/index.ts +++ b/src/modules/base/forms/index.ts @@ -9,6 +9,7 @@ import { registry } from '@core/module-engine' import { Type, Value, type Static } from '@core/utils/typeboxHelpers' import { normalizeIdentifierValue } from '@core/utils/identifier' import { safeUrl } from '@modules/base/utils/escape' +import { FORM_RUNTIME_JS } from './formRuntimeJs' import { FileTextSolidIcon } from 'pixel-art-icons/icons/file-text-solid' import { TextStartTIcon } from 'pixel-art-icons/icons/text-start-t' import { CheckboxSolidIcon } from 'pixel-art-icons/icons/checkbox-solid' @@ -204,7 +205,12 @@ export const FormModule: ModuleDefinition = { const honeypot = props.mode === 'cms' ? `` : '' - return { html: `${honeypot}${renderedChildren.join('')}` } + return { + html: `
${honeypot}${renderedChildren.join('')}
`, + // CMS-native forms need the browser runtime; custom-action forms are + // plain HTML form submissions and ship zero JS. + ...(props.mode === 'cms' ? { js: FORM_RUNTIME_JS } : {}), + } }, } From c99ea70da8415d61b7abba29d572d633ae800cd3 Mon Sep 17 00:00:00 2001 From: David Babinec <49069339+DavidBabinec@users.noreply.github.com> Date: Thu, 11 Jun 2026 19:06:37 +0200 Subject: [PATCH 13/42] Inline text editing on the canvas (double-click to edit) (#25) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: add approved inline text editing design spec Approach 1A — parent-doc overlay editor over the breakpoint iframe. Source of truth for the inline-text-editing implementation tasks. Co-Authored-By: Claude Fable 5 * feat(module-engine): add inlineTextEdit contract, declare on text/button/link Optional ModuleDefinition.inlineTextEdit { prop, multiline? } is the extension point for canvas double-click inline editing — no per-module branches in canvas code. Co-Authored-By: Claude Fable 5 * feat(editor-store): add inline text edit session slice with coalesced live commit activeInlineEdit + startInlineEdit/applyInlineEditValue/endInlineEdit/ cancelInlineEdit. Live commits ride updateNodeProps' props:: coalescing so a whole session is one undo entry; start/end reset _historyCoalesceKey to isolate the burst; cancel undoes once iff committed. Co-Authored-By: Claude Fable 5 * feat(editor-store): force-close inline edit sessions on deletion and doc switch clearCanvasSelectionDraft (page/VC/document switches) and pruneCanvasSelectionDraft (node deletion, incl. swept descendants) now clear activeInlineEdit, funnelling every stale-session path through the same helpers the selection already uses. Co-Authored-By: Claude Fable 5 * feat(canvas): add inline-edit typography mirroring helpers scaleCssLength + mirrorInlineEditTypography — computed styles read through iframe.contentWindow, px lengths scaled by the iframe zoom factor so the parent-doc field matches the text it floats over at every zoom level. Co-Authored-By: Claude Fable 5 * feat(canvas): add InlineTextEditOverlay, mounted per breakpoint frame Parent-document textarea/input portaled into the canvas root and RAF-tracked over the node rect (BreakpointSelectionOverlay pattern), with live commit per keystroke, select-all on entry, Enter/blur commit, Shift+Enter newline in multiline, Escape cancel, and force-close on node unmount / frame unmount. Co-Authored-By: Claude Fable 5 * feat(canvas): wire double-click inline editing and hide doubled text onNodeDoubleClick carries the originating breakpoint and starts a session (design mode, content-edit permission); NodeRenderer flags the edited node in the session's frame; CANVAS_CHROME_CSS paints it transparent via -webkit-text-fill-color so the overlay can still mirror computed color. Extracting CANVAS_CHROME_CSS + applyIframeBodyReset (and the IframeInteraction type) into canvas/iframeBodyReset.ts keeps IframeFrameSurface.tsx under the 700-line module ceiling. Co-Authored-By: Claude Fable 5 * docs: document canvas inline text editing (parent-doc overlay) Replaces the "inline text editing removed" known-limitation with the implemented design; adds the inlineEditSlice and InlineTextEditOverlay to the editor reference. Co-Authored-By: Claude Fable 5 * docs: fix remaining stale slice counts in editor.md (11 -> 12) Task 8 updated the slice table heading to 12 slices (inlineEditSlice joined the store) but lines 15 and 257 still said 11, leaving the doc internally contradictory. store.ts composes 12 slice factories. The "11 named mutation actions" lines are untouched - they count tree mutations, not slices. Co-Authored-By: Claude Fable 5 * docs: fix stale slice count in adminUi header comment (11 -> 12) The editor store gained inlineEditSlice on this branch; the adminUi header comment still said it carries 11 slices. Co-Authored-By: Claude Fable 5 * fix(canvas): render canvas + inline-edit field in the site's fonts, not the editor's Two font-fidelity bugs the inline text editor exposed: 1. EditorChromeInjector copied the admin app's --font-sans (Inter) straight onto the iframe :root. The injector is unlayered, so it beat the site's own --font-sans (which lives in @layer user-authored) — every canvas element silently rendered in the editor font instead of the site's configured font (e.g. PP Right Grotesk), and Inter isn't even loaded in the iframe so it fell back to Helvetica. Forward the editor font under a chrome-namespaced --editor-chrome-font-sans instead; the site's --font-sans is left alone and now wins for content. Admin chrome is unchanged (its own --font-sans still drives the UI). 2. The inline-edit field is a parent-document overlay, but the site's font @font-face rules were injected only into the canvas iframe. Mirroring a node's font-family onto the field couldn't render it — the parent fell back to a different typeface than the published text the field covers. New ParentDocumentSiteFontsInjector (mounted in CanvasRoot) injects the site @font-face into the parent — faces only, no --font-* token variables, so the admin UI font is untouched. Verified in a real browser: the edited heading's text now measures byte-identical between the canvas and the overlay field (0px delta, both PP Right Grotesk), where it was ~6.7% off before. Co-Authored-By: Claude Opus 4.8 * fix(canvas): stop inline-edit field scroll-shifting multi-line text A