mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-11-10 06:24:35 +09:00
## Summary Supersede #6182 with a clean implementation of MDX-backed Content Blocks on top of the unchanged `main` Content Block editing model. A connected `.mdx` Asset changes where authored children are loaded and saved; it must not change how Content mode inserts, edits, selects, moves, copies, or deletes instances. The previous implementation remains available in `agent/mdx-content-block-6124` and `agent/mdx-content-block-6124-review`. Reuse its independently tested parsers, converters, schemas, Asset repository, template resolution, publication, MCP, and UI work where those pieces fit the corrected architecture. Do not port its per-operation storage routing, projected editing stores, MDX undo/history, or atomic multi-root machinery. Ref #6124 Ref #6182 Closes #6261 ## Locked behavior - `.md` keeps its existing Markdown behavior; `.mdx` enables MDX. - Standard Markdown structures are stored as Markdown. JSX is used only for structures Markdown cannot represent losslessly. - Generic elements use `<ws.element ws:tag="…">`; template instances use `<ws.element ws:name="…">`. - Invalid, unsafe, or unsupported MDX never prevents editing the file or publishing the project. Builder and publication use the same recovery result: recoverable nodes are skipped with source-located warnings, while an unrecoverable document renders no authored file content in either environment. - Connecting a source never serializes existing Content Block children into the file. **Connect** removes ordinary persisted children, preserves Templates, sets `src`, and uses file content. - Disconnecting resets `src`, discards derived instances, leaves the Content Block empty, and never changes the Asset. - While connected, normal Content Block mutations update in-memory instances synchronously. Asset persistence happens afterward and never blocks rendering. - Genuine external revision conflicts use the standard reload-required flow. Rapid local edits are ordered/coalesced and must never conflict with each other. - Repeated scopes use Content Block ID plus Collection item identity. - Multiple Content Blocks may intentionally share one MDX Asset. They share one revisioned document session and edits propagate to every connected occurrence. - Missing templates show warnings/placeholders only in Builder and are skipped on published sites. ## Reuse strategy - [x] Audit all 62 original implementation commits and 5 later review/fix commits - [x] Reuse the shared Content Engine Markdown/MDX AST, parser, serializer, diagnostics, reference discovery, and tests - [x] Reuse SDK source schemas, instance-name utilities, unique template naming, and source-integrity validation - [x] Reuse template resolution/materialization and authored-tree conversion after removing storage-operation coupling - [x] Reuse revisioned Asset content repository, authorization, limits, and browser/server transport - [x] Reuse publication, document-graph, CLI, MCP, source-control UI, frontmatter UI, and E2E work where contracts remain valid - [x] Reuse tests as behavioral specifications; rewrite tests that assert the rejected projection/routing architecture - [x] Do not port per-operation MDX routing, alternate projected mutation stores, semantic reconciliation after each command, MDX Builder history/undo, atomic multi-file rollback, or canvas loading/saving UI ## Old-branch porting map — do not rewrite Before implementing any checklist batch, inspect the named implementation and tests on `agent/mdx-content-block-6124-review`. Move the implementation and its tests first; change only the boundary that conflicts with the rebuilt architecture. Do not create a second implementation under a new name. When a test encodes the rejected projected-store architecture, retain its behavioral assertion and replace only its setup. ### Already ported - `parseMarkdownAst`, `createMdxDocumentFromMarkdown`, `parseMdxDocument`, `serializeMdxDocument`, `preferMarkdownSyntax`, `discoverMdxBodyAssetReferences`, `discoverMdxAssetReferences`, and `previewMarkdownToMdxConversion` - Asset-reference, byte-stream, document-graph, Markdown, and MDX helpers included in commit `76bc388ba9` - Tests: the corresponding `packages/content-engine/src/**/*.test.ts` files already on this branch ### Port with their existing tests | Area | Functions to move or adapt | Tests to move with them | | --- | --- | --- | | Source contract and integrity | `parseContentBlockSourceProp`, `getContentBlockSource`, `isEqualContentBlockSource`, `formatContentBlockSourceIntegrityIssue`, `getContentBlockSourceIntegrityIssues`, `allocateUniqueContentBlockTemplateName` | `packages/sdk/src/content-block.test.ts`, `packages/sdk/src/schema/content-block.test.ts` | | Existing instance/name utilities | `findChildReferenceIndex`, `findParentInstanceReference`, `getInstanceName` | `packages/sdk/src/instances-utils.test.tsx` | | Template naming in mutations | `assignUniqueBlockTemplateNamesMutable`, `findBlockTemplateNameCollision`, `getSourceBackedBlockTemplateContext`, `getBlockTemplateNameConfirmation`, `getBlockTemplateInsertionIndex` | `packages/project-build/src/runtime/block.test.ts` | | Content-mode property permissions | `getContentModePropEligibility`; preserve its distinction between editable, design-only, incompatible, and unknown props | `packages/project-build/src/runtime/content-mode-permissions.test.ts` | | Template resolution | `resolveMdxTemplates` | `packages/project-build/src/runtime/mdx-template-resolution.test.ts` | | Template materialization | `createMdxScopeIdGenerator`, `materializeMdxTemplates` | `packages/project-build/src/runtime/mdx-materialization.test.ts` | | MDX → instances and instances → MDX | `isMaterializedMdxAuthoredContentRoot`, `preserveMdxAuthoredContentIds`, `materializeMdxAuthoredContent`, `reconcileMdxAuthoredContent`, `omitTransientEmptyParagraphs`, `serializeMdxTemplateInsertion` | `packages/project-build/src/runtime/mdx-authored-content.test.ts`; retain the pure serialization cases from `mdx-storage-adapter.test.ts` | | Revisioned Asset I/O | `createAssetContentRevision`, `readAssetContentBytes`, the package-owned `createHttpAssetContentRepository`, the thin Builder `createBuilderHttpAssetContentRepository`, `createAssetContentBridge`, `initAssetContentBridge`, `getAssetContentBridge`; keep one repository implementation rather than copying the old Builder duplicate | `packages/asset-uploader/src/content-repository.test.ts`, `apps/builder/app/builder/shared/assets/mdx-content-repository.test.ts`, `apps/builder/app/shared/asset-content-bridge.client.test.ts` | | Source lifecycle planning | `createContentBlockBodyRemoval`, `prepareContentBlockConnect`, `prepareContentBlockDisconnect`, `prepareContentBlockSwitch`, `ContentBlockSourceRevisionConflictError` | `packages/project-build/src/runtime/content-block-source-lifecycle.test.ts` | | Per-Asset revision/session behavior | `getContentBlockSessionSource`, `isContentBlockSessionSourceCommitted`; reuse the loading, authorization, revision, queue, conflict, retry, cancellation, and source-replacement internals of `createMdxAssetEditingSession`, but key the rebuilt session by Asset rather than Content Block/render root | Port the applicable cases from `packages/project-build/src/runtime/mdx-asset-session.test.ts`; replace root-projection setup with in-memory snapshot setup | | Template migrations | `validateMdxTemplateMigrationConfirmation`, `planMdxTemplateMigration` | `packages/project-build/src/runtime/mdx-template-migration.test.ts` | | Publication | `createPublishedMdxMaterializationCache`, `materializePublishedMdx`, `getPublishedMdxContentDatabaseMaxBytes`, `getDynamicPublishedMdxSourceBlockIds`, `hasDynamicPublishedMdxSources`, `createPublishedBuildContentCompilationPlan`, `resolvePublishedMdxDependencyClosure`, `resolvePublishedMdxAssetCandidates` | `packages/project-build/src/runtime/published-mdx.test.ts`, `packages/project-build/src/content-database.test.ts` | | Build/import integrity | `assertContentBlockSourceIntegrity` and the Content Block cases in `getBuildIntegrityIssues`/`formatBuildIntegrityIssue` | `packages/project-build/src/build-integrity.test.ts` | | MDX paste/MCP insertion | `insertMdxText` and its input/result schemas; retain parsing, confirmation, template-resolution, and insertion-index logic, then invoke the normal mutation path | `packages/project-build/src/runtime/mdx-paste.test.ts` | | Builder source control | `createContentBlockSourceController`, `createBuilderContentBlockSourceController` and the existing source-control components | `content-block-source-controller.test.ts`, `content-block-source.test.tsx` | | Diagnostics | `formatContentBlockDiagnostic`, `deduplicateContentBlockDiagnostics`, `takeNewContentBlockDiagnostics` | Port only their focused cases from `apps/builder/app/shared/content-block-content.test.ts` | ### Explicitly do not port - `projectContentStorageChanges`, `createContentStorageProjection`, `createContentStorageSelectorProjection`, `executeContentStorageMutation`, `executeContentStorageTextReplacement`, `executeContentStoragePropMutation`, and `executeContentStorageStructuralMutation` - `applyMdxContentStorageChanges` and `prepareMdxContentStorageWrites`; replace them with one post-mutation snapshot serializer built from the reusable pure `reconcileMdxAuthoredContent` converter - `createContentBlockApplicationOperations` and the persistence-plan wrappers that route individual editing commands - Builder `$runtime*` projected stores, materialized mutation functions, alternate selector/tree helpers, pending projected-change state, and MDX-specific mutation sequences from `content-block-content.ts` - Canvas loading/saving/open-file presentation state, MDX history/undo, and atomic rollback machinery ## Implementation checklist ### 1. Preserve the `main` editing architecture - [x] Keep normal synchronous insert, text, Enter, slash menu, property, delete, move, wrap, copy/paste, selection, and focus mutations unchanged - [x] Materialize connected content through the same runtime instance interfaces used by ordinary Content Blocks - [x] Keep connected authored children in memory and exclude them from persisted build data - [x] Route persistence only after a synchronous mutation has updated the canvas - [x] Never wait for Asset loading, conversion, or saving before rendering an interaction - [x] Add no MDX-specific implementation to individual editing commands ### 2. Shared Markdown and MDX conversion - [x] Parse Markdown and the Markdown portion of MDX through one shared AST and plugin configuration - [x] Implement one shared MDX-to-instances converter and one shared instances-to-MDX converter - [x] Serialize headings, paragraphs, links, images, lists, tables, task lists, blockquotes, thematic breaks, code, emphasis, strong, strikethrough, and other supported GFM structures as Markdown - [x] Serialize only non-lossless/custom structures as Webstudio JSX - [x] Preserve nested Markdown in MDX elements, frontmatter, comments, tight/loose list semantics, code whitespace, reference links, and Asset references - [x] Omit transient empty draft paragraphs instead of emitting `<ws.element ws:tag="p" />` - [x] Preserve unsupported source text and authored metadata whenever it cannot be converted safely - [x] Add deterministic round-trip and canonical-output coverage for every supported Markdown structure ### 3. Invalid and unsafe MDX - [x] Preserve file source exactly; never silently repair or rewrite invalid source - [x] Ignore imports, exports, expressions, executable JavaScript, invalid properties, and other unsupported nodes when they can be isolated - [x] Continue rendering valid siblings and report every skipped subtree once with filename, line, and column - [x] Use the same recovery pipeline in Builder and publication so both render the same authored result for the current file revision - [x] When structural syntax prevents recovery, render no authored file content in Builder or publication instead of retaining a stale materialization - [x] Skip an unrecoverable Content Block on published sites and continue the build with a warning - [x] Keep invalid and unsafe files editable and reconnectable - [x] Enforce UTF-8, byte-size, depth, node-count, property-count, and generated-output limits with source-located diagnostics where possible ### 4. File formats and Asset creation - [x] Keep `.md` parsing and raw-HTML behavior unchanged - [x] Support creating, opening, editing, deleting, and recreating `.mdx` files through the normal text-file UI - [x] Open `.mdx` in the same editor experience as `.md` - [x] Always create an independent empty MDX Asset even when identical empty files exist - [x] Allow renaming `.md` to `.mdx` without converting or rewriting its contents; after rename, handle invalid contents through the normal MDX diagnostics and recovery pipeline - [x] Update an already-open MDX editor immediately after canvas persistence - [x] Bound read/write buffering and verify the returned Asset identity and revision ### 5. Source contracts and lifecycle - [x] Support direct Asset bindings and dynamic expression bindings - [x] Keep source mutation controls available only in Design mode; show the connected filename and **Open** action read-only in Content mode - [x] Reject duplicate source props, unsupported prop types, missing Assets, non-MDX Assets, and unauthorized Assets - [x] Analyze selected file diagnostics before confirmation without modifying the block or file; MDX syntax diagnostics do not block connection - [x] For a non-empty block, show **Abort** and **Connect** and explain that ordinary children will be replaced - [x] On connect, remove every non-template child, preserve Templates, set `src`, and materialize file content - [x] Replace disconnect handoff with normal `src` reset; discard derived content and leave the block empty - [x] Remove disconnect confirmation and conversion; resetting a repeated binding leaves every occurrence empty - [x] Switching sources validates/loads the target before replacing the usable binding and leaves both files unchanged - [x] Revalidate Asset revision and project version when confirmation is submitted - [x] Make repeated connect/disconnect operations idempotent - [x] Prevent disposed, stale, or out-of-order loads from replacing the newest source - [x] Keep loading, saving, conflict, empty-file, and open-file actions off canvas ### 6. Asynchronous persistence and conflicts - [x] Snapshot the current in-memory authored root after normal mutations - [x] Serialize/coalesce rapid edits per resolved Asset and always prepare against the latest local snapshot - [x] Allow independent Assets to save concurrently without scope leakage - [x] Use one revisioned document session and one ordered save queue per Asset, even when multiple Content Blocks or render scopes reference it - [x] After a shared Asset changes, rematerialize every connected occurrence without persisting their generated instances to project data - [x] Pin the resolved Asset and exact loaded revision for each write - [x] Recheck authorization immediately before storage - [x] Ensure local queued edits never produce stale-revision conflicts with each other - [x] On a genuine external revision conflict, preserve local content, stop writes, and enter the standard reload-required state - [x] Keep failed non-conflicting writes retryable without discarding local content - [x] Finish already accepted persistence writes after a Content Block unmounts or switches source - [x] Cancel obsolete loads, materializations, and UI updates after unmount, reload, or source switch - [x] Dispose project-scoped roots, queues, parse caches, failure state, generations, and session subscriptions when the Asset session is replaced - [x] Invalidate Asset resources and synchronize open file editors after successful writes - [x] Keep multi-root persistence ordered, serial, and non-atomic; report partial completion without rollback - [x] Do not add Asset-history undo/redo; native text-editor undo remains local to the open editor ### 7. Content mode parity - [x] Empty connected blocks remain selectable and expose the normal Content-mode `+` affordance - [x] First insertion renders immediately - [x] Enter creates and focuses the next paragraph immediately - [x] Slash commands work in newly created empty paragraphs - [x] Deleting one selected instance deletes exactly that instance - [x] Mouse and keyboard moves use the same operation and permissions - [x] Preserve sibling reorder, nested reparenting, and moves between editable Content Blocks where `main` permits them - [x] Preserve content-editable props such as Link `href` - [x] Preserve copy, paste, cut, multi-cut, wrapping, unwrapping, grid insertion, selection, Navigator order, and canvas order - [x] Cover mutation routing centrally, retain the full unconnected Builder suite, and exercise representative connected interactions in real Builder E2E ### 8. Template names and permissions - [x] Resolve only exact user-facing names from the direct flat Templates list - [x] Enforce unique names within each Templates list - [x] Reject duplicate names during rename with inline validation - [x] Allocate unique names during insert, paste, duplicate, move, wrap, and unwrap - [x] Apply valid template properties using the same permissions as Content mode - [x] Preserve and warn about ignored design-only, invalid, or stale properties - [x] Preserve template shell provenance without serializing expanded template internals - [x] Generate deterministic materialized IDs from source identity, render scope, and authored usage path so focus/selection survives saves - [x] Warn before renaming or deleting referenced templates; files remain unchanged - [x] Provide shared AST-based rename/remove migration operations for MCP-assisted multi-file cleanup ### 9. Scope, frontmatter, and diagnostics - [x] Scope materialized content by Content Block ID plus exact Collection-item/render context - [x] Prevent repeated instances and dynamic candidates from leaking generated IDs, template resolution, or diagnostics across scopes while sharing the underlying Asset source and revision where applicable - [x] Remove frontmatter editing from Settings; edit the exact YAML through the MDX Asset file editor - [x] Preserve the exact frontmatter source byte-for-byte during canvas body saves - [x] Route canvas body changes and complete-file editor saves through one revisioned per-Asset coordinator - [x] Show a source warning indicator in Settings and precise source-located diagnostics in the Asset editor - [x] Deduplicate repeated diagnostics without losing distinct locations - [x] Keep unresolved-template placeholders selectable and accessible only in Builder ### 10. Asset references, transfer, and integrity - [x] Discover and rewrite Asset references in frontmatter, Markdown links/images, and MDX properties - [x] Preserve Asset references while editing unrelated content - [x] Include referenced Asset and template revisions in dependency/cache identity - [x] Route referenced Assets through published URL rewriting - [x] Validate Content Block source integrity during project import, fragment/page transfer, copy/paste, build integrity, and pre-publish audit - [x] Allow multiple Content Blocks and dynamic render scopes to reference the same MDX Asset without treating the connection as an integrity error - [x] Preserve Templates when copying a connected Content Block - [x] Preserve direct and dynamic Content Block source bindings when copying or pasting within the same project - [x] When copying only one rendered Collection item, resolve that item’s current MDX Asset and replace the copied Content Block’s dynamic source with a direct binding to that same Asset - [x] When copying an entire Collection, preserve the Collection, its instances, and its dynamic source bindings - [x] Preserve the complete MDX source and frontmatter by reference; copying a connected block must not rewrite the shared file - [x] During cross-project transfer, copy every discoverable distinct bound MDX Asset and rewrite transferred bindings to the destination Assets - [x] If some cross-project dynamic candidates cannot be discovered or copied, continue with successful copies, preserve what can be represented safely, and warn about skipped candidates - [x] Revalidate source revisions and the destination project before applying a cross-project transfer - [x] If cross-project Asset creation or project insertion fails, leave the source project unchanged and clean up any newly reserved destination Asset - [x] Use the same transfer operation through Builder and MCP ### 11. Dynamic sources and publication - [x] Resolve dynamic bindings once per render/save scope - [x] Require finite, discoverable candidate sets for publication and load no unrelated MDX - [x] Warn and skip unbounded, missing, unreadable, malformed, or unsafe sources without failing the complete build - [x] Materialize only rendered Content Blocks; ignore Templates definitions and `src` on non-Content-Block instances - [x] Skip unresolved template subtrees while keeping valid siblings - [x] Include selected template styles, fonts, resources, unresolved names, and referenced Asset revisions in generated dependency output - [x] Keep draft-only MDX Assets out of publication retention ### 12. Builder, MCP, CLI, and shared operations - [x] Use the same converters, source lifecycle, revision checks, persistence service, diagnostics, and template migrations in Builder and MCP - [x] Keep frontmatter editing file-based; retain whole-frontmatter runtime operations only where compatibility requires them - [x] Avoid Builder-only or MCP-only mutation/conversion implementations - [x] Regenerate runtime/API contracts from their source definitions - [x] Update CLI Content Engine reference and generated MCP documentation ### 13. Verification and delivery - [x] Add regression tests that reproduce failures from the previous implementation, including double deletion and blocked rendering - [x] Cover two Content Blocks sharing one Asset: edits from either block serialize once, update both canvases, preserve scope-specific Templates, and cannot overwrite a newer local snapshot - [x] Verify new tests fail against the broken implementation and pass with the corrected behavior - [x] Run SDK, Content Engine, Asset Uploader, Project Build, Builder, React SDK, CLI, Protocol, and HTTP Client affected suites and typechecks - [x] Run real Builder E2E for source lifecycle, live file refresh, empty insertion, Enter/slash focus, Link props, delete, keyboard reorder, switching, disconnect, conflicts, unresolved templates, dynamic sources, and repeated scopes - [x] Regenerate shared project fixture bundles and verify a second generation leaves the tree clean - [x] Run lint, package boundaries, `pnpm oxfmt --check`, and `git diff --check` - [x] Review and update authoritative `docs/` content and navigation - [ ] Run the complete agent evaluation suite — not run because it requires separate explicit approval ### 14. Content-mode parity and disconnect handoff - [x] Allow keyboard reordering in Content mode through the normal move command while keeping the editable Content Block boundary - [x] Let already accepted Asset writes drain independently before resetting the source; disconnect does not load, flush, or convert the file - [x] Remove MDX-to-instance copying from disconnect - [x] Reset `src`, remove the derived tree, and leave the ordinary Content Block empty - [x] Preserve automatic text-editor focus after inserting editable content from the template menu - [x] Remove Builder frontmatter-save UI and report complete-file save failures through the Asset editor - [x] Add regression coverage for keyboard movement, disconnect teardown, source switching, insertion focus, and paragraph deletion focus restoration - [x] Run the complete six-scenario MDX Content Block Builder E2E suite ### 15. Final architecture review fixes - [x] Keep one canonical ordered pending document per Asset so shared Content Block occurrences cannot conflict with one another - [x] Merge ordered local edits from different occurrences while preserving text changes, insertions, deletions, and sibling reordering; keep external revisions on the strict reload-required path - [x] Centralize typed render-scope parsing, repeated-occurrence detection, scoped variable lookup, and dynamic Asset resolution across Canvas, Settings, source lifecycle, and copy/paste - [x] Capture the owning Asset session when a save is accepted so normal unmount/source-switch teardown cannot redirect or lose it - [x] Dispose project-owned roots, queues, caches, generations, failure state, and subscriptions when replacing the project Asset session - [x] Add regression coverage for same-Asset edits from two occurrences, same-node last-write ordering, structural reorder, insertion preservation, scoped source resolution, and project cleanup - [x] Re-run affected full unit suites, typechecks, production build, static checks, and all six MDX Content Block Builder E2E scenarios ### 16. Final persistence and transfer hardening - [x] Drain every accepted per-Asset edit before project-session teardown while retaining the owning authorization context - [x] Drain canvas serialization queues and revisioned Asset writes before pre-publish audit and publication - [x] Block publication on a queued serialization failure or Asset revision conflict - [x] Copy actual Asset bytes during cross-project paste, preserve MDX reference names, rewrite direct and Collection-backed Asset IDs, and clean up newly created Assets when insertion fails - [x] Include every resolved dynamic MDX source when copying an entire Collection and warn while preserving successful copies when individual Assets cannot be transferred - [x] Keep MDX editor save failures visible with retry and route conflicts through the standard reload-required flow - [x] Give keyboard nested reparenting the same editable Content Block boundary used by pointer movement - [x] Add regression coverage and rerun the complete Content Engine and Builder unit suites plus type, lint, formatting, package-boundary, and diff checks ## Current rebuild status - The connected Content Block now owns one complete MDX document: frontmatter feeds designed-shell bindings and the explicit Body outlet owns the authored MDX body. - Builder and published output resolve the same frontmatter document graph, including nested document references and Asset references. - Direct frontmatter text/property edits use the existing ordered per-Asset session; referenced-document values remain file-owned. - Repeated Collection occurrences keep isolated runtime shell/Body identities and occurrence-specific document values. - The local blog article layout has been upgraded to the document/Body model and its bindings validate cleanly. - Full affected package suites and typechecks pass; every new regression was demonstrated fail/pass. - `pnpm lint`, package boundaries, `pnpm oxfmt --check`, generated API/docs checks, fixture builds, and `git diff --check` pass. - Full fixture synchronization is blocked by the development API rejecting this local CLI version before fixture generation; existing fixture bundles build successfully. - Complete agent evaluations remain pending explicit approval. ### 17. Treat Content Block source as a property - [x] Declare Content Block `src` in component metadata as a Design-mode initial property labeled **Source** - [x] Add the short property description: “Create an .mdx file in Assets, then select or bind it here.” - [x] Accept only a direct Asset or an expression resolving to an Asset ID; reject URL/string sources, missing Assets, and non-MDX Assets - [x] Extract and reuse the Image-style Asset picker and binding presentation without duplicating source-control UI - [x] Render Source through the normal properties section and remove the separate source-section placement and special `src` filtering - [x] Route Source changes through Content Block connect, switch, and disconnect lifecycle operations instead of generic prop updates - [x] Keep connect confirmation, Open, diagnostics, and persistence state attached to Source; remove frontmatter and disconnect UI - [x] Add regression coverage for direct Assets, dynamic Asset bindings, invalid resolved values, lifecycle operations, and Design/Content mode visibility - [x] Re-review authoritative `docs/` content for the Source-property UI change and update it if required ### 18. Source property UX - [x] Show one full-width bindable Connect .mdx file button while disconnected - [x] Show equal-width filename and Open buttons while connected - [x] Switch files from the filename button through the source lifecycle - [x] Override PropertyLabel reset and Alt-click with the disconnect lifecycle - [x] Preserve confirmation, pending, failure, repeated-scope, and dynamic-source behavior - [x] Add regression coverage for connect, switch, binding, reset, and disconnect - [x] Update Content Block documentation - [x] Run affected tests, typecheck, lint, formatting, package boundaries, and diff checks ### 19. Reduced file-owned frontmatter follow-up This checklist supersedes the earlier structured-frontmatter and disconnect-copy behavior. Frontmatter remains file-owned and is edited only in the MDX Asset editor. #### A. Remove structured frontmatter UI - [x] Remove the JSON frontmatter editor and unused Builder controller/state - [x] Keep source mutation Design-mode-only; show connected Source read-only in Content mode and allow authorized editors to open the complete file - [x] Retain runtime frontmatter APIs only where compatibility requires them #### B. Preserve source and separate validation - [x] Preserve the exact frontmatter envelope during every canvas body save without parsing/stringifying YAML - [x] Preserve comments, ordering, quoting, whitespace, BOM, newline style, delimiters, and invalid but closed YAML - [x] Keep the existing unsupported-node/property MDX recovery; do not add an ad hoc partial parser - [x] Parse body and frontmatter independently so either valid half remains usable - [x] Keep bounded Asset-query frontmatter extraction unchanged - [x] Treat structurally malformed MDX as having no safe body tree #### C. Share Asset state with the raw editor - [x] Route canvas and complete-file changes through one ordered, non-blocking per-Asset coordinator - [x] Update canvas and clean raw editors immediately from shared in-memory source - [x] Reject stale dirty-editor saves, preserve unsaved text, and require reload; never force-overwrite - [x] Drain accepted writes after source replacement/disconnect and report background failures globally - [x] Dispose sessions only after they have no consumers and no queued work #### D. Connect, replace, and disconnect - [x] Load and diagnose before connect; block only unreadable/unauthorized/non-MDX Assets - [x] Show **Abort / Connect** for non-empty blocks, including diagnostics and the recoverable-content warning - [x] Set `src` and delete persisted children through one runtime mutation - [x] Switch to a loaded source without waiting for the previous Asset's network save - [x] Remove disconnect confirmation and conversion - [x] Reset `src` immediately, discard the derived tree, leave the block empty, and preserve the Asset - [x] Resetting a bound source leaves every repeated occurrence empty #### E. Dynamic and shared sources - [x] Keep one source/revision state per Asset and a separate derived instances tree per occurrence - [x] Resolve Templates independently per Content Block and preserve Builder-only missing-template placeholders - [x] Resolve bound `src` from the selected Collection occurrence - [x] Make **Open** use the occurrence's resolved Asset; update the filename and disable Open when unresolved - [x] Add direct/bound tests including Collection item A opening `a.mdx` and item B opening `b.mdx` #### F. Diagnostics, MCP, and publication - [x] Show an accessible warning icon beside Source with the first diagnostic, line/column, and remaining count - [x] Show precise YAML/MDX diagnostics in the Asset editor without repetitive canvas/toast UI - [x] Reuse parser/diagnostic/revision contracts in Builder, build, publication, and MCP - [x] Keep MCP frontmatter editing file-based and return structured diagnostics for complete-source writes - [x] Emit build warnings; published sites skip invalid/unsupported content without diagnostic UI #### G. Verification and documentation - [x] Add fail/pass regressions for source preservation, independent validation, stale raw saves, lifecycle, dynamic Open, shared Assets, and cleanup - [x] Run affected tests, typechecks, lint, formatting, package boundaries, fixtures, and diff checks - [x] Open Markdown and MDX editors at twice the default dialog size with the right-side preview visible initially - [x] Document canvas body editing, file-owned frontmatter, Content-mode Asset access, and cloning an MDX Asset for a new post - [x] Perform a final correctness, data-loss, DRY, simplification, accessibility, and performance review - [x] Keep this checklist synchronized with implementation status ### 20. Content-mode component properties - [x] Show a connected Content Block Source read-only in Content mode with an active **Open** action - [x] Keep source switching, binding, reset, and disconnect available only in Design mode - [x] Expose HTML Embed code and preserve Code Text's code-editor metadata on authored Text Content - [x] Audit every registered component and expose authored payload while keeping design, structure, and behavior controls in Design mode - [x] Keep media dimensions and Code Text theme Design-only while preserving adapter-owned intrinsic Image metadata during MDX round trips - [x] Add exact component-permission and regression coverage - [x] Update authoritative component documentation - [x] Run affected full suites, typechecks, formatting, lint, package boundaries, and generated contract/documentation checks ### 21. Designed document shell and Body outlet - [x] Add one structural **Content Block Body** outlet and keep legacy Content Blocks without it compatible - [x] Materialize and persist only `document.body` children through the Body outlet while preserving the surrounding designed shell and Templates - [x] Expose the connected document frontmatter through a Content Block `document` parameter for shell text and property bindings - [x] Write direct frontmatter-bound text and properties back through the same ordered per-Asset session without blocking canvas mutations - [x] Resolve document `$ref` dependencies and Asset references consistently in Builder and published output - [x] Keep referenced-document values read-only on canvas and direct editors to the referenced file - [x] Isolate repeated Collection occurrences while retaining occurrence-specific source, frontmatter, shell, and Body content - [x] Render the same designed shell, frontmatter values, and Body substitution in generated/published output - [x] Upgrade the local blog article layout to bind title, excerpt, dates, author data, and images from frontmatter and render MDX through Body - [x] Add fail/pass regressions for nested Body discovery, lifecycle, permissions, repeated values, document references, and generated shell rendering - [x] Update authoritative Content Block documentation - [x] Run affected tests and typechecks, formatting, lint, package boundaries, generated API/docs checks, and fixture builds ### 22. Final nested references and frontmatter editing - [x] Keep direct frontmatter-bound text and property controls editable in Content mode without exposing or replacing their bindings - [x] Keep referenced-document values read-only and preserve legacy instance-property name normalization - [x] Render direct frontmatter text bindings through the plain-text editing path - [x] Resolve nested legacy and structured Asset references from referenced Markdown/MDX frontmatter - [x] Preserve missing Asset references, emit structured `ASSET_NOT_FOUND` diagnostics, and keep them out of the document graph - [x] Add fail/pass regressions for the real Settings control and root/nested missing Asset-reference cases - [x] Fix exact Content Block selection after the Body node appears in Navigator - [x] Run full affected suites/typechecks, all six MDX Content Block E2E scenarios, deterministic fixture builds, generated checks, lint, formatting, package boundaries, and diff checks ### Before merge - [x] Resolve #6261: Nested Asset references in referenced Markdown frontmatter work consistently in Assets queries, previews, publication, and static export ### 23. Explicit writable expression bindings - [x] Add an explicit `readwrite` mode to expression bindings; existing expressions remain read-only by default - [x] Accept `readwrite` only for direct static member paths and centralize their parsing and validation - [x] Require the expression root to provide a writable data source; initially support the connected Content Block document frontmatter - [x] Update Content Block permissions, canvas text editing, and property controls to require a valid writable binding - [x] Persist direct frontmatter edits through the existing ordered per-Asset session - [x] Create new direct frontmatter bindings with `readwrite` mode and migrate existing compatible bindings - [x] Preserve writable mode through schemas, serialization, loading, copy/paste, transfer, and export - [x] Add fail/pass regressions for valid paths, derived expressions, unsupported sources, permissions, persistence, and compatibility - [x] Review authoritative `docs/` content and update it if the persisted binding contract affects user-facing documentation - [x] Reject unsafe frontmatter paths and reject `readwrite` bindings outside a connected Content Block document - [x] Preserve expression binding mode through the public HTTP client and document the MCP authoring contract - [x] Limit frontmatter value write-back to Content mode so Design mode retains normal binding replacement/reset behavior - [x] Keep bindings inside Templates and across nested Content Block ownership boundaries read-only - [x] Upgrade compatible legacy bindings even when the Content Block is disconnected during migration - [x] Run affected tests, typechecks, formatting, lint, package boundaries, fixture checks, and `git diff --check` Verification: full SDK (567), Project Build (2,277), and Builder (2,695) suites pass, along with affected Expression, Migration, Template, HTTP Client, and MCP suites; typechecks, lint, formatting, package boundaries, and diff checks pass. Generated contracts are refreshed. Fixture synchronization remains blocked before generation because the development API rejects this local CLI version as incompatible. The checked-in bundles pass the current Protocol suite and every fixture build with the current bundle version.
1432 lines
37 KiB
TypeScript
1432 lines
37 KiB
TypeScript
import { describe, expect, expectTypeOf, test } from "vitest";
|
|
import {
|
|
blockComponent,
|
|
blockTemplateComponent,
|
|
elementComponent,
|
|
type Asset,
|
|
type Instance,
|
|
type PageTemplate,
|
|
} from "@webstudio-is/sdk";
|
|
import { createDefaultPages } from "@webstudio-is/project-build";
|
|
import { findCycles } from "@webstudio-is/project-build/runtime";
|
|
import type { BuilderRuntimeOperationInput } from "@webstudio-is/project-build/runtime";
|
|
import {
|
|
executeRuntimeMutation,
|
|
executeRuntimeMutationAsync,
|
|
executeRuntimeMutationSequence,
|
|
getWebstudioData,
|
|
migrateLoadedWebstudioData,
|
|
type RuntimeMutationOperation,
|
|
} from "./data";
|
|
import {
|
|
externalContentSyncStore,
|
|
registerContainers,
|
|
serverSyncStore,
|
|
} from "../sync/sync-stores";
|
|
import {
|
|
$assets,
|
|
$breakpoints,
|
|
$dataSources,
|
|
$instances,
|
|
$pages,
|
|
$projectSettings,
|
|
$props,
|
|
$resources,
|
|
$styleSourceSelections,
|
|
$styleSources,
|
|
$styles,
|
|
} from "../sync/data-stores";
|
|
import { $selectedPageId } from "../nano-states/pages";
|
|
import { $authPermit, $builderMode } from "../nano-states/misc";
|
|
import { selectInstance } from "../nano-states";
|
|
import { $externalContentRoots } from "../external-content-mutations";
|
|
|
|
const createInstance = (
|
|
id: Instance["id"],
|
|
component: string,
|
|
children: Instance["children"]
|
|
): Instance => {
|
|
return { type: "instance", id, component, children };
|
|
};
|
|
|
|
registerContainers();
|
|
|
|
test("accepts only mutation operation ids in the Builder commit helpers", () => {
|
|
expectTypeOf<
|
|
Extract<RuntimeMutationOperation, { id: "pages.create" }>
|
|
>().not.toBeNever();
|
|
expectTypeOf<
|
|
Extract<RuntimeMutationOperation, { id: "pages.list" }>
|
|
>().toBeNever();
|
|
});
|
|
|
|
const setBaseStores = () => {
|
|
serverSyncStore.transactionManager.currentStack = [];
|
|
serverSyncStore.transactionManager.undoneStack = [];
|
|
serverSyncStore.popAll();
|
|
externalContentSyncStore.transactionManager.currentStack = [];
|
|
externalContentSyncStore.transactionManager.undoneStack = [];
|
|
externalContentSyncStore.popAll();
|
|
$externalContentRoots.set(new Map());
|
|
selectInstance(undefined);
|
|
const pages = createDefaultPages({ rootInstanceId: "body" });
|
|
$pages.set(pages);
|
|
$selectedPageId.set(pages.homePageId);
|
|
$builderMode.set("design");
|
|
$authPermit.set("build");
|
|
$instances.set(new Map([["body", createInstance("body", "Body", [])]]));
|
|
$props.set(new Map());
|
|
$breakpoints.set(new Map());
|
|
$styleSourceSelections.set(new Map());
|
|
$styleSources.set(new Map());
|
|
$styles.set(new Map());
|
|
$dataSources.set(new Map());
|
|
$resources.set(new Map());
|
|
$assets.set(new Map());
|
|
$projectSettings.set({ meta: {}, compiler: {} });
|
|
return { pages };
|
|
};
|
|
|
|
const expectGeneratedId = (value: string | undefined, label: string) => {
|
|
expect(value).toEqual(expect.any(String));
|
|
if (value === undefined) {
|
|
throw new Error(`Expected ${label} to be generated`);
|
|
}
|
|
return value;
|
|
};
|
|
|
|
const createImageAsset = (id: string): Asset => ({
|
|
id,
|
|
projectId: "project",
|
|
size: 1,
|
|
name: `${id}.png`,
|
|
type: "image",
|
|
format: "png",
|
|
createdAt: "2026-01-01T00:00:00.000Z",
|
|
description: null,
|
|
meta: { width: 100, height: 100 },
|
|
});
|
|
|
|
describe("data store helpers", () => {
|
|
test("publishes external children only after their instances exist", () => {
|
|
setBaseStores();
|
|
$instances.set(
|
|
new Map([
|
|
[
|
|
"body",
|
|
createInstance("body", "Body", [{ type: "id", value: "block" }]),
|
|
],
|
|
[
|
|
"block",
|
|
createInstance("block", blockComponent, [
|
|
{ type: "id", value: "templates" },
|
|
]),
|
|
],
|
|
["templates", createInstance("templates", blockTemplateComponent, [])],
|
|
])
|
|
);
|
|
$externalContentRoots.set(
|
|
new Map([
|
|
[
|
|
"root",
|
|
{
|
|
blockInstanceId: "block",
|
|
renderScope: JSON.stringify(["block", "body"]),
|
|
instanceIds: new Set<string>(),
|
|
mutationRevision: 0,
|
|
},
|
|
],
|
|
])
|
|
);
|
|
selectInstance(["block", "body"]);
|
|
const missingInstanceIds: string[] = [];
|
|
const unsubscribe = $instances.listen((instances) => {
|
|
for (const child of instances.get("block")?.children ?? []) {
|
|
if (child.type === "id" && instances.has(child.value) === false) {
|
|
missingInstanceIds.push(child.value);
|
|
}
|
|
}
|
|
});
|
|
|
|
executeRuntimeMutation({
|
|
id: "instances.insertFragment",
|
|
input: {
|
|
parentInstanceId: "block",
|
|
fragment: {
|
|
children: [{ type: "id", value: "paragraph-template" }],
|
|
instances: [
|
|
{
|
|
...createInstance("paragraph-template", elementComponent, []),
|
|
tag: "p",
|
|
},
|
|
],
|
|
styleSourceSelections: [],
|
|
styleSources: [],
|
|
breakpoints: [],
|
|
styles: [],
|
|
dataSources: [],
|
|
resources: [],
|
|
props: [],
|
|
assets: [],
|
|
},
|
|
},
|
|
});
|
|
unsubscribe();
|
|
|
|
expect($instances.get().get("block")?.children).toHaveLength(2);
|
|
expect(missingInstanceIds).toEqual([]);
|
|
});
|
|
|
|
test("deletes only the selected external child and preserves the normal focus target", () => {
|
|
setBaseStores();
|
|
$instances.set(
|
|
new Map([
|
|
[
|
|
"body",
|
|
createInstance("body", "Body", [{ type: "id", value: "block" }]),
|
|
],
|
|
[
|
|
"block",
|
|
createInstance("block", blockComponent, [
|
|
{ type: "id", value: "templates" },
|
|
{ type: "id", value: "previous-paragraph" },
|
|
{ type: "id", value: "paragraph" },
|
|
{ type: "id", value: "next-paragraph" },
|
|
]),
|
|
],
|
|
["templates", createInstance("templates", blockTemplateComponent, [])],
|
|
[
|
|
"previous-paragraph",
|
|
{
|
|
...createInstance("previous-paragraph", elementComponent, []),
|
|
tag: "p",
|
|
},
|
|
],
|
|
[
|
|
"paragraph",
|
|
{
|
|
...createInstance("paragraph", elementComponent, []),
|
|
tag: "p",
|
|
},
|
|
],
|
|
[
|
|
"next-paragraph",
|
|
{
|
|
...createInstance("next-paragraph", elementComponent, []),
|
|
tag: "p",
|
|
},
|
|
],
|
|
])
|
|
);
|
|
$externalContentRoots.set(
|
|
new Map([
|
|
[
|
|
"root",
|
|
{
|
|
blockInstanceId: "block",
|
|
renderScope: JSON.stringify(["block", "body"]),
|
|
instanceIds: new Set([
|
|
"previous-paragraph",
|
|
"paragraph",
|
|
"next-paragraph",
|
|
]),
|
|
mutationRevision: 0,
|
|
},
|
|
],
|
|
])
|
|
);
|
|
selectInstance(["paragraph", "block", "body"]);
|
|
const missingInstanceIds: string[] = [];
|
|
const unsubscribeInstances = $instances.subscribe((instances) => {
|
|
for (const child of instances.get("block")?.children ?? []) {
|
|
if (child.type === "id" && instances.has(child.value) === false) {
|
|
missingInstanceIds.push(child.value);
|
|
}
|
|
}
|
|
});
|
|
|
|
const result = executeRuntimeMutation({
|
|
id: "instances.deleteBySelector",
|
|
input: { instanceSelector: ["paragraph", "block", "body"] },
|
|
});
|
|
unsubscribeInstances();
|
|
|
|
expect(result?.result.instanceSelector).toEqual([
|
|
"next-paragraph",
|
|
"block",
|
|
"body",
|
|
]);
|
|
expect($instances.get().get("block")?.children).toEqual([
|
|
{ type: "id", value: "templates" },
|
|
{ type: "id", value: "previous-paragraph" },
|
|
{ type: "id", value: "next-paragraph" },
|
|
]);
|
|
expect($instances.get().has("previous-paragraph")).toBe(true);
|
|
expect($instances.get().has("paragraph")).toBe(false);
|
|
expect($instances.get().has("next-paragraph")).toBe(true);
|
|
expect(missingInstanceIds).toEqual([]);
|
|
});
|
|
|
|
test("mutates only the selected scoped Content Block occurrence", () => {
|
|
setBaseStores();
|
|
$instances.set(
|
|
new Map([
|
|
[
|
|
"body",
|
|
createInstance("body", "Body", [{ type: "id", value: "collection" }]),
|
|
],
|
|
[
|
|
"collection",
|
|
createInstance("collection", "Collection", [
|
|
{ type: "id", value: "block" },
|
|
]),
|
|
],
|
|
[
|
|
"block",
|
|
createInstance("block", blockComponent, [
|
|
{ type: "id", value: "templates" },
|
|
]),
|
|
],
|
|
["templates", createInstance("templates", blockTemplateComponent, [])],
|
|
[
|
|
"scoped-block",
|
|
createInstance("scoped-block", blockComponent, [
|
|
{ type: "id", value: "templates" },
|
|
{ type: "id", value: "previous-paragraph" },
|
|
{ type: "id", value: "paragraph" },
|
|
]),
|
|
],
|
|
[
|
|
"previous-paragraph",
|
|
{
|
|
...createInstance("previous-paragraph", elementComponent, []),
|
|
tag: "p",
|
|
},
|
|
],
|
|
[
|
|
"paragraph",
|
|
{
|
|
...createInstance("paragraph", elementComponent, []),
|
|
tag: "p",
|
|
},
|
|
],
|
|
])
|
|
);
|
|
const scopedSelector = [
|
|
"scoped-block",
|
|
"collection[first]",
|
|
"collection",
|
|
"body",
|
|
];
|
|
$externalContentRoots.set(
|
|
new Map([
|
|
[
|
|
"root",
|
|
{
|
|
sourceBlockInstanceId: "block",
|
|
sourceRenderScope: JSON.stringify([
|
|
"block",
|
|
"collection[first]",
|
|
"collection",
|
|
"body",
|
|
]),
|
|
blockInstanceId: "scoped-block",
|
|
renderScope: JSON.stringify(scopedSelector),
|
|
instanceIds: new Set(["previous-paragraph", "paragraph"]),
|
|
mutationRevision: 0,
|
|
},
|
|
],
|
|
])
|
|
);
|
|
selectInstance(["paragraph", ...scopedSelector]);
|
|
serverSyncStore.popAll();
|
|
|
|
const result = executeRuntimeMutation({
|
|
id: "instances.deleteBySelector",
|
|
input: { instanceSelector: ["paragraph", ...scopedSelector] },
|
|
});
|
|
|
|
expect(result?.result.instanceSelector).toEqual([
|
|
"previous-paragraph",
|
|
...scopedSelector,
|
|
]);
|
|
expect($instances.get().get("scoped-block")?.children).toEqual([
|
|
{ type: "id", value: "templates" },
|
|
{ type: "id", value: "previous-paragraph" },
|
|
]);
|
|
expect($instances.get().get("block")?.children).toEqual([
|
|
{ type: "id", value: "templates" },
|
|
]);
|
|
expect(serverSyncStore.popAll()).toEqual([]);
|
|
expect($externalContentRoots.get().get("root")?.mutationRevision).toBe(1);
|
|
});
|
|
|
|
test("persists both external roots when content moves between them", () => {
|
|
setBaseStores();
|
|
$instances.set(
|
|
new Map([
|
|
[
|
|
"body",
|
|
createInstance("body", "Body", [
|
|
{ type: "id", value: "source-block" },
|
|
{ type: "id", value: "target-block" },
|
|
]),
|
|
],
|
|
[
|
|
"source-block",
|
|
createInstance("source-block", blockComponent, [
|
|
{ type: "id", value: "source-templates" },
|
|
{ type: "id", value: "paragraph" },
|
|
]),
|
|
],
|
|
[
|
|
"target-block",
|
|
createInstance("target-block", blockComponent, [
|
|
{ type: "id", value: "target-templates" },
|
|
]),
|
|
],
|
|
[
|
|
"source-templates",
|
|
createInstance("source-templates", blockTemplateComponent, []),
|
|
],
|
|
[
|
|
"target-templates",
|
|
createInstance("target-templates", blockTemplateComponent, []),
|
|
],
|
|
[
|
|
"paragraph",
|
|
{
|
|
...createInstance("paragraph", elementComponent, []),
|
|
tag: "p",
|
|
},
|
|
],
|
|
])
|
|
);
|
|
$externalContentRoots.set(
|
|
new Map([
|
|
[
|
|
"source-root",
|
|
{
|
|
blockInstanceId: "source-block",
|
|
renderScope: JSON.stringify(["source-block", "body"]),
|
|
instanceIds: new Set(["paragraph"]),
|
|
mutationRevision: 0,
|
|
},
|
|
],
|
|
[
|
|
"target-root",
|
|
{
|
|
blockInstanceId: "target-block",
|
|
renderScope: JSON.stringify(["target-block", "body"]),
|
|
instanceIds: new Set<string>(),
|
|
mutationRevision: 0,
|
|
},
|
|
],
|
|
])
|
|
);
|
|
serverSyncStore.popAll();
|
|
|
|
executeRuntimeMutation({
|
|
id: "instances.move",
|
|
input: {
|
|
moves: [{ instanceId: "paragraph", parentInstanceId: "target-block" }],
|
|
},
|
|
});
|
|
|
|
expect($instances.get().get("source-block")?.children).toEqual([
|
|
{ type: "id", value: "source-templates" },
|
|
]);
|
|
expect($instances.get().get("target-block")?.children).toEqual([
|
|
{ type: "id", value: "target-templates" },
|
|
{ type: "id", value: "paragraph" },
|
|
]);
|
|
expect(serverSyncStore.popAll()).toEqual([]);
|
|
expect(
|
|
Array.from(
|
|
$externalContentRoots.get().values(),
|
|
({ mutationRevision }) => mutationRevision
|
|
)
|
|
).toEqual([1, 1]);
|
|
});
|
|
|
|
test("getWebstudioData reads all instance-related stores", () => {
|
|
const pages = createDefaultPages({ rootInstanceId: "body" });
|
|
const instances = new Map([["body", createInstance("body", "Body", [])]]);
|
|
$pages.set(pages);
|
|
$instances.set(instances);
|
|
$props.set(new Map());
|
|
$breakpoints.set(new Map());
|
|
$styleSourceSelections.set(new Map());
|
|
$styleSources.set(new Map());
|
|
$styles.set(new Map());
|
|
$dataSources.set(new Map());
|
|
$resources.set(new Map());
|
|
$assets.set(new Map());
|
|
$projectSettings.set({ meta: {}, compiler: {} });
|
|
|
|
expect(getWebstudioData()).toMatchObject({
|
|
pages,
|
|
instances,
|
|
});
|
|
});
|
|
|
|
test("executes runtime mutations against builder stores", () => {
|
|
setBaseStores();
|
|
|
|
const result = executeRuntimeMutation({
|
|
id: "instances.updateProps",
|
|
input: {
|
|
updates: [
|
|
{
|
|
instanceId: "body",
|
|
name: "id",
|
|
type: "string",
|
|
value: "main",
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
expect(result?.result.propIds).toHaveLength(1);
|
|
expect(Array.from($props.get().values())).toEqual([
|
|
expect.objectContaining({
|
|
instanceId: "body",
|
|
name: "id",
|
|
type: "string",
|
|
value: "main",
|
|
}),
|
|
]);
|
|
});
|
|
|
|
test("commits runtime patch payloads with sync undo and redo support", () => {
|
|
setBaseStores();
|
|
|
|
executeRuntimeMutation({
|
|
id: "instances.updateProps",
|
|
input: {
|
|
updates: [
|
|
{
|
|
instanceId: "body",
|
|
name: "id",
|
|
type: "string",
|
|
value: "main",
|
|
},
|
|
],
|
|
},
|
|
});
|
|
const [prop] = Array.from($props.get().values());
|
|
expect(prop).toEqual(
|
|
expect.objectContaining({
|
|
instanceId: "body",
|
|
name: "id",
|
|
value: "main",
|
|
})
|
|
);
|
|
|
|
serverSyncStore.undo();
|
|
expect($props.get()).toEqual(new Map());
|
|
|
|
serverSyncStore.redo();
|
|
expect(Array.from($props.get().values())).toEqual([
|
|
expect.objectContaining({
|
|
instanceId: "body",
|
|
name: "id",
|
|
value: "main",
|
|
}),
|
|
]);
|
|
});
|
|
|
|
test("commits a runtime mutation sequence as one undoable transaction", () => {
|
|
setBaseStores();
|
|
|
|
executeRuntimeMutationSequence([
|
|
{
|
|
id: "instances.updateProps",
|
|
input: {
|
|
updates: [
|
|
{
|
|
instanceId: "body",
|
|
name: "id",
|
|
type: "string",
|
|
value: "main",
|
|
},
|
|
],
|
|
},
|
|
},
|
|
{
|
|
id: "instances.updateProps",
|
|
input: {
|
|
updates: [
|
|
{
|
|
instanceId: "body",
|
|
name: "title",
|
|
type: "string",
|
|
value: "Main content",
|
|
},
|
|
],
|
|
},
|
|
},
|
|
]);
|
|
|
|
expect(Array.from($props.get().values())).toHaveLength(2);
|
|
serverSyncStore.undo();
|
|
expect($props.get()).toEqual(new Map());
|
|
serverSyncStore.redo();
|
|
expect(Array.from($props.get().values())).toHaveLength(2);
|
|
});
|
|
|
|
test("runtime bridge keeps sync changes scoped to changed namespaces", () => {
|
|
setBaseStores();
|
|
serverSyncStore.popAll();
|
|
|
|
executeRuntimeMutation({
|
|
id: "instances.setTextContent",
|
|
input: {
|
|
operation: "set",
|
|
instanceId: "body",
|
|
mode: "text",
|
|
text: "Hello",
|
|
},
|
|
});
|
|
|
|
expect(
|
|
serverSyncStore
|
|
.popAll()
|
|
.flatMap((item) => item.changes.map((change) => change.namespace))
|
|
).toEqual(["instances"]);
|
|
});
|
|
|
|
test("runtime bridge preserves parent record add patches from runtime payloads", () => {
|
|
const { pages } = setBaseStores();
|
|
pages.pageTemplates = new Map([
|
|
[
|
|
"template",
|
|
{
|
|
id: "template",
|
|
name: "Template",
|
|
title: JSON.stringify("Template"),
|
|
rootInstanceId: "template-root",
|
|
meta: {
|
|
socialImageUrl: JSON.stringify(""),
|
|
custom: [
|
|
{ property: "template", content: JSON.stringify("content") },
|
|
],
|
|
},
|
|
},
|
|
],
|
|
]);
|
|
$instances.set(
|
|
new Map([
|
|
["body", createInstance("body", "Body", [])],
|
|
[
|
|
"template-root",
|
|
{
|
|
type: "instance",
|
|
id: "template-root",
|
|
component: "ws:element",
|
|
tag: "body",
|
|
children: [],
|
|
},
|
|
],
|
|
])
|
|
);
|
|
serverSyncStore.popAll();
|
|
|
|
const result = executeRuntimeMutation({
|
|
id: "pageTemplates.createPage",
|
|
input: {
|
|
projectId: "project",
|
|
templateId: "template",
|
|
parentFolderId: pages.rootFolderId,
|
|
name: "Created from template",
|
|
path: "/created-from-template",
|
|
contentMode: true,
|
|
},
|
|
});
|
|
const pageId = expectGeneratedId(result?.result.pageId, "page id");
|
|
|
|
const pagesChange = serverSyncStore
|
|
.popAll()
|
|
.flatMap((item) => item.changes)
|
|
.find((change) => change.namespace === "pages");
|
|
expect(pagesChange).toBeDefined();
|
|
if (pagesChange === undefined) {
|
|
throw new Error("Expected pages change");
|
|
}
|
|
expect(pagesChange.patches).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ op: "add", path: ["pages", pageId] }),
|
|
])
|
|
);
|
|
expect(pagesChange.revisePatches).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ op: "remove", path: ["pages", pageId] }),
|
|
])
|
|
);
|
|
|
|
const getSyncedPages = () => {
|
|
const syncedPages = $pages.get();
|
|
expect(syncedPages).toBeDefined();
|
|
return syncedPages;
|
|
};
|
|
if (getSyncedPages() === undefined) {
|
|
throw new Error("Expected pages to be loaded");
|
|
}
|
|
|
|
expect(getSyncedPages()?.pages.has(pageId)).toEqual(true);
|
|
serverSyncStore.undo();
|
|
expect(getSyncedPages()?.pages.has(pageId)).toEqual(false);
|
|
serverSyncStore.redo();
|
|
expect(getSyncedPages()?.pages.has(pageId)).toEqual(true);
|
|
});
|
|
|
|
test("runtime bridge moves page tree items with sync undo and redo support", () => {
|
|
const { pages } = setBaseStores();
|
|
const rootFolder = pages.folders.get(pages.rootFolderId);
|
|
if (rootFolder === undefined) {
|
|
throw new Error("Expected root folder");
|
|
}
|
|
pages.pages.set("page-a", {
|
|
id: "page-a",
|
|
name: "Page A",
|
|
path: "/page-a",
|
|
title: JSON.stringify("Page A"),
|
|
meta: {},
|
|
rootInstanceId: "body",
|
|
});
|
|
pages.pages.set("page-b", {
|
|
id: "page-b",
|
|
name: "Page B",
|
|
path: "/page-b",
|
|
title: JSON.stringify("Page B"),
|
|
meta: {},
|
|
rootInstanceId: "body",
|
|
});
|
|
pages.folders.set("folder", {
|
|
id: "folder",
|
|
name: "Folder",
|
|
slug: "folder",
|
|
children: [],
|
|
});
|
|
rootFolder.children.push("page-a", "page-b", "folder");
|
|
serverSyncStore.popAll();
|
|
|
|
executeRuntimeMutation({
|
|
id: "pageTree.move",
|
|
input: {
|
|
childId: "page-b",
|
|
parentFolderId: "folder",
|
|
position: 0,
|
|
},
|
|
});
|
|
|
|
expect($pages.get()?.folders.get(pages.rootFolderId)?.children).toEqual([
|
|
pages.homePageId,
|
|
"page-a",
|
|
"folder",
|
|
]);
|
|
expect($pages.get()?.folders.get("folder")?.children).toEqual(["page-b"]);
|
|
|
|
const pageTreeChanges = serverSyncStore
|
|
.popAll()
|
|
.flatMap((item) => item.changes)
|
|
.filter((change) => change.namespace === "pages");
|
|
expect(pageTreeChanges).toHaveLength(1);
|
|
|
|
serverSyncStore.undo();
|
|
expect($pages.get()?.folders.get(pages.rootFolderId)?.children).toEqual([
|
|
pages.homePageId,
|
|
"page-a",
|
|
"page-b",
|
|
"folder",
|
|
]);
|
|
expect($pages.get()?.folders.get("folder")?.children).toEqual([]);
|
|
|
|
serverSyncStore.redo();
|
|
expect($pages.get()?.folders.get(pages.rootFolderId)?.children).toEqual([
|
|
pages.homePageId,
|
|
"page-a",
|
|
"folder",
|
|
]);
|
|
expect($pages.get()?.folders.get("folder")?.children).toEqual(["page-b"]);
|
|
});
|
|
|
|
test("runtime bridge reorders page templates with sync undo and redo support", () => {
|
|
const { pages } = setBaseStores();
|
|
pages.pageTemplates = new Map(
|
|
["first", "second", "third"].map((id) => [
|
|
id,
|
|
{
|
|
id,
|
|
name: id,
|
|
title: JSON.stringify(id),
|
|
rootInstanceId: "body",
|
|
meta: {},
|
|
},
|
|
])
|
|
);
|
|
serverSyncStore.popAll();
|
|
|
|
executeRuntimeMutation({
|
|
id: "pageTemplates.reorder",
|
|
input: {
|
|
sourceTemplateId: "third",
|
|
targetTemplateId: "first",
|
|
position: "before",
|
|
},
|
|
});
|
|
|
|
expect(Array.from($pages.get()?.pageTemplates?.keys() ?? [])).toEqual([
|
|
"third",
|
|
"first",
|
|
"second",
|
|
]);
|
|
|
|
const templateChanges = serverSyncStore
|
|
.popAll()
|
|
.flatMap((item) => item.changes)
|
|
.filter((change) => change.namespace === "pages");
|
|
expect(templateChanges).toHaveLength(1);
|
|
|
|
serverSyncStore.undo();
|
|
expect(Array.from($pages.get()?.pageTemplates?.keys() ?? [])).toEqual([
|
|
"first",
|
|
"second",
|
|
"third",
|
|
]);
|
|
|
|
serverSyncStore.redo();
|
|
expect(Array.from($pages.get()?.pageTemplates?.keys() ?? [])).toEqual([
|
|
"third",
|
|
"first",
|
|
"second",
|
|
]);
|
|
});
|
|
|
|
test("uses runtime mutation input validation before updating stores", () => {
|
|
setBaseStores();
|
|
|
|
const invalidInput = {
|
|
updates: [
|
|
{
|
|
instanceId: "body",
|
|
name: "id",
|
|
type: "number",
|
|
value: "not-a-number",
|
|
},
|
|
],
|
|
} as unknown as BuilderRuntimeOperationInput<"instances.updateProps">;
|
|
|
|
expect(() =>
|
|
executeRuntimeMutation({
|
|
id: "instances.updateProps",
|
|
input: invalidInput,
|
|
})
|
|
).toThrow();
|
|
|
|
expect($props.get()).toEqual(new Map());
|
|
});
|
|
|
|
test("executes async runtime mutations against builder stores", async () => {
|
|
setBaseStores();
|
|
|
|
const result = await executeRuntimeMutationAsync({
|
|
id: "instances.insertComponent",
|
|
input: {
|
|
parentInstanceId: "body",
|
|
component: "ws:element",
|
|
tag: "div",
|
|
},
|
|
});
|
|
|
|
const rootInstanceId = expectGeneratedId(
|
|
result?.result.rootInstanceIds[0],
|
|
"root instance id"
|
|
);
|
|
expect($instances.get().get("body")?.children).toEqual([
|
|
{ type: "id", value: rootInstanceId },
|
|
]);
|
|
expect($instances.get().get(rootInstanceId)).toMatchObject({
|
|
component: "ws:element",
|
|
tag: "div",
|
|
});
|
|
});
|
|
|
|
test("executes text content runtime mutations against builder stores", () => {
|
|
setBaseStores();
|
|
|
|
const result = executeRuntimeMutation({
|
|
id: "instances.setTextContent",
|
|
input: {
|
|
operation: "set",
|
|
instanceId: "body",
|
|
mode: "text",
|
|
text: "Hello",
|
|
},
|
|
});
|
|
|
|
expect(result?.result).toEqual({
|
|
instanceId: "body",
|
|
operation: "set",
|
|
mode: "text",
|
|
});
|
|
expect($instances.get().get("body")?.children).toEqual([
|
|
{ type: "text", value: "Hello" },
|
|
]);
|
|
});
|
|
|
|
test("uses text content runtime validation before updating stores", () => {
|
|
setBaseStores();
|
|
|
|
expect(() =>
|
|
executeRuntimeMutation({
|
|
id: "instances.setTextContent",
|
|
input: {
|
|
operation: "set",
|
|
instanceId: "body",
|
|
mode: "expression",
|
|
text: "invalid expression {",
|
|
},
|
|
})
|
|
).toThrow("Unexpected token");
|
|
|
|
expect($instances.get().get("body")?.children).toEqual([]);
|
|
});
|
|
|
|
test("executes breakpoint runtime mutations against builder stores", () => {
|
|
setBaseStores();
|
|
|
|
const createResult = executeRuntimeMutation({
|
|
id: "breakpoints.create",
|
|
input: {
|
|
label: "Tablet",
|
|
minWidth: 768,
|
|
},
|
|
});
|
|
const breakpointId = expectGeneratedId(
|
|
createResult?.result.breakpointId,
|
|
"breakpoint id"
|
|
);
|
|
|
|
expect($breakpoints.get().get(breakpointId)).toEqual({
|
|
id: breakpointId,
|
|
label: "Tablet",
|
|
minWidth: 768,
|
|
});
|
|
|
|
executeRuntimeMutation({
|
|
id: "breakpoints.update",
|
|
input: {
|
|
breakpointId,
|
|
values: {
|
|
label: "Desktop",
|
|
minWidth: 1024,
|
|
},
|
|
},
|
|
});
|
|
|
|
expect($breakpoints.get().get(breakpointId)).toEqual({
|
|
id: breakpointId,
|
|
label: "Desktop",
|
|
minWidth: 1024,
|
|
});
|
|
});
|
|
|
|
test("deletes breakpoint styles through runtime mutation", () => {
|
|
setBaseStores();
|
|
$breakpoints.set(
|
|
new Map([["tablet", { id: "tablet", label: "Tablet", minWidth: 768 }]])
|
|
);
|
|
$styles.set(
|
|
new Map([
|
|
[
|
|
"style",
|
|
{
|
|
styleSourceId: "local",
|
|
breakpointId: "tablet",
|
|
property: "color",
|
|
value: { type: "keyword", value: "red" },
|
|
},
|
|
],
|
|
])
|
|
);
|
|
|
|
executeRuntimeMutation({
|
|
id: "breakpoints.delete",
|
|
input: {
|
|
breakpointId: "tablet",
|
|
},
|
|
});
|
|
|
|
expect($breakpoints.get()).toEqual(new Map());
|
|
expect($styles.get()).toEqual(new Map());
|
|
});
|
|
|
|
test("executes redirect runtime mutations against builder stores", () => {
|
|
setBaseStores();
|
|
|
|
executeRuntimeMutation({
|
|
id: "redirects.setAll",
|
|
input: {
|
|
redirects: [{ old: "/old", new: "/new", status: "301" }],
|
|
},
|
|
});
|
|
|
|
expect($pages.get()?.redirects).toEqual([
|
|
{ old: "/old", new: "/new", status: "301" },
|
|
]);
|
|
});
|
|
|
|
test("executes folder runtime mutations against builder stores", () => {
|
|
setBaseStores();
|
|
|
|
const createResult = executeRuntimeMutation({
|
|
id: "folders.create",
|
|
input: {
|
|
name: "Docs",
|
|
slug: "docs",
|
|
},
|
|
});
|
|
const folderId = expectGeneratedId(
|
|
createResult?.result.folderId,
|
|
"folder id"
|
|
);
|
|
|
|
expect($pages.get()?.folders.get(folderId)).toEqual({
|
|
id: folderId,
|
|
name: "Docs",
|
|
slug: "docs",
|
|
children: [],
|
|
});
|
|
|
|
executeRuntimeMutation({
|
|
id: "folders.update",
|
|
input: {
|
|
folderId,
|
|
values: {
|
|
name: "Guides",
|
|
slug: "guides",
|
|
},
|
|
},
|
|
});
|
|
|
|
expect($pages.get()?.folders.get(folderId)).toEqual({
|
|
id: folderId,
|
|
name: "Guides",
|
|
slug: "guides",
|
|
children: [],
|
|
});
|
|
|
|
executeRuntimeMutation({
|
|
id: "folders.delete",
|
|
input: {
|
|
folderId,
|
|
},
|
|
});
|
|
|
|
expect($pages.get()?.folders.has(folderId)).toEqual(false);
|
|
});
|
|
|
|
test("executes page create and delete runtime mutations against builder stores", () => {
|
|
setBaseStores();
|
|
|
|
const createResult = executeRuntimeMutation({
|
|
id: "pages.create",
|
|
input: {
|
|
name: "Pricing",
|
|
path: "/pricing",
|
|
},
|
|
});
|
|
const pageId = expectGeneratedId(createResult?.result.pageId, "page id");
|
|
const rootInstanceId = expectGeneratedId(
|
|
createResult?.result.rootInstanceId,
|
|
"page root instance id"
|
|
);
|
|
|
|
expect($pages.get()?.pages.get(pageId)).toEqual(
|
|
expect.objectContaining({
|
|
id: pageId,
|
|
name: "Pricing",
|
|
path: "/pricing",
|
|
rootInstanceId,
|
|
})
|
|
);
|
|
expect($instances.get().get(rootInstanceId)).toEqual(
|
|
expect.objectContaining({
|
|
id: rootInstanceId,
|
|
component: "ws:element",
|
|
tag: "body",
|
|
})
|
|
);
|
|
|
|
executeRuntimeMutation({
|
|
id: "pages.delete",
|
|
input: {
|
|
pageId,
|
|
},
|
|
});
|
|
|
|
expect($pages.get()?.pages.has(pageId)).toEqual(false);
|
|
expect($instances.get().has(rootInstanceId)).toEqual(false);
|
|
});
|
|
|
|
test("executes data variable runtime mutations against builder stores", () => {
|
|
setBaseStores();
|
|
const page = $pages.get()?.pages.get($pages.get()?.homePageId ?? "");
|
|
|
|
const createResult = executeRuntimeMutation({
|
|
id: "variables.create",
|
|
input: {
|
|
scopeInstanceId: "body",
|
|
name: "message",
|
|
value: { type: "string", value: "Hello" },
|
|
},
|
|
});
|
|
const dataSourceId = expectGeneratedId(
|
|
createResult?.result.dataSourceId,
|
|
"data source id"
|
|
);
|
|
|
|
expect($dataSources.get().get(dataSourceId)).toEqual({
|
|
type: "variable",
|
|
id: dataSourceId,
|
|
scopeInstanceId: "body",
|
|
name: "message",
|
|
value: { type: "string", value: "Hello" },
|
|
});
|
|
|
|
executeRuntimeMutation({
|
|
id: "variables.update",
|
|
input: {
|
|
dataSourceId,
|
|
values: {
|
|
name: "greeting",
|
|
},
|
|
},
|
|
});
|
|
|
|
expect($dataSources.get().get(dataSourceId)).toEqual(
|
|
expect.objectContaining({
|
|
name: "greeting",
|
|
})
|
|
);
|
|
|
|
if (page !== undefined) {
|
|
page.systemDataSourceId = dataSourceId;
|
|
}
|
|
executeRuntimeMutation({
|
|
id: "variables.delete",
|
|
input: {
|
|
dataSourceId,
|
|
},
|
|
});
|
|
|
|
expect($dataSources.get().has(dataSourceId)).toEqual(false);
|
|
expect(
|
|
$pages.get()?.pages.get($pages.get()?.homePageId ?? "")
|
|
).not.toHaveProperty("systemDataSourceId");
|
|
});
|
|
|
|
test("executes css variable runtime mutations against builder stores", () => {
|
|
setBaseStores();
|
|
$styles.set(
|
|
new Map([
|
|
[
|
|
"local:base:--old:",
|
|
{
|
|
styleSourceId: "local",
|
|
breakpointId: "base",
|
|
property: "--old",
|
|
value: { type: "unparsed", value: "red" },
|
|
},
|
|
],
|
|
[
|
|
"local:base:color:",
|
|
{
|
|
styleSourceId: "local",
|
|
breakpointId: "base",
|
|
property: "color",
|
|
value: { type: "unparsed", value: "var(--old)" },
|
|
},
|
|
],
|
|
])
|
|
);
|
|
$props.set(
|
|
new Map([
|
|
[
|
|
"code-prop",
|
|
{
|
|
id: "code-prop",
|
|
instanceId: "body",
|
|
name: "code",
|
|
type: "string",
|
|
value: "<style>.x{color:var(--old)}</style>",
|
|
},
|
|
],
|
|
])
|
|
);
|
|
|
|
executeRuntimeMutation({
|
|
id: "cssVariables.rename",
|
|
input: {
|
|
oldName: "--old",
|
|
newName: "--new",
|
|
},
|
|
});
|
|
|
|
expect($styles.get().has("local:base:--old:")).toEqual(false);
|
|
expect($styles.get().get("local:base:--new:")).toEqual(
|
|
expect.objectContaining({
|
|
property: "--new",
|
|
})
|
|
);
|
|
expect($styles.get().get("local:base:color:")).toEqual(
|
|
expect.objectContaining({
|
|
value: { type: "unparsed", value: "var(--new)" },
|
|
})
|
|
);
|
|
expect($props.get().get("code-prop")).toEqual(
|
|
expect.objectContaining({
|
|
value: "<style>.x{color:var(--new)}</style>",
|
|
})
|
|
);
|
|
|
|
executeRuntimeMutation({
|
|
id: "cssVariables.delete",
|
|
input: {
|
|
names: ["--new"],
|
|
force: true,
|
|
},
|
|
});
|
|
|
|
expect($styles.get().has("local:base:--new:")).toEqual(false);
|
|
});
|
|
|
|
test("executes resource runtime mutations against builder stores", () => {
|
|
setBaseStores();
|
|
|
|
const upsertResult = executeRuntimeMutation({
|
|
id: "resources.upsertProp",
|
|
input: {
|
|
instanceId: "body",
|
|
propName: "action",
|
|
resource: {
|
|
name: "Submit lead",
|
|
method: "post",
|
|
url: `"https://example.com/leads"`,
|
|
headers: [{ name: `"content-type"`, value: `"application/json"` }],
|
|
body: `"email=hello@example.com"`,
|
|
},
|
|
dataSourceName: "leadResponse",
|
|
},
|
|
});
|
|
const resourceId = expectGeneratedId(
|
|
upsertResult?.result.resourceId,
|
|
"resource id"
|
|
);
|
|
expect(upsertResult?.result.dataSourceId).toBeUndefined();
|
|
const propId = upsertResult?.result.propIds[0];
|
|
expectGeneratedId(propId, "resource prop id");
|
|
|
|
expect($resources.get().get(resourceId)).toEqual(
|
|
expect.objectContaining({
|
|
id: resourceId,
|
|
name: "Submit lead",
|
|
method: "post",
|
|
url: `"https://example.com/leads"`,
|
|
})
|
|
);
|
|
expect($dataSources.get()).toEqual(new Map());
|
|
expect($props.get().get(propId ?? "")).toEqual(
|
|
expect.objectContaining({
|
|
instanceId: "body",
|
|
name: "action",
|
|
type: "resource",
|
|
value: resourceId,
|
|
})
|
|
);
|
|
|
|
executeRuntimeMutation({
|
|
id: "resources.update",
|
|
input: {
|
|
resourceId,
|
|
values: {
|
|
name: "Submit customer lead",
|
|
url: `"https://example.com/customers"`,
|
|
},
|
|
dataSourceName: "customerLeadResponse",
|
|
},
|
|
});
|
|
|
|
expect($resources.get().get(resourceId)).toEqual(
|
|
expect.objectContaining({
|
|
name: "Submit customer lead",
|
|
url: `"https://example.com/customers"`,
|
|
})
|
|
);
|
|
expect($dataSources.get()).toEqual(new Map());
|
|
|
|
executeRuntimeMutation({
|
|
id: "resources.delete",
|
|
input: {
|
|
resourceId,
|
|
force: true,
|
|
},
|
|
});
|
|
|
|
expect($resources.get().has(resourceId)).toEqual(false);
|
|
expect($dataSources.get()).toEqual(new Map());
|
|
expect($props.get().has(propId ?? "")).toEqual(false);
|
|
});
|
|
|
|
test("executes asset runtime mutations against builder stores", () => {
|
|
setBaseStores();
|
|
const asset = createImageAsset("asset");
|
|
$assets.set(new Map([[asset.id, asset]]));
|
|
|
|
executeRuntimeMutation({
|
|
id: "assets.update",
|
|
input: {
|
|
assetId: asset.id,
|
|
values: {
|
|
filename: "hero",
|
|
description: "Hero image",
|
|
},
|
|
},
|
|
});
|
|
|
|
expect($assets.get().get(asset.id)).toEqual(
|
|
expect.objectContaining({
|
|
filename: "hero",
|
|
description: "Hero image",
|
|
})
|
|
);
|
|
|
|
executeRuntimeMutation({
|
|
id: "assets.delete",
|
|
input: {
|
|
assetIdsOrPrefixes: [asset.id],
|
|
force: true,
|
|
},
|
|
});
|
|
|
|
expect($assets.get().has(asset.id)).toEqual(false);
|
|
});
|
|
|
|
test("executes project settings runtime mutations against builder stores", () => {
|
|
const { pages } = setBaseStores();
|
|
const legacyMeta = pages.meta;
|
|
const legacyCompiler = pages.compiler;
|
|
|
|
executeRuntimeMutation({
|
|
id: "projectSettings.update",
|
|
input: {
|
|
meta: {
|
|
contactEmail: "hello@example.com",
|
|
},
|
|
compiler: {
|
|
atomicStyles: true,
|
|
},
|
|
},
|
|
});
|
|
|
|
expect($projectSettings.get()?.meta).toEqual(
|
|
expect.objectContaining({
|
|
contactEmail: "hello@example.com",
|
|
})
|
|
);
|
|
expect($projectSettings.get()?.compiler).toEqual(
|
|
expect.objectContaining({
|
|
atomicStyles: true,
|
|
})
|
|
);
|
|
expect($pages.get()?.meta).toEqual(legacyMeta);
|
|
expect($pages.get()?.compiler).toEqual(legacyCompiler);
|
|
|
|
executeRuntimeMutation({
|
|
id: "projectSettings.update",
|
|
input: {
|
|
meta: {
|
|
contactEmail: null,
|
|
},
|
|
},
|
|
});
|
|
|
|
expect($projectSettings.get()?.meta).not.toHaveProperty("contactEmail");
|
|
expect($pages.get()?.meta).toEqual(legacyMeta);
|
|
});
|
|
|
|
test("runtime bridge skips page templates without build access", () => {
|
|
const pages = createDefaultPages({ rootInstanceId: "body" });
|
|
const template: PageTemplate = {
|
|
id: "template",
|
|
name: "Template",
|
|
title: "Template",
|
|
rootInstanceId: "template-root",
|
|
meta: {},
|
|
};
|
|
pages.pageTemplates = new Map([[template.id, template]]);
|
|
$pages.set(pages);
|
|
$selectedPageId.set(template.id);
|
|
$builderMode.set("design");
|
|
$authPermit.set("view");
|
|
$instances.set(new Map([["body", createInstance("body", "Body", [])]]));
|
|
$props.set(new Map());
|
|
$breakpoints.set(new Map());
|
|
$styleSourceSelections.set(new Map());
|
|
$styleSources.set(new Map());
|
|
$styles.set(new Map());
|
|
$dataSources.set(new Map());
|
|
$resources.set(new Map());
|
|
$assets.set(new Map());
|
|
|
|
executeRuntimeMutation({
|
|
id: "instances.setTextContent",
|
|
input: {
|
|
operation: "set",
|
|
instanceId: "body",
|
|
mode: "text",
|
|
text: "Skipped",
|
|
},
|
|
});
|
|
|
|
expect($instances.get().get("body")?.children).toEqual([]);
|
|
});
|
|
|
|
test("runtime bridge repairs cycles during loaded data migration", () => {
|
|
const pages = createDefaultPages({ rootInstanceId: "body" });
|
|
const instances = new Map([
|
|
[
|
|
"body",
|
|
createInstance("body", "Body", [{ type: "id", value: "parent" }]),
|
|
],
|
|
[
|
|
"parent",
|
|
createInstance("parent", "Box", [{ type: "id", value: "child" }]),
|
|
],
|
|
["child", createInstance("child", "Box", [])],
|
|
]);
|
|
$pages.set(pages);
|
|
$selectedPageId.set(pages.homePageId);
|
|
$builderMode.set("design");
|
|
$authPermit.set("build");
|
|
$instances.set(instances);
|
|
$props.set(new Map());
|
|
$breakpoints.set(new Map());
|
|
$styleSourceSelections.set(new Map());
|
|
$styleSources.set(new Map());
|
|
$styles.set(new Map());
|
|
$dataSources.set(new Map());
|
|
$resources.set(new Map());
|
|
$assets.set(new Map());
|
|
$instances.get().get("child")?.children.push({
|
|
type: "id",
|
|
value: "parent",
|
|
});
|
|
|
|
migrateLoadedWebstudioData();
|
|
|
|
expect(findCycles($instances.get().values())).toEqual([]);
|
|
});
|
|
});
|