mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-10-23 03:27:27 +09:00
feat: MDX-backed Content Blocks (#6234)
## 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.
This commit is contained in:
1 parent
7e7c14868a
commit
f9681dc30f
422 files changed
+45410
-1436
No files matched your search
@@ -70,6 +70,7 @@ import { KeyboardShortcutsDialog } from "./features/keyboard-shortcuts-dialog";
|
||||
import { TokenConflictDialog } from "~/shared/token-conflict-dialog";
|
||||
import { RootStyleConflictDialog } from "~/shared/root-style-conflict-dialog";
|
||||
import { DesignTokenImportDialog } from "~/shared/design-token-import-dialog";
|
||||
import { TemplateNameConfirmationDialog } from "./features/template-name-confirmation-dialog";
|
||||
import type { User } from "~/shared/db/user.server";
|
||||
|
||||
import {
|
||||
@@ -529,6 +530,7 @@ export const Builder = (props: BuilderProps) => {
|
||||
<DesignTokenImportDialog />
|
||||
<TokenConflictDialog />
|
||||
<RootStyleConflictDialog />
|
||||
<TemplateNameConfirmationDialog />
|
||||
<RemoteDialog />
|
||||
<Toaster />
|
||||
</div>
|
||||
|
||||
@@ -94,7 +94,7 @@ describe("canWrapInstance for components", () => {
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test("should allow wrapping text in a legacy CodeText", () => {
|
||||
test("should prevent wrapping an instance in CodeText", () => {
|
||||
$instances.set(
|
||||
renderData(
|
||||
<$.Body ws:id="body">
|
||||
@@ -114,7 +114,7 @@ describe("canWrapInstance for components", () => {
|
||||
$props.get(),
|
||||
$registeredComponentMetas.get()
|
||||
);
|
||||
expect(result).toBe(true);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
import { beforeEach, describe, expect, test } from "vitest";
|
||||
import { blockComponent } from "@webstudio-is/sdk";
|
||||
import { __testing__ } from "./navigator-tree";
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
import {
|
||||
blockBodyComponent,
|
||||
blockComponent,
|
||||
elementComponent,
|
||||
} from "@webstudio-is/sdk";
|
||||
import { $flatTree, __testing__ } from "./navigator-tree";
|
||||
import {
|
||||
$allSelectedInstanceSelectors,
|
||||
$builderMode,
|
||||
$selectedPageId,
|
||||
selectInstances,
|
||||
} from "~/shared/nano-states";
|
||||
import { $instances } from "~/shared/sync/data-stores";
|
||||
import { $instances, $pages } from "~/shared/sync/data-stores";
|
||||
import { $externalContentRoots } from "~/shared/external-content-mutations";
|
||||
|
||||
const {
|
||||
commitNavigatorDrop,
|
||||
@@ -87,6 +94,181 @@ const createTreeItem = ({
|
||||
};
|
||||
};
|
||||
|
||||
test("shows connected Content Block instances in Content mode", () => {
|
||||
$pages.set({
|
||||
homePageId: "home",
|
||||
rootFolderId: "root",
|
||||
pages: new Map([
|
||||
[
|
||||
"home",
|
||||
{
|
||||
id: "home",
|
||||
name: "Home",
|
||||
path: "",
|
||||
title: "Home",
|
||||
meta: {},
|
||||
rootInstanceId: "body",
|
||||
},
|
||||
],
|
||||
]),
|
||||
folders: new Map([
|
||||
["root", { id: "root", name: "Root", slug: "", children: ["home"] }],
|
||||
]),
|
||||
});
|
||||
$instances.set(
|
||||
new Map([
|
||||
[
|
||||
"body",
|
||||
{
|
||||
type: "instance",
|
||||
id: "body",
|
||||
component: "Body",
|
||||
children: [{ type: "id", value: "block" }],
|
||||
},
|
||||
],
|
||||
[
|
||||
"block",
|
||||
{
|
||||
type: "instance",
|
||||
id: "block",
|
||||
component: blockComponent,
|
||||
children: [{ type: "id", value: "heading" }],
|
||||
},
|
||||
],
|
||||
[
|
||||
"heading",
|
||||
{
|
||||
type: "instance",
|
||||
id: "heading",
|
||||
component: elementComponent,
|
||||
tag: "h1",
|
||||
children: [{ type: "text", value: "MDX heading" }],
|
||||
},
|
||||
],
|
||||
])
|
||||
);
|
||||
$selectedPageId.set("home");
|
||||
$builderMode.set("content");
|
||||
expect($flatTree.get().map(({ instance }) => instance.id)).toEqual([
|
||||
"block",
|
||||
"heading",
|
||||
]);
|
||||
});
|
||||
|
||||
test("shows the scoped Content Block occurrence in Content mode", () => {
|
||||
$pages.set({
|
||||
homePageId: "home",
|
||||
rootFolderId: "root",
|
||||
pages: new Map([
|
||||
[
|
||||
"home",
|
||||
{
|
||||
id: "home",
|
||||
name: "Home",
|
||||
path: "",
|
||||
title: "Home",
|
||||
meta: {},
|
||||
rootInstanceId: "body",
|
||||
},
|
||||
],
|
||||
]),
|
||||
folders: new Map([
|
||||
["root", { id: "root", name: "Root", slug: "", children: ["home"] }],
|
||||
]),
|
||||
});
|
||||
$instances.set(
|
||||
new Map([
|
||||
[
|
||||
"body",
|
||||
{
|
||||
type: "instance",
|
||||
id: "body",
|
||||
component: "Body",
|
||||
children: [{ type: "id", value: "block" }],
|
||||
},
|
||||
],
|
||||
[
|
||||
"block",
|
||||
{
|
||||
type: "instance",
|
||||
id: "block",
|
||||
component: blockComponent,
|
||||
children: [{ type: "id", value: "content" }],
|
||||
},
|
||||
],
|
||||
[
|
||||
"content",
|
||||
{
|
||||
type: "instance",
|
||||
id: "content",
|
||||
component: blockBodyComponent,
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
[
|
||||
"scoped-block",
|
||||
{
|
||||
type: "instance",
|
||||
id: "scoped-block",
|
||||
component: blockComponent,
|
||||
children: [{ type: "id", value: "content" }],
|
||||
},
|
||||
],
|
||||
[
|
||||
"scoped-content",
|
||||
{
|
||||
type: "instance",
|
||||
id: "scoped-content",
|
||||
component: blockBodyComponent,
|
||||
children: [{ type: "id", value: "scoped-heading" }],
|
||||
},
|
||||
],
|
||||
[
|
||||
"scoped-heading",
|
||||
{
|
||||
type: "instance",
|
||||
id: "scoped-heading",
|
||||
component: elementComponent,
|
||||
tag: "h1",
|
||||
children: [{ type: "text", value: "Scoped MDX heading" }],
|
||||
},
|
||||
],
|
||||
])
|
||||
);
|
||||
$selectedPageId.set("home");
|
||||
$builderMode.set("content");
|
||||
$externalContentRoots.set(
|
||||
new Map([
|
||||
[
|
||||
"root",
|
||||
{
|
||||
sourceBlockInstanceId: "block",
|
||||
sourceRenderScope: '["block","body"]',
|
||||
blockInstanceId: "scoped-block",
|
||||
sourceContentInstanceId: "content",
|
||||
contentInstanceId: "scoped-content",
|
||||
renderScope: '["scoped-block","body"]',
|
||||
instanceIds: new Set(["scoped-heading"]),
|
||||
mutationRevision: 0,
|
||||
},
|
||||
],
|
||||
])
|
||||
);
|
||||
|
||||
expect($flatTree.get().map(({ instance }) => instance.id)).toEqual([
|
||||
"scoped-block",
|
||||
"scoped-content",
|
||||
]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
$builderMode.set("design");
|
||||
$selectedPageId.set(undefined);
|
||||
$pages.set(undefined);
|
||||
$instances.set(new Map());
|
||||
$externalContentRoots.set(new Map());
|
||||
});
|
||||
|
||||
describe("getNavigatorSelectionUpdate", () => {
|
||||
beforeEach(() => {
|
||||
selectInstances([]);
|
||||
|
||||
@@ -2,7 +2,7 @@ import {
|
||||
reparentInstance,
|
||||
toggleInstanceShow,
|
||||
} from "~/shared/instance-utils/mutation";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { atom, computed } from "nanostores";
|
||||
import { mergeRefs } from "@react-aria/utils";
|
||||
import { useStore } from "@nanostores/react";
|
||||
@@ -27,6 +27,7 @@ import { showAttribute, getCollectionEntries } from "@webstudio-is/react-sdk";
|
||||
import {
|
||||
ROOT_INSTANCE_ID,
|
||||
collectionComponent,
|
||||
blockBodyComponent,
|
||||
blockComponent,
|
||||
rootComponent,
|
||||
blockTemplateComponent,
|
||||
@@ -74,8 +75,17 @@ import {
|
||||
} from "@webstudio-is/project-build/runtime";
|
||||
import { emitCommand } from "~/builder/shared/commands";
|
||||
import { useContentEditable } from "~/shared/dom-hooks";
|
||||
import { executeRuntimeMutation } from "~/shared/instance-utils/data";
|
||||
import {
|
||||
executeRuntimeMutation,
|
||||
getDuplicateTemplateNameMessage,
|
||||
} from "~/shared/instance-utils/data";
|
||||
import { isRichTextContent } from "@webstudio-is/project-build/runtime";
|
||||
import {
|
||||
$externalContentRoots,
|
||||
externalContentInstanceNameMessage,
|
||||
isExternalContentInstance,
|
||||
resolveExternalContentOccurrence,
|
||||
} from "~/shared/external-content-mutations";
|
||||
import {
|
||||
getInstanceLabel,
|
||||
InstanceIcon,
|
||||
@@ -224,6 +234,7 @@ export const $flatTree = computed(
|
||||
$propValuesByInstanceSelector,
|
||||
$dropTarget,
|
||||
$isContentMode,
|
||||
$externalContentRoots,
|
||||
],
|
||||
(
|
||||
page,
|
||||
@@ -231,7 +242,8 @@ export const $flatTree = computed(
|
||||
expandedItems,
|
||||
propValuesByInstanceSelector,
|
||||
dropTarget,
|
||||
isContentMode
|
||||
isContentMode,
|
||||
externalContentRoots
|
||||
) => {
|
||||
const flatTree: TreeItem[] = [];
|
||||
if (page === undefined) {
|
||||
@@ -246,12 +258,28 @@ export const $flatTree = computed(
|
||||
isLastChild = false,
|
||||
indexWithinChildren = 0
|
||||
) => {
|
||||
const instance = instances.get(instanceId);
|
||||
let instance = instances.get(instanceId);
|
||||
if (instance === undefined) {
|
||||
// log instead of failing navigator tree
|
||||
console.error(`Unknown instance ${instanceId}`);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
isContentMode &&
|
||||
(instance.component === blockComponent ||
|
||||
instance.component === blockBodyComponent)
|
||||
) {
|
||||
const occurrence = resolveExternalContentOccurrence({
|
||||
sourceInstance: instance,
|
||||
sourceSelector: selector,
|
||||
instances,
|
||||
roots: externalContentRoots,
|
||||
});
|
||||
if (occurrence !== undefined) {
|
||||
instance = occurrence.instance;
|
||||
selector = occurrence.selector;
|
||||
}
|
||||
}
|
||||
const propValues = propValuesByInstanceSelector.get(
|
||||
getInstanceKey(selector)
|
||||
);
|
||||
@@ -502,44 +530,73 @@ const EditableTreeNodeLabel = styled("div", {
|
||||
userSelect: "text",
|
||||
},
|
||||
},
|
||||
hasError: {
|
||||
true: {
|
||||
outline: `1px solid ${theme.colors.borderDestructiveMain}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const TreeNodeContent = ({
|
||||
instance,
|
||||
isEditing,
|
||||
isNameEditable,
|
||||
onIsEditingChange,
|
||||
}: {
|
||||
instance: Instance;
|
||||
isEditing: boolean;
|
||||
isNameEditable: boolean;
|
||||
onIsEditingChange: (isEditing: boolean) => void;
|
||||
}) => {
|
||||
const editableRef = useRef<HTMLDivElement | null>(null);
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
const label = getInstanceLabel(instance);
|
||||
const { ref, handlers } = useContentEditable({
|
||||
value: label,
|
||||
isEditable: true,
|
||||
isEditing,
|
||||
isEditable: isNameEditable,
|
||||
isEditing: isNameEditable && isEditing,
|
||||
onChangeValue: (value: string) => {
|
||||
if (isExternalContentInstance($externalContentRoots.get(), instance.id)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
executeRuntimeMutation({
|
||||
id: "instances.setLabel",
|
||||
input: { instanceId: instance.id, label: value },
|
||||
});
|
||||
setError(undefined);
|
||||
editableRef.current?.closest("button")?.focus();
|
||||
} catch (error) {
|
||||
const message = getDuplicateTemplateNameMessage(error);
|
||||
if (message === undefined) {
|
||||
throw error;
|
||||
}
|
||||
setError(message);
|
||||
onIsEditingChange(true);
|
||||
}
|
||||
},
|
||||
onChangeEditing: onIsEditingChange,
|
||||
});
|
||||
|
||||
return (
|
||||
<TreeNodeLabel prefix={<InstanceIcon instance={instance} />}>
|
||||
<Tooltip
|
||||
content={isNameEditable ? error : externalContentInstanceNameMessage}
|
||||
delayDuration={0}
|
||||
>
|
||||
<EditableTreeNodeLabel
|
||||
ref={mergeRefs(editableRef, ref)}
|
||||
{...handlers}
|
||||
isEditing={isEditing}
|
||||
hasError={error !== undefined}
|
||||
aria-invalid={error === undefined ? undefined : true}
|
||||
onInput={() => setError(undefined)}
|
||||
>
|
||||
{label}
|
||||
</EditableTreeNodeLabel>
|
||||
</Tooltip>
|
||||
</TreeNodeLabel>
|
||||
);
|
||||
};
|
||||
@@ -704,6 +761,7 @@ const commitNavigatorDrop = ({
|
||||
export const NavigatorTree = () => {
|
||||
const isContentMode = useStore($isContentMode);
|
||||
const flatTree = useStore($flatTree);
|
||||
const externalContentRoots = useStore($externalContentRoots);
|
||||
const selectedInstanceSelectors = useStore($allSelectedInstanceSelectors);
|
||||
const selectedInstanceSelector = useStore($selectedInstanceSelector);
|
||||
const selectedKeys = useMemo(
|
||||
@@ -1111,6 +1169,12 @@ export const NavigatorTree = () => {
|
||||
>
|
||||
<TreeNodeContent
|
||||
instance={item.instance}
|
||||
isNameEditable={
|
||||
isExternalContentInstance(
|
||||
externalContentRoots,
|
||||
item.instance.id
|
||||
) === false
|
||||
}
|
||||
isEditing={areInstanceSelectorsEqual(
|
||||
item.selector,
|
||||
editingItemSelector
|
||||
|
||||
@@ -55,7 +55,7 @@ import {
|
||||
$stagingUsername,
|
||||
$stagingPassword,
|
||||
} from "~/shared/nano-states";
|
||||
import { $publisherHost } from "~/shared/sync/data-stores";
|
||||
import { $assets, $publisherHost } from "~/shared/sync/data-stores";
|
||||
import {
|
||||
$publishDialog,
|
||||
setActiveSidebarPanel,
|
||||
@@ -104,6 +104,7 @@ import {
|
||||
} from "@webstudio-is/project-build/runtime";
|
||||
import { showContentDatabasePublishWarning } from "./content-database-publish-warning";
|
||||
import { showPublishWarning } from "./publish-warning";
|
||||
import { flushExternalContentProject } from "~/shared/external-content-roots";
|
||||
|
||||
const PrePublishAuditMessage = ({
|
||||
finding,
|
||||
@@ -173,6 +174,7 @@ const getPrePublishAuditMessages = () => {
|
||||
props: $props.get(),
|
||||
dataSources: $dataSources.get(),
|
||||
resources: $resources.get(),
|
||||
assets: $assets.get(),
|
||||
metas: $registeredComponentMetas.get(),
|
||||
});
|
||||
const getMessage = (severity: PrePublishAuditFinding["severity"]) => {
|
||||
@@ -629,20 +631,6 @@ const Publish = ({
|
||||
setPublishError(undefined);
|
||||
setPublishWarning(undefined);
|
||||
|
||||
const { error: auditError, warning: auditWarning } =
|
||||
getPrePublishAuditMessages();
|
||||
if (auditError !== undefined) {
|
||||
toast.error(auditError);
|
||||
setPublishError(auditError);
|
||||
return;
|
||||
}
|
||||
if (auditWarning !== undefined) {
|
||||
showPublishWarning({
|
||||
message: auditWarning,
|
||||
setWarning: setPublishWarning,
|
||||
});
|
||||
}
|
||||
|
||||
// Custom domain checkboxes are disabled on free plan so they are never
|
||||
// submitted — only the staging (wstd.io) domain can appear in formData.
|
||||
const domains = formData
|
||||
@@ -658,6 +646,20 @@ const Publish = ({
|
||||
setIsPublishing(true);
|
||||
|
||||
try {
|
||||
await flushExternalContentProject({ projectId: project.id });
|
||||
const { error: auditError, warning: auditWarning } =
|
||||
getPrePublishAuditMessages();
|
||||
if (auditError !== undefined) {
|
||||
toast.error(auditError);
|
||||
setPublishError(auditError);
|
||||
return;
|
||||
}
|
||||
if (auditWarning !== undefined) {
|
||||
showPublishWarning({
|
||||
message: auditWarning,
|
||||
setWarning: setPublishWarning,
|
||||
});
|
||||
}
|
||||
await showContentDatabasePublishWarning({
|
||||
projectId: project.id,
|
||||
setWarning: setPublishWarning,
|
||||
|
||||
@@ -14,7 +14,7 @@ export const BooleanControl = ({
|
||||
}: ControlProps<"boolean">) => {
|
||||
const label = humanizeAttribute(meta.label || propName);
|
||||
const binding = useBindableControl({
|
||||
boundExpression: prop?.type === "expression" ? prop.value : undefined,
|
||||
boundExpression: prop?.type === "expression" ? prop : undefined,
|
||||
fallbackExpression: JSON.stringify(computedValue),
|
||||
});
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ export const CheckControl = ({
|
||||
const id = useId();
|
||||
const label = humanizeAttribute(meta.label || propName);
|
||||
const binding = useBindableControl({
|
||||
boundExpression: prop?.type === "expression" ? prop.value : undefined,
|
||||
boundExpression: prop?.type === "expression" ? prop : undefined,
|
||||
fallbackExpression: JSON.stringify(computedValue),
|
||||
});
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ export const matchCodeTextEditorLanguage = (selectedLanguage: unknown) => {
|
||||
);
|
||||
};
|
||||
|
||||
const useCodeTextLanguageSupport = (selectedLanguage: unknown) => {
|
||||
export const useCodeTextLanguageSupport = (selectedLanguage: unknown) => {
|
||||
const [languageSupport, setLanguageSupport] = useState<LanguageSupport>();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -163,10 +163,14 @@ export const CodeControl = ({
|
||||
const editorValue = behavior
|
||||
? behavior.formatValue(computedValue)
|
||||
: String(computedValue ?? "");
|
||||
const binding = useBindableControl({
|
||||
boundExpression: prop?.type === "expression" ? prop : undefined,
|
||||
fallbackExpression: JSON.stringify(computedValue),
|
||||
});
|
||||
const localValue = useDraftValue(
|
||||
editorValue,
|
||||
(value) => {
|
||||
if (prop?.type === "expression") {
|
||||
if (binding.bindingState.overwritable === false) {
|
||||
return;
|
||||
}
|
||||
let storedValue = value;
|
||||
@@ -198,11 +202,6 @@ export const CodeControl = ({
|
||||
{ autoSave: behavior?.autoSave ?? true }
|
||||
);
|
||||
|
||||
const binding = useBindableControl({
|
||||
boundExpression: prop?.type === "expression" ? prop.value : undefined,
|
||||
fallbackExpression: JSON.stringify(computedValue),
|
||||
});
|
||||
|
||||
const errorInfo = (
|
||||
<ErrorInfo
|
||||
error={error}
|
||||
|
||||
+168
@@ -0,0 +1,168 @@
|
||||
import { expect, test, vi } from "vitest";
|
||||
import { createDefaultPages } from "@webstudio-is/project-build";
|
||||
import {
|
||||
blockComponent,
|
||||
blockTemplateComponent,
|
||||
elementComponent,
|
||||
} from "@webstudio-is/sdk";
|
||||
import { applyBuilderPatchTransactions } from "@webstudio-is/project-build/state";
|
||||
import { createContentBlockSourceController } from "./content-block-source-controller";
|
||||
|
||||
const createState = () => ({
|
||||
pages: createDefaultPages({ rootInstanceId: "block" }),
|
||||
instances: new Map([
|
||||
[
|
||||
"block",
|
||||
{
|
||||
type: "instance" as const,
|
||||
id: "block",
|
||||
component: blockComponent,
|
||||
children: [
|
||||
{ type: "id" as const, value: "templates" },
|
||||
{ type: "id" as const, value: "body" },
|
||||
],
|
||||
},
|
||||
],
|
||||
[
|
||||
"templates",
|
||||
{
|
||||
type: "instance" as const,
|
||||
id: "templates",
|
||||
component: blockTemplateComponent,
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
[
|
||||
"body",
|
||||
{
|
||||
type: "instance" as const,
|
||||
id: "body",
|
||||
component: elementComponent,
|
||||
tag: "p",
|
||||
children: [{ type: "text" as const, value: "Existing" }],
|
||||
},
|
||||
],
|
||||
]),
|
||||
props: new Map(),
|
||||
dataSources: new Map(),
|
||||
resources: new Map(),
|
||||
styleSources: new Map(),
|
||||
styleSourceSelections: new Map(),
|
||||
styles: new Map(),
|
||||
breakpoints: new Map(),
|
||||
assets: new Map(),
|
||||
});
|
||||
|
||||
test("validates before confirmation and revalidates before replacing existing content", async () => {
|
||||
let state = createState();
|
||||
let currentName = "article_v1.mdx";
|
||||
const openAsset = vi.fn(async () => ({
|
||||
asset: {
|
||||
id: "asset",
|
||||
projectId: "project",
|
||||
name: currentName,
|
||||
type: "file" as const,
|
||||
format: "mdx",
|
||||
size: 0,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
},
|
||||
source: "",
|
||||
status: "saved" as const,
|
||||
}));
|
||||
const reloadAsset = vi.fn(async (_assetId: string, expectedName: string) => {
|
||||
if (expectedName !== currentName) {
|
||||
throw new Error("Revision conflict");
|
||||
}
|
||||
return openAsset();
|
||||
});
|
||||
const controller = createContentBlockSourceController({
|
||||
blockInstanceId: "block",
|
||||
renderScope: '["block"]',
|
||||
projectId: "project",
|
||||
getState: () => state,
|
||||
openAsset,
|
||||
reloadAsset,
|
||||
resolveExpressionAssetId: () => undefined,
|
||||
flushAsset: vi.fn(),
|
||||
updateAssetSource: vi.fn(),
|
||||
commitProjectPayload: (payload) => {
|
||||
state = applyBuilderPatchTransactions(state, [
|
||||
{ id: "test", payload: [...payload] },
|
||||
]).state as typeof state;
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
controller.requestSource({
|
||||
source: { type: "asset", assetId: "asset" },
|
||||
})
|
||||
).resolves.toEqual({ status: "requires-confirmation", diagnostics: [] });
|
||||
expect(openAsset).toHaveBeenCalledOnce();
|
||||
expect(state.instances.get("body")).toBeDefined();
|
||||
|
||||
await expect(
|
||||
controller.requestSource({
|
||||
source: { type: "asset", assetId: "asset" },
|
||||
confirmed: true,
|
||||
})
|
||||
).resolves.toMatchObject({ status: "applied" });
|
||||
expect(reloadAsset).toHaveBeenCalledWith("asset", "article_v1.mdx");
|
||||
expect(state.instances.has("body")).toBe(false);
|
||||
expect(
|
||||
Array.from(state.props.values()).find(
|
||||
(prop) => prop.instanceId === "block" && prop.name === "src"
|
||||
)
|
||||
).toMatchObject({
|
||||
type: "asset",
|
||||
value: "asset",
|
||||
});
|
||||
});
|
||||
|
||||
test("disconnects without loading or copying the Asset source", async () => {
|
||||
let state = createState();
|
||||
state.props.set("src", {
|
||||
id: "src",
|
||||
instanceId: "block",
|
||||
name: "src",
|
||||
type: "asset",
|
||||
value: "asset",
|
||||
});
|
||||
const source = "# From MDX";
|
||||
const openAsset = vi.fn(async () => ({
|
||||
asset: {
|
||||
id: "asset",
|
||||
projectId: "project",
|
||||
name: "article.mdx",
|
||||
type: "file" as const,
|
||||
format: "mdx",
|
||||
size: new TextEncoder().encode(source).byteLength,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
},
|
||||
source,
|
||||
status: "saved" as const,
|
||||
}));
|
||||
const controller = createContentBlockSourceController({
|
||||
blockInstanceId: "block",
|
||||
renderScope: '["block"]',
|
||||
projectId: "project",
|
||||
getState: () => state,
|
||||
openAsset,
|
||||
reloadAsset: vi.fn(),
|
||||
resolveExpressionAssetId: () => undefined,
|
||||
flushAsset: vi.fn(),
|
||||
updateAssetSource: vi.fn(),
|
||||
commitProjectPayload: (payload) => {
|
||||
state = applyBuilderPatchTransactions(state, [
|
||||
{ id: "test", payload: [...payload] },
|
||||
]).state as typeof state;
|
||||
},
|
||||
});
|
||||
|
||||
await expect(controller.disconnect()).resolves.toEqual({ status: "applied" });
|
||||
expect(openAsset).not.toHaveBeenCalled();
|
||||
expect(state.props.has("src")).toBe(false);
|
||||
expect(state.instances.get("block")?.children).toEqual([
|
||||
{ type: "id", value: "templates" },
|
||||
{ type: "id", value: "body" },
|
||||
]);
|
||||
});
|
||||
+273
@@ -0,0 +1,273 @@
|
||||
import type { AssetContentSessionState } from "@webstudio-is/content-engine/asset-content-session";
|
||||
import type { BuilderPatchChange } from "@webstudio-is/project-build/contracts";
|
||||
import type { BuilderState } from "@webstudio-is/project-build/state";
|
||||
import { createContentBlockApplication } from "@webstudio-is/project-build/runtime";
|
||||
import {
|
||||
getContentBlockSource,
|
||||
isEqualContentBlockSource,
|
||||
type ContentBlockDiagnostic,
|
||||
type ContentBlockSource,
|
||||
} from "@webstudio-is/sdk";
|
||||
import { componentMetas } from "@webstudio-is/sdk-components-registry/metas";
|
||||
import { $variableValuesByInstanceSelector } from "~/shared/nano-states";
|
||||
import { getWebstudioData } from "~/shared/instance-utils/data";
|
||||
import { createTransactionFromBuilderPatchPayload } from "~/shared/sync/builder-patch";
|
||||
import { readBuilderStateStores } from "~/shared/sync/data-stores";
|
||||
import {
|
||||
flushExternalContentAsset,
|
||||
openExternalContentAsset,
|
||||
reloadExternalContentAsset,
|
||||
updateExternalContentAssetSource,
|
||||
} from "~/shared/external-content-roots";
|
||||
import {
|
||||
parseContentBlockRenderScope,
|
||||
resolveContentBlockOccurrenceAssetId,
|
||||
} from "~/shared/content-block-source-utils";
|
||||
|
||||
type MutationResult =
|
||||
| Readonly<{ status: "applied"; state?: AssetContentSessionState }>
|
||||
| Readonly<{ status: "blocked"; message: string }>;
|
||||
|
||||
export type ContentBlockSourceControllerResult =
|
||||
| MutationResult
|
||||
| Readonly<{
|
||||
status: "requires-confirmation";
|
||||
diagnostics: readonly ContentBlockDiagnostic[];
|
||||
}>;
|
||||
|
||||
export const createContentBlockSourceController = ({
|
||||
blockInstanceId,
|
||||
renderScope,
|
||||
projectId,
|
||||
getState,
|
||||
openAsset,
|
||||
reloadAsset,
|
||||
resolveExpressionAssetId,
|
||||
flushAsset,
|
||||
updateAssetSource,
|
||||
commitProjectPayload,
|
||||
}: {
|
||||
blockInstanceId: string;
|
||||
renderScope: string;
|
||||
projectId: string;
|
||||
getState: () => BuilderState;
|
||||
openAsset: (assetId: string) => Promise<AssetContentSessionState>;
|
||||
reloadAsset: (
|
||||
assetId: string,
|
||||
expectedName: string
|
||||
) => Promise<AssetContentSessionState>;
|
||||
resolveExpressionAssetId: (
|
||||
expression: string,
|
||||
renderScope: string
|
||||
) => string | undefined;
|
||||
flushAsset: (assetId: string) => Promise<unknown>;
|
||||
updateAssetSource: (
|
||||
assetId: string,
|
||||
update: (source: string) => string | Promise<string>
|
||||
) => Promise<unknown>;
|
||||
commitProjectPayload: (payload: readonly BuilderPatchChange[]) => void;
|
||||
}) => {
|
||||
const stagedSources = new Map<string, string>();
|
||||
const application = createContentBlockApplication({
|
||||
projectId,
|
||||
metas: componentMetas,
|
||||
resolveSourceAssetId: ({ source }) =>
|
||||
source.type === "asset"
|
||||
? source.assetId
|
||||
: resolveExpressionAssetId(source.value, renderScope),
|
||||
session: {
|
||||
open: openAsset,
|
||||
reload: (assetId, options) => {
|
||||
if (options?.expectedName === undefined) {
|
||||
throw new Error("The expected Asset name is required for reload");
|
||||
}
|
||||
return reloadAsset(assetId, options.expectedName);
|
||||
},
|
||||
save: (assetId, source) => {
|
||||
stagedSources.set(assetId, source);
|
||||
},
|
||||
flush: async (assetId) => {
|
||||
const source = stagedSources.get(assetId);
|
||||
if (source !== undefined) {
|
||||
await updateAssetSource(assetId, () => source);
|
||||
stagedSources.delete(assetId);
|
||||
}
|
||||
await flushAsset(assetId);
|
||||
return openAsset(assetId);
|
||||
},
|
||||
},
|
||||
});
|
||||
let pending:
|
||||
| Readonly<{
|
||||
source: ContentBlockSource;
|
||||
assetId: string;
|
||||
expectedName: string;
|
||||
instances: BuilderState["instances"];
|
||||
props: BuilderState["props"];
|
||||
}>
|
||||
| undefined;
|
||||
|
||||
const requestSource = async ({
|
||||
source,
|
||||
confirmed = false,
|
||||
}: {
|
||||
source: ContentBlockSource;
|
||||
confirmed?: boolean;
|
||||
}): Promise<ContentBlockSourceControllerResult> => {
|
||||
const state = getState();
|
||||
const configured = getContentBlockSource({
|
||||
blockInstanceId,
|
||||
props: state.props?.values() ?? [],
|
||||
});
|
||||
if (configured !== undefined) {
|
||||
pending = undefined;
|
||||
if (isEqualContentBlockSource(configured, source)) {
|
||||
const inspection = await application.inspect({
|
||||
state,
|
||||
blockInstanceId,
|
||||
renderScope,
|
||||
source,
|
||||
});
|
||||
return { status: "applied", state: inspection.sessionState };
|
||||
}
|
||||
const prepared = await application.switchSource({
|
||||
state,
|
||||
blockInstanceId,
|
||||
renderScope,
|
||||
source,
|
||||
});
|
||||
const current = getState();
|
||||
if (
|
||||
current.instances !== state.instances ||
|
||||
current.props !== state.props
|
||||
) {
|
||||
return {
|
||||
status: "blocked",
|
||||
message: "The Content Block changed while the source was loading.",
|
||||
};
|
||||
}
|
||||
commitProjectPayload(prepared.projectPayload);
|
||||
return { status: "applied", state: prepared.inspection.sessionState };
|
||||
}
|
||||
if (confirmed) {
|
||||
if (
|
||||
pending === undefined ||
|
||||
isEqualContentBlockSource(pending.source, source) === false ||
|
||||
pending.instances !== state.instances ||
|
||||
pending.props !== state.props
|
||||
) {
|
||||
pending = undefined;
|
||||
return {
|
||||
status: "blocked",
|
||||
message: "The Content Block changed before connection was confirmed.",
|
||||
};
|
||||
}
|
||||
await reloadAsset(pending.assetId, pending.expectedName);
|
||||
}
|
||||
const prepared = await application.connect({
|
||||
state,
|
||||
blockInstanceId,
|
||||
renderScope,
|
||||
source,
|
||||
});
|
||||
const current = getState();
|
||||
if (
|
||||
current.instances !== state.instances ||
|
||||
current.props !== state.props
|
||||
) {
|
||||
pending = undefined;
|
||||
return {
|
||||
status: "blocked",
|
||||
message: "The Content Block changed while the source was loading.",
|
||||
};
|
||||
}
|
||||
if (prepared.requiresConfirmation && confirmed === false) {
|
||||
pending = {
|
||||
source,
|
||||
assetId: prepared.inspection.identity.assetId,
|
||||
expectedName: prepared.inspection.sessionState.asset.name,
|
||||
instances: state.instances,
|
||||
props: state.props,
|
||||
};
|
||||
return {
|
||||
status: "requires-confirmation",
|
||||
diagnostics: prepared.inspection.diagnostics,
|
||||
};
|
||||
}
|
||||
pending = undefined;
|
||||
commitProjectPayload(prepared.projectPayload);
|
||||
return { status: "applied", state: prepared.inspection.sessionState };
|
||||
};
|
||||
|
||||
const disconnect = async (): Promise<MutationResult> => {
|
||||
const state = getState();
|
||||
const prepared = await application.disconnect({
|
||||
state,
|
||||
blockInstanceId,
|
||||
});
|
||||
const current = getState();
|
||||
if (
|
||||
current.instances !== state.instances ||
|
||||
current.props !== state.props
|
||||
) {
|
||||
return {
|
||||
status: "blocked",
|
||||
message: "The Content Block changed while it was disconnecting.",
|
||||
};
|
||||
}
|
||||
commitProjectPayload(prepared.projectPayload);
|
||||
return { status: "applied" };
|
||||
};
|
||||
|
||||
return {
|
||||
requestSource,
|
||||
disconnect,
|
||||
open: async (source: ContentBlockSource) =>
|
||||
(
|
||||
await application.inspect({
|
||||
state: getState(),
|
||||
blockInstanceId,
|
||||
renderScope,
|
||||
source,
|
||||
})
|
||||
).sessionState,
|
||||
};
|
||||
};
|
||||
|
||||
export const createBuilderContentBlockSourceController = ({
|
||||
blockInstanceId,
|
||||
renderScope,
|
||||
projectId,
|
||||
}: {
|
||||
blockInstanceId: string;
|
||||
renderScope: string;
|
||||
projectId: string;
|
||||
}) =>
|
||||
createContentBlockSourceController({
|
||||
blockInstanceId,
|
||||
renderScope,
|
||||
projectId,
|
||||
getState: readBuilderStateStores,
|
||||
openAsset: (assetId) => openExternalContentAsset({ projectId, assetId }),
|
||||
reloadAsset: (assetId, expectedName) =>
|
||||
reloadExternalContentAsset({ projectId, assetId, expectedName }),
|
||||
resolveExpressionAssetId: (expression, scope) => {
|
||||
const instanceSelector = parseContentBlockRenderScope(scope);
|
||||
return instanceSelector === undefined
|
||||
? undefined
|
||||
: resolveContentBlockOccurrenceAssetId({
|
||||
source: { type: "expression", value: expression },
|
||||
instanceSelector,
|
||||
variableValuesByRenderScope:
|
||||
$variableValuesByInstanceSelector.get(),
|
||||
});
|
||||
},
|
||||
flushAsset: (assetId) => flushExternalContentAsset({ projectId, assetId }),
|
||||
updateAssetSource: (assetId, update) =>
|
||||
updateExternalContentAssetSource({ projectId, assetId, update }),
|
||||
commitProjectPayload: (payload) =>
|
||||
createTransactionFromBuilderPatchPayload({
|
||||
data: getWebstudioData(),
|
||||
payload: [...payload],
|
||||
}),
|
||||
});
|
||||
+196
@@ -0,0 +1,196 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useStore } from "@nanostores/react";
|
||||
import {
|
||||
contentBlockSourceProp,
|
||||
getContentBlockSource,
|
||||
} from "@webstudio-is/sdk";
|
||||
import { TextFileEditor } from "~/builder/features/text-file-editor/text-file-editor";
|
||||
import {
|
||||
$authPermit,
|
||||
$isContentMode,
|
||||
$variableValuesByInstanceSelector,
|
||||
} from "~/shared/nano-states";
|
||||
import type { AssetContentSessionState } from "@webstudio-is/content-engine/asset-content-session";
|
||||
import { $assets, $project, $props } from "~/shared/sync/data-stores";
|
||||
import {
|
||||
retryExternalContentAsset,
|
||||
subscribeExternalContentAsset,
|
||||
} from "~/shared/external-content-roots";
|
||||
import {
|
||||
$externalContentRoots,
|
||||
findExternalContentRoot,
|
||||
} from "~/shared/external-content-mutations";
|
||||
import { ContentBlockSourceControl } from "./content-block-source";
|
||||
import { createBuilderContentBlockSourceController } from "./content-block-source-controller";
|
||||
import {
|
||||
parseContentBlockRenderScope,
|
||||
resolveContentBlockOccurrenceAssetId,
|
||||
} from "~/shared/content-block-source-utils";
|
||||
import { PropertyLabel } from "../property-label";
|
||||
import { VerticalLayout } from "../shared";
|
||||
|
||||
export const ContentBlockSourceSection = ({
|
||||
blockInstanceId,
|
||||
renderScope,
|
||||
}: {
|
||||
blockInstanceId: string;
|
||||
renderScope: string;
|
||||
}) => {
|
||||
const project = useStore($project);
|
||||
const props = useStore($props);
|
||||
const assets = useStore($assets);
|
||||
const authPermit = useStore($authPermit);
|
||||
const isContentMode = useStore($isContentMode);
|
||||
const variableValues = useStore($variableValuesByInstanceSelector);
|
||||
const externalContentRoots = useStore($externalContentRoots);
|
||||
const source = useMemo(
|
||||
() => getContentBlockSource({ blockInstanceId, props: props.values() }),
|
||||
[blockInstanceId, props]
|
||||
);
|
||||
const instanceSelector = useMemo(
|
||||
() => parseContentBlockRenderScope(renderScope),
|
||||
[renderScope]
|
||||
);
|
||||
const resolvedAssetId = useMemo(() => {
|
||||
if (source === undefined || instanceSelector === undefined) {
|
||||
return;
|
||||
}
|
||||
return resolveContentBlockOccurrenceAssetId({
|
||||
source,
|
||||
instanceSelector,
|
||||
variableValuesByRenderScope: variableValues,
|
||||
});
|
||||
}, [instanceSelector, source, variableValues]);
|
||||
const resolvedAsset =
|
||||
resolvedAssetId === undefined ? undefined : assets.get(resolvedAssetId);
|
||||
const [loading, setLoading] = useState(source !== undefined);
|
||||
const [error, setError] = useState<string>();
|
||||
const [openedAssetId, setOpenedAssetId] = useState<string>();
|
||||
const [disconnecting, setDisconnecting] = useState(false);
|
||||
const [contentState, setContentState] = useState<AssetContentSessionState>();
|
||||
const controller = useMemo(
|
||||
() =>
|
||||
project === undefined
|
||||
? undefined
|
||||
: createBuilderContentBlockSourceController({
|
||||
blockInstanceId,
|
||||
renderScope,
|
||||
projectId: project.id,
|
||||
}),
|
||||
[blockInstanceId, project, renderScope]
|
||||
);
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
if (source === undefined || controller === undefined) {
|
||||
setLoading(false);
|
||||
setError(undefined);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
void controller
|
||||
.open(source)
|
||||
.then(() => {
|
||||
if (active === false) {
|
||||
return;
|
||||
}
|
||||
setError(undefined);
|
||||
})
|
||||
.catch(
|
||||
(error) =>
|
||||
active &&
|
||||
setError(
|
||||
error instanceof Error ? error.message : "Unable to load MDX"
|
||||
)
|
||||
)
|
||||
.finally(() => active && setLoading(false));
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [controller, resolvedAsset?.name, resolvedAsset?.size, source]);
|
||||
|
||||
useEffect(() => {
|
||||
if (project === undefined || resolvedAssetId === undefined) {
|
||||
setContentState(undefined);
|
||||
return;
|
||||
}
|
||||
return subscribeExternalContentAsset({
|
||||
projectId: project.id,
|
||||
assetId: resolvedAssetId,
|
||||
listener: setContentState,
|
||||
});
|
||||
}, [project, resolvedAssetId]);
|
||||
|
||||
if (project === undefined || controller === undefined) {
|
||||
return;
|
||||
}
|
||||
const externalContentRoot = findExternalContentRoot(
|
||||
externalContentRoots,
|
||||
blockInstanceId,
|
||||
renderScope
|
||||
);
|
||||
|
||||
return (
|
||||
<VerticalLayout
|
||||
label={
|
||||
<PropertyLabel
|
||||
name={contentBlockSourceProp}
|
||||
readOnly={isContentMode}
|
||||
deletable={isContentMode === false && authPermit !== "view"}
|
||||
onDelete={() => {
|
||||
if (disconnecting) {
|
||||
return;
|
||||
}
|
||||
setDisconnecting(true);
|
||||
setError(undefined);
|
||||
void controller
|
||||
.disconnect()
|
||||
.then((result) => {
|
||||
if (result.status === "blocked") {
|
||||
setError(result.message);
|
||||
}
|
||||
})
|
||||
.catch((error) =>
|
||||
setError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Unable to disconnect source"
|
||||
)
|
||||
)
|
||||
.finally(() => setDisconnecting(false));
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<ContentBlockSourceControl
|
||||
source={source}
|
||||
resolvedAsset={resolvedAsset}
|
||||
readOnly={isContentMode}
|
||||
disabled={authPermit === "view" || disconnecting}
|
||||
loading={loading}
|
||||
error={error}
|
||||
diagnostics={externalContentRoot?.diagnostics}
|
||||
persistenceStatus={contentState?.status}
|
||||
persistenceError={contentState?.error?.message}
|
||||
onRetry={
|
||||
resolvedAssetId === undefined
|
||||
? undefined
|
||||
: async () => {
|
||||
await retryExternalContentAsset({
|
||||
projectId: project.id,
|
||||
assetId: resolvedAssetId,
|
||||
});
|
||||
}
|
||||
}
|
||||
onRequestSource={controller.requestSource}
|
||||
onOpen={setOpenedAssetId}
|
||||
/>
|
||||
{openedAssetId !== undefined && (
|
||||
<TextFileEditor
|
||||
key={openedAssetId}
|
||||
assetId={openedAssetId}
|
||||
onOpenChange={(open) => open === false && setOpenedAssetId(undefined)}
|
||||
/>
|
||||
)}
|
||||
</VerticalLayout>
|
||||
);
|
||||
};
|
||||
+349
@@ -0,0 +1,349 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { act } from "react-dom/test-utils";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import type { ComponentProps } from "react";
|
||||
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
||||
import { TooltipProvider } from "@webstudio-is/design-system";
|
||||
import type { Asset } from "@webstudio-is/sdk";
|
||||
import { $assets } from "~/shared/sync/data-stores";
|
||||
import { ContentBlockSourceControl } from "./content-block-source";
|
||||
|
||||
(
|
||||
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
const asset: Asset = {
|
||||
id: "post",
|
||||
projectId: "project",
|
||||
name: "post.mdx",
|
||||
format: "mdx",
|
||||
size: 10,
|
||||
type: "file",
|
||||
meta: {},
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
const targetAsset: Asset = { ...asset, id: "other", name: "other.mdx" };
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
const scrollIntoView = Object.getOwnPropertyDescriptor(
|
||||
HTMLElement.prototype,
|
||||
"scrollIntoView"
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal(
|
||||
"ResizeObserver",
|
||||
class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
);
|
||||
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
|
||||
configurable: true,
|
||||
value: vi.fn(),
|
||||
});
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
$assets.set(new Map([asset, targetAsset].map((item) => [item.id, item])));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
document.body.innerHTML = "";
|
||||
vi.unstubAllGlobals();
|
||||
$assets.set(new Map());
|
||||
if (scrollIntoView === undefined) {
|
||||
delete (HTMLElement.prototype as { scrollIntoView?: unknown })
|
||||
.scrollIntoView;
|
||||
} else {
|
||||
Object.defineProperty(
|
||||
HTMLElement.prototype,
|
||||
"scrollIntoView",
|
||||
scrollIntoView
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const findButton = (label: string) => {
|
||||
const button = Array.from(
|
||||
document.body.querySelectorAll<HTMLButtonElement>("button")
|
||||
).find((candidate) => candidate.textContent?.trim() === label);
|
||||
if (button === undefined) {
|
||||
throw new Error(`Expected button "${label}"`);
|
||||
}
|
||||
return button;
|
||||
};
|
||||
|
||||
const chooseAsset = async (triggerLabel: string, assetName: string) => {
|
||||
await act(async () => {
|
||||
findButton(triggerLabel).click();
|
||||
await new Promise<void>((resolve) =>
|
||||
requestAnimationFrame(() => resolve())
|
||||
);
|
||||
});
|
||||
const option = Array.from(
|
||||
document.body.querySelectorAll<HTMLElement>('[role="option"]')
|
||||
).find((element) => element.textContent?.includes(assetName));
|
||||
const target = option?.querySelector<HTMLElement>(
|
||||
"[data-asset-thumbnail] > div"
|
||||
);
|
||||
if (target === undefined || target === null) {
|
||||
throw new Error(`Expected compatible Asset "${assetName}"`);
|
||||
}
|
||||
await act(async () => {
|
||||
target.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
};
|
||||
|
||||
const renderControl = ({
|
||||
onRequestSource = async () => ({ status: "applied" as const }),
|
||||
onOpen = () => {},
|
||||
source = { type: "asset" as const, assetId: asset.id },
|
||||
resolvedAsset = asset,
|
||||
loading = false,
|
||||
readOnly = false,
|
||||
diagnostics,
|
||||
persistenceStatus,
|
||||
persistenceError,
|
||||
onRetry,
|
||||
}: {
|
||||
onRequestSource?: ComponentProps<
|
||||
typeof ContentBlockSourceControl
|
||||
>["onRequestSource"];
|
||||
onOpen?: ComponentProps<typeof ContentBlockSourceControl>["onOpen"];
|
||||
source?:
|
||||
| { type: "asset"; assetId: string }
|
||||
| { type: "expression"; value: string };
|
||||
resolvedAsset?: Asset;
|
||||
loading?: boolean;
|
||||
readOnly?: boolean;
|
||||
diagnostics?: ComponentProps<typeof ContentBlockSourceControl>["diagnostics"];
|
||||
persistenceStatus?: ComponentProps<
|
||||
typeof ContentBlockSourceControl
|
||||
>["persistenceStatus"];
|
||||
persistenceError?: string;
|
||||
onRetry?: () => Promise<void>;
|
||||
} = {}) => {
|
||||
act(() => {
|
||||
root.render(
|
||||
<TooltipProvider>
|
||||
<ContentBlockSourceControl
|
||||
source={source}
|
||||
resolvedAsset={resolvedAsset}
|
||||
loading={loading}
|
||||
readOnly={readOnly}
|
||||
diagnostics={diagnostics}
|
||||
persistenceStatus={persistenceStatus}
|
||||
persistenceError={persistenceError}
|
||||
onRetry={onRetry}
|
||||
onRequestSource={onRequestSource}
|
||||
onOpen={onOpen}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
test("shows a warning indicator for source diagnostics", () => {
|
||||
const diagnostic = {
|
||||
code: "ignored-template-prop" as const,
|
||||
severity: "warning" as const,
|
||||
blockInstanceId: "block",
|
||||
assetId: "post",
|
||||
contentRef: "post.mdx",
|
||||
renderScope: "collection:item-2",
|
||||
templateName: "Hero",
|
||||
propName: "tone",
|
||||
reason: "design-only" as const,
|
||||
sourceRange: {
|
||||
start: { line: 7, column: 4 },
|
||||
end: { line: 7, column: 12 },
|
||||
},
|
||||
};
|
||||
renderControl({
|
||||
diagnostics: [diagnostic, diagnostic],
|
||||
});
|
||||
|
||||
expect(
|
||||
container.querySelector('[aria-label^="MDX source warning:"]')
|
||||
).not.toBeNull();
|
||||
expect(container.textContent).not.toContain("Render scope:");
|
||||
});
|
||||
|
||||
test("keeps the resolved filename visible while refreshed content loads", () => {
|
||||
renderControl({ loading: true });
|
||||
|
||||
expect(findButton("post.mdx")).not.toBeNull();
|
||||
expect(findButton("Open").matches(":disabled")).toBe(true);
|
||||
});
|
||||
|
||||
test("shows equal filename and Open actions for a connected source", () => {
|
||||
renderControl();
|
||||
|
||||
const actions = document.querySelector(
|
||||
'[aria-label="Content source actions"]'
|
||||
);
|
||||
expect(
|
||||
Array.from(actions?.querySelectorAll("button") ?? []).map((button) =>
|
||||
button.textContent?.trim()
|
||||
)
|
||||
).toEqual(["post.mdx", "Open"]);
|
||||
});
|
||||
|
||||
test("opens the resolved Asset for a bound source", () => {
|
||||
const onOpen = vi.fn();
|
||||
renderControl({
|
||||
source: { type: "expression", value: "post.body" },
|
||||
resolvedAsset: targetAsset,
|
||||
onOpen,
|
||||
});
|
||||
|
||||
act(() => findButton("Open").click());
|
||||
|
||||
expect(onOpen).toHaveBeenCalledWith(targetAsset.id);
|
||||
});
|
||||
|
||||
test("keeps a connected source visible and openable when read-only", () => {
|
||||
const onOpen = vi.fn();
|
||||
const onRequestSource = vi.fn(async () => ({ status: "applied" as const }));
|
||||
renderControl({ readOnly: true, onOpen, onRequestSource });
|
||||
|
||||
expect(findButton("post.mdx")).not.toBeNull();
|
||||
expect(findButton("post.mdx").matches(":disabled")).toBe(true);
|
||||
expect(container.querySelector('[data-variant="bound"]')).toBeNull();
|
||||
|
||||
act(() => findButton("Open").click());
|
||||
|
||||
expect(onOpen).toHaveBeenCalledWith(asset.id);
|
||||
expect(onRequestSource).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("switches a connected source from the filename button", async () => {
|
||||
const onRequestSource = vi.fn(async () => ({ status: "applied" as const }));
|
||||
renderControl({ onRequestSource });
|
||||
|
||||
await chooseAsset("post.mdx", "other.mdx");
|
||||
|
||||
expect(onRequestSource).toHaveBeenCalledWith({
|
||||
source: { type: "asset", assetId: targetAsset.id },
|
||||
confirmed: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test("shows the resolved filename while retaining a dynamic binding", () => {
|
||||
renderControl({ source: { type: "expression", value: "post.body" } });
|
||||
|
||||
expect(findButton("post.mdx")).not.toBeNull();
|
||||
expect(container.querySelector('[data-variant="bound"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
test("shows only a full-width bindable connect button without a source", () => {
|
||||
act(() => {
|
||||
root.render(
|
||||
<TooltipProvider>
|
||||
<ContentBlockSourceControl
|
||||
onRequestSource={async () => ({ status: "applied" })}
|
||||
onOpen={() => {}}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
);
|
||||
});
|
||||
|
||||
expect(document.querySelector("input")).toBeNull();
|
||||
expect(findButton("Connect .mdx file")).not.toBeNull();
|
||||
expect(document.body.textContent).not.toContain("Create MDX file");
|
||||
});
|
||||
|
||||
test("asks for confirmation before connecting over existing content", async () => {
|
||||
const onRequestSource = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
status: "requires-confirmation",
|
||||
diagnostics: [
|
||||
{
|
||||
code: "invalid-mdx",
|
||||
severity: "error",
|
||||
blockInstanceId: "block",
|
||||
assetId: "other",
|
||||
renderScope: "scope",
|
||||
message: "Unexpected closing tag.",
|
||||
sourceRange: {
|
||||
start: { line: 3, column: 2 },
|
||||
end: { line: 3, column: 4 },
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
.mockResolvedValueOnce({ status: "applied" });
|
||||
act(() => {
|
||||
root.render(
|
||||
<TooltipProvider>
|
||||
<ContentBlockSourceControl
|
||||
onRequestSource={onRequestSource}
|
||||
onOpen={() => {}}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
);
|
||||
});
|
||||
|
||||
await chooseAsset("Connect .mdx file", "other.mdx");
|
||||
|
||||
expect(document.body.textContent).toContain("Connect content source");
|
||||
expect(document.body.textContent).toContain(
|
||||
"The MDX file will not be changed."
|
||||
);
|
||||
expect(document.body.textContent).toContain(
|
||||
"Unexpected closing tag. Line 3, column 2."
|
||||
);
|
||||
const dialogButtons = Array.from(
|
||||
document.body.querySelectorAll<HTMLButtonElement>("button")
|
||||
)
|
||||
.map((button) => button.textContent?.trim())
|
||||
.filter((label) => label === "Connect" || label === "Abort");
|
||||
// DialogActions reverses DOM order so Abort is left and Connect is right.
|
||||
expect(dialogButtons).toEqual(["Connect", "Abort"]);
|
||||
expect(onRequestSource).toHaveBeenNthCalledWith(1, {
|
||||
source: { type: "asset", assetId: targetAsset.id },
|
||||
confirmed: undefined,
|
||||
});
|
||||
|
||||
await act(async () => findButton("Connect").click());
|
||||
expect(onRequestSource).toHaveBeenNthCalledWith(2, {
|
||||
source: { type: "asset", assetId: targetAsset.id },
|
||||
confirmed: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps a failed Asset write visible and retryable", async () => {
|
||||
const onRetry = vi.fn(async () => {});
|
||||
renderControl({
|
||||
persistenceStatus: "failed",
|
||||
persistenceError: "Temporary storage failure",
|
||||
onRetry,
|
||||
});
|
||||
|
||||
expect(document.querySelector('[role="alert"]')?.textContent).toBe(
|
||||
"Temporary storage failure"
|
||||
);
|
||||
await act(async () => findButton("Retry").click());
|
||||
expect(onRetry).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test("allows retrying a failed Asset write when the source is read-only", async () => {
|
||||
const onRetry = vi.fn(async () => {});
|
||||
renderControl({
|
||||
readOnly: true,
|
||||
persistenceStatus: "failed",
|
||||
persistenceError: "Temporary storage failure",
|
||||
onRetry,
|
||||
});
|
||||
|
||||
await act(async () => findButton("Retry").click());
|
||||
|
||||
expect(onRetry).toHaveBeenCalledOnce();
|
||||
});
|
||||
@@ -0,0 +1,375 @@
|
||||
import { useRef, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
Box,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogTitle,
|
||||
Flex,
|
||||
Grid,
|
||||
Text,
|
||||
Tooltip,
|
||||
rawTheme,
|
||||
theme,
|
||||
} from "@webstudio-is/design-system";
|
||||
import { AlertIcon } from "@webstudio-is/icons";
|
||||
import {
|
||||
formatAssetName,
|
||||
type Asset,
|
||||
type ContentBlockDiagnostic,
|
||||
type ContentBlockSource,
|
||||
} from "@webstudio-is/sdk";
|
||||
import { BindableExpressionControl } from "~/builder/shared/bindable-expression";
|
||||
import { useBindableControl } from "./use-bindable-control";
|
||||
import { SelectAsset } from "./select-asset";
|
||||
import {
|
||||
deduplicateContentBlockDiagnostics,
|
||||
formatContentBlockDiagnostic,
|
||||
} from "~/shared/content-block-diagnostics";
|
||||
|
||||
type ContentBlockSourceMutationResult =
|
||||
| Readonly<{ status: "applied" }>
|
||||
| Readonly<{ status: "partial"; message: string }>
|
||||
| Readonly<{ status: "blocked"; message: string }>;
|
||||
|
||||
type ContentBlockSourceActionResult =
|
||||
| ContentBlockSourceMutationResult
|
||||
| Readonly<{
|
||||
status: "requires-confirmation";
|
||||
diagnostics?: readonly ContentBlockDiagnostic[];
|
||||
}>;
|
||||
|
||||
type PendingSource = Readonly<{
|
||||
source: ContentBlockSource;
|
||||
diagnostics: readonly ContentBlockDiagnostic[];
|
||||
}>;
|
||||
|
||||
const getErrorMessage = (error: unknown, fallback: string) =>
|
||||
error instanceof Error ? error.message : fallback;
|
||||
|
||||
const ConnectSourceDialog = ({
|
||||
disabled,
|
||||
error,
|
||||
diagnostics,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: {
|
||||
disabled: boolean;
|
||||
error?: string;
|
||||
diagnostics: readonly ContentBlockDiagnostic[];
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
}) => (
|
||||
<Dialog open onOpenChange={(open) => open === false && onClose()}>
|
||||
<DialogContent>
|
||||
<DialogTitle>Connect content source</DialogTitle>
|
||||
<DialogDescription asChild>
|
||||
<Text css={{ padding: theme.panel.padding }}>
|
||||
Connecting this file will replace the existing Content Block content.
|
||||
The MDX file will not be changed.
|
||||
</Text>
|
||||
</DialogDescription>
|
||||
{error !== undefined && (
|
||||
<Text
|
||||
role="alert"
|
||||
color="destructive"
|
||||
variant="tiny"
|
||||
css={{
|
||||
paddingInline: theme.panel.paddingInline,
|
||||
paddingBottom: theme.panel.paddingBlock,
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</Text>
|
||||
)}
|
||||
{diagnostics.map((diagnostic) => (
|
||||
<Text
|
||||
key={JSON.stringify(diagnostic)}
|
||||
role="status"
|
||||
color="subtle"
|
||||
variant="tiny"
|
||||
css={{ paddingInline: theme.panel.paddingInline }}
|
||||
>
|
||||
{formatContentBlockDiagnostic(diagnostic)}
|
||||
</Text>
|
||||
))}
|
||||
<DialogActions>
|
||||
<Button disabled={disabled} onClick={onConfirm}>
|
||||
Connect
|
||||
</Button>
|
||||
<DialogClose>
|
||||
<Button autoFocus color="ghost" disabled={disabled}>
|
||||
Abort
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</DialogActions>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
/**
|
||||
* Presentation and interaction boundary for Content Block source lifecycle.
|
||||
* The caller owns loading, exact-Asset authorization, lifecycle preparation,
|
||||
* and persistence. This component never updates the src prop directly.
|
||||
*/
|
||||
export const ContentBlockSourceControl = ({
|
||||
source,
|
||||
resolvedAsset,
|
||||
readOnly = false,
|
||||
disabled = false,
|
||||
loading = false,
|
||||
error,
|
||||
diagnostics = [],
|
||||
persistenceStatus,
|
||||
persistenceError,
|
||||
onRetry,
|
||||
onRequestSource,
|
||||
onOpen,
|
||||
}: {
|
||||
source?: ContentBlockSource;
|
||||
resolvedAsset?: Asset;
|
||||
readOnly?: boolean;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
error?: string;
|
||||
diagnostics?: readonly ContentBlockDiagnostic[];
|
||||
persistenceStatus?: "saved" | "pending" | "saving" | "failed" | "conflicting";
|
||||
persistenceError?: string;
|
||||
onRetry?: () => Promise<void>;
|
||||
onRequestSource: (input: {
|
||||
source: ContentBlockSource;
|
||||
confirmed?: boolean;
|
||||
}) => Promise<ContentBlockSourceActionResult>;
|
||||
onOpen: (assetId: string) => void;
|
||||
}) => {
|
||||
const [pendingSource, setPendingSource] = useState<PendingSource>();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const busyRef = useRef(false);
|
||||
const [localError, setLocalError] = useState<string>();
|
||||
const isDisabled = disabled || loading || busy;
|
||||
const isSourceMutationDisabled = readOnly || isDisabled;
|
||||
const connected = source !== undefined;
|
||||
const binding = useBindableControl({
|
||||
boundExpression: source?.type === "expression" ? source : undefined,
|
||||
fallbackExpression: JSON.stringify(resolvedAsset?.id ?? ""),
|
||||
});
|
||||
const uniqueDiagnostics = deduplicateContentBlockDiagnostics(diagnostics);
|
||||
const sourceLabel = resolvedAsset
|
||||
? formatAssetName(resolvedAsset)
|
||||
: loading
|
||||
? "Loading content source…"
|
||||
: source?.type === "expression"
|
||||
? "Dynamic content source"
|
||||
: source?.type === "asset"
|
||||
? "Missing MDX Asset"
|
||||
: "No content source";
|
||||
|
||||
const beginOperation = () => {
|
||||
if (disabled || loading || busyRef.current) {
|
||||
return false;
|
||||
}
|
||||
busyRef.current = true;
|
||||
setBusy(true);
|
||||
setLocalError(undefined);
|
||||
return true;
|
||||
};
|
||||
|
||||
const finishOperation = () => {
|
||||
busyRef.current = false;
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
const requestSource = async (
|
||||
requestedSource: ContentBlockSource,
|
||||
confirmed?: boolean
|
||||
) => {
|
||||
if (readOnly || beginOperation() === false) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await onRequestSource({
|
||||
source: requestedSource,
|
||||
confirmed,
|
||||
});
|
||||
if (result.status === "requires-confirmation") {
|
||||
setPendingSource({
|
||||
source: requestedSource,
|
||||
diagnostics: result.diagnostics ?? [],
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (result.status === "blocked" || result.status === "partial") {
|
||||
setLocalError(result.message);
|
||||
return;
|
||||
}
|
||||
setPendingSource(undefined);
|
||||
} catch (error) {
|
||||
setLocalError(getErrorMessage(error, "Unable to change source"));
|
||||
} finally {
|
||||
finishOperation();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<fieldset disabled={isDisabled} style={{ display: "contents" }}>
|
||||
<Grid gap="2">
|
||||
<BindableExpressionControl
|
||||
{...binding}
|
||||
showBinding={readOnly === false}
|
||||
value={resolvedAsset?.id}
|
||||
validate={(value) =>
|
||||
typeof value === "string" && value !== ""
|
||||
? undefined
|
||||
: "Content source must resolve to an Asset ID"
|
||||
}
|
||||
onChangeValue={(value) => {
|
||||
if (typeof value === "string" && value !== "") {
|
||||
void requestSource({ type: "asset", assetId: value });
|
||||
}
|
||||
}}
|
||||
onChangeExpression={(value) => {
|
||||
void requestSource({ type: "expression", value });
|
||||
}}
|
||||
onRemove={(value) => {
|
||||
if (typeof value === "string" && value !== "") {
|
||||
void requestSource({ type: "asset", assetId: value });
|
||||
}
|
||||
}}
|
||||
renderControl={() =>
|
||||
connected ? (
|
||||
<Grid columns={2} gap="2" aria-label="Content source actions">
|
||||
<Flex align="center" gap="1">
|
||||
<Box css={{ flex: 1, minWidth: 0 }}>
|
||||
<SelectAsset
|
||||
assetId={resolvedAsset?.id}
|
||||
title="Switch MDX file"
|
||||
accept=".mdx"
|
||||
disabled={isSourceMutationDisabled}
|
||||
triggerLabel={sourceLabel}
|
||||
onChange={(assetId) =>
|
||||
void requestSource({ type: "asset", assetId })
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
{uniqueDiagnostics.length > 0 && (
|
||||
<Tooltip
|
||||
content={
|
||||
<Grid gap="1">
|
||||
<Text>
|
||||
{formatContentBlockDiagnostic(uniqueDiagnostics[0])}
|
||||
</Text>
|
||||
{uniqueDiagnostics.length > 1 && (
|
||||
<Text>
|
||||
{uniqueDiagnostics.length - 1} more diagnostic
|
||||
{uniqueDiagnostics.length === 2 ? "" : "s"}
|
||||
</Text>
|
||||
)}
|
||||
</Grid>
|
||||
}
|
||||
>
|
||||
<Flex
|
||||
as="span"
|
||||
align="center"
|
||||
role="img"
|
||||
tabIndex={0}
|
||||
aria-label={`MDX source warning: ${formatContentBlockDiagnostic(uniqueDiagnostics[0])}${uniqueDiagnostics.length > 1 ? ` ${uniqueDiagnostics.length - 1} more diagnostic${uniqueDiagnostics.length === 2 ? "" : "s"}.` : ""}`}
|
||||
css={{
|
||||
color: rawTheme.colors.backgroundAlertMain,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<AlertIcon size={14} />
|
||||
</Flex>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Flex>
|
||||
<Button
|
||||
color="neutral"
|
||||
disabled={isDisabled || resolvedAsset === undefined}
|
||||
css={{ width: "100%" }}
|
||||
onClick={() => {
|
||||
if (resolvedAsset !== undefined) {
|
||||
onOpen(resolvedAsset.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Open
|
||||
</Button>
|
||||
</Grid>
|
||||
) : readOnly ? null : (
|
||||
<SelectAsset
|
||||
title="Choose MDX file"
|
||||
accept=".mdx"
|
||||
disabled={isDisabled}
|
||||
triggerLabel="Connect .mdx file"
|
||||
onChange={(assetId) =>
|
||||
void requestSource({ type: "asset", assetId })
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
{(localError ?? error) !== undefined && pendingSource === undefined && (
|
||||
<Text role="alert" color="destructive" variant="tiny">
|
||||
{localError ?? error}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{(persistenceStatus === "pending" ||
|
||||
persistenceStatus === "saving") && (
|
||||
<Text role="status" variant="tiny">
|
||||
Saving content source…
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{persistenceStatus === "failed" && (
|
||||
<Flex gap="2" align="center" wrap="wrap">
|
||||
<Text role="alert" color="destructive" variant="tiny">
|
||||
{persistenceError ?? "Unable to save content source."}
|
||||
</Text>
|
||||
{onRetry !== undefined && (
|
||||
<Button
|
||||
color="ghost"
|
||||
disabled={isDisabled}
|
||||
onClick={() => {
|
||||
if (beginOperation() === false) {
|
||||
return;
|
||||
}
|
||||
void onRetry()
|
||||
.catch((error) =>
|
||||
setLocalError(
|
||||
getErrorMessage(error, "Unable to retry save")
|
||||
)
|
||||
)
|
||||
.finally(finishOperation);
|
||||
}}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
)}
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
{busy && (
|
||||
<Text role="status" variant="tiny">
|
||||
Updating content source…
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{pendingSource !== undefined && (
|
||||
<ConnectSourceDialog
|
||||
disabled={isSourceMutationDisabled}
|
||||
error={localError ?? error}
|
||||
diagnostics={pendingSource.diagnostics}
|
||||
onClose={() => setPendingSource(undefined)}
|
||||
onConfirm={() => void requestSource(pendingSource.source, true)}
|
||||
/>
|
||||
)}
|
||||
</Grid>
|
||||
</fieldset>
|
||||
);
|
||||
};
|
||||
@@ -45,6 +45,10 @@ export const FileControl = ({
|
||||
onChange,
|
||||
}: ControlProps<"file">) => {
|
||||
const id = useId();
|
||||
const binding = useBindableControl({
|
||||
boundExpression: prop?.type === "expression" ? prop : undefined,
|
||||
fallbackExpression: JSON.stringify(computedValue),
|
||||
});
|
||||
|
||||
const localStringValue = useDraftValue(
|
||||
// use undefined for asset type to not delete
|
||||
@@ -53,7 +57,7 @@ export const FileControl = ({
|
||||
? String(computedValue)
|
||||
: undefined,
|
||||
(value) => {
|
||||
if (value === undefined || prop?.type === "expression") {
|
||||
if (value === undefined || binding.bindingState.overwritable === false) {
|
||||
return;
|
||||
}
|
||||
onChange({ type: "string", value });
|
||||
@@ -61,10 +65,6 @@ export const FileControl = ({
|
||||
);
|
||||
|
||||
const label = humanizeAttribute(meta.label || propName);
|
||||
const binding = useBindableControl({
|
||||
boundExpression: prop?.type === "expression" ? prop.value : undefined,
|
||||
fallbackExpression: JSON.stringify(computedValue),
|
||||
});
|
||||
return (
|
||||
<VerticalLayout label={<PropertyLabel name={propName} />}>
|
||||
<Flex css={{ gap: theme.spacing[3] }} direction="column" justify="center">
|
||||
@@ -90,9 +90,9 @@ export const FileControl = ({
|
||||
)}
|
||||
/>
|
||||
<SelectAsset
|
||||
prop={prop?.type === "asset" ? prop : undefined}
|
||||
assetId={prop?.type === "asset" ? prop.value : undefined}
|
||||
accept={meta.accept}
|
||||
onChange={onChange}
|
||||
onChange={(assetId) => onChange({ type: "asset", value: assetId })}
|
||||
/>
|
||||
</Flex>
|
||||
</VerticalLayout>
|
||||
|
||||
@@ -18,8 +18,12 @@ export const JsonControl = ({
|
||||
}: ControlProps<"json">) => {
|
||||
const [error, setError] = useState<boolean>(false);
|
||||
const valueString = formatValue(computedValue ?? "");
|
||||
const binding = useBindableControl({
|
||||
boundExpression: prop?.type === "expression" ? prop : undefined,
|
||||
fallbackExpression: valueString,
|
||||
});
|
||||
const localValue = useDraftValue(valueString, (value) => {
|
||||
if (prop?.type === "expression") {
|
||||
if (binding.bindingState.overwritable === false) {
|
||||
return;
|
||||
}
|
||||
const isLiteral = isLiteralExpression(value);
|
||||
@@ -37,11 +41,6 @@ export const JsonControl = ({
|
||||
}
|
||||
});
|
||||
|
||||
const binding = useBindableControl({
|
||||
boundExpression: prop?.type === "expression" ? prop.value : undefined,
|
||||
fallbackExpression: valueString,
|
||||
});
|
||||
|
||||
return (
|
||||
<VerticalLayout
|
||||
label={
|
||||
|
||||
@@ -21,10 +21,14 @@ export const NumberControl = ({
|
||||
|
||||
const [isInvalid, setIsInvalid] = useState(false);
|
||||
const number = Number(computedValue);
|
||||
const binding = useBindableControl({
|
||||
boundExpression: prop?.type === "expression" ? prop : undefined,
|
||||
fallbackExpression: JSON.stringify(computedValue),
|
||||
});
|
||||
const localValue = useDraftValue(
|
||||
Number.isNaN(number) ? "" : number,
|
||||
(value) => {
|
||||
if (prop?.type === "expression") {
|
||||
if (binding.bindingState.overwritable === false) {
|
||||
return;
|
||||
}
|
||||
if (typeof value === "number") {
|
||||
@@ -37,11 +41,6 @@ export const NumberControl = ({
|
||||
);
|
||||
|
||||
const label = humanizeAttribute(meta.label || propName);
|
||||
const binding = useBindableControl({
|
||||
boundExpression: prop?.type === "expression" ? prop.value : undefined,
|
||||
fallbackExpression: JSON.stringify(computedValue),
|
||||
});
|
||||
|
||||
return (
|
||||
<ResponsiveLayout
|
||||
label={
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import { useMemo } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { computed } from "nanostores";
|
||||
import { useStore } from "@nanostores/react";
|
||||
import { Button, Flex, Text, FloatingPanel } from "@webstudio-is/design-system";
|
||||
import { acceptToMimeCategories } from "@webstudio-is/sdk";
|
||||
import { Button, Flex, FloatingPanel } from "@webstudio-is/design-system";
|
||||
import {
|
||||
acceptToMimeCategories,
|
||||
formatAssetName,
|
||||
type Asset,
|
||||
} from "@webstudio-is/sdk";
|
||||
import { $assets } from "~/shared/sync/data-stores";
|
||||
import { AssetManager } from "~/builder/shared/asset-manager";
|
||||
import { type PropValue } from "../shared";
|
||||
import { formatAssetName } from "@webstudio-is/project-build/runtime";
|
||||
import { AssetUpload } from "~/builder/shared/assets";
|
||||
|
||||
// tests whether we can use AssetManager for the given "accept" value
|
||||
const isImageAccept = (accept?: string) => {
|
||||
const acceptsImageUpload = (accept?: string) => {
|
||||
const acceptCategories = acceptToMimeCategories(accept || "");
|
||||
return (
|
||||
acceptCategories === "*" ||
|
||||
@@ -20,39 +21,56 @@ const isImageAccept = (accept?: string) => {
|
||||
|
||||
type Props = {
|
||||
accept?: string;
|
||||
prop?: Extract<PropValue, { type: "asset" }>;
|
||||
onChange: (value: Extract<PropValue, { type: "asset" }>) => void;
|
||||
assetId?: Asset["id"];
|
||||
title?: string;
|
||||
triggerLabel?: string;
|
||||
disabled?: boolean;
|
||||
onChange: (assetId: Asset["id"]) => void;
|
||||
};
|
||||
|
||||
export const SelectAsset = ({ prop, onChange, accept }: Props) => {
|
||||
export const SelectAsset = ({
|
||||
accept,
|
||||
assetId,
|
||||
title,
|
||||
triggerLabel,
|
||||
disabled,
|
||||
onChange,
|
||||
}: Props) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const $asset = useMemo(
|
||||
() =>
|
||||
computed($assets, (assets) =>
|
||||
prop ? assets.get(prop.value) : undefined
|
||||
assetId === undefined ? undefined : assets.get(assetId)
|
||||
),
|
||||
[prop]
|
||||
[assetId]
|
||||
);
|
||||
|
||||
const asset = useStore($asset);
|
||||
|
||||
if (isImageAccept(accept) === false) {
|
||||
return <Text color="destructive">Unsupported accept value: {accept}</Text>;
|
||||
}
|
||||
const acceptsImages = acceptsImageUpload(accept);
|
||||
|
||||
return (
|
||||
<Flex gap={2} css={{ flex: 1 }} align="center">
|
||||
<FloatingPanel
|
||||
title="Images"
|
||||
titleSuffix={<AssetUpload type="image" accept={accept} />}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
title={title ?? (acceptsImages ? "Images" : "Assets")}
|
||||
titleSuffix={
|
||||
acceptsImages ? (
|
||||
<AssetUpload type="image" accept={accept} />
|
||||
) : undefined
|
||||
}
|
||||
content={
|
||||
<AssetManager
|
||||
onChange={(assetId) => onChange({ type: "asset", value: assetId })}
|
||||
onChange={(assetId) => {
|
||||
setOpen(false);
|
||||
onChange(assetId);
|
||||
}}
|
||||
accept={accept}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Button color="neutral" css={{ flex: 1 }}>
|
||||
{asset ? formatAssetName(asset) : "Choose source"}
|
||||
<Button color="neutral" css={{ flex: 1 }} disabled={disabled}>
|
||||
{triggerLabel ?? (asset ? formatAssetName(asset) : "Choose source")}
|
||||
</Button>
|
||||
</FloatingPanel>
|
||||
</Flex>
|
||||
|
||||
@@ -19,6 +19,7 @@ test("updates the targeted expression without replacing its siblings", () => {
|
||||
]),
|
||||
type: "expression",
|
||||
value: "2 + 2",
|
||||
expressionBindingMode: "readwrite",
|
||||
})
|
||||
).toEqual({
|
||||
id: "instances.updateText",
|
||||
@@ -27,6 +28,7 @@ test("updates the targeted expression without replacing its siblings", () => {
|
||||
childIndex: 1,
|
||||
mode: "expression",
|
||||
text: "2 + 2",
|
||||
expressionBindingMode: "readwrite",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import type { Instance } from "@webstudio-is/sdk";
|
||||
import type { ExpressionBindingMode, Instance } from "@webstudio-is/sdk";
|
||||
import { getEditableTextTarget } from "@webstudio-is/project-build/runtime";
|
||||
|
||||
export const getTextContentUpdateOperation = ({
|
||||
instance,
|
||||
type,
|
||||
value,
|
||||
expressionBindingMode,
|
||||
}: {
|
||||
instance: Instance | undefined;
|
||||
type: "text" | "expression";
|
||||
value: string;
|
||||
expressionBindingMode?: ExpressionBindingMode;
|
||||
}) => {
|
||||
if (instance === undefined) {
|
||||
return;
|
||||
@@ -22,6 +24,9 @@ export const getTextContentUpdateOperation = ({
|
||||
childIndex,
|
||||
mode: type,
|
||||
text: value,
|
||||
...(expressionBindingMode === undefined
|
||||
? {}
|
||||
: { expressionBindingMode }),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -35,6 +40,7 @@ export const getTextContentUpdateOperation = ({
|
||||
instanceId: instance.id,
|
||||
mode: type,
|
||||
text: value,
|
||||
...(expressionBindingMode === undefined ? {} : { expressionBindingMode }),
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -168,7 +168,7 @@ test("updates only the targeted expression child through the binding editor", as
|
||||
|
||||
expect($instances.get().get("reading-time")?.children).toEqual([
|
||||
{ type: "text", value: " · " },
|
||||
{ type: "expression", value: "2 + 2" },
|
||||
{ type: "expression", value: "2 + 2", mode: "read" },
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -9,11 +9,15 @@ import {
|
||||
Flex,
|
||||
rawTheme,
|
||||
Text,
|
||||
toast,
|
||||
} from "@webstudio-is/design-system";
|
||||
import type { Instance } from "@webstudio-is/sdk";
|
||||
import type { ExpressionBindingMode, Instance } from "@webstudio-is/sdk";
|
||||
import { AlertIcon } from "@webstudio-is/icons";
|
||||
import { $instances } from "~/shared/sync/data-stores";
|
||||
import { validatePrimitiveValue } from "@webstudio-is/project-build/runtime";
|
||||
import {
|
||||
getEditableTextTarget,
|
||||
validatePrimitiveValue,
|
||||
} from "@webstudio-is/project-build/runtime";
|
||||
import { useDraftValue } from "~/builder/shared/use-draft-value";
|
||||
import { BindableExpressionControl } from "~/builder/shared/bindable-expression";
|
||||
import { executeRuntimeMutation } from "~/shared/instance-utils/data";
|
||||
@@ -22,8 +26,8 @@ import { type ControlProps, VerticalLayout } from "../shared";
|
||||
import { FieldLabel, useIsBindingResetForbidden } from "../property-label";
|
||||
import { useBindableControl } from "./use-bindable-control";
|
||||
import { evaluateExpressionWithinScope } from "~/builder/shared/binding-popover";
|
||||
import { getEditableTextTarget } from "@webstudio-is/project-build/runtime";
|
||||
import { getTextContentUpdateOperation } from "./text-content-utils";
|
||||
import { useCodeTextLanguageSupport } from "./code";
|
||||
|
||||
const useInstance = (instanceId: Instance["id"]) => {
|
||||
const $store = useMemo(() => {
|
||||
@@ -34,16 +38,31 @@ const useInstance = (instanceId: Instance["id"]) => {
|
||||
|
||||
export const TextContent = ({
|
||||
instanceId,
|
||||
meta,
|
||||
computedValue,
|
||||
computedProps,
|
||||
}: ControlProps<"textContent">) => {
|
||||
const instance = useInstance(instanceId);
|
||||
const languageProp = meta.editor?.languageProp;
|
||||
const languageSupport = useCodeTextLanguageSupport(
|
||||
languageProp === undefined ? undefined : computedProps?.get(languageProp)
|
||||
);
|
||||
const childrenCount = instance?.children.length ?? 0;
|
||||
const hasChildren = childrenCount > 0;
|
||||
const hasMixedContent = childrenCount > 1;
|
||||
const target = instance && getEditableTextTarget(instance);
|
||||
const child = target?.child ?? { type: "text" as const, value: "" };
|
||||
const updateChild = (type: "text" | "expression", value: string) => {
|
||||
const operation = getTextContentUpdateOperation({ instance, type, value });
|
||||
const updateChild = (
|
||||
type: "text" | "expression",
|
||||
value: string,
|
||||
expressionBindingMode?: ExpressionBindingMode
|
||||
) => {
|
||||
const operation = getTextContentUpdateOperation({
|
||||
instance,
|
||||
type,
|
||||
value,
|
||||
expressionBindingMode,
|
||||
});
|
||||
if (operation !== undefined) {
|
||||
executeRuntimeMutation(operation);
|
||||
}
|
||||
@@ -82,7 +101,7 @@ export const TextContent = ({
|
||||
child.type === "text" ? JSON.stringify(child.value) : child.value;
|
||||
|
||||
const binding = useBindableControl({
|
||||
boundExpression: child.type === "expression" ? expression : undefined,
|
||||
boundExpression: child.type === "expression" ? child : undefined,
|
||||
fallbackExpression: expression,
|
||||
});
|
||||
let displayedValue = computedValue;
|
||||
@@ -98,6 +117,13 @@ export const TextContent = ({
|
||||
}
|
||||
const localValue = useDraftValue(String(displayedValue ?? ""), (value) => {
|
||||
if (child.type === "expression") {
|
||||
void binding.writeBoundValue?.(value).catch((error) => {
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Unable to update MDX frontmatter"
|
||||
);
|
||||
});
|
||||
return;
|
||||
}
|
||||
updateChild("text", value);
|
||||
@@ -150,7 +176,9 @@ export const TextContent = ({
|
||||
value={localValue.value}
|
||||
validate={(value) => validatePrimitiveValue(value, "Text Content")}
|
||||
onChangeValue={(value) => updateChild("text", value)}
|
||||
onChangeExpression={(value) => updateChild("expression", value)}
|
||||
onChangeExpression={(value) =>
|
||||
updateChild("expression", value, binding.getExpressionMode(value))
|
||||
}
|
||||
onRemove={resetBindings}
|
||||
renderControl={({ readOnly }) => (
|
||||
<CodeEditor
|
||||
@@ -168,6 +196,7 @@ export const TextContent = ({
|
||||
</DialogTitle>
|
||||
}
|
||||
size="small"
|
||||
languageSupport={languageSupport}
|
||||
readOnly={readOnly}
|
||||
value={localValue.value}
|
||||
onChange={localValue.set}
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { act } from "react-dom/test-utils";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
||||
import { TooltipProvider } from "@webstudio-is/design-system";
|
||||
import {
|
||||
blockComponent,
|
||||
contentBlockDocumentProp,
|
||||
encodeDataSourceVariable,
|
||||
type Instance,
|
||||
type Prop,
|
||||
} from "@webstudio-is/sdk";
|
||||
import { $builderMode, selectInstance } from "~/shared/nano-states";
|
||||
import { $instances, $props } from "~/shared/sync/data-stores";
|
||||
import { $externalContentRoots } from "~/shared/external-content-mutations";
|
||||
import { TextControl } from "./text";
|
||||
|
||||
(
|
||||
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
const block: Instance = {
|
||||
type: "instance",
|
||||
id: "block",
|
||||
component: blockComponent,
|
||||
children: [{ type: "id", value: "heading" }],
|
||||
};
|
||||
const heading: Instance = {
|
||||
type: "instance",
|
||||
id: "heading",
|
||||
component: "Heading",
|
||||
children: [],
|
||||
};
|
||||
const documentProp: Prop = {
|
||||
id: "document",
|
||||
instanceId: block.id,
|
||||
name: contentBlockDocumentProp,
|
||||
type: "parameter",
|
||||
value: "document-data-source",
|
||||
};
|
||||
const titleProp: Prop = {
|
||||
id: "title",
|
||||
instanceId: heading.id,
|
||||
name: "title",
|
||||
type: "expression",
|
||||
value: `${encodeDataSourceVariable("document-data-source")}.frontmatter.title`,
|
||||
mode: "readwrite",
|
||||
};
|
||||
const sourceProp: Prop = {
|
||||
id: "source",
|
||||
instanceId: block.id,
|
||||
name: "src",
|
||||
type: "asset",
|
||||
value: "article",
|
||||
};
|
||||
$instances.set(
|
||||
new Map([
|
||||
[block.id, block],
|
||||
[heading.id, heading],
|
||||
])
|
||||
);
|
||||
$props.set(
|
||||
new Map<string, Prop>([
|
||||
[documentProp.id, documentProp],
|
||||
[sourceProp.id, sourceProp],
|
||||
[titleProp.id, titleProp],
|
||||
])
|
||||
);
|
||||
$externalContentRoots.set(
|
||||
new Map([
|
||||
[
|
||||
"root",
|
||||
{
|
||||
blockInstanceId: block.id,
|
||||
instanceIds: new Set([heading.id]),
|
||||
mutationRevision: 0,
|
||||
document: {
|
||||
frontmatter: { properties: { title: "Before" } },
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
])
|
||||
);
|
||||
$builderMode.set("content");
|
||||
selectInstance([heading.id, block.id]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
selectInstance(undefined);
|
||||
$instances.set(new Map());
|
||||
$props.set(new Map());
|
||||
$externalContentRoots.set(new Map());
|
||||
$builderMode.set("design");
|
||||
});
|
||||
|
||||
test("saves an edited direct frontmatter binding without exposing the binding", () => {
|
||||
const onChange = vi.fn();
|
||||
const prop = $props.get().get("title");
|
||||
if (prop === undefined) {
|
||||
throw new Error("Expected title prop");
|
||||
}
|
||||
act(() => {
|
||||
root.render(
|
||||
<TooltipProvider>
|
||||
<TextControl
|
||||
instanceId="heading"
|
||||
meta={{ type: "string", control: "text", required: false }}
|
||||
prop={prop}
|
||||
propName="title"
|
||||
computedValue="Before"
|
||||
onChange={onChange}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
);
|
||||
});
|
||||
const input = container.querySelector<HTMLTextAreaElement>("textarea");
|
||||
if (input === null) {
|
||||
throw new Error("Expected text control");
|
||||
}
|
||||
|
||||
expect(input.disabled).toBe(false);
|
||||
expect(container.querySelector('[data-variant="bound"]')).toBeNull();
|
||||
act(() => {
|
||||
input.focus();
|
||||
Object.getOwnPropertyDescriptor(
|
||||
HTMLTextAreaElement.prototype,
|
||||
"value"
|
||||
)?.set?.call(input, "After");
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
act(() => {
|
||||
input.blur();
|
||||
});
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({ type: "string", value: "After" });
|
||||
});
|
||||
@@ -18,19 +18,18 @@ export const TextControl = ({
|
||||
computedValue,
|
||||
onChange,
|
||||
}: ControlProps<"text">) => {
|
||||
const binding = useBindableControl({
|
||||
boundExpression: prop?.type === "expression" ? prop : undefined,
|
||||
fallbackExpression: JSON.stringify(computedValue),
|
||||
});
|
||||
const localValue = useDraftValue(String(computedValue ?? ""), (value) => {
|
||||
if (prop?.type === "expression") {
|
||||
if (binding.bindingState.overwritable === false) {
|
||||
return;
|
||||
}
|
||||
onChange({ type: "string", value });
|
||||
});
|
||||
const id = useId();
|
||||
const label = humanizeAttribute(meta.label || propName);
|
||||
const binding = useBindableControl({
|
||||
boundExpression: prop?.type === "expression" ? prop.value : undefined,
|
||||
fallbackExpression: JSON.stringify(computedValue),
|
||||
});
|
||||
|
||||
const input = (
|
||||
<BindableExpressionControl
|
||||
{...binding}
|
||||
|
||||
@@ -192,8 +192,12 @@ export const TimeZoneControl = ({
|
||||
const savedValue = String(
|
||||
computedValue ?? meta.defaultValue ?? defaultTimeZone
|
||||
);
|
||||
const binding = useBindableControl({
|
||||
boundExpression: prop?.type === "expression" ? prop : undefined,
|
||||
fallbackExpression: JSON.stringify(computedValue),
|
||||
});
|
||||
const localValue = useDraftValue(savedValue, (value) => {
|
||||
if (prop?.type === "expression") {
|
||||
if (binding.bindingState.overwritable === false) {
|
||||
return;
|
||||
}
|
||||
onChange({ type: "string", value });
|
||||
@@ -202,11 +206,6 @@ export const TimeZoneControl = ({
|
||||
const selectedItem = getTimeZoneItem(savedValue);
|
||||
const currentItem = getTimeZoneItem(localValue.value);
|
||||
const label = humanizeAttribute(meta.label || propName);
|
||||
const binding = useBindableControl({
|
||||
boundExpression: prop?.type === "expression" ? prop.value : undefined,
|
||||
fallbackExpression: JSON.stringify(computedValue),
|
||||
});
|
||||
|
||||
const datetime = getStringProp(
|
||||
props,
|
||||
instanceId,
|
||||
|
||||
@@ -103,9 +103,9 @@ const addHttpsIfMissing = (url: string) => {
|
||||
return url;
|
||||
};
|
||||
|
||||
const BaseUrl = ({ readOnly, prop, value, onChange, id }: BaseControlProps) => {
|
||||
const BaseUrl = ({ readOnly, value, onChange, id }: BaseControlProps) => {
|
||||
const localValue = useDraftValue(value, (value) => {
|
||||
if (prop?.type === "expression") {
|
||||
if (readOnly) {
|
||||
return;
|
||||
}
|
||||
onChange({ type: "string", value });
|
||||
@@ -140,17 +140,11 @@ const BaseUrl = ({ readOnly, prop, value, onChange, id }: BaseControlProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
const BasePhone = ({
|
||||
readOnly,
|
||||
prop,
|
||||
value,
|
||||
onChange,
|
||||
id,
|
||||
}: BaseControlProps) => {
|
||||
const BasePhone = ({ readOnly, value, onChange, id }: BaseControlProps) => {
|
||||
const localValue = useDraftValue(
|
||||
value.startsWith("tel:") ? value.slice(4) : "",
|
||||
(value) => {
|
||||
if (prop?.type === "expression") {
|
||||
if (readOnly) {
|
||||
return;
|
||||
}
|
||||
const nextValue = `tel:${value}`;
|
||||
@@ -219,15 +213,9 @@ export const __testing__ = {
|
||||
propToEmail,
|
||||
};
|
||||
|
||||
const BaseEmail = ({
|
||||
readOnly,
|
||||
prop,
|
||||
value,
|
||||
onChange,
|
||||
id,
|
||||
}: BaseControlProps) => {
|
||||
const BaseEmail = ({ readOnly, value, onChange, id }: BaseControlProps) => {
|
||||
const localValue = useDraftValue(propToEmail(value), ({ email, subject }) => {
|
||||
if (prop?.type === "expression") {
|
||||
if (readOnly) {
|
||||
return;
|
||||
}
|
||||
const value = emailToProp({ email, subject });
|
||||
@@ -426,8 +414,8 @@ const BasePage = ({ prop, onChange }: BaseControlProps) => {
|
||||
const BaseAttachment = ({ prop, onChange }: BaseControlProps) => (
|
||||
<Row>
|
||||
<SelectAsset
|
||||
prop={prop?.type === "asset" ? prop : undefined}
|
||||
onChange={onChange}
|
||||
assetId={prop?.type === "asset" ? prop.value : undefined}
|
||||
onChange={(assetId) => onChange({ type: "asset", value: assetId })}
|
||||
/>
|
||||
</Row>
|
||||
);
|
||||
@@ -546,7 +534,7 @@ export const UrlControl = ({
|
||||
const value = String(computedValue ?? "");
|
||||
const label = humanizeAttribute(meta.label || propName);
|
||||
const binding = useBindableControl({
|
||||
boundExpression: prop?.type === "expression" ? prop.value : undefined,
|
||||
boundExpression: prop?.type === "expression" ? prop : undefined,
|
||||
fallbackExpression: JSON.stringify(computedValue),
|
||||
});
|
||||
|
||||
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
import { expect, test } from "vitest";
|
||||
import { getBindableControlPresentation } from "./use-bindable-control";
|
||||
|
||||
test("edits a frontmatter value without exposing its binding", () => {
|
||||
expect(
|
||||
getBindableControlPresentation({
|
||||
bindingState: { overwritable: false, variant: "bound" },
|
||||
isFrontmatterBinding: true,
|
||||
isEditableFrontmatterBinding: true,
|
||||
})
|
||||
).toEqual({
|
||||
bindingState: { overwritable: true, variant: "bound" },
|
||||
showBinding: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps ordinary expression bindings read-only and visible", () => {
|
||||
expect(
|
||||
getBindableControlPresentation({
|
||||
bindingState: { overwritable: false, variant: "bound" },
|
||||
isFrontmatterBinding: false,
|
||||
isEditableFrontmatterBinding: false,
|
||||
})
|
||||
).toEqual({
|
||||
bindingState: { overwritable: false, variant: "bound" },
|
||||
showBinding: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps an unavailable frontmatter value read-only without exposing its binding", () => {
|
||||
expect(
|
||||
getBindableControlPresentation({
|
||||
bindingState: { overwritable: false, variant: "bound" },
|
||||
isFrontmatterBinding: true,
|
||||
isEditableFrontmatterBinding: false,
|
||||
})
|
||||
).toEqual({
|
||||
bindingState: { overwritable: false, variant: "bound" },
|
||||
showBinding: false,
|
||||
});
|
||||
});
|
||||
@@ -1,17 +1,126 @@
|
||||
import { useStore } from "@nanostores/react";
|
||||
import { useBindingState } from "~/builder/shared/bindable-expression";
|
||||
import {
|
||||
useBindingState,
|
||||
type BindingState,
|
||||
} from "~/builder/shared/bindable-expression";
|
||||
import {
|
||||
$isContentMode,
|
||||
$selectedInstanceSelector,
|
||||
} from "~/shared/nano-states";
|
||||
import { $instances, $props } from "~/shared/sync/data-stores";
|
||||
import {
|
||||
$externalContentRoots,
|
||||
findExternalContentRootEntryBySelector,
|
||||
} from "~/shared/external-content-mutations";
|
||||
import { updateExternalContentFrontmatter } from "~/shared/external-content-roots";
|
||||
import {
|
||||
getSelectedContentBlockDocumentBindingPath,
|
||||
getSelectedContentBlockExpressionMode,
|
||||
isObjectPathWritable,
|
||||
} from "~/shared/content-block-document";
|
||||
import { $selectedInstanceScope } from "../shared";
|
||||
import type {
|
||||
ExpressionBinding,
|
||||
ExpressionBindingMode,
|
||||
} from "@webstudio-is/sdk";
|
||||
|
||||
export const getBindableControlPresentation = ({
|
||||
bindingState,
|
||||
isFrontmatterBinding,
|
||||
isEditableFrontmatterBinding,
|
||||
}: {
|
||||
bindingState: BindingState;
|
||||
isFrontmatterBinding: boolean;
|
||||
isEditableFrontmatterBinding: boolean;
|
||||
}) => ({
|
||||
bindingState: {
|
||||
...bindingState,
|
||||
overwritable:
|
||||
isEditableFrontmatterBinding || bindingState.overwritable === true,
|
||||
},
|
||||
showBinding: isFrontmatterBinding === false,
|
||||
});
|
||||
|
||||
export const useBindableControl = ({
|
||||
boundExpression,
|
||||
fallbackExpression,
|
||||
}: {
|
||||
boundExpression?: string;
|
||||
boundExpression?: Pick<ExpressionBinding, "value" | "mode">;
|
||||
fallbackExpression: string;
|
||||
}) => {
|
||||
const { scope, aliases } = useStore($selectedInstanceScope);
|
||||
const bindingState = useBindingState(boundExpression);
|
||||
const selectedInstanceSelector = useStore($selectedInstanceSelector);
|
||||
const externalContentRoots = useStore($externalContentRoots);
|
||||
const isContentMode = useStore($isContentMode);
|
||||
const bindingState = useBindingState(boundExpression?.value);
|
||||
const externalEntry =
|
||||
selectedInstanceSelector === undefined
|
||||
? undefined
|
||||
: findExternalContentRootEntryBySelector(
|
||||
externalContentRoots,
|
||||
selectedInstanceSelector
|
||||
);
|
||||
const externalRoot = externalEntry?.[1];
|
||||
const frontmatterPath =
|
||||
isContentMode &&
|
||||
boundExpression !== undefined &&
|
||||
selectedInstanceSelector !== undefined
|
||||
? getSelectedContentBlockDocumentBindingPath({
|
||||
binding: {
|
||||
type: "expression",
|
||||
...boundExpression,
|
||||
},
|
||||
instanceSelector: selectedInstanceSelector,
|
||||
instances: $instances.get(),
|
||||
props: $props.get(),
|
||||
sourceBlockInstanceId: externalRoot?.sourceBlockInstanceId,
|
||||
renderedBlockInstanceId: externalRoot?.blockInstanceId,
|
||||
})
|
||||
: undefined;
|
||||
const isEditableFrontmatterBinding =
|
||||
frontmatterPath !== undefined &&
|
||||
externalRoot?.document !== undefined &&
|
||||
isObjectPathWritable({
|
||||
value: externalRoot.document.frontmatter.properties,
|
||||
path: frontmatterPath,
|
||||
});
|
||||
const bound = boundExpression !== undefined;
|
||||
const expression = boundExpression ?? fallbackExpression;
|
||||
return { expression, bound, scope, aliases, bindingState };
|
||||
const expression = boundExpression?.value ?? fallbackExpression;
|
||||
const writeBoundValue =
|
||||
frontmatterPath !== undefined &&
|
||||
externalEntry !== undefined &&
|
||||
isEditableFrontmatterBinding
|
||||
? (value: unknown) =>
|
||||
updateExternalContentFrontmatter({
|
||||
rootKey: externalEntry[0],
|
||||
path: frontmatterPath,
|
||||
value,
|
||||
})
|
||||
: undefined;
|
||||
const getExpressionMode = (value: string): ExpressionBindingMode => {
|
||||
if (selectedInstanceSelector === undefined) {
|
||||
return "read";
|
||||
}
|
||||
return getSelectedContentBlockExpressionMode({
|
||||
expression: value,
|
||||
instanceSelector: selectedInstanceSelector,
|
||||
instances: $instances.get(),
|
||||
props: $props.get(),
|
||||
sourceBlockInstanceId: externalRoot?.sourceBlockInstanceId,
|
||||
renderedBlockInstanceId: externalRoot?.blockInstanceId,
|
||||
});
|
||||
};
|
||||
return {
|
||||
expression,
|
||||
bound,
|
||||
scope,
|
||||
aliases,
|
||||
getExpressionMode,
|
||||
writeBoundValue,
|
||||
...getBindableControlPresentation({
|
||||
bindingState,
|
||||
isFrontmatterBinding: frontmatterPath !== undefined,
|
||||
isEditableFrontmatterBinding,
|
||||
}),
|
||||
};
|
||||
};
|
||||
@@ -20,7 +20,7 @@ export const useStringOptions = ({
|
||||
: [value, ...meta.options];
|
||||
const label = humanizeAttribute(meta.label || propName);
|
||||
const binding = useBindableControl({
|
||||
boundExpression: prop?.type === "expression" ? prop.value : undefined,
|
||||
boundExpression: prop?.type === "expression" ? prop : undefined,
|
||||
fallbackExpression: JSON.stringify(computedValue),
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { act } from "react-dom/test-utils";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
||||
import { TooltipProvider } from "@webstudio-is/design-system";
|
||||
import {
|
||||
blockComponent,
|
||||
contentBlockSourceProp,
|
||||
coreMetas,
|
||||
type Instance,
|
||||
type Prop,
|
||||
} from "@webstudio-is/sdk";
|
||||
import {
|
||||
$authPermit,
|
||||
$registeredComponentMetas,
|
||||
selectInstance,
|
||||
} from "~/shared/nano-states";
|
||||
import { $instances, $props } from "~/shared/sync/data-stores";
|
||||
import { PropertyLabel } from "./property-label";
|
||||
|
||||
(
|
||||
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
const block: Instance = {
|
||||
type: "instance",
|
||||
id: "block",
|
||||
component: blockComponent,
|
||||
children: [],
|
||||
};
|
||||
const source: Prop = {
|
||||
id: "source",
|
||||
instanceId: block.id,
|
||||
name: contentBlockSourceProp,
|
||||
type: "asset",
|
||||
value: "asset",
|
||||
};
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal(
|
||||
"ResizeObserver",
|
||||
class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
);
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
$instances.set(new Map([[block.id, block]]));
|
||||
$props.set(new Map([[source.id, source]]));
|
||||
$registeredComponentMetas.set(new Map(Object.entries(coreMetas)));
|
||||
$authPermit.set("build");
|
||||
selectInstance([block.id]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
document.body.innerHTML = "";
|
||||
selectInstance(undefined);
|
||||
$instances.set(new Map());
|
||||
$props.set(new Map());
|
||||
$registeredComponentMetas.set(new Map());
|
||||
$authPermit.set("view");
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test("uses the deletion override for tooltip reset and Alt-click", () => {
|
||||
const onDelete = vi.fn();
|
||||
act(() => {
|
||||
root.render(
|
||||
<TooltipProvider>
|
||||
<PropertyLabel name={contentBlockSourceProp} onDelete={onDelete} />
|
||||
</TooltipProvider>
|
||||
);
|
||||
});
|
||||
|
||||
const label = Array.from(
|
||||
container.querySelectorAll<HTMLButtonElement>("button")
|
||||
).find((element) => element.textContent === "Source");
|
||||
if (label === undefined) {
|
||||
throw new Error("Expected Source property label");
|
||||
}
|
||||
|
||||
act(() => label.click());
|
||||
const reset = Array.from(
|
||||
document.body.querySelectorAll<HTMLButtonElement>("button")
|
||||
).find((button) => button.textContent?.includes("Reset value"));
|
||||
if (reset === undefined) {
|
||||
throw new Error("Expected Reset value action");
|
||||
}
|
||||
act(() => reset.click());
|
||||
expect(onDelete).toHaveBeenCalledOnce();
|
||||
|
||||
act(() =>
|
||||
label.dispatchEvent(
|
||||
new MouseEvent("click", { altKey: true, bubbles: true })
|
||||
)
|
||||
);
|
||||
expect(onDelete).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
@@ -96,9 +96,13 @@ const useIsResettable = (name: string) => {
|
||||
export const PropertyLabel = ({
|
||||
name,
|
||||
readOnly,
|
||||
deletable = true,
|
||||
onDelete,
|
||||
}: {
|
||||
name: string;
|
||||
readOnly?: boolean;
|
||||
deletable?: boolean;
|
||||
onDelete?: () => void;
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const propMeta = usePropMeta(name);
|
||||
@@ -109,7 +113,16 @@ export const PropertyLabel = ({
|
||||
const isResettable = useIsResettable(name);
|
||||
const isBindingResetForbidden = useIsBindingResetForbidden();
|
||||
const canDelete =
|
||||
isDeletable && !(prop?.type === "expression" && isBindingResetForbidden);
|
||||
deletable &&
|
||||
isDeletable &&
|
||||
!(prop?.type === "expression" && isBindingResetForbidden);
|
||||
const handleDelete = () => {
|
||||
if (onDelete === undefined) {
|
||||
deleteProp(name);
|
||||
return;
|
||||
}
|
||||
onDelete();
|
||||
};
|
||||
return (
|
||||
<Flex align="center" css={{ gap: theme.spacing[3] }}>
|
||||
{/* prevent label growing */}
|
||||
@@ -122,7 +135,7 @@ export const PropertyLabel = ({
|
||||
if (event.altKey) {
|
||||
event.preventDefault();
|
||||
if (canDelete) {
|
||||
deleteProp(name);
|
||||
handleDelete();
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -159,7 +172,7 @@ export const PropertyLabel = ({
|
||||
suffix={<Kbd value={["alt", "click"]} color="moreSubtle" />}
|
||||
css={{ gridTemplateColumns: "1fr max-content 1fr" }}
|
||||
onClick={() => {
|
||||
deleteProp(name);
|
||||
handleDelete();
|
||||
setIsOpen(false);
|
||||
}}
|
||||
>
|
||||
|
||||
+74
-1
@@ -1,13 +1,49 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { rootComponent } from "@webstudio-is/sdk";
|
||||
import { blockComponent, rootComponent, type Prop } from "@webstudio-is/sdk";
|
||||
import { __testing__ } from "./props-section";
|
||||
|
||||
const {
|
||||
shouldShowPropertiesSection,
|
||||
shouldRenderPropsSectionContainer,
|
||||
shouldSyncMediaAssetProps,
|
||||
findExpressionPropByStandardName,
|
||||
shouldWriteBoundValue,
|
||||
} = __testing__;
|
||||
|
||||
test("finds a legacy React-named expression by its standard attribute name", () => {
|
||||
const className: Prop = {
|
||||
id: "class-name",
|
||||
instanceId: "heading",
|
||||
name: "className",
|
||||
type: "expression",
|
||||
value: "$ws$dataSource$document.frontmatter.className",
|
||||
};
|
||||
|
||||
expect(
|
||||
findExpressionPropByStandardName({
|
||||
props: [className],
|
||||
instanceId: "heading",
|
||||
propName: "class",
|
||||
})
|
||||
).toBe(className);
|
||||
});
|
||||
|
||||
test("writes bound values only in Content mode", () => {
|
||||
expect(
|
||||
shouldWriteBoundValue(true, { type: "string", value: "New title" })
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldWriteBoundValue(false, { type: "string", value: "New title" })
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldWriteBoundValue(true, {
|
||||
type: "expression",
|
||||
value: "$ws$dataSource$document.frontmatter.subtitle",
|
||||
mode: "readwrite",
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
describe("shouldShowPropertiesSection", () => {
|
||||
test("shows properties in design mode even when empty", () => {
|
||||
expect(
|
||||
@@ -55,6 +91,7 @@ describe("shouldRenderPropsSectionContainer", () => {
|
||||
propsMetasSize: 1,
|
||||
hasVisibleProps: false,
|
||||
isContentMode: false,
|
||||
isDesignMode: true,
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
@@ -66,6 +103,7 @@ describe("shouldRenderPropsSectionContainer", () => {
|
||||
propsMetasSize: 0,
|
||||
hasVisibleProps: true,
|
||||
isContentMode: true,
|
||||
isDesignMode: false,
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
@@ -77,6 +115,7 @@ describe("shouldRenderPropsSectionContainer", () => {
|
||||
propsMetasSize: 1,
|
||||
hasVisibleProps: true,
|
||||
isContentMode: true,
|
||||
isDesignMode: true,
|
||||
})
|
||||
).toBe(false);
|
||||
expect(
|
||||
@@ -85,6 +124,7 @@ describe("shouldRenderPropsSectionContainer", () => {
|
||||
propsMetasSize: 0,
|
||||
hasVisibleProps: true,
|
||||
isContentMode: false,
|
||||
isDesignMode: false,
|
||||
})
|
||||
).toBe(false);
|
||||
expect(
|
||||
@@ -93,6 +133,28 @@ describe("shouldRenderPropsSectionContainer", () => {
|
||||
propsMetasSize: 0,
|
||||
hasVisibleProps: false,
|
||||
isContentMode: true,
|
||||
isDesignMode: false,
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("renders an empty Content Block settings container only in Design mode", () => {
|
||||
expect(
|
||||
shouldRenderPropsSectionContainer({
|
||||
component: blockComponent,
|
||||
propsMetasSize: 0,
|
||||
hasVisibleProps: false,
|
||||
isContentMode: false,
|
||||
isDesignMode: true,
|
||||
})
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldRenderPropsSectionContainer({
|
||||
component: blockComponent,
|
||||
propsMetasSize: 0,
|
||||
hasVisibleProps: false,
|
||||
isContentMode: true,
|
||||
isDesignMode: false,
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
@@ -132,4 +194,15 @@ describe("shouldSyncMediaAssetProps", () => {
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("does not copy an Asset into related frontmatter bindings", () => {
|
||||
expect(
|
||||
shouldSyncMediaAssetProps({
|
||||
component: "Image",
|
||||
propName: "src",
|
||||
propValue: { type: "asset" },
|
||||
propType: "expression",
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,11 @@ import { useStore } from "@nanostores/react";
|
||||
import { matchSorter } from "match-sorter";
|
||||
import {
|
||||
type Instance,
|
||||
type Prop,
|
||||
type Props,
|
||||
blockComponent,
|
||||
contentBlockSourceProp,
|
||||
toAssetReferenceRuntimeData,
|
||||
descendantComponent,
|
||||
rootComponent,
|
||||
} from "@webstudio-is/sdk";
|
||||
@@ -16,6 +20,7 @@ import {
|
||||
Flex,
|
||||
Box,
|
||||
Grid,
|
||||
toast,
|
||||
} from "@webstudio-is/design-system";
|
||||
import {
|
||||
isAttributeNameSafe,
|
||||
@@ -31,11 +36,17 @@ import {
|
||||
$memoryProps,
|
||||
$selectedBreakpoint,
|
||||
} from "~/shared/nano-states";
|
||||
import { $props } from "~/shared/sync/data-stores";
|
||||
import {
|
||||
$assetFolders,
|
||||
$assets,
|
||||
$instances,
|
||||
$props,
|
||||
} from "~/shared/sync/data-stores";
|
||||
import { CollapsibleSectionWithAddButton } from "~/builder/shared/collapsible-section";
|
||||
import {
|
||||
$selectedInstance,
|
||||
$selectedInstanceKey,
|
||||
$selectedInstanceSelector,
|
||||
getInstanceKey,
|
||||
} from "~/shared/nano-states";
|
||||
import { renderControl } from "../controls/combined";
|
||||
@@ -48,6 +59,23 @@ import {
|
||||
$selectedInstancePropsMetas,
|
||||
} from "../shared";
|
||||
import { executeRuntimeMutation } from "~/shared/instance-utils/data";
|
||||
import { ContentBlockSourceSection } from "../controls/content-block-source-section";
|
||||
import {
|
||||
$externalContentRoots,
|
||||
findExternalContentRootEntryBySelector,
|
||||
} from "~/shared/external-content-mutations";
|
||||
import { updateExternalContentFrontmatter } from "~/shared/external-content-roots";
|
||||
import {
|
||||
getSelectedContentBlockDocumentBindingPath,
|
||||
getSelectedContentBlockExpressionMode,
|
||||
isObjectPathWritable,
|
||||
} from "~/shared/content-block-document";
|
||||
import {
|
||||
createMdxAssetReferenceValues,
|
||||
findProp,
|
||||
} from "@webstudio-is/project-build/runtime";
|
||||
import { resolveAssetValueReferences } from "@webstudio-is/content-engine";
|
||||
import type { PropValue } from "../shared";
|
||||
|
||||
type Item = {
|
||||
name: string;
|
||||
@@ -97,15 +125,20 @@ const shouldRenderPropsSectionContainer = ({
|
||||
propsMetasSize,
|
||||
hasVisibleProps,
|
||||
isContentMode,
|
||||
isDesignMode,
|
||||
}: {
|
||||
component: Instance["component"];
|
||||
propsMetasSize: number;
|
||||
hasVisibleProps: boolean;
|
||||
isContentMode: boolean;
|
||||
isDesignMode: boolean;
|
||||
}) => {
|
||||
if (component === rootComponent) {
|
||||
return false;
|
||||
}
|
||||
if (component === blockComponent) {
|
||||
return isDesignMode || (isContentMode && hasVisibleProps);
|
||||
}
|
||||
return propsMetasSize > 0 || (isContentMode && hasVisibleProps);
|
||||
};
|
||||
|
||||
@@ -113,14 +146,43 @@ const shouldSyncMediaAssetProps = ({
|
||||
component,
|
||||
propName,
|
||||
propValue,
|
||||
propType,
|
||||
}: {
|
||||
component: Instance["component"];
|
||||
propName: string;
|
||||
propValue: { type: string };
|
||||
propType?: Prop["type"];
|
||||
}) =>
|
||||
(component === "Image" || component === "Video") &&
|
||||
propName === "src" &&
|
||||
propValue.type === "asset";
|
||||
propValue.type === "asset" &&
|
||||
propType !== "expression";
|
||||
|
||||
const findExpressionPropByStandardName = ({
|
||||
props,
|
||||
instanceId,
|
||||
propName,
|
||||
}: {
|
||||
props: readonly Prop[];
|
||||
instanceId: Instance["id"];
|
||||
propName: Prop["name"];
|
||||
}) => {
|
||||
const findExpression = (name: string) => {
|
||||
const prop = findProp(props, instanceId, name);
|
||||
return prop?.type === "expression" ? prop : undefined;
|
||||
};
|
||||
const reactPropName = standardAttributesToReactProps[propName];
|
||||
return (
|
||||
findExpression(propName) ??
|
||||
(reactPropName === undefined ? undefined : findExpression(reactPropName))
|
||||
);
|
||||
};
|
||||
|
||||
const shouldWriteBoundValue = (
|
||||
isContentMode: boolean,
|
||||
update: PropValue
|
||||
): update is Exclude<PropValue, { type: "expression" }> =>
|
||||
isContentMode && update.type !== "expression";
|
||||
|
||||
const renderProperty = (
|
||||
{
|
||||
@@ -129,10 +191,20 @@ const renderProperty = (
|
||||
propValuesByInstanceSelector,
|
||||
component,
|
||||
instanceId,
|
||||
selectedInstanceKey,
|
||||
}: PropsSectionProps,
|
||||
item: PropAndMeta
|
||||
) => {
|
||||
const { prop, propName, meta } = item;
|
||||
if (component === blockComponent && propName === contentBlockSourceProp) {
|
||||
return (
|
||||
<ContentBlockSourceSection
|
||||
key={propName}
|
||||
blockInstanceId={instanceId}
|
||||
renderScope={selectedInstanceKey}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const targetInstanceId = item.instanceId ?? instanceId;
|
||||
const targetPropValues =
|
||||
item.instanceSelector === undefined
|
||||
@@ -161,6 +233,7 @@ const renderProperty = (
|
||||
component,
|
||||
propName,
|
||||
propValue,
|
||||
propType: prop?.type,
|
||||
})
|
||||
) {
|
||||
logic.handleChangeByPropName("width", propValue);
|
||||
@@ -274,8 +347,9 @@ export const PropsSection = (props: PropsSectionProps) => {
|
||||
|
||||
const matchMediaValue = matchMediaBreakpoints(matchingBreakpoints);
|
||||
|
||||
const hasProperties =
|
||||
logic.addedProps.length > 0 || logic.initialProps.length > 0;
|
||||
const addedProps = logic.addedProps;
|
||||
const initialProps = logic.initialProps;
|
||||
const hasProperties = addedProps.length > 0 || initialProps.length > 0;
|
||||
const hasItems = hasProperties || (isDesignMode && addingProp);
|
||||
|
||||
const animationAction = logic.initialProps.find(
|
||||
@@ -368,8 +442,8 @@ export const PropsSection = (props: PropsSectionProps) => {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{logic.addedProps.map((item) => renderProperty(props, item))}
|
||||
{logic.initialProps.map((item) => renderProperty(props, item))}
|
||||
{addedProps.map((item) => renderProperty(props, item))}
|
||||
{initialProps.map((item) => renderProperty(props, item))}
|
||||
</Flex>
|
||||
</CollapsibleSectionWithAddButton>
|
||||
)}
|
||||
@@ -381,6 +455,8 @@ export const __testing__ = {
|
||||
shouldShowPropertiesSection,
|
||||
shouldRenderPropsSectionContainer,
|
||||
shouldSyncMediaAssetProps,
|
||||
findExpressionPropByStandardName,
|
||||
shouldWriteBoundValue,
|
||||
};
|
||||
|
||||
const $propValues = computed(
|
||||
@@ -399,16 +475,149 @@ export const PropsSectionContainer = ({
|
||||
const { propsByInstanceId } = useStore($propsIndex);
|
||||
const propValues = useStore($propValues);
|
||||
const propValuesByInstanceSelector = useStore($propValuesByInstanceSelector);
|
||||
const selectedInstanceSelector = useStore($selectedInstanceSelector);
|
||||
const externalContentRoots = useStore($externalContentRoots);
|
||||
const assets = useStore($assets);
|
||||
const assetFolders = useStore($assetFolders);
|
||||
|
||||
const logic = usePropsLogic({
|
||||
instance,
|
||||
props: propsByInstanceId.get(instance.id) ?? [],
|
||||
|
||||
updateProp: (update) => {
|
||||
const originalProp = findExpressionPropByStandardName({
|
||||
props: propsByInstanceId.get(instance.id) ?? [],
|
||||
instanceId: instance.id,
|
||||
propName: update.name,
|
||||
});
|
||||
const externalEntry =
|
||||
selectedInstanceSelector === undefined
|
||||
? undefined
|
||||
: findExternalContentRootEntryBySelector(
|
||||
externalContentRoots,
|
||||
selectedInstanceSelector
|
||||
);
|
||||
const candidateRoot = externalEntry?.[1];
|
||||
const persistedUpdate =
|
||||
update.type === "expression"
|
||||
? {
|
||||
...update,
|
||||
mode:
|
||||
selectedInstanceSelector === undefined
|
||||
? "read"
|
||||
: getSelectedContentBlockExpressionMode({
|
||||
expression: update.value,
|
||||
instanceSelector: selectedInstanceSelector,
|
||||
instances: $instances.get(),
|
||||
props: $props.get(),
|
||||
sourceBlockInstanceId:
|
||||
candidateRoot?.sourceBlockInstanceId,
|
||||
renderedBlockInstanceId: candidateRoot?.blockInstanceId,
|
||||
}),
|
||||
}
|
||||
: update;
|
||||
if (
|
||||
originalProp?.type === "expression" &&
|
||||
shouldWriteBoundValue($isContentMode.get(), persistedUpdate)
|
||||
) {
|
||||
const path =
|
||||
selectedInstanceSelector === undefined
|
||||
? undefined
|
||||
: getSelectedContentBlockDocumentBindingPath({
|
||||
binding: originalProp,
|
||||
instanceSelector: selectedInstanceSelector,
|
||||
instances: $instances.get(),
|
||||
props: $props.get(),
|
||||
sourceBlockInstanceId: candidateRoot?.sourceBlockInstanceId,
|
||||
renderedBlockInstanceId: candidateRoot?.blockInstanceId,
|
||||
});
|
||||
if (path !== undefined && externalEntry === undefined) {
|
||||
toast.error("The MDX content source is not ready for editing.");
|
||||
return;
|
||||
}
|
||||
if (path === undefined || externalEntry === undefined) {
|
||||
executeRuntimeMutation({
|
||||
id: "instances.updateProps",
|
||||
input: { updates: [persistedUpdate] },
|
||||
});
|
||||
return;
|
||||
}
|
||||
const [rootKey, externalRoot] = externalEntry;
|
||||
let targetPath: readonly string[] = path;
|
||||
let nextValue: unknown = update.value;
|
||||
let nextResolvedValue: unknown = update.value;
|
||||
if (update.type === "asset" && externalRoot.identity !== undefined) {
|
||||
const sourceAsset = assets.get(externalRoot.identity.assetId);
|
||||
const targetAsset = assets.get(update.value);
|
||||
const reference =
|
||||
sourceAsset === undefined || targetAsset === undefined
|
||||
? undefined
|
||||
: createMdxAssetReferenceValues({
|
||||
source: sourceAsset,
|
||||
assets: assets.values(),
|
||||
assetFolders,
|
||||
}).get(targetAsset.id);
|
||||
if (reference !== undefined && targetAsset !== undefined) {
|
||||
targetPath =
|
||||
path.length > 1 && path.at(-1) === "src"
|
||||
? path.slice(0, -1)
|
||||
: path;
|
||||
nextValue = { $ref: reference };
|
||||
nextResolvedValue = resolveAssetValueReferences({
|
||||
value: nextValue,
|
||||
references: [
|
||||
{
|
||||
path: [],
|
||||
assetId: targetAsset.id,
|
||||
structured: true,
|
||||
},
|
||||
],
|
||||
runtimeAssets: {
|
||||
[targetAsset.id]: toAssetReferenceRuntimeData(
|
||||
targetAsset,
|
||||
window.location.origin
|
||||
),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
if (externalRoot.document === undefined) {
|
||||
toast.error("The MDX content source is not ready for editing.");
|
||||
return;
|
||||
}
|
||||
if (
|
||||
isObjectPathWritable({
|
||||
value: externalRoot.document.frontmatter.properties,
|
||||
path: targetPath,
|
||||
}) === false
|
||||
) {
|
||||
toast.error("Open the referenced file to edit this value.");
|
||||
return;
|
||||
}
|
||||
if (update.type === "asset" && typeof nextValue !== "object") {
|
||||
toast.error(
|
||||
"The selected Asset cannot be referenced from this file."
|
||||
);
|
||||
return;
|
||||
}
|
||||
void updateExternalContentFrontmatter({
|
||||
rootKey,
|
||||
path: targetPath,
|
||||
value: nextValue,
|
||||
resolvedValue: nextResolvedValue,
|
||||
}).catch((error) => {
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Unable to update MDX frontmatter"
|
||||
);
|
||||
});
|
||||
return;
|
||||
}
|
||||
executeRuntimeMutation({
|
||||
id: "instances.updateProps",
|
||||
input: {
|
||||
updates: [update],
|
||||
updates: [persistedUpdate],
|
||||
},
|
||||
});
|
||||
},
|
||||
@@ -416,6 +625,7 @@ export const PropsSectionContainer = ({
|
||||
|
||||
const propsMetas = useStore($selectedInstancePropsMetas);
|
||||
const isContentMode = useStore($isContentMode);
|
||||
const isDesignMode = useStore($isDesignMode);
|
||||
const hasVisibleProps =
|
||||
logic.systemProps.length > 0 ||
|
||||
logic.initialProps.length > 0 ||
|
||||
@@ -426,6 +636,7 @@ export const PropsSectionContainer = ({
|
||||
propsMetasSize: propsMetas.size,
|
||||
hasVisibleProps,
|
||||
isContentMode,
|
||||
isDesignMode,
|
||||
}) === false
|
||||
) {
|
||||
return;
|
||||
|
||||
+106
@@ -5,6 +5,7 @@ import type {
|
||||
PropMeta,
|
||||
WsComponentMeta,
|
||||
} from "@webstudio-is/sdk";
|
||||
import { blockComponent, contentBlockSourceProp } from "@webstudio-is/sdk";
|
||||
import { textContentAttribute } from "@webstudio-is/react-sdk";
|
||||
import { __testing__ } from "./use-props-logic";
|
||||
import type { ContentModeCapabilities } from "@webstudio-is/project-build/runtime";
|
||||
@@ -15,6 +16,7 @@ const { isPropVisibleInContentMode, getAndDelete, canShowTextContent } =
|
||||
const getInput = (
|
||||
input: Partial<Parameters<typeof isPropVisibleInContentMode>[0]> = {}
|
||||
): Parameters<typeof isPropVisibleInContentMode>[0] => ({
|
||||
component: "Box",
|
||||
propName: "title",
|
||||
props: [],
|
||||
propsMetas: new Map(),
|
||||
@@ -66,6 +68,52 @@ describe("isPropVisibleInContentMode", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("shows only the exact frontmatter-bound shell property", () => {
|
||||
const title: Prop = {
|
||||
id: "title-prop",
|
||||
instanceId: "editable-instance",
|
||||
name: "title",
|
||||
type: "expression",
|
||||
value: "$ws$dataSource$document.frontmatter.title",
|
||||
};
|
||||
const description: Prop = {
|
||||
id: "description-prop",
|
||||
instanceId: "editable-instance",
|
||||
name: "description",
|
||||
type: "string",
|
||||
value: "Designed description",
|
||||
};
|
||||
const capabilities = {
|
||||
...getInput().capabilities,
|
||||
editableInstanceIds: new Set<string>(),
|
||||
frontmatterBoundPropIds: new Set([title.id]),
|
||||
};
|
||||
|
||||
expect(
|
||||
isPropVisibleInContentMode(
|
||||
getInput({ props: [title, description], capabilities })
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
isPropVisibleInContentMode(
|
||||
getInput({
|
||||
propName: "description",
|
||||
props: [title, description],
|
||||
capabilities,
|
||||
})
|
||||
)
|
||||
).toBe(false);
|
||||
expect(
|
||||
isPropVisibleInContentMode(
|
||||
getInput({
|
||||
propName: textContentAttribute,
|
||||
props: [title, description],
|
||||
capabilities,
|
||||
})
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("shows existing asset props as content", () => {
|
||||
const prop: Prop = {
|
||||
id: "image-prop",
|
||||
@@ -106,6 +154,64 @@ describe("isPropVisibleInContentMode", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("keeps explicitly Design-mode file controls out of Content mode", () => {
|
||||
const fileMeta: PropMeta = {
|
||||
type: "string",
|
||||
control: "file",
|
||||
required: false,
|
||||
contentMode: false,
|
||||
};
|
||||
|
||||
expect(
|
||||
isPropVisibleInContentMode(
|
||||
getInput({
|
||||
propName: "src",
|
||||
propsMetas: new Map([["src", fileMeta]]),
|
||||
})
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("shows an existing Content Block source without making it editable", () => {
|
||||
const source: Prop = {
|
||||
id: "source-prop",
|
||||
instanceId: "editable-instance",
|
||||
name: contentBlockSourceProp,
|
||||
type: "asset",
|
||||
value: "post",
|
||||
};
|
||||
const sourceMeta: PropMeta = {
|
||||
type: "string",
|
||||
control: "file",
|
||||
required: false,
|
||||
contentMode: false,
|
||||
};
|
||||
|
||||
expect(
|
||||
isPropVisibleInContentMode(
|
||||
getInput({
|
||||
component: blockComponent,
|
||||
propName: contentBlockSourceProp,
|
||||
props: [source],
|
||||
propsMetas: new Map([[contentBlockSourceProp, sourceMeta]]),
|
||||
capabilities: {
|
||||
...getInput().capabilities,
|
||||
editableInstanceIds: new Set(),
|
||||
},
|
||||
})
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
isPropVisibleInContentMode(
|
||||
getInput({
|
||||
component: blockComponent,
|
||||
propName: contentBlockSourceProp,
|
||||
propsMetas: new Map([[contentBlockSourceProp, sourceMeta]]),
|
||||
})
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("shows only props marked as content mode in metadata", () => {
|
||||
const contentModeMeta: PropMeta = {
|
||||
type: "string",
|
||||
|
||||
@@ -6,7 +6,11 @@ import type {
|
||||
Props,
|
||||
WsComponentMeta,
|
||||
} from "@webstudio-is/sdk";
|
||||
import { descendantComponent } from "@webstudio-is/sdk";
|
||||
import {
|
||||
blockComponent,
|
||||
contentBlockSourceProp,
|
||||
descendantComponent,
|
||||
} from "@webstudio-is/sdk";
|
||||
import {
|
||||
canHaveTextContent,
|
||||
createStartingPropValueFromMeta,
|
||||
@@ -49,27 +53,50 @@ export type PropAndMeta = {
|
||||
};
|
||||
|
||||
const isPropVisibleInContentMode = ({
|
||||
component,
|
||||
propName,
|
||||
props,
|
||||
propsMetas,
|
||||
selectedInstanceSelector,
|
||||
capabilities,
|
||||
}: {
|
||||
component: Instance["component"];
|
||||
propName: string;
|
||||
props: Prop[];
|
||||
propsMetas: Map<string, PropMeta>;
|
||||
selectedInstanceSelector: undefined | Instance["id"][];
|
||||
capabilities: ContentModeCapabilities;
|
||||
}) => {
|
||||
if (
|
||||
selectedInstanceSelector === undefined ||
|
||||
capabilities.editableInstanceIds.has(selectedInstanceSelector[0]) === false
|
||||
) {
|
||||
if (selectedInstanceSelector === undefined) {
|
||||
return false;
|
||||
}
|
||||
if (component === blockComponent && propName === contentBlockSourceProp) {
|
||||
return props.some(
|
||||
(prop) =>
|
||||
prop.name === contentBlockSourceProp &&
|
||||
(prop.type === "asset" || prop.type === "expression")
|
||||
);
|
||||
}
|
||||
const instanceId = selectedInstanceSelector[0];
|
||||
if (capabilities.editableInstanceIds.has(instanceId) === false) {
|
||||
if (propName === textContentAttribute) {
|
||||
return (
|
||||
capabilities.frontmatterBoundTextInstanceIds?.has(instanceId) === true
|
||||
);
|
||||
}
|
||||
return props.some(
|
||||
(prop) =>
|
||||
prop.name === propName &&
|
||||
capabilities.frontmatterBoundPropIds?.has(prop.id) === true
|
||||
);
|
||||
}
|
||||
if (propName === textContentAttribute) {
|
||||
return true;
|
||||
}
|
||||
const propMeta = propsMetas.get(propName);
|
||||
if (propMeta?.contentMode === false) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
props.some(
|
||||
(prop) =>
|
||||
@@ -78,7 +105,6 @@ const isPropVisibleInContentMode = ({
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
const propMeta = propsMetas.get(propName);
|
||||
if (propMeta?.type === "string" && propMeta.control === "file") {
|
||||
return true;
|
||||
}
|
||||
@@ -181,6 +207,7 @@ export const usePropsLogic = ({
|
||||
return false;
|
||||
}
|
||||
return isPropVisibleInContentMode({
|
||||
component: instance.component,
|
||||
propName,
|
||||
props,
|
||||
propsMetas,
|
||||
@@ -256,7 +283,7 @@ export const usePropsLogic = ({
|
||||
propName: textContentAttribute,
|
||||
instanceId: textContentTarget.instanceId,
|
||||
instanceSelector: textContentTarget.instanceSelector,
|
||||
meta: {
|
||||
meta: metas.get(instance.component)?.textContent ?? {
|
||||
required: false,
|
||||
control: "textContent",
|
||||
type: "string",
|
||||
|
||||
@@ -1,25 +1,54 @@
|
||||
import { useId } from "react";
|
||||
import { useEffect, useId, useState } from "react";
|
||||
import { useStore } from "@nanostores/react";
|
||||
import { InputField } from "@webstudio-is/design-system";
|
||||
import { InputField, Tooltip } from "@webstudio-is/design-system";
|
||||
import { useDraftValue } from "~/builder/shared/use-draft-value";
|
||||
import { HorizontalLayout, Label, Row } from "./shared";
|
||||
import { $selectedInstance } from "~/shared/nano-states";
|
||||
import { getInstanceLabel } from "~/builder/shared/instance-label";
|
||||
import { executeRuntimeMutation } from "~/shared/instance-utils/data";
|
||||
|
||||
const saveLabel = (label: string, instanceId: string) => {
|
||||
executeRuntimeMutation({
|
||||
id: "instances.setLabel",
|
||||
input: { instanceId, label },
|
||||
});
|
||||
};
|
||||
import {
|
||||
executeRuntimeMutation,
|
||||
getDuplicateTemplateNameMessage,
|
||||
} from "~/shared/instance-utils/data";
|
||||
import {
|
||||
$externalContentRoots,
|
||||
externalContentInstanceNameMessage,
|
||||
isExternalContentInstance,
|
||||
} from "~/shared/external-content-mutations";
|
||||
|
||||
export const SettingsSection = () => {
|
||||
const selectedInstance = useStore($selectedInstance);
|
||||
const externalContentRoots = useStore($externalContentRoots);
|
||||
const id = useId();
|
||||
const [error, setError] = useState<string>();
|
||||
useEffect(() => setError(undefined), [selectedInstance?.id]);
|
||||
const localValue = useDraftValue(
|
||||
selectedInstance?.label ?? "",
|
||||
(value) => selectedInstance && saveLabel(value, selectedInstance.id),
|
||||
(value) => {
|
||||
if (selectedInstance === undefined) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
isExternalContentInstance(
|
||||
$externalContentRoots.get(),
|
||||
selectedInstance.id
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
executeRuntimeMutation({
|
||||
id: "instances.setLabel",
|
||||
input: { instanceId: selectedInstance.id, label: value },
|
||||
});
|
||||
setError(undefined);
|
||||
} catch (error) {
|
||||
const message = getDuplicateTemplateNameMessage(error);
|
||||
if (message === undefined) {
|
||||
throw error;
|
||||
}
|
||||
setError(message);
|
||||
}
|
||||
},
|
||||
{ autoSave: false }
|
||||
);
|
||||
|
||||
@@ -28,19 +57,33 @@ export const SettingsSection = () => {
|
||||
}
|
||||
|
||||
const placeholder = getInstanceLabel(selectedInstance);
|
||||
const isNameEditable =
|
||||
isExternalContentInstance(externalContentRoots, selectedInstance.id) ===
|
||||
false;
|
||||
|
||||
return (
|
||||
<Row>
|
||||
<HorizontalLayout label={<Label htmlFor={id}>Name</Label>}>
|
||||
<Tooltip
|
||||
content={isNameEditable ? error : externalContentInstanceNameMessage}
|
||||
delayDuration={0}
|
||||
>
|
||||
<InputField
|
||||
id={id}
|
||||
/* Key is required, otherwise when label is undefined, previous value stayed */
|
||||
key={selectedInstance.id}
|
||||
placeholder={placeholder}
|
||||
value={localValue.value}
|
||||
onChange={(event) => localValue.set(event.target.value)}
|
||||
color={error === undefined ? undefined : "error"}
|
||||
aria-invalid={error === undefined ? undefined : true}
|
||||
disabled={isNameEditable === false}
|
||||
onChange={(event) => {
|
||||
setError(undefined);
|
||||
localValue.set(event.target.value);
|
||||
}}
|
||||
onBlur={localValue.save}
|
||||
/>
|
||||
</Tooltip>
|
||||
</HorizontalLayout>
|
||||
</Row>
|
||||
);
|
||||
|
||||
@@ -54,7 +54,7 @@ export type PropValue =
|
||||
| { type: "boolean"; value: boolean }
|
||||
| { type: "json"; value: unknown }
|
||||
| { type: "string[]"; value: string[] }
|
||||
| { type: "expression"; value: string }
|
||||
| Pick<Extract<Prop, { type: "expression" }>, "type" | "value" | "mode">
|
||||
| { type: "asset"; value: Asset["id"] }
|
||||
| { type: "page"; value: Extract<Prop, { type: "page" }>["value"] }
|
||||
| { type: "action"; value: Extract<Prop, { type: "action" }>["value"] }
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { act } from "react-dom/test-utils";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
||||
import { $pendingTemplateNameConfirmation } from "~/shared/instance-utils/data";
|
||||
import { TemplateNameConfirmationDialog } from "./template-name-confirmation-dialog";
|
||||
|
||||
(
|
||||
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal(
|
||||
"ResizeObserver",
|
||||
class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
);
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
document.body.innerHTML = "";
|
||||
$pendingTemplateNameConfirmation.set(undefined);
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ action: "rename" as const, title: "Rename template", confirm: "Rename" },
|
||||
{ action: "delete" as const, title: "Delete template", confirm: "Delete" },
|
||||
])("shows exact $action actions and aborts accessibly", (expected) => {
|
||||
act(() => {
|
||||
$pendingTemplateNameConfirmation.set({
|
||||
operation:
|
||||
expected.action === "rename"
|
||||
? {
|
||||
id: "instances.setLabel",
|
||||
input: { instanceId: "card", label: "Hero Card" },
|
||||
}
|
||||
: { id: "instances.delete", input: { instanceIds: ["card"] } },
|
||||
confirmation: {
|
||||
action: expected.action,
|
||||
templates: [{ instanceId: "card", oldName: "Card" }],
|
||||
},
|
||||
});
|
||||
root.render(<TemplateNameConfirmationDialog />);
|
||||
});
|
||||
|
||||
const dialog = document.body.querySelector('[role="dialog"]');
|
||||
expect(dialog?.getAttribute("aria-labelledby")).toBeTruthy();
|
||||
expect(dialog?.textContent).toContain(expected.title);
|
||||
expect(
|
||||
Array.from(dialog?.querySelectorAll("button") ?? [], (button) =>
|
||||
button.textContent?.trim()
|
||||
).filter(Boolean)
|
||||
).toEqual(["Abort", expected.confirm]);
|
||||
|
||||
act(() => {
|
||||
Array.from(dialog?.querySelectorAll("button") ?? [])
|
||||
.find((button) => button.textContent === "Abort")
|
||||
?.click();
|
||||
});
|
||||
expect($pendingTemplateNameConfirmation.get()).toBeUndefined();
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useStore } from "@nanostores/react";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogTitle,
|
||||
Text,
|
||||
theme,
|
||||
} from "@webstudio-is/design-system";
|
||||
import {
|
||||
$pendingTemplateNameConfirmation,
|
||||
abortPendingTemplateNameConfirmation,
|
||||
confirmPendingTemplateNameChange,
|
||||
} from "~/shared/instance-utils/data";
|
||||
|
||||
export const TemplateNameConfirmationDialog = () => {
|
||||
const pending = useStore($pendingTemplateNameConfirmation);
|
||||
const action = pending?.confirmation.action;
|
||||
return (
|
||||
<Dialog
|
||||
open={pending !== undefined}
|
||||
onOpenChange={(open) => {
|
||||
if (open === false) {
|
||||
abortPendingTemplateNameConfirmation();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogTitle suffix={false}>
|
||||
{action === "delete" ? "Delete template" : "Rename template"}
|
||||
</DialogTitle>
|
||||
<DialogDescription asChild>
|
||||
<Text css={{ padding: theme.spacing[5] }}>
|
||||
{action === "delete"
|
||||
? "Deleting this template may disconnect references in connected MDX files. The files will not be changed."
|
||||
: "Renaming this template may disconnect references in connected MDX files. The files will not be changed."}
|
||||
</Text>
|
||||
</DialogDescription>
|
||||
<DialogActions>
|
||||
<Button
|
||||
autoFocus
|
||||
color="neutral"
|
||||
onClick={abortPendingTemplateNameConfirmation}
|
||||
>
|
||||
Abort
|
||||
</Button>
|
||||
<Button
|
||||
color={action === "delete" ? "destructive" : "primary"}
|
||||
onClick={confirmPendingTemplateNameChange}
|
||||
>
|
||||
{action === "delete" ? "Delete" : "Rename"}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +1,8 @@
|
||||
import { expect, test } from "vitest";
|
||||
import { expect, test, vi } from "vitest";
|
||||
import type { Asset } from "@webstudio-is/sdk";
|
||||
import {
|
||||
createTextFileData,
|
||||
createTextFile,
|
||||
getTextFileNameError,
|
||||
} from "./create-text-file-dialog";
|
||||
|
||||
@@ -29,6 +30,9 @@ test("accepts supported text names and rejects invalid or duplicate names", () =
|
||||
expect(
|
||||
getTextFileNameError({ name: "data.json", assets: [existing] })
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
getTextFileNameError({ name: "component.mdx", assets: [] })
|
||||
).toBeUndefined();
|
||||
expect(getTextFileNameError({ name: "image.png", assets: [] })).toBe(
|
||||
"Use a supported editable text extension."
|
||||
);
|
||||
@@ -54,6 +58,38 @@ test("compares global complete display names after an asset is renamed", () => {
|
||||
})
|
||||
).toBe("A file with this name already exists.");
|
||||
});
|
||||
|
||||
test("can restrict creation to the requested text format", () => {
|
||||
expect(
|
||||
getTextFileNameError({
|
||||
name: "post.mdx",
|
||||
assets: [],
|
||||
allowedExtensions: ["mdx"],
|
||||
})
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
getTextFileNameError({
|
||||
name: "post.md",
|
||||
assets: [],
|
||||
allowedExtensions: ["mdx"],
|
||||
})
|
||||
).toBe("Use a supported editable text extension.");
|
||||
expect(
|
||||
getTextFileNameError({
|
||||
name: "post.MDX",
|
||||
assets: [],
|
||||
allowedExtensions: ["MDX"],
|
||||
})
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
getTextFileNameError({
|
||||
name: "post.mdx.txt",
|
||||
assets: [],
|
||||
allowedExtensions: ["mdx"],
|
||||
})
|
||||
).toBe("Use a supported editable text extension.");
|
||||
});
|
||||
|
||||
test("creates valid initial content for editable file types", async () => {
|
||||
const jsonFile = createTextFileData("data.json");
|
||||
if (jsonFile === undefined) {
|
||||
@@ -72,3 +108,23 @@ test("creates valid initial content for editable file types", async () => {
|
||||
|
||||
expect(createTextFileData("image.png")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("creates an independent asset instead of deduplicating empty files", async () => {
|
||||
const asset = {
|
||||
...existing,
|
||||
id: "new",
|
||||
name: "post_hash.mdx",
|
||||
format: "mdx",
|
||||
};
|
||||
const upload = vi.fn(async () => asset);
|
||||
|
||||
await expect(createTextFile({ name: "post.mdx", upload })).resolves.toEqual(
|
||||
asset
|
||||
);
|
||||
expect(upload).toHaveBeenCalledOnce();
|
||||
expect(upload).toHaveBeenCalledWith(
|
||||
"file",
|
||||
expect.objectContaining({ name: "post.mdx" }),
|
||||
{ folderId: undefined, deduplicate: false }
|
||||
);
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useLayoutEffect, useState, type KeyboardEvent } from "react";
|
||||
import { useLayoutEffect, useRef, useState, type KeyboardEvent } from "react";
|
||||
import isValidFilename from "valid-filename";
|
||||
import {
|
||||
Button,
|
||||
@@ -25,16 +25,22 @@ import { uploadSingleAsset } from "~/builder/shared/assets/upload-assets";
|
||||
export const getTextFileNameError = ({
|
||||
name,
|
||||
assets,
|
||||
allowedExtensions,
|
||||
}: {
|
||||
name: string;
|
||||
assets: Iterable<Asset>;
|
||||
allowedExtensions?: readonly string[];
|
||||
}) => {
|
||||
if (isValidFilename(name) === false) {
|
||||
return "Enter a valid file name.";
|
||||
}
|
||||
const extension = getFileExtension(name)?.toLowerCase() ?? "";
|
||||
if (
|
||||
isTextFileAsset({ format: getFileExtension(name)?.toLowerCase() ?? "" }) ===
|
||||
false
|
||||
isTextFileAsset({ format: extension }) === false ||
|
||||
(allowedExtensions !== undefined &&
|
||||
allowedExtensions.some(
|
||||
(allowedExtension) => allowedExtension.toLowerCase() === extension
|
||||
) === false)
|
||||
) {
|
||||
return "Use a supported editable text extension.";
|
||||
}
|
||||
@@ -55,18 +61,20 @@ export const createTextFileData = (name: string): File | undefined => {
|
||||
});
|
||||
};
|
||||
|
||||
const createTextFile = async ({
|
||||
export const createTextFile = async ({
|
||||
name,
|
||||
folderId,
|
||||
upload = uploadSingleAsset,
|
||||
}: {
|
||||
name: string;
|
||||
folderId?: string;
|
||||
upload?: typeof uploadSingleAsset;
|
||||
}): Promise<Asset | undefined> => {
|
||||
const file = createTextFileData(name);
|
||||
if (file === undefined) {
|
||||
return;
|
||||
}
|
||||
return uploadSingleAsset("file", file, { folderId });
|
||||
return upload("file", file, { folderId, deduplicate: false });
|
||||
};
|
||||
|
||||
const stopEscapePropagation = (event: KeyboardEvent) => {
|
||||
@@ -78,34 +86,45 @@ const stopEscapePropagation = (event: KeyboardEvent) => {
|
||||
export const CreateTextFileDialog = ({
|
||||
open,
|
||||
folderId,
|
||||
defaultName = "untitled.md",
|
||||
allowedExtensions,
|
||||
title = "New text file",
|
||||
disabled = false,
|
||||
onOpenChange,
|
||||
onCreated,
|
||||
}: {
|
||||
open: boolean;
|
||||
folderId?: string;
|
||||
defaultName?: string;
|
||||
allowedExtensions?: readonly string[];
|
||||
title?: string;
|
||||
disabled?: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onCreated: (assetId: string) => void;
|
||||
}) => {
|
||||
const [name, setName] = useState("untitled.md");
|
||||
const [name, setName] = useState(defaultName);
|
||||
const [error, setError] = useState<string>();
|
||||
const [creating, setCreating] = useState(false);
|
||||
const nameInputRef = useRef<HTMLInputElement>(null);
|
||||
const dialogContentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (open) {
|
||||
setName("untitled.md");
|
||||
setName(defaultName);
|
||||
setError(undefined);
|
||||
setCreating(false);
|
||||
}
|
||||
}, [open]);
|
||||
}, [defaultName, open]);
|
||||
|
||||
const normalizedName = name.trim();
|
||||
const submit = async () => {
|
||||
if (creating) {
|
||||
if (creating || disabled) {
|
||||
return;
|
||||
}
|
||||
const validationError = getTextFileNameError({
|
||||
name: normalizedName,
|
||||
assets: $assets.get().values(),
|
||||
allowedExtensions,
|
||||
});
|
||||
setError(validationError);
|
||||
if (validationError !== undefined) {
|
||||
@@ -134,18 +153,29 @@ export const CreateTextFileDialog = ({
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
ref={dialogContentRef}
|
||||
minWidth={360}
|
||||
aria-describedby={undefined}
|
||||
onKeyDown={stopEscapePropagation}
|
||||
onOpenAutoFocus={(event) => {
|
||||
event.preventDefault();
|
||||
if (disabled) {
|
||||
dialogContentRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
const focusName = () => nameInputRef.current?.focus();
|
||||
focusName();
|
||||
requestAnimationFrame(focusName);
|
||||
}}
|
||||
>
|
||||
<DialogTitle>New text file</DialogTitle>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<Grid gap={3} css={{ padding: theme.panel.padding }}>
|
||||
<Grid gap={1}>
|
||||
<Label htmlFor="asset-text-file-name">File name</Label>
|
||||
<InputField
|
||||
id="asset-text-file-name"
|
||||
autoFocus
|
||||
disabled={creating}
|
||||
inputRef={nameInputRef}
|
||||
disabled={creating || disabled}
|
||||
value={name}
|
||||
color={error === undefined ? undefined : "error"}
|
||||
onChange={(event) => {
|
||||
@@ -165,7 +195,10 @@ export const CreateTextFileDialog = ({
|
||||
)}
|
||||
</Grid>
|
||||
<Flex justify="end">
|
||||
<Button disabled={creating} onClick={() => void submit()}>
|
||||
<Button
|
||||
disabled={creating || disabled}
|
||||
onClick={() => void submit()}
|
||||
>
|
||||
{creating ? "Creating…" : "Create file"}
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { act } from "react-dom/test-utils";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
||||
import { CreateTextFileDialog } from "./create-text-file-dialog";
|
||||
|
||||
(
|
||||
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
let animationFrames: FrameRequestCallback[];
|
||||
|
||||
beforeEach(() => {
|
||||
animationFrames = [];
|
||||
vi.stubGlobal(
|
||||
"ResizeObserver",
|
||||
class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
);
|
||||
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
|
||||
animationFrames.push(callback);
|
||||
return animationFrames.length;
|
||||
});
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
document.body.innerHTML = "";
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test("focuses the file name after the opening control restores focus", () => {
|
||||
act(() => {
|
||||
root.render(
|
||||
<CreateTextFileDialog open onOpenChange={vi.fn()} onCreated={vi.fn()} />
|
||||
);
|
||||
});
|
||||
|
||||
const input = document.querySelector<HTMLInputElement>(
|
||||
"#asset-text-file-name"
|
||||
);
|
||||
const close = document.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="Close"]'
|
||||
);
|
||||
if (input === null || close === null) {
|
||||
throw new Error("Expected the text file dialog controls");
|
||||
}
|
||||
|
||||
close.focus();
|
||||
expect(document.activeElement).toBe(close);
|
||||
|
||||
act(() => {
|
||||
for (const callback of animationFrames.splice(0)) {
|
||||
callback(0);
|
||||
}
|
||||
});
|
||||
|
||||
expect(document.activeElement).toBe(input);
|
||||
});
|
||||
|
||||
test("focuses the dialog itself when creation is disabled", () => {
|
||||
act(() => {
|
||||
root.render(
|
||||
<CreateTextFileDialog
|
||||
open
|
||||
disabled
|
||||
onOpenChange={vi.fn()}
|
||||
onCreated={vi.fn()}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
expect(document.activeElement).toBe(
|
||||
document.querySelector<HTMLElement>('[role="dialog"]')
|
||||
);
|
||||
expect(animationFrames).toHaveLength(0);
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { EditorState } from "@codemirror/state";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { forceLinting, forEachDiagnostic } from "@codemirror/lint";
|
||||
import { expect, test } from "vitest";
|
||||
import { getTextFileEditorExtensions } from "./text-file-utils";
|
||||
|
||||
test("decorates the exact unsupported MDX source range", async () => {
|
||||
const parent = document.createElement("div");
|
||||
document.body.appendChild(parent);
|
||||
const view = new EditorView({
|
||||
parent,
|
||||
state: EditorState.create({
|
||||
doc: "# Kept\n\n{danger()}\n",
|
||||
extensions: getTextFileEditorExtensions({ format: "mdx" }),
|
||||
}),
|
||||
});
|
||||
|
||||
forceLinting(view);
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
const diagnostics: unknown[] = [];
|
||||
forEachDiagnostic(view.state, (diagnostic, from, to) => {
|
||||
diagnostics.push({ ...diagnostic, from, to });
|
||||
});
|
||||
|
||||
expect(diagnostics).toEqual([
|
||||
{
|
||||
from: 8,
|
||||
to: 18,
|
||||
severity: "warning",
|
||||
message: "Executable MDX expressions are not supported",
|
||||
},
|
||||
]);
|
||||
view.destroy();
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
@@ -38,8 +39,16 @@ import {
|
||||
TextItalicIcon,
|
||||
TextStrikethroughIcon,
|
||||
} from "@webstudio-is/icons";
|
||||
import { formatAssetName } from "@webstudio-is/project-build/runtime";
|
||||
import { getAssetUrl, getPagePath, type Asset } from "@webstudio-is/sdk";
|
||||
import {
|
||||
formatAssetName,
|
||||
MdxAuthoredContentConflictError,
|
||||
} from "@webstudio-is/project-build/runtime";
|
||||
import {
|
||||
getAssetUrl,
|
||||
getPagePath,
|
||||
isMdxFileAsset,
|
||||
type Asset,
|
||||
} from "@webstudio-is/sdk";
|
||||
import { CodeEditor } from "~/shared/code-editor";
|
||||
import { EditorDialog, type EditorApi } from "~/shared/code-editor-base";
|
||||
import {
|
||||
@@ -61,10 +70,19 @@ import {
|
||||
} from "~/builder/features/settings-panel/controls/url";
|
||||
import {
|
||||
getTextFileEditorExtensions,
|
||||
getMdxPersistenceFeedback,
|
||||
isMarkdownAsset,
|
||||
type MdxPersistenceFeedback,
|
||||
normalizeTextFileContent,
|
||||
} from "./text-file-utils";
|
||||
import { MarkdownSplitView } from "./markdown-preview";
|
||||
import { getAssetContentBridge } from "~/shared/asset-content-bridge.client";
|
||||
import { $externalContentRoots } from "~/shared/external-content-mutations";
|
||||
import type { AssetContentSessionState } from "@webstudio-is/content-engine/asset-content-session";
|
||||
import {
|
||||
replaceExternalContentAssetSource,
|
||||
retryExternalContentAsset,
|
||||
} from "~/shared/external-content-roots";
|
||||
|
||||
type TextFileState =
|
||||
| { status: "loading" }
|
||||
@@ -429,16 +447,44 @@ export const TextFileEditor = ({
|
||||
}) => {
|
||||
const assets = useStore($assets);
|
||||
const assetFolders = useStore($assetFolders);
|
||||
const externalContentRoots = useStore($externalContentRoots);
|
||||
const asset = assets.get(assetId);
|
||||
const { assetContainers } = useAssets();
|
||||
const canEdit = useStore($authPermit) !== "view";
|
||||
const [state, setState] = useState<TextFileState>({ status: "loading" });
|
||||
const [previewOpen, setPreviewOpen] = useState(false);
|
||||
const [persistenceFeedback, setPersistenceFeedback] =
|
||||
useState<MdxPersistenceFeedback>();
|
||||
const [previewOpen, setPreviewOpen] = useState(true);
|
||||
const currentAssetRef = useRef<Asset>();
|
||||
const persistedContentRef = useRef<string>();
|
||||
const requestedContentRef = useRef<string>();
|
||||
const pendingMdxSavesRef = useRef(0);
|
||||
const saveQueueRef = useRef(Promise.resolve());
|
||||
const editorApiRef = useRef<EditorApi>();
|
||||
const reportedConflictRef = useRef<string>();
|
||||
const mdxSession =
|
||||
asset !== undefined &&
|
||||
isMdxFileAsset(asset) &&
|
||||
asset.projectId !== undefined
|
||||
? getAssetContentBridge().getContentSession?.(asset.projectId)
|
||||
: undefined;
|
||||
const semanticDiagnostics = useMemo(
|
||||
() =>
|
||||
Array.from(externalContentRoots.values()).flatMap((root) =>
|
||||
root.identity?.assetId === assetId &&
|
||||
(root.projectId === undefined || root.projectId === asset?.projectId)
|
||||
? (root.diagnostics ?? [])
|
||||
: []
|
||||
),
|
||||
[asset?.projectId, assetId, externalContentRoots]
|
||||
);
|
||||
const languageExtensions = useMemo(
|
||||
() =>
|
||||
asset === undefined
|
||||
? []
|
||||
: getTextFileEditorExtensions(asset, semanticDiagnostics),
|
||||
[asset, semanticDiagnostics]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const assetToLoad = $assets.get().get(assetId);
|
||||
@@ -450,9 +496,44 @@ export const TextFileEditor = ({
|
||||
|
||||
const controller = new AbortController();
|
||||
setState({ status: "loading" });
|
||||
setPersistenceFeedback(undefined);
|
||||
reportedConflictRef.current = undefined;
|
||||
|
||||
const applySessionState = (sessionState: AssetContentSessionState) => {
|
||||
const feedback = getMdxPersistenceFeedback(sessionState);
|
||||
setPersistenceFeedback(feedback);
|
||||
if (
|
||||
feedback?.kind === "conflicting" &&
|
||||
reportedConflictRef.current !== feedback.message
|
||||
) {
|
||||
reportedConflictRef.current = feedback.message;
|
||||
getAssetContentBridge().requireReload(feedback.message);
|
||||
}
|
||||
if (feedback?.kind !== "conflicting") {
|
||||
reportedConflictRef.current = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
if (isMdxFileAsset(assetToLoad)) {
|
||||
const session = getAssetContentBridge().getContentSession?.(
|
||||
assetToLoad.projectId
|
||||
);
|
||||
if (session === undefined) {
|
||||
throw new Error("MDX content session is not available");
|
||||
}
|
||||
const opened = await session.open(assetId);
|
||||
if (controller.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
currentAssetRef.current = assetToLoad;
|
||||
persistedContentRef.current = opened.source;
|
||||
requestedContentRef.current = opened.source;
|
||||
applySessionState(opened);
|
||||
setState({ status: "loaded", content: opened.source });
|
||||
return;
|
||||
}
|
||||
const response = await fetch(
|
||||
getAssetUrl(assetToLoad, window.location.origin),
|
||||
{ signal: controller.signal }
|
||||
@@ -475,7 +556,30 @@ export const TextFileEditor = ({
|
||||
};
|
||||
|
||||
void load();
|
||||
return () => controller.abort();
|
||||
const unsubscribe = isMdxFileAsset(assetToLoad)
|
||||
? getAssetContentBridge()
|
||||
.getContentSession?.(assetToLoad.projectId)
|
||||
.subscribe((changedAssetId, sessionState) => {
|
||||
if (changedAssetId !== assetId || controller.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
currentAssetRef.current = sessionState.asset as Asset;
|
||||
persistedContentRef.current = sessionState.source;
|
||||
applySessionState(sessionState);
|
||||
if (requestedContentRef.current === sessionState.source) {
|
||||
return;
|
||||
}
|
||||
if (pendingMdxSavesRef.current > 0) {
|
||||
return;
|
||||
}
|
||||
requestedContentRef.current = sessionState.source;
|
||||
setState({ status: "loaded", content: sessionState.source });
|
||||
})
|
||||
: undefined;
|
||||
return () => {
|
||||
controller.abort();
|
||||
unsubscribe?.();
|
||||
};
|
||||
}, [assetId]);
|
||||
|
||||
const save = (content: string) => {
|
||||
@@ -496,7 +600,42 @@ export const TextFileEditor = ({
|
||||
if (normalizedContent !== content) {
|
||||
setState({ status: "loaded", content: normalizedContent });
|
||||
}
|
||||
const expectedContent = requestedContentRef.current;
|
||||
requestedContentRef.current = normalizedContent;
|
||||
if (isMdxFileAsset(currentAsset) && mdxSession !== undefined) {
|
||||
if (
|
||||
expectedContent === undefined ||
|
||||
currentAsset.projectId === undefined
|
||||
) {
|
||||
toast.error("Unable to save: MDX content is not loaded");
|
||||
return;
|
||||
}
|
||||
pendingMdxSavesRef.current += 1;
|
||||
void replaceExternalContentAssetSource({
|
||||
projectId: currentAsset.projectId,
|
||||
assetId,
|
||||
expectedSource: expectedContent,
|
||||
source: normalizedContent,
|
||||
})
|
||||
.catch((error) => {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Unable to save this file.";
|
||||
const feedback: MdxPersistenceFeedback = {
|
||||
kind:
|
||||
error instanceof MdxAuthoredContentConflictError
|
||||
? "conflicting"
|
||||
: "failed",
|
||||
message,
|
||||
};
|
||||
setPersistenceFeedback(feedback);
|
||||
})
|
||||
.finally(() => {
|
||||
pendingMdxSavesRef.current -= 1;
|
||||
});
|
||||
return;
|
||||
}
|
||||
saveQueueRef.current = saveQueueRef.current.then(async () => {
|
||||
const requestedContent = requestedContentRef.current;
|
||||
if (
|
||||
@@ -534,13 +673,16 @@ export const TextFileEditor = ({
|
||||
<CodeEditor
|
||||
editorApiRef={editorApiRef}
|
||||
value={state.content}
|
||||
languageExtensions={getTextFileEditorExtensions(asset)}
|
||||
languageExtensions={languageExtensions}
|
||||
size="full"
|
||||
expandable={false}
|
||||
chromeless
|
||||
readOnly={canEdit === false}
|
||||
onChange={(content) => {
|
||||
setState({ status: "loaded", content });
|
||||
if (isMdxFileAsset(asset)) {
|
||||
save(content);
|
||||
}
|
||||
}}
|
||||
onChangeComplete={save}
|
||||
/>
|
||||
@@ -551,6 +693,8 @@ export const TextFileEditor = ({
|
||||
<EditorDialog
|
||||
title={title}
|
||||
contentPadding={false}
|
||||
width={isMarkdown ? 1280 : undefined}
|
||||
height={isMarkdown ? 960 : undefined}
|
||||
open
|
||||
onOpenChange={(open) => {
|
||||
if (open === false && state.status === "loaded") {
|
||||
@@ -578,11 +722,46 @@ export const TextFileEditor = ({
|
||||
css={{
|
||||
display: "grid",
|
||||
gridTemplateRows: isMarkdown
|
||||
? persistenceFeedback === undefined
|
||||
? "auto minmax(0, 1fr)"
|
||||
: "auto auto minmax(0, 1fr)"
|
||||
: "minmax(0, 1fr)",
|
||||
height: "100%",
|
||||
}}
|
||||
>
|
||||
{persistenceFeedback !== undefined && (
|
||||
<Flex
|
||||
gap="2"
|
||||
align="center"
|
||||
css={{ padding: rawTheme.spacing[5] }}
|
||||
>
|
||||
<Text role="alert" color="destructive" variant="tiny">
|
||||
{persistenceFeedback.message}
|
||||
</Text>
|
||||
{persistenceFeedback.kind === "failed" &&
|
||||
mdxSession !== undefined && (
|
||||
<Button
|
||||
color="ghost"
|
||||
onClick={() => {
|
||||
void retryExternalContentAsset({
|
||||
projectId: asset.projectId!,
|
||||
assetId,
|
||||
}).catch((error) => {
|
||||
setPersistenceFeedback({
|
||||
kind: "failed",
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Unable to save this file.",
|
||||
});
|
||||
});
|
||||
}}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
)}
|
||||
</Flex>
|
||||
)}
|
||||
{isMarkdown && (
|
||||
<MarkdownToolbar
|
||||
editorApiRef={editorApiRef}
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
} from "@webstudio-is/sdk";
|
||||
import {
|
||||
getTextFileEditorExtensions,
|
||||
getMdxEditorDiagnostics,
|
||||
getMdxPersistenceFeedback,
|
||||
isMarkdownAsset,
|
||||
normalizeTextFileContent,
|
||||
normalizeTextFileConversion,
|
||||
@@ -38,7 +40,11 @@ describe("text file assets", () => {
|
||||
const language = getAssetTextEditorLanguage({ format });
|
||||
expect(isTextFileAsset({ format })).toBe(language !== undefined);
|
||||
expect(getTextFileEditorExtensions({ format })).toHaveLength(
|
||||
language === undefined || language === "plain" ? 0 : 1
|
||||
language === undefined || language === "plain"
|
||||
? 0
|
||||
: format === "mdx"
|
||||
? 4
|
||||
: 1
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -78,6 +84,103 @@ describe("text file assets", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("reports MDX diagnostics at parser source ranges", async () => {
|
||||
await expect(
|
||||
getMdxEditorDiagnostics("# Kept\n\n{danger()}\n")
|
||||
).resolves.toEqual([
|
||||
{
|
||||
from: 8,
|
||||
to: 18,
|
||||
severity: "warning",
|
||||
message: "Executable MDX expressions are not supported",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("reports unrecoverable MDX without blocking the source", async () => {
|
||||
const source = "# Kept\n\n<ws.element";
|
||||
const diagnostics = await getMdxEditorDiagnostics(source);
|
||||
expect(diagnostics).toHaveLength(1);
|
||||
expect(diagnostics[0]).toMatchObject({
|
||||
severity: "error",
|
||||
from: source.length,
|
||||
to: source.length,
|
||||
});
|
||||
});
|
||||
|
||||
test("reports connected Content Block warnings at property ranges", async () => {
|
||||
const source = '<ws.element ws:name="Card" class="legacy" />';
|
||||
await expect(
|
||||
getMdxEditorDiagnostics(source, [
|
||||
{
|
||||
code: "ignored-template-prop",
|
||||
severity: "warning",
|
||||
blockInstanceId: "block",
|
||||
assetId: "asset",
|
||||
templateName: "Card",
|
||||
propName: "class",
|
||||
reason: "incompatible",
|
||||
sourceRange: {
|
||||
start: { line: 1, column: 28, offset: 27 },
|
||||
end: { line: 1, column: 42, offset: 41 },
|
||||
},
|
||||
},
|
||||
])
|
||||
).resolves.toEqual([
|
||||
{
|
||||
from: 27,
|
||||
to: 41,
|
||||
severity: "warning",
|
||||
message:
|
||||
'Property "class" on template "Card" was ignored because it is incompatible. Line 1, column 28.',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("reports connected Content Block content-model errors", async () => {
|
||||
const source = '<ws.element ws:tag="li">nested item</ws.element>';
|
||||
await expect(
|
||||
getMdxEditorDiagnostics(source, [
|
||||
{
|
||||
code: "invalid-mdx",
|
||||
severity: "error",
|
||||
blockInstanceId: "block",
|
||||
assetId: "asset",
|
||||
message: "Placing <li> element inside a <li> violates HTML spec.",
|
||||
sourceRange: {
|
||||
start: { line: 1, column: 1, offset: 0 },
|
||||
end: { line: 1, column: source.length + 1, offset: source.length },
|
||||
},
|
||||
},
|
||||
])
|
||||
).resolves.toEqual([
|
||||
{
|
||||
from: 0,
|
||||
to: source.length,
|
||||
severity: "error",
|
||||
message: "Placing <li> element inside a <li> violates HTML spec.",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("keeps failed and conflicting MDX saves visible", () => {
|
||||
expect(
|
||||
getMdxPersistenceFeedback({
|
||||
status: "failed",
|
||||
error: new Error("Network unavailable"),
|
||||
})
|
||||
).toEqual({ kind: "failed", message: "Network unavailable" });
|
||||
expect(
|
||||
getMdxPersistenceFeedback({
|
||||
status: "conflicting",
|
||||
})
|
||||
).toEqual({
|
||||
kind: "conflicting",
|
||||
message: "This file changed elsewhere. Reload before continuing.",
|
||||
});
|
||||
expect(getMdxPersistenceFeedback({ status: "saved" })).toBeUndefined();
|
||||
});
|
||||
|
||||
test("rejects incomplete object syntax", () => {
|
||||
expect(normalizeTextFileContent({ format: "json" }, "{ title:")).toEqual({
|
||||
error: "Enter a JSON-compatible value.",
|
||||
|
||||
@@ -1,14 +1,121 @@
|
||||
import type { Extension } from "@codemirror/state";
|
||||
import { keymap } from "@codemirror/view";
|
||||
import {
|
||||
linter,
|
||||
lintGutter,
|
||||
lintKeymap,
|
||||
type Diagnostic,
|
||||
} from "@codemirror/lint";
|
||||
import { css } from "@codemirror/lang-css";
|
||||
import { html } from "@codemirror/lang-html";
|
||||
import { javascript } from "@codemirror/lang-javascript";
|
||||
import { markdown } from "@codemirror/lang-markdown";
|
||||
import { parseJsonExpression } from "@webstudio-is/expression";
|
||||
import {
|
||||
createMdxSourceDiagnostics,
|
||||
parseMdxDocumentRecovering,
|
||||
type MdxSourcePoint,
|
||||
} from "@webstudio-is/content-engine/mdx";
|
||||
import {
|
||||
getAssetTextEditorLanguage,
|
||||
type Asset,
|
||||
type AssetTextEditorLanguage,
|
||||
type ContentBlockDiagnostic,
|
||||
} from "@webstudio-is/sdk";
|
||||
import type { AssetContentSessionState } from "@webstudio-is/content-engine/asset-content-session";
|
||||
import { formatContentBlockDiagnostic } from "~/shared/content-block-diagnostics";
|
||||
|
||||
export type MdxPersistenceFeedback = Readonly<{
|
||||
kind: "failed" | "conflicting";
|
||||
message: string;
|
||||
}>;
|
||||
|
||||
export const getMdxPersistenceFeedback = (
|
||||
state: Pick<AssetContentSessionState, "status" | "error">
|
||||
): MdxPersistenceFeedback | undefined => {
|
||||
if (state.status === "failed") {
|
||||
return {
|
||||
kind: "failed",
|
||||
message: state.error?.message ?? "Unable to save this file.",
|
||||
};
|
||||
}
|
||||
if (state.status === "conflicting") {
|
||||
return {
|
||||
kind: "conflicting",
|
||||
message:
|
||||
state.error?.message ??
|
||||
"This file changed elsewhere. Reload before continuing.",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const getPointOffset = (source: string, point: MdxSourcePoint) => {
|
||||
if (point.offset !== undefined) {
|
||||
return Math.min(point.offset, source.length);
|
||||
}
|
||||
const lines = source.split("\n");
|
||||
let offset = 0;
|
||||
for (let index = 0; index < point.line - 1 && index < lines.length; index++) {
|
||||
offset += lines[index].length + 1;
|
||||
}
|
||||
return Math.min(offset + point.column - 1, source.length);
|
||||
};
|
||||
|
||||
export const getMdxEditorDiagnostics = async (
|
||||
source: string,
|
||||
semanticDiagnostics: readonly ContentBlockDiagnostic[] = []
|
||||
): Promise<Diagnostic[]> => {
|
||||
const result = await parseMdxDocumentRecovering({ source });
|
||||
const diagnostics = [
|
||||
...createMdxSourceDiagnostics(result.diagnostics).map((diagnostic) => ({
|
||||
sourceRange: diagnostic.sourceRange,
|
||||
severity: diagnostic.severity,
|
||||
message: diagnostic.message,
|
||||
})),
|
||||
...semanticDiagnostics.map((diagnostic) => ({
|
||||
sourceRange: diagnostic.sourceRange,
|
||||
severity: diagnostic.severity,
|
||||
message:
|
||||
diagnostic.code === "invalid-mdx"
|
||||
? diagnostic.message
|
||||
: diagnostic.code === "unsafe-mdx"
|
||||
? diagnostic.reason
|
||||
: formatContentBlockDiagnostic(diagnostic),
|
||||
})),
|
||||
].map((diagnostic) => {
|
||||
const from =
|
||||
diagnostic.sourceRange === undefined
|
||||
? 0
|
||||
: getPointOffset(source, diagnostic.sourceRange.start);
|
||||
const to =
|
||||
diagnostic.sourceRange === undefined
|
||||
? Math.min(1, source.length)
|
||||
: Math.max(from, getPointOffset(source, diagnostic.sourceRange.end));
|
||||
return {
|
||||
from,
|
||||
to,
|
||||
severity: diagnostic.severity,
|
||||
message: diagnostic.message,
|
||||
};
|
||||
});
|
||||
return Array.from(
|
||||
new Map(
|
||||
diagnostics.map((diagnostic) => [JSON.stringify(diagnostic), diagnostic])
|
||||
).values()
|
||||
);
|
||||
};
|
||||
|
||||
const getMdxExtensions = (
|
||||
semanticDiagnostics: readonly ContentBlockDiagnostic[]
|
||||
): Extension[] => [
|
||||
linter(
|
||||
(view) =>
|
||||
getMdxEditorDiagnostics(view.state.doc.toString(), semanticDiagnostics),
|
||||
{ delay: 300 }
|
||||
),
|
||||
lintGutter(),
|
||||
keymap.of(lintKeymap),
|
||||
];
|
||||
|
||||
const languageExtensions = {
|
||||
plain: [],
|
||||
@@ -21,13 +128,17 @@ const languageExtensions = {
|
||||
} satisfies Record<AssetTextEditorLanguage, Extension[]>;
|
||||
|
||||
export const getTextFileEditorExtensions = (
|
||||
asset: Pick<Asset, "format">
|
||||
asset: Pick<Asset, "format">,
|
||||
semanticDiagnostics: readonly ContentBlockDiagnostic[] = []
|
||||
): Extension[] => {
|
||||
const language = getAssetTextEditorLanguage(asset);
|
||||
if (language === undefined) {
|
||||
return [];
|
||||
}
|
||||
return languageExtensions[language];
|
||||
const extensions = languageExtensions[language];
|
||||
return asset.format.toLowerCase() === "mdx"
|
||||
? [...extensions, ...getMdxExtensions(semanticDiagnostics)]
|
||||
: extensions;
|
||||
};
|
||||
|
||||
export const isMarkdownAsset = (asset: Pick<Asset, "format">) =>
|
||||
|
||||
+21
-1
@@ -6,6 +6,7 @@ import {
|
||||
$textEditingInstanceSelector,
|
||||
$textEditorContextMenu,
|
||||
$textEditorContextMenuCommand,
|
||||
execTextEditorContextMenuCommand,
|
||||
} from "~/shared/nano-states";
|
||||
import { $instances } from "~/shared/sync/data-stores";
|
||||
import { applyScale } from "./outline";
|
||||
@@ -82,7 +83,21 @@ const Menu = ({
|
||||
const handleValueChangeComplete = useCallback(
|
||||
(templateSelector: InstanceSelector) => {
|
||||
const insertBefore = modifierKeys.altKey;
|
||||
insertTemplateAt(templateSelector, anchor, insertBefore);
|
||||
execTextEditorContextMenuCommand({ type: "templateInsertionStarted" });
|
||||
void insertTemplateAt(templateSelector, anchor, insertBefore).then(
|
||||
(didInsert) => {
|
||||
if (didInsert === false) {
|
||||
execTextEditorContextMenuCommand({
|
||||
type: "templateInsertionCancelled",
|
||||
});
|
||||
}
|
||||
},
|
||||
() => {
|
||||
execTextEditorContextMenuCommand({
|
||||
type: "templateInsertionCancelled",
|
||||
});
|
||||
}
|
||||
);
|
||||
},
|
||||
[anchor, modifierKeys.altKey]
|
||||
);
|
||||
@@ -142,6 +157,11 @@ const Menu = ({
|
||||
break;
|
||||
}
|
||||
|
||||
case "templateInsertionStarted":
|
||||
case "templateInsertionCancelled": {
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
(type) satisfies never;
|
||||
}
|
||||
|
||||
+30
-15
@@ -46,6 +46,7 @@ import {
|
||||
getInstanceLabel,
|
||||
InstanceIcon,
|
||||
} from "~/builder/shared/instance-label";
|
||||
import { useOutlineControlPosition } from "./use-outline-control-position";
|
||||
|
||||
export const TemplatesMenu = ({
|
||||
onOpenChange,
|
||||
@@ -76,10 +77,12 @@ export const TemplatesMenu = ({
|
||||
}) => {
|
||||
const instances = useStore($instances);
|
||||
const modifierKeys = useStore($modifierKeys);
|
||||
const preventCloseAutoFocusRef = useRef(false);
|
||||
|
||||
const blockInstanceSelector = findBlockSelector({ anchor, instances });
|
||||
|
||||
const handleValueChangeComplete = useEffectEvent((value: string) => {
|
||||
preventCloseAutoFocusRef.current = true;
|
||||
const templateSelector = JSON.parse(value) as InstanceSelector;
|
||||
onValueChangeComplete(templateSelector);
|
||||
});
|
||||
@@ -125,6 +128,13 @@ export const TemplatesMenu = ({
|
||||
collisionPadding={16}
|
||||
side="bottom"
|
||||
loop
|
||||
onCloseAutoFocus={(event) => {
|
||||
if (preventCloseAutoFocusRef.current === false) {
|
||||
return;
|
||||
}
|
||||
preventCloseAutoFocusRef.current = false;
|
||||
event.preventDefault();
|
||||
}}
|
||||
// @todo remove inert after creation
|
||||
{...(inert ? { inert: "" } : {})}
|
||||
>
|
||||
@@ -239,6 +249,9 @@ export const BlockChildHoveredInstanceOutline = () => {
|
||||
>(undefined);
|
||||
|
||||
const outline = blockChildOutline ?? buttonOutline;
|
||||
const rect =
|
||||
outline === undefined ? undefined : applyScale(outline.rect, scale);
|
||||
const [controlRef, controlPosition] = useOutlineControlPosition(rect);
|
||||
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
|
||||
@@ -250,6 +263,10 @@ export const BlockChildHoveredInstanceOutline = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (rect === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (clampingRect === undefined) {
|
||||
return;
|
||||
}
|
||||
@@ -282,11 +299,6 @@ export const BlockChildHoveredInstanceOutline = () => {
|
||||
// 1 child is Templates instance
|
||||
const hasChildren = blockInstance.children.length > 1;
|
||||
|
||||
const rect = applyScale(outline.rect, scale);
|
||||
|
||||
// Check if the top edge of the component is hidden (clipped by viewport/clamping)
|
||||
const isTopEdgeHidden = rect.top < clampingRect.top;
|
||||
|
||||
const canDeleteHoveredInstance =
|
||||
shallowEqual(outline.selector, outline.hoveredSelector) &&
|
||||
canDeleteInstanceInContentMode({
|
||||
@@ -318,18 +330,21 @@ export const BlockChildHoveredInstanceOutline = () => {
|
||||
return (
|
||||
<Outline rect={rect} clampingRect={clampingRect}>
|
||||
<Flex
|
||||
ref={controlRef}
|
||||
css={{
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
paddingRight: theme.sizes.controlHeight,
|
||||
...(isTopEdgeHidden
|
||||
...(controlPosition === "top"
|
||||
? {
|
||||
bottom: `calc(-${theme.sizes.controlHeight} )`,
|
||||
clipPath: `polygon(0% 0%, 100% 0%, ${theme.sizes.controlHeight} 100%, 0% 100%)`,
|
||||
}
|
||||
: {
|
||||
top: `calc(-${theme.sizes.controlHeight})`,
|
||||
clipPath: `polygon(0% 0%, ${theme.sizes.controlHeight} 0%, 100% 100%, 0% 100%)`,
|
||||
}
|
||||
: {
|
||||
...(controlPosition === "bottom"
|
||||
? { bottom: `calc(-${theme.sizes.controlHeight})` }
|
||||
: { top: 0 }),
|
||||
clipPath: `polygon(0% 0%, 100% 0%, ${theme.sizes.controlHeight} 100%, 0% 100%)`,
|
||||
}),
|
||||
// Define grace area for the button
|
||||
pointerEvents: isMenuOpen ? "none" : "all",
|
||||
@@ -399,14 +414,14 @@ export const BlockChildHoveredInstanceOutline = () => {
|
||||
? `oklch(from ${theme.colors.backgroundPrimary} l c h / 0.7)`
|
||||
: undefined,
|
||||
borderRadius: theme.borderRadius[4],
|
||||
...(isTopEdgeHidden
|
||||
...(controlPosition === "top"
|
||||
? {
|
||||
borderTopLeftRadius: 0,
|
||||
borderTopRightRadius: 0,
|
||||
}
|
||||
: {
|
||||
borderBottomLeftRadius: 0,
|
||||
borderBottomRightRadius: 0,
|
||||
}
|
||||
: {
|
||||
borderTopLeftRadius: 0,
|
||||
borderTopRightRadius: 0,
|
||||
}),
|
||||
// Define grace area for the button
|
||||
pointerEvents: isMenuOpen ? "none" : "all",
|
||||
|
||||
+85
-3
@@ -1,8 +1,16 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import type { Instance, WebstudioFragment } from "@webstudio-is/sdk";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import {
|
||||
blockComponent,
|
||||
blockTemplateComponent,
|
||||
coreMetas,
|
||||
elementComponent,
|
||||
type Instance,
|
||||
type WebstudioFragment,
|
||||
} from "@webstudio-is/sdk";
|
||||
import * as defaultMetas from "@webstudio-is/sdk-components-react/metas";
|
||||
import type { Project } from "@webstudio-is/project";
|
||||
import { createDefaultPages } from "@webstudio-is/project-build";
|
||||
import { __testing__, insertListItemAt } from "./block-utils";
|
||||
import { __testing__, insertListItemAt, insertTemplateAt } from "./block-utils";
|
||||
import { registerContainers } from "~/shared/sync/sync-stores";
|
||||
import {
|
||||
$instances,
|
||||
@@ -12,7 +20,10 @@ import {
|
||||
} from "~/shared/sync/data-stores";
|
||||
import {
|
||||
$selectedInstanceSelector,
|
||||
$builderMode,
|
||||
$registeredComponentMetas,
|
||||
$textEditingInstanceSelector,
|
||||
selectPage,
|
||||
selectInstance,
|
||||
} from "~/shared/nano-states";
|
||||
|
||||
@@ -119,3 +130,74 @@ describe("insertListItemAt", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("insertTemplateAt", () => {
|
||||
beforeEach(() => {
|
||||
$project.set({ id: "projectId" } as Project);
|
||||
$pages.set(
|
||||
createDefaultPages({ homePageId: "home", rootInstanceId: "body" })
|
||||
);
|
||||
selectPage("home");
|
||||
$props.set(new Map());
|
||||
$builderMode.set("content");
|
||||
$registeredComponentMetas.set(
|
||||
new Map(Object.entries({ ...defaultMetas, ...coreMetas }))
|
||||
);
|
||||
selectInstance(["current", "block", "body"]);
|
||||
$textEditingInstanceSelector.set(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
$builderMode.set("design");
|
||||
});
|
||||
|
||||
test("inserts synchronously when content mode has no token conflicts", async () => {
|
||||
$instances.set(
|
||||
new Map<Instance["id"], Instance>([
|
||||
[
|
||||
"body",
|
||||
createInstance("body", "Body", [{ type: "id", value: "block" }]),
|
||||
],
|
||||
[
|
||||
"block",
|
||||
createInstance("block", blockComponent, [
|
||||
{ type: "id", value: "templates" },
|
||||
{ type: "id", value: "current" },
|
||||
]),
|
||||
],
|
||||
[
|
||||
"templates",
|
||||
createInstance("templates", blockTemplateComponent, [
|
||||
{ type: "id", value: "template" },
|
||||
]),
|
||||
],
|
||||
[
|
||||
"template",
|
||||
{
|
||||
...createInstance("template", elementComponent),
|
||||
tag: "p",
|
||||
},
|
||||
],
|
||||
[
|
||||
"current",
|
||||
{
|
||||
...createInstance("current", elementComponent),
|
||||
tag: "p",
|
||||
},
|
||||
],
|
||||
])
|
||||
);
|
||||
|
||||
const insertion = insertTemplateAt(
|
||||
["template", "templates", "block", "body"],
|
||||
["current", "block", "body"],
|
||||
false
|
||||
);
|
||||
|
||||
const insertedSelector = $selectedInstanceSelector.get();
|
||||
expect(insertedSelector?.[0]).not.toBe("current");
|
||||
expect(insertedSelector?.slice(1)).toEqual(["block", "body"]);
|
||||
|
||||
await expect(insertion).resolves.toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -26,7 +26,7 @@ import type {
|
||||
InstanceSelector,
|
||||
} from "@webstudio-is/project-build/runtime";
|
||||
import {
|
||||
findBlockSelector,
|
||||
findBlockContentSelector,
|
||||
getBlockTemplateInsertionIndex,
|
||||
} from "@webstudio-is/project-build/runtime";
|
||||
|
||||
@@ -130,10 +130,10 @@ export const insertTemplateAt = async (
|
||||
templateSelector[0]
|
||||
);
|
||||
|
||||
const parentSelector = findBlockSelector({ anchor, instances });
|
||||
const parentSelector = findBlockContentSelector({ anchor, instances });
|
||||
|
||||
if (parentSelector === undefined) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
const position = getBlockTemplateInsertionIndex({
|
||||
@@ -143,7 +143,7 @@ export const insertTemplateAt = async (
|
||||
});
|
||||
|
||||
if (position === undefined) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
const target: DroppableTarget = {
|
||||
@@ -158,23 +158,26 @@ export const insertTemplateAt = async (
|
||||
targetData: getWebstudioData(),
|
||||
contentMode,
|
||||
});
|
||||
const conflictResolution = await resolveTokenConflicts(conflicts);
|
||||
const conflictResolution =
|
||||
conflicts.length === 0
|
||||
? "theirs"
|
||||
: await resolveTokenConflicts(conflicts);
|
||||
if (conflictResolution === "cancel") {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
const didInsert = await insertWebstudioFragmentAt(
|
||||
const didInsert = insertWebstudioFragmentAt(
|
||||
fragment,
|
||||
target,
|
||||
conflictResolution,
|
||||
{ contentMode }
|
||||
);
|
||||
if (didInsert === false) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
const selectedInstanceSelector = $selectedInstanceSelector.get();
|
||||
if (selectedInstanceSelector === undefined) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
const data = getWebstudioData();
|
||||
const editableInstanceSelector = findTextEditorTarget({
|
||||
@@ -188,8 +191,9 @@ export const insertTemplateAt = async (
|
||||
? { selector: editableInstanceSelector, reason: "new" }
|
||||
: undefined
|
||||
);
|
||||
return true;
|
||||
} catch {
|
||||
// User cancelled the operation
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { styled, type Rect } from "@webstudio-is/design-system";
|
||||
import type { Instance } from "@webstudio-is/sdk";
|
||||
import { theme } from "@webstudio-is/design-system";
|
||||
@@ -6,39 +5,7 @@ import {
|
||||
InstanceIcon,
|
||||
getInstanceLabel,
|
||||
} from "~/builder/shared/instance-label";
|
||||
|
||||
type LabelPosition = "top" | "inside" | "bottom";
|
||||
type LabelRefCallback = (element: HTMLElement | null) => void;
|
||||
|
||||
/**
|
||||
* Detects if there is no space on top and for the label and tells to show it inside.
|
||||
* - if there is enough space for the label on top of the instance - top
|
||||
* - else if instance height is more than 250px - bottom
|
||||
* - else inside-top - last resort because it covers a bit of the instance content
|
||||
*/
|
||||
const useLabelPosition = (
|
||||
instanceRect: Rect
|
||||
): [LabelRefCallback, LabelPosition] => {
|
||||
const [position, setPosition] = useState<LabelPosition>("top");
|
||||
|
||||
const ref = useCallback(
|
||||
(element: null | HTMLElement) => {
|
||||
if (element === null || instanceRect === undefined) {
|
||||
return;
|
||||
}
|
||||
const labelRect = element.getBoundingClientRect();
|
||||
let nextPosition: LabelPosition = "top";
|
||||
// Label won't fit above the instance outline
|
||||
if (labelRect.height > instanceRect.top) {
|
||||
nextPosition = instanceRect.height < 250 ? "bottom" : "inside";
|
||||
}
|
||||
setPosition(nextPosition);
|
||||
},
|
||||
[instanceRect]
|
||||
);
|
||||
|
||||
return [ref, position];
|
||||
};
|
||||
import { useOutlineControlPosition } from "./use-outline-control-position";
|
||||
|
||||
const LabelContainer = styled(
|
||||
"div",
|
||||
@@ -98,7 +65,7 @@ type LabelProps = {
|
||||
};
|
||||
|
||||
export const Label = ({ instance, instanceRect, variant }: LabelProps) => {
|
||||
const [labelRef, position] = useLabelPosition(instanceRect);
|
||||
const [labelRef, position] = useOutlineControlPosition(instanceRect);
|
||||
return (
|
||||
<LabelContainer position={position} variant={variant} ref={labelRef}>
|
||||
<InstanceIcon size="1em" instance={instance} />
|
||||
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { getOutlineControlPosition } from "./use-outline-control-position";
|
||||
|
||||
describe("getOutlineControlPosition", () => {
|
||||
test("positions above when the control fits", () => {
|
||||
expect(
|
||||
getOutlineControlPosition({
|
||||
controlHeight: 20,
|
||||
instanceRect: { top: 20, height: 100, left: 0, width: 100 },
|
||||
})
|
||||
).toBe("top");
|
||||
});
|
||||
|
||||
test("positions below a short instance when the control does not fit", () => {
|
||||
expect(
|
||||
getOutlineControlPosition({
|
||||
controlHeight: 20,
|
||||
instanceRect: { top: 19, height: 100, left: 0, width: 100 },
|
||||
})
|
||||
).toBe("bottom");
|
||||
});
|
||||
|
||||
test("positions inside a tall instance when the control does not fit", () => {
|
||||
expect(
|
||||
getOutlineControlPosition({
|
||||
controlHeight: 20,
|
||||
instanceRect: { top: 19, height: 250, left: 0, width: 100 },
|
||||
})
|
||||
).toBe("inside");
|
||||
});
|
||||
});
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import type { Rect } from "@webstudio-is/design-system";
|
||||
|
||||
export type OutlineControlPosition = "top" | "inside" | "bottom";
|
||||
|
||||
// Prefer placing controls outside the instance. For tall instances, keep the
|
||||
// control visible inside the outline instead of moving it far below the fold.
|
||||
export const getOutlineControlPosition = ({
|
||||
controlHeight,
|
||||
instanceRect,
|
||||
}: {
|
||||
controlHeight: number;
|
||||
instanceRect: Rect;
|
||||
}): OutlineControlPosition => {
|
||||
if (controlHeight <= instanceRect.top) {
|
||||
return "top";
|
||||
}
|
||||
return instanceRect.height < 250 ? "bottom" : "inside";
|
||||
};
|
||||
|
||||
type ControlRefCallback = (element: HTMLElement | null) => void;
|
||||
|
||||
export const useOutlineControlPosition = (
|
||||
instanceRect: Rect | undefined
|
||||
): [ControlRefCallback, OutlineControlPosition] => {
|
||||
const [position, setPosition] = useState<OutlineControlPosition>("top");
|
||||
const ref = useCallback(
|
||||
(element: HTMLElement | null) => {
|
||||
if (element === null || instanceRect === undefined) {
|
||||
return;
|
||||
}
|
||||
setPosition(
|
||||
getOutlineControlPosition({
|
||||
controlHeight: element.getBoundingClientRect().height,
|
||||
instanceRect,
|
||||
})
|
||||
);
|
||||
},
|
||||
[instanceRect]
|
||||
);
|
||||
return [ref, position];
|
||||
};
|
||||
@@ -49,6 +49,7 @@ const EXTENSION_TO_DISPLAY_CATEGORY: Record<
|
||||
// Code
|
||||
txt: "Code",
|
||||
md: "Code",
|
||||
mdx: "Code",
|
||||
js: "Code",
|
||||
css: "Code",
|
||||
json: "Code",
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { AssetContentRepository } from "@webstudio-is/content-engine/asset-content-repository";
|
||||
import { getAssetContentBridge } from "~/shared/asset-content-bridge.client";
|
||||
import { $authToken } from "~/shared/nano-states";
|
||||
import { createHttpAssetContentRepository } from "./mdx-content-repository";
|
||||
|
||||
export const createBuilderHttpAssetContentRepository = ({
|
||||
projectId,
|
||||
origin,
|
||||
}: {
|
||||
projectId: string;
|
||||
origin?: string;
|
||||
}): AssetContentRepository => {
|
||||
let repository: AssetContentRepository | undefined;
|
||||
const getRepository = () => {
|
||||
if (repository !== undefined) {
|
||||
return repository;
|
||||
}
|
||||
const requestOrigin = origin ?? globalThis.location?.origin;
|
||||
if (requestOrigin === undefined) {
|
||||
throw new Error("Asset content requests require a browser origin");
|
||||
}
|
||||
repository = createHttpAssetContentRepository({
|
||||
projectId,
|
||||
origin: requestOrigin,
|
||||
authToken: () => $authToken.get(),
|
||||
request: (input, init) =>
|
||||
getAssetContentBridge().request(String(input), init),
|
||||
});
|
||||
return repository;
|
||||
};
|
||||
return {
|
||||
readContent: (input) => getRepository().readContent(input),
|
||||
updateContent: (input) => getRepository().updateContent(input),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,259 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import {
|
||||
AssetContentAuthorizationError,
|
||||
AssetRevisionConflictError,
|
||||
} from "@webstudio-is/content-engine/asset-content-repository";
|
||||
import {
|
||||
assetContentDescriptorHeader,
|
||||
serializeAssetContentDescriptor,
|
||||
} from "@webstudio-is/protocol/asset-resource-api";
|
||||
import { contentEngineLimits } from "@webstudio-is/content-engine/limits";
|
||||
import { createHttpAssetContentRepository } from "./mdx-content-repository";
|
||||
import { createBuilderHttpAssetContentRepository } from "./builder-mdx-content-repository.client";
|
||||
|
||||
const asset = {
|
||||
id: "asset-1",
|
||||
projectId: "project-1",
|
||||
name: "article_revision.mdx",
|
||||
type: "file" as const,
|
||||
format: "mdx",
|
||||
size: 7,
|
||||
createdAt: "2026-08-15T00:00:00.000Z",
|
||||
};
|
||||
|
||||
const collect = async (data: AsyncIterable<Uint8Array>) => {
|
||||
const chunks: Uint8Array[] = [];
|
||||
for await (const chunk of data) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
return new Uint8Array(chunks.flatMap((chunk) => Array.from(chunk.values())));
|
||||
};
|
||||
|
||||
describe("HTTP MDX content repository", () => {
|
||||
test("can be created during server rendering without reading browser globals", () => {
|
||||
const browserWindow = globalThis.window;
|
||||
vi.stubGlobal("window", undefined);
|
||||
try {
|
||||
expect(() =>
|
||||
createBuilderHttpAssetContentRepository({ projectId: asset.projectId })
|
||||
).not.toThrow();
|
||||
} finally {
|
||||
vi.stubGlobal("window", browserWindow);
|
||||
}
|
||||
});
|
||||
|
||||
test("returns content and exact revision identity from one response", async () => {
|
||||
const request = vi.fn<typeof fetch>(async () =>
|
||||
Promise.resolve(
|
||||
new Response("# Hello", {
|
||||
headers: {
|
||||
"content-length": "7",
|
||||
[assetContentDescriptorHeader]:
|
||||
serializeAssetContentDescriptor(asset),
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
const repository = createHttpAssetContentRepository({
|
||||
projectId: asset.projectId,
|
||||
origin: "https://webstudio.is",
|
||||
authToken: () => "token",
|
||||
request,
|
||||
});
|
||||
|
||||
const content = await repository.readContent({ assetId: asset.id });
|
||||
|
||||
expect(content.asset).toEqual(asset);
|
||||
expect(content.contentLength).toBe(7);
|
||||
expect(new TextDecoder().decode(await collect(content.data))).toBe(
|
||||
"# Hello"
|
||||
);
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
new Headers(request.mock.calls[0]?.[1]?.headers).get("x-auth-token")
|
||||
).toBe("token");
|
||||
});
|
||||
|
||||
test("rejects content without the identity of the streamed revision", async () => {
|
||||
const repository = createHttpAssetContentRepository({
|
||||
projectId: asset.projectId,
|
||||
origin: "https://webstudio.is",
|
||||
request: async () => new Response("# Hello"),
|
||||
});
|
||||
|
||||
await expect(repository.readContent({ assetId: asset.id })).rejects.toThrow(
|
||||
"missing its revision identity"
|
||||
);
|
||||
});
|
||||
|
||||
test.each([
|
||||
[{ ...asset, id: "other" }, "requested Asset"],
|
||||
[{ ...asset, projectId: "other" }, "requested Asset"],
|
||||
])("rejects substituted content identity %#", async (descriptor, message) => {
|
||||
const repository = createHttpAssetContentRepository({
|
||||
projectId: asset.projectId,
|
||||
origin: "https://webstudio.is",
|
||||
request: async () =>
|
||||
new Response("# Hello", {
|
||||
headers: {
|
||||
[assetContentDescriptorHeader]:
|
||||
serializeAssetContentDescriptor(descriptor),
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(repository.readContent({ assetId: asset.id })).rejects.toThrow(
|
||||
message
|
||||
);
|
||||
});
|
||||
|
||||
test("requires a partial response for a ranged read", async () => {
|
||||
const repository = createHttpAssetContentRepository({
|
||||
projectId: asset.projectId,
|
||||
origin: "https://webstudio.is",
|
||||
request: async () =>
|
||||
new Response("Hell", {
|
||||
headers: {
|
||||
[assetContentDescriptorHeader]:
|
||||
serializeAssetContentDescriptor(asset),
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(
|
||||
repository.readContent({
|
||||
assetId: asset.id,
|
||||
range: { offset: 2, length: 4 },
|
||||
})
|
||||
).rejects.toThrow("unexpected status 200");
|
||||
});
|
||||
|
||||
test("rejects an oversized full-file descriptor before exposing its body", async () => {
|
||||
const repository = createHttpAssetContentRepository({
|
||||
projectId: asset.projectId,
|
||||
origin: "https://webstudio.is",
|
||||
request: async () =>
|
||||
new Response("body", {
|
||||
headers: {
|
||||
[assetContentDescriptorHeader]: serializeAssetContentDescriptor({
|
||||
...asset,
|
||||
size: contentEngineLimits.hydratedFileBytes + 1,
|
||||
}),
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(repository.readContent({ assetId: asset.id })).rejects.toThrow(
|
||||
"exceeds the MDX editing limit"
|
||||
);
|
||||
});
|
||||
|
||||
test.each([
|
||||
[403, AssetContentAuthorizationError],
|
||||
[409, AssetRevisionConflictError],
|
||||
])(
|
||||
"maps HTTP %s to the shared repository error",
|
||||
async (status, ErrorType) => {
|
||||
const repository = createHttpAssetContentRepository({
|
||||
projectId: asset.projectId,
|
||||
origin: "https://webstudio.is",
|
||||
request: async () =>
|
||||
new Response(JSON.stringify({ errors: "Denied" }), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(
|
||||
repository.readContent({ assetId: asset.id })
|
||||
).rejects.toThrow(ErrorType);
|
||||
}
|
||||
);
|
||||
|
||||
test("updates with the pinned storage name and returns the next identity", async () => {
|
||||
const next = { ...asset, name: "article_next.mdx", size: 4 };
|
||||
const request = vi.fn<typeof fetch>(async (_url, init) => {
|
||||
expect(init?.method).toBe("PUT");
|
||||
expect(await new Response(init?.body).text()).toBe("next");
|
||||
return new Response(JSON.stringify({ asset: next }), {
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
});
|
||||
const repository = createHttpAssetContentRepository({
|
||||
projectId: asset.projectId,
|
||||
origin: "https://webstudio.is",
|
||||
request,
|
||||
});
|
||||
|
||||
await expect(
|
||||
repository.updateContent({
|
||||
assetId: asset.id,
|
||||
expectedName: asset.name,
|
||||
data: new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode("next"));
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
})
|
||||
).resolves.toEqual(next);
|
||||
|
||||
const url = request.mock.calls[0]?.[0];
|
||||
expect(new URL(String(url)).searchParams.get("expectedName")).toBe(
|
||||
asset.name
|
||||
);
|
||||
});
|
||||
|
||||
test("bounds update buffering before making a request", async () => {
|
||||
const request = vi.fn<typeof fetch>();
|
||||
let cancelled = false;
|
||||
const repository = createHttpAssetContentRepository({
|
||||
projectId: asset.projectId,
|
||||
origin: "https://webstudio.is",
|
||||
request,
|
||||
});
|
||||
|
||||
await expect(
|
||||
repository.updateContent({
|
||||
assetId: asset.id,
|
||||
expectedName: asset.name,
|
||||
data: new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(
|
||||
new Uint8Array(contentEngineLimits.hydratedFileBytes + 1)
|
||||
);
|
||||
},
|
||||
cancel() {
|
||||
cancelled = true;
|
||||
},
|
||||
}),
|
||||
})
|
||||
).rejects.toThrow();
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
expect(cancelled).toBe(true);
|
||||
});
|
||||
|
||||
test("rejects a substituted identity after update", async () => {
|
||||
const repository = createHttpAssetContentRepository({
|
||||
projectId: asset.projectId,
|
||||
origin: "https://webstudio.is",
|
||||
request: async () =>
|
||||
new Response(JSON.stringify({ asset: { ...asset, id: "other" } }), {
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(
|
||||
repository.updateContent({
|
||||
assetId: asset.id,
|
||||
expectedName: asset.name,
|
||||
data: new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode("next"));
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
})
|
||||
).rejects.toThrow("requested Asset");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import {
|
||||
createHttpAssetContentRepository as createSharedRepository,
|
||||
type AssetContentRepository,
|
||||
} from "@webstudio-is/content-engine/asset-content-repository";
|
||||
import { createProjectAssetContentTransport } from "@webstudio-is/http-client";
|
||||
|
||||
type HttpAssetContentRepositoryDependencies = Readonly<{
|
||||
projectId: string;
|
||||
origin: string;
|
||||
authToken?: () => string | undefined;
|
||||
request?: typeof fetch;
|
||||
}>;
|
||||
|
||||
export const createHttpAssetContentRepository = ({
|
||||
projectId,
|
||||
origin,
|
||||
authToken = () => undefined,
|
||||
request = fetch,
|
||||
}: HttpAssetContentRepositoryDependencies): AssetContentRepository =>
|
||||
createSharedRepository({
|
||||
projectId,
|
||||
...createProjectAssetContentTransport({
|
||||
projectId,
|
||||
origin,
|
||||
authToken,
|
||||
request,
|
||||
}),
|
||||
});
|
||||
@@ -247,6 +247,44 @@ describe("upload-assets", () => {
|
||||
expect(waitForUpload).toHaveBeenCalledWith(importedAsset.id);
|
||||
});
|
||||
|
||||
test.each(["md", "mdx", "json"])(
|
||||
"imports mutable %s documents without deduplicating them",
|
||||
async (format) => {
|
||||
$project.set({ id: "target-project" } as Project);
|
||||
const sourceAsset = {
|
||||
id: "article",
|
||||
projectId: "source-project",
|
||||
name: `article.${format}`,
|
||||
type: "file",
|
||||
format,
|
||||
size: 1,
|
||||
createdAt: "2026-01-01",
|
||||
description: null,
|
||||
meta: {},
|
||||
} satisfies Asset;
|
||||
const importedAsset = {
|
||||
...sourceAsset,
|
||||
id: "article-copy",
|
||||
projectId: "target-project",
|
||||
} satisfies Asset;
|
||||
const upload = vi.fn(async (_type, [url]: URL[]) => {
|
||||
return new Map([[url, importedAsset.id]]);
|
||||
});
|
||||
const waitForUpload = vi.fn(async () => importedAsset);
|
||||
const url = new URL(`https://source.example.com/article.${format}`);
|
||||
|
||||
await importAssets(
|
||||
"target-project",
|
||||
[{ asset: sourceAsset, url: url.href }],
|
||||
{ upload, waitForUpload }
|
||||
);
|
||||
|
||||
expect(upload).toHaveBeenCalledWith("file", [url], {
|
||||
deduplicate: false,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
test("reuses matching assets from the current deployment", async () => {
|
||||
$project.set({ id: "target-project" } as Project);
|
||||
const existingAsset = {
|
||||
@@ -313,6 +351,29 @@ describe("upload-assets", () => {
|
||||
expect(init.body.get("contentHash")).toBe("a".repeat(64));
|
||||
});
|
||||
|
||||
test("can reserve an independent asset with identical content", async () => {
|
||||
request.mockResolvedValue(
|
||||
Response.json({
|
||||
assetId: "new-asset-id",
|
||||
name: "new-name",
|
||||
deduplicated: false,
|
||||
})
|
||||
);
|
||||
|
||||
await createUploadTicket({
|
||||
authToken: "token",
|
||||
projectId: "project-id",
|
||||
fileOrUrl: new File([], "empty.mdx", { type: "text/mdx" }),
|
||||
contentHash: "a".repeat(64),
|
||||
deduplicate: false,
|
||||
assetType: "file",
|
||||
request,
|
||||
});
|
||||
|
||||
const [, init] = request.mock.calls[0] as [string, { body: FormData }];
|
||||
expect(init.body.get("contentHash")).toBeNull();
|
||||
});
|
||||
|
||||
test("reports non-error upload failures", async () => {
|
||||
request.mockRejectedValue("network down");
|
||||
const onCompleted = vi.fn();
|
||||
|
||||
@@ -36,6 +36,7 @@ type AssetActionResponse = AssetUploadResult | { errors: string };
|
||||
type UploadAssetsOptions = {
|
||||
folderId?: string;
|
||||
dimensions?: { width: number; height: number };
|
||||
deduplicate?: boolean;
|
||||
};
|
||||
|
||||
const safeDeleteAssets = (assetIds: Asset["id"][], projectId: string) => {
|
||||
@@ -301,6 +302,7 @@ const createUploadTicket = async ({
|
||||
projectId,
|
||||
fileOrUrl,
|
||||
contentHash,
|
||||
deduplicate = true,
|
||||
assetType,
|
||||
request = fetch,
|
||||
}: {
|
||||
@@ -308,6 +310,7 @@ const createUploadTicket = async ({
|
||||
projectId: string;
|
||||
fileOrUrl: File | URL;
|
||||
contentHash?: string;
|
||||
deduplicate?: boolean;
|
||||
assetType: AssetType;
|
||||
request?: typeof fetch;
|
||||
}): Promise<UploadTicket> => {
|
||||
@@ -315,7 +318,7 @@ const createUploadTicket = async ({
|
||||
const metaFormData = new FormData();
|
||||
metaFormData.append("projectId", projectId);
|
||||
metaFormData.append("type", assetType);
|
||||
if (contentHash !== undefined) {
|
||||
if (contentHash !== undefined && deduplicate) {
|
||||
metaFormData.append("contentHash", contentHash);
|
||||
}
|
||||
const existingNames = new Set<string>();
|
||||
@@ -525,6 +528,7 @@ export const uploadAssets = async <T extends File | URL>(
|
||||
fileData.source === "file" ? fileData.file : new URL(fileData.url),
|
||||
contentHash:
|
||||
fileData.source === "file" ? fileData.contentHash : undefined,
|
||||
deduplicate: options.deduplicate,
|
||||
assetType: fileData.type,
|
||||
});
|
||||
fileData.assetId = ticket.assetId;
|
||||
@@ -615,15 +619,23 @@ export const importAssets = async (
|
||||
if (url === undefined) {
|
||||
throw new Error("Asset source URL is missing");
|
||||
}
|
||||
const options =
|
||||
source.asset.type === "video"
|
||||
? {
|
||||
let options: UploadAssetsOptions = {};
|
||||
if (source.asset.type === "video") {
|
||||
options = {
|
||||
dimensions: {
|
||||
width: source.asset.meta.width,
|
||||
height: source.asset.meta.height,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (
|
||||
source.asset.type === "file" &&
|
||||
["md", "mdx", "json"].includes(source.asset.format.toLowerCase())
|
||||
) {
|
||||
// Copy mutable documents independently because reference rewriting must
|
||||
// never modify an existing target Asset that upload deduplication reused.
|
||||
options = { deduplicate: false };
|
||||
}
|
||||
: {};
|
||||
const assetId = (await upload(source.asset.type, [url], options)).get(url);
|
||||
if (assetId === undefined) {
|
||||
throw new Error("Failed to import asset");
|
||||
@@ -641,7 +653,7 @@ export const importAssets = async (
|
||||
export const uploadSingleAsset = async (
|
||||
type: AssetType,
|
||||
file: File,
|
||||
options: { folderId?: string } = {}
|
||||
options: { folderId?: string; deduplicate?: boolean } = {}
|
||||
): Promise<Asset | undefined> => {
|
||||
const assetId = (await uploadAssets(type, [file], options)).get(file);
|
||||
return assetId === undefined ? undefined : waitForAssetUpload(assetId);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { createDefaultPages } from "@webstudio-is/project-build";
|
||||
import {
|
||||
blockComponent,
|
||||
blockTemplateComponent,
|
||||
type DataSource,
|
||||
type Instance,
|
||||
@@ -1616,6 +1617,65 @@ describe("move instance commands", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("moves direct Content Block children in content mode", () => {
|
||||
setupMoveInstanceProject();
|
||||
$instances.set(
|
||||
new Map($instances.get()).set("body", {
|
||||
...$instances.get().get("body")!,
|
||||
component: blockComponent,
|
||||
})
|
||||
);
|
||||
$builderMode.set("content");
|
||||
selectInstance(["heading", "body"]);
|
||||
|
||||
emitCommand("moveInstanceUp");
|
||||
|
||||
expect($instances.get().get("body")?.children).toEqual([
|
||||
{ type: "id", value: "heading" },
|
||||
{ type: "id", value: "box" },
|
||||
{ type: "id", value: "paragraph" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("does not move Content Block children outside it in content mode", () => {
|
||||
setupMoveInstanceProject();
|
||||
$instances.set(
|
||||
new Map($instances.get()).set("body", {
|
||||
...$instances.get().get("body")!,
|
||||
component: blockComponent,
|
||||
})
|
||||
);
|
||||
$builderMode.set("content");
|
||||
selectInstance(["box", "body"]);
|
||||
|
||||
emitCommand("moveInstanceUp");
|
||||
|
||||
expect($instances.get().get("body")?.children).toEqual([
|
||||
{ type: "id", value: "box" },
|
||||
{ type: "id", value: "heading" },
|
||||
{ type: "id", value: "paragraph" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("moves Content Block descendants into siblings in content mode", () => {
|
||||
setupMoveInstanceProject();
|
||||
$instances.set(
|
||||
new Map($instances.get()).set("body", {
|
||||
...$instances.get().get("body")!,
|
||||
component: blockComponent,
|
||||
})
|
||||
);
|
||||
$builderMode.set("content");
|
||||
selectInstance(["heading", "body"]);
|
||||
|
||||
emitCommand("moveInstanceIntoPreviousSibling");
|
||||
|
||||
expect($instances.get().get("box")?.children).toEqual([
|
||||
{ type: "id", value: "nested" },
|
||||
{ type: "id", value: "heading" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("moves the first child above its parent", () => {
|
||||
setupMoveInstanceProject();
|
||||
selectInstance(["nested", "box", "body"]);
|
||||
|
||||
@@ -3,7 +3,10 @@ import {
|
||||
deleteSelectedInstance,
|
||||
reparentInstance,
|
||||
} from "~/shared/instance-utils/mutation";
|
||||
import { sortInstancePathsForChildMutation } from "@webstudio-is/project-build/runtime";
|
||||
import {
|
||||
canMoveInstanceInContentMode,
|
||||
sortInstancePathsForChildMutation,
|
||||
} from "@webstudio-is/project-build/runtime";
|
||||
import { toggleInstanceShow } from "~/shared/instance-utils/mutation";
|
||||
import { insertWebstudioFragmentAt } from "~/shared/instance-utils/insert";
|
||||
import { toast } from "@webstudio-is/design-system";
|
||||
@@ -30,6 +33,11 @@ import {
|
||||
toggleBuilderMode,
|
||||
} from "~/shared/nano-states";
|
||||
import { $instances } from "~/shared/sync/data-stores";
|
||||
import {
|
||||
externalContentInstanceNameMessage,
|
||||
getExternalContentRoots,
|
||||
isExternalContentInstance,
|
||||
} from "~/shared/external-content-mutations";
|
||||
|
||||
// Declare command for type safety
|
||||
declare module "~/shared/pubsub" {
|
||||
@@ -596,10 +604,12 @@ const getInstanceMoveTarget = (direction: InstanceMoveDirection) => {
|
||||
};
|
||||
|
||||
const moveSelectedInstance = (direction: InstanceMoveDirection) => {
|
||||
const isContentMode = $isContentMode.get();
|
||||
if (
|
||||
guardDesignModeCommand({
|
||||
guardDesignOrContentModeCommand({
|
||||
isContentMode,
|
||||
isDesignMode: $isDesignMode.get(),
|
||||
message: "Moving is only allowed in design mode.",
|
||||
message: "Moving is only allowed in design or content mode.",
|
||||
}) === false
|
||||
) {
|
||||
return;
|
||||
@@ -613,6 +623,17 @@ const moveSelectedInstance = (direction: InstanceMoveDirection) => {
|
||||
if (target === undefined) {
|
||||
return;
|
||||
}
|
||||
if (isContentMode) {
|
||||
if (
|
||||
canMoveInstanceInContentMode({
|
||||
instanceSelector: selectedItem.instanceSelector,
|
||||
parentSelector: target.parentSelector,
|
||||
instances: $instances.get(),
|
||||
}) === false
|
||||
) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
reparentInstance(selectedItem.instanceSelector, target);
|
||||
};
|
||||
|
||||
@@ -1019,6 +1040,15 @@ export const { emitCommand, subscribeCommands } = createCommandsEmitter({
|
||||
return;
|
||||
}
|
||||
const [selectedItem] = instancePath;
|
||||
if (
|
||||
isExternalContentInstance(
|
||||
getExternalContentRoots(),
|
||||
selectedItem.instance.id
|
||||
)
|
||||
) {
|
||||
toast.info(externalContentInstanceNameMessage);
|
||||
return;
|
||||
}
|
||||
$editingItemSelector.set(selectedItem.instanceSelector);
|
||||
},
|
||||
},
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
pickedCompletion,
|
||||
} from "@codemirror/autocomplete";
|
||||
import { javascript } from "@codemirror/lang-javascript";
|
||||
import { textVariants, css, rawTheme } from "@webstudio-is/design-system";
|
||||
import { css } from "@webstudio-is/design-system";
|
||||
import {
|
||||
lintExpression,
|
||||
allowedStringMethods,
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
type EditorApi,
|
||||
foldGutterExtension,
|
||||
getCodeEditorCssVars,
|
||||
linterTooltipTheme,
|
||||
normalizeEditorValue,
|
||||
} from "~/shared/code-editor-base";
|
||||
import {
|
||||
@@ -540,28 +541,6 @@ const wrapperStyle = css({
|
||||
defaultVariants: { size: "default" },
|
||||
});
|
||||
|
||||
const linterTooltipTheme = EditorView.theme({
|
||||
".cm-tooltip:has(.cm-tooltip-lint)": {
|
||||
backgroundColor: "transparent",
|
||||
borderWidth: 0,
|
||||
paddingTop: rawTheme.spacing[5],
|
||||
paddingBottom: rawTheme.spacing[5],
|
||||
pointerEvents: "none",
|
||||
},
|
||||
".cm-tooltip-lint": {
|
||||
backgroundColor: rawTheme.colors.backgroundTooltipMain,
|
||||
color: rawTheme.colors.foregroundContrastMain,
|
||||
borderRadius: rawTheme.borderRadius[7],
|
||||
padding: rawTheme.spacing[5],
|
||||
},
|
||||
".cm-tooltip-lint .cm-diagnostic": {
|
||||
borderWidth: 0,
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
...textVariants.regular,
|
||||
},
|
||||
});
|
||||
|
||||
const fullSizeEditorTheme = EditorView.theme({
|
||||
".cm-scroller": {
|
||||
minHeight: 0,
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import type {
|
||||
AnyComponent,
|
||||
WebstudioComponentSystemProps,
|
||||
} from "@webstudio-is/react-sdk";
|
||||
import * as React from "react";
|
||||
import { useStore } from "@nanostores/react";
|
||||
import { $isPreviewMode } from "~/shared/nano-states";
|
||||
|
||||
export const BlockBody = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
{ children: React.ReactNode } & WebstudioComponentSystemProps
|
||||
>(({ children, ...props }, ref) => {
|
||||
const isPreviewMode = useStore($isPreviewMode);
|
||||
const isEmpty = React.Children.count(children) === 0;
|
||||
if (isPreviewMode && isEmpty) {
|
||||
return <></>;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
style={
|
||||
isEmpty ? { minHeight: "1lh", width: "100%" } : { display: "contents" }
|
||||
}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}) as AnyComponent;
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useStore } from "@nanostores/react";
|
||||
import { blockTemplateComponent } from "@webstudio-is/sdk";
|
||||
import { findContentBlockTemplateContainers } from "@webstudio-is/sdk";
|
||||
import {
|
||||
idAttribute,
|
||||
selectorIdAttribute,
|
||||
@@ -31,21 +31,14 @@ export const Block = React.forwardRef<
|
||||
return <div>Content Block instance is undefined</div>;
|
||||
}
|
||||
|
||||
const templateInstanceId = instance.children.find(
|
||||
(child) =>
|
||||
child.type === "id" &&
|
||||
instances.get(child.value)?.component === blockTemplateComponent
|
||||
)?.value;
|
||||
|
||||
if (templateInstanceId === undefined) {
|
||||
const templateInstance = findContentBlockTemplateContainers({
|
||||
blockInstance: instance,
|
||||
instances,
|
||||
})[0];
|
||||
if (templateInstance === undefined) {
|
||||
return <div>Content Block template child is not found</div>;
|
||||
}
|
||||
|
||||
const templateInstance = instances.get(templateInstanceId);
|
||||
|
||||
if (templateInstance === undefined) {
|
||||
return <div>Content Block template instance is not found</div>;
|
||||
}
|
||||
const templateInstanceId = templateInstance.id;
|
||||
|
||||
if (isDesignMode) {
|
||||
if (selectedInstanceSelector !== undefined) {
|
||||
|
||||
@@ -3,7 +3,12 @@ import { createHeadlessEditor } from "@lexical/headless";
|
||||
import { LinkNode } from "@lexical/link";
|
||||
import { $createParagraphNode, $createTextNode, $getRoot } from "lexical";
|
||||
import { $, renderData, renderTemplate, ws } from "@webstudio-is/template";
|
||||
import { $convertToLexical, $convertToUpdates, type Refs } from "./interop";
|
||||
import {
|
||||
$convertToLexical,
|
||||
$convertToPlainTextUpdate,
|
||||
$convertToUpdates,
|
||||
type Refs,
|
||||
} from "./interop";
|
||||
|
||||
const { instances } = renderData(
|
||||
<$.Body ws:id="bodyId">
|
||||
@@ -222,3 +227,34 @@ test("convert lexical to instances uses supplied id generator for new formatting
|
||||
).instances
|
||||
);
|
||||
});
|
||||
|
||||
test("convert lexical formatting to plain text content", async () => {
|
||||
const editor = createHeadlessEditor({ nodes: [LinkNode] });
|
||||
await new Promise<void>((resolve) => {
|
||||
editor.update(
|
||||
() => {
|
||||
const paragraph = $createParagraphNode();
|
||||
const text = $createTextNode("const answer = 42;");
|
||||
text.toggleFormat("bold");
|
||||
paragraph.append(text);
|
||||
$getRoot().append(paragraph);
|
||||
},
|
||||
{ onUpdate: resolve }
|
||||
);
|
||||
});
|
||||
const treeRootInstance = instances.get("emptyBoxId");
|
||||
if (treeRootInstance === undefined) {
|
||||
throw Error("Tree root instance should be in test data");
|
||||
}
|
||||
|
||||
expect(
|
||||
editor
|
||||
.getEditorState()
|
||||
.read(() => $convertToPlainTextUpdate(treeRootInstance))
|
||||
).toEqual([
|
||||
{
|
||||
...treeRootInstance,
|
||||
children: [{ type: "text", value: "const answer = 42;" }],
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -154,6 +154,13 @@ export const $convertToUpdates = (
|
||||
return instancesList;
|
||||
};
|
||||
|
||||
export const $convertToPlainTextUpdate = (treeRootInstance: Instance) => [
|
||||
{
|
||||
...treeRootInstance,
|
||||
children: [{ type: "text" as const, value: $getRoot().getTextContent() }],
|
||||
},
|
||||
];
|
||||
|
||||
const $writeLexical = (
|
||||
parent: ElementNode | TextNode,
|
||||
children: Instance["children"],
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { act } from "react-dom/test-utils";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import { ContentEditable } from "@lexical/react/LexicalContentEditable";
|
||||
import { elementComponent, type Instance } from "@webstudio-is/sdk";
|
||||
import { $textEditingInstanceSelector } from "~/shared/nano-states";
|
||||
import { TextEditor } from "./text-editor";
|
||||
|
||||
(
|
||||
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
let root: Root | undefined;
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
root?.unmount();
|
||||
});
|
||||
root = undefined;
|
||||
document.body.innerHTML = "";
|
||||
$textEditingInstanceSelector.set(undefined);
|
||||
});
|
||||
|
||||
describe("TextEditor", () => {
|
||||
test("focuses the editable element when initializing a new empty instance", async () => {
|
||||
const instance: Instance = {
|
||||
type: "instance",
|
||||
id: "list-item",
|
||||
component: elementComponent,
|
||||
tag: "li",
|
||||
children: [],
|
||||
};
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
$textEditingInstanceSelector.set({
|
||||
selector: [instance.id],
|
||||
reason: "new",
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
<TextEditor
|
||||
rootInstanceSelector={[instance.id]}
|
||||
instances={new Map([[instance.id, instance]])}
|
||||
props={new Map()}
|
||||
contentEditable={<ContentEditable />}
|
||||
onChange={() => {}}
|
||||
onSelectInstance={() => {}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
const editable = container.querySelector<HTMLElement>(
|
||||
"[data-lexical-editor]"
|
||||
);
|
||||
expect(editable).not.toBeNull();
|
||||
expect(document.activeElement).toBe(editable);
|
||||
expect(editable?.contains(window.getSelection()?.anchorNode ?? null)).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -64,7 +64,12 @@ import {
|
||||
} from "@webstudio-is/project-build/runtime";
|
||||
import { builderRuntimeContext } from "@webstudio-is/project-build/runtime";
|
||||
import { ToolbarConnectorPlugin } from "./toolbar-connector";
|
||||
import { type Refs, $convertToLexical, $convertToUpdates } from "./interop";
|
||||
import {
|
||||
type Refs,
|
||||
$convertToLexical,
|
||||
$convertToPlainTextUpdate,
|
||||
$convertToUpdates,
|
||||
} from "./interop";
|
||||
import { useEffectEvent } from "~/shared/hook-utils/effect-event";
|
||||
import { deleteInstanceBySelector } from "~/shared/instance-utils/mutation";
|
||||
import {
|
||||
@@ -74,6 +79,7 @@ import {
|
||||
$registeredComponentMetas,
|
||||
$textEditingInstanceSelector,
|
||||
$textEditorContextMenu,
|
||||
$textEditorContextMenuCommand,
|
||||
execTextEditorContextMenuCommand,
|
||||
} from "~/shared/nano-states";
|
||||
import { $instances } from "~/shared/sync/data-stores";
|
||||
@@ -790,6 +796,9 @@ const InitCursorPlugin = () => {
|
||||
// We are controlling scroll ourself in instance-selected.ts see updateScroll.
|
||||
// Without skipping we are getting side effects of composition in scrollBy, scrollIntoView calls
|
||||
tag: "skip-scroll-into-view",
|
||||
onUpdate: () => {
|
||||
editor.focus();
|
||||
},
|
||||
}
|
||||
);
|
||||
}, [editor]);
|
||||
@@ -1070,15 +1079,18 @@ const RichTextContentPluginInternal = ({
|
||||
let menuState: "closed" | "opening" | "opened" = "closed";
|
||||
|
||||
let slashNodeKey: NodeKey | undefined = undefined;
|
||||
let removeSlashWhenSelectionChanges = false;
|
||||
|
||||
const closeMenu = () => {
|
||||
if (menuState === "closed") {
|
||||
if (menuState === "closed" && removeSlashWhenSelectionChanges === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (menuState !== "closed") {
|
||||
menuState = "closed";
|
||||
|
||||
handleOpen(editor.getEditorState(), undefined);
|
||||
}
|
||||
|
||||
if (slashNodeKey === undefined) {
|
||||
return;
|
||||
@@ -1098,6 +1110,8 @@ const RichTextContentPluginInternal = ({
|
||||
|
||||
if (!isSelectionInSameComponent) {
|
||||
node?.remove();
|
||||
slashNodeKey = undefined;
|
||||
removeSlashWhenSelectionChanges = false;
|
||||
|
||||
// Delete current
|
||||
if ($getRoot().getTextContentSize() === 0) {
|
||||
@@ -1123,6 +1137,17 @@ const RichTextContentPluginInternal = ({
|
||||
selection.setStyle("");
|
||||
};
|
||||
|
||||
const unsubscribeContextMenuCommand = $textEditorContextMenuCommand.listen(
|
||||
(command) => {
|
||||
if (command?.type === "templateInsertionStarted") {
|
||||
removeSlashWhenSelectionChanges = true;
|
||||
}
|
||||
if (command?.type === "templateInsertionCancelled") {
|
||||
removeSlashWhenSelectionChanges = false;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const unsubscibeSelectionChange = editor.registerCommand(
|
||||
SELECTION_CHANGE_COMMAND,
|
||||
() => {
|
||||
@@ -1335,7 +1360,7 @@ const RichTextContentPluginInternal = ({
|
||||
);
|
||||
|
||||
const closeMenuWithUpdate = () => {
|
||||
if (menuState === "closed") {
|
||||
if (menuState === "closed" && removeSlashWhenSelectionChanges === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1407,6 +1432,7 @@ const RichTextContentPluginInternal = ({
|
||||
unsubscribeUpdateListener();
|
||||
unsubscibeSelectionChange();
|
||||
unsubscribeBlurListener();
|
||||
unsubscribeContextMenuCommand();
|
||||
// Safari and FF support as no blur event is triggered in some cases
|
||||
closeMenuWithUpdate();
|
||||
};
|
||||
@@ -1425,6 +1451,7 @@ type TextEditorProps = {
|
||||
props: Props;
|
||||
contentEditable: JSX.Element;
|
||||
editable?: boolean;
|
||||
plainText?: boolean;
|
||||
onChange: (instancesList: Instance[]) => void | Record<string, string>;
|
||||
onSelectInstance: (instanceId: Instance["id"]) => void;
|
||||
};
|
||||
@@ -1506,6 +1533,7 @@ export const TextEditor = ({
|
||||
props,
|
||||
contentEditable,
|
||||
editable,
|
||||
plainText = false,
|
||||
onChange,
|
||||
onSelectInstance,
|
||||
}: TextEditorProps) => {
|
||||
@@ -1527,14 +1555,15 @@ export const TextEditor = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const idMap = onChange(
|
||||
$convertToUpdates(
|
||||
const updates = plainText
|
||||
? $convertToPlainTextUpdate(treeRootInstance)
|
||||
: $convertToUpdates(
|
||||
treeRootInstance,
|
||||
refs,
|
||||
newLinkKeyToInstanceId,
|
||||
builderRuntimeContext.createId
|
||||
)
|
||||
);
|
||||
const idMap = onChange(updates);
|
||||
if (idMap !== undefined) {
|
||||
for (const [key, instanceId] of refs) {
|
||||
refs.set(key, idMap[instanceId] ?? instanceId);
|
||||
@@ -1724,6 +1753,7 @@ export const TextEditor = ({
|
||||
<LexicalComposer initialConfig={initialConfig}>
|
||||
<RemoveParagaphsPlugin />
|
||||
<CaretColorPlugin />
|
||||
{plainText === false && (
|
||||
<ToolbarConnectorPlugin
|
||||
onSelectNode={(nodeKey) => {
|
||||
const instanceId = refs.get(`${nodeKey}:span`);
|
||||
@@ -1732,6 +1762,7 @@ export const TextEditor = ({
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<BindInstanceToNodePlugin
|
||||
refs={refs}
|
||||
rootInstanceSelector={rootInstanceSelector}
|
||||
@@ -1740,26 +1771,30 @@ export const TextEditor = ({
|
||||
ErrorBoundary={LexicalErrorBoundary}
|
||||
contentEditable={contentEditable}
|
||||
/>
|
||||
<LinkPlugin />
|
||||
{plainText === false && <LinkPlugin />}
|
||||
|
||||
<LinkSanitizePlugin />
|
||||
{plainText === false && <LinkSanitizePlugin />}
|
||||
<HistoryPlugin />
|
||||
|
||||
{/* oxlint-disable-next-line react-hooks/rules-of-hooks -- our useEffectEvent is a stable callback */}
|
||||
<SwitchBlockPlugin onNext={handleNext} />
|
||||
{plainText === false && (
|
||||
<RichTextContentPlugin
|
||||
onOpen={handleContextMenuOpen}
|
||||
rootInstanceSelector={rootInstanceSelector}
|
||||
// oxlint-disable-next-line react-hooks/rules-of-hooks -- our useEffectEvent is a stable callback
|
||||
onNext={handleNext}
|
||||
/>
|
||||
)}
|
||||
{/* oxlint-disable-next-line react-hooks/rules-of-hooks -- our useEffectEvent is a stable callback */}
|
||||
<OnChangeOnBlurPlugin onChange={handleChange} />
|
||||
<InitCursorPlugin />
|
||||
{plainText === false && (
|
||||
<LinkSelectionPlugin
|
||||
rootInstanceSelector={rootInstanceSelector}
|
||||
registerNewLink={registerNewLink}
|
||||
/>
|
||||
)}
|
||||
<AnyKeyDownPlugin onKeyDown={handleAnyKeydown} />
|
||||
<InitialJSONStatePlugin
|
||||
onInitialState={(json) => {
|
||||
|
||||
@@ -24,8 +24,10 @@ import {
|
||||
collectionComponent,
|
||||
descendantComponent,
|
||||
blockComponent,
|
||||
blockBodyComponent,
|
||||
blockTemplateComponent,
|
||||
getIndexesWithinAncestors,
|
||||
getContentBlockSource,
|
||||
elementComponent,
|
||||
} from "@webstudio-is/sdk";
|
||||
import { indexProperty, tagProperty } from "@webstudio-is/sdk/runtime";
|
||||
@@ -39,7 +41,7 @@ import {
|
||||
standardAttributesToReactProps,
|
||||
getCollectionEntries,
|
||||
} from "@webstudio-is/react-sdk";
|
||||
import { rawTheme } from "@webstudio-is/design-system";
|
||||
import { rawTheme, toast } from "@webstudio-is/design-system";
|
||||
import {
|
||||
Input,
|
||||
Link,
|
||||
@@ -51,10 +53,12 @@ import {
|
||||
$propValuesByInstanceSelectorWithMemoryProps,
|
||||
getIndexedInstanceId,
|
||||
$registeredComponentMetas,
|
||||
$variableValuesByInstanceSelector,
|
||||
$isDesignMode,
|
||||
$selectedInstanceRenderState,
|
||||
$selectedPageHash,
|
||||
} from "~/shared/nano-states";
|
||||
import { $props } from "~/shared/sync/data-stores";
|
||||
import { $project, $props } from "~/shared/sync/data-stores";
|
||||
import { $textEditingInstanceSelector } from "~/shared/nano-states";
|
||||
import { $instances } from "~/shared/sync/data-stores";
|
||||
import {
|
||||
@@ -74,12 +78,33 @@ import {
|
||||
type WebstudioComponentProps,
|
||||
} from "~/canvas/elements";
|
||||
import { Block } from "../build-mode/block";
|
||||
import { BlockBody } from "../build-mode/block-body";
|
||||
import { BlockTemplate } from "../build-mode/block-template";
|
||||
import {
|
||||
editablePlaceholderAttribute,
|
||||
editingPlaceholderVariable,
|
||||
} from "~/canvas/shared/styles";
|
||||
import { richTextPlaceholders } from "@webstudio-is/project-build/runtime";
|
||||
import {
|
||||
acquireExternalContentRoot,
|
||||
updateExternalContentFrontmatter,
|
||||
} from "~/shared/external-content-roots";
|
||||
import {
|
||||
$externalContentRoots,
|
||||
findExternalContentRoot,
|
||||
findExternalContentRootEntryBySelector,
|
||||
getExternalContentRoots,
|
||||
resolveExternalContentOccurrence,
|
||||
} from "~/shared/external-content-mutations";
|
||||
import {
|
||||
getSelectedContentBlockDocumentBindingPath,
|
||||
isObjectPathWritable,
|
||||
} from "~/shared/content-block-document";
|
||||
import {
|
||||
formatContentBlockDiagnostic,
|
||||
takeNewContentBlockDiagnostics,
|
||||
} from "~/shared/content-block-diagnostics";
|
||||
import { resolveContentBlockOccurrenceAssetId } from "~/shared/content-block-source-utils";
|
||||
|
||||
const computeComponentKey = (props: Record<string, unknown>) => {
|
||||
const assetId = props.$webstudio$canvasOnly$assetId;
|
||||
@@ -476,13 +501,167 @@ const getEditableComponentPlaceholder = (
|
||||
return placeholder;
|
||||
};
|
||||
|
||||
export const WebstudioComponentCanvas = forwardRef<
|
||||
const ExternalContentRootLoader = ({
|
||||
instance,
|
||||
instanceSelector,
|
||||
includeUnresolvedTemplatePlaceholders,
|
||||
showDiagnostics,
|
||||
}: {
|
||||
instance: Instance;
|
||||
instanceSelector: InstanceSelector;
|
||||
includeUnresolvedTemplatePlaceholders: boolean;
|
||||
showDiagnostics: boolean;
|
||||
}) => {
|
||||
const allProps = useStore($props);
|
||||
const project = useStore($project);
|
||||
const variableValues = useStore($variableValuesByInstanceSelector);
|
||||
const externalContentRoots = useStore($externalContentRoots);
|
||||
const renderScope = getInstanceKey(instanceSelector);
|
||||
const source = useMemo(
|
||||
() =>
|
||||
getContentBlockSource({
|
||||
blockInstanceId: instance.id,
|
||||
props: allProps.values(),
|
||||
}),
|
||||
[allProps, instance.id]
|
||||
);
|
||||
const sourceAssetId = useMemo(() => {
|
||||
if (source === undefined) {
|
||||
return;
|
||||
}
|
||||
return resolveContentBlockOccurrenceAssetId({
|
||||
source,
|
||||
instanceSelector,
|
||||
variableValuesByRenderScope: variableValues,
|
||||
});
|
||||
}, [instanceSelector, source, variableValues]);
|
||||
|
||||
const diagnosticsSnapshot = findExternalContentRoot(
|
||||
externalContentRoots,
|
||||
instance.id,
|
||||
renderScope
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (showDiagnostics === false) {
|
||||
return;
|
||||
}
|
||||
for (const diagnostic of takeNewContentBlockDiagnostics(
|
||||
diagnosticsSnapshot?.diagnostics ?? [],
|
||||
diagnosticsSnapshot?.identity?.revision
|
||||
)) {
|
||||
toast.warn(formatContentBlockDiagnostic(diagnostic));
|
||||
}
|
||||
}, [
|
||||
diagnosticsSnapshot?.diagnostics,
|
||||
diagnosticsSnapshot?.identity?.revision,
|
||||
showDiagnostics,
|
||||
]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (project === undefined || sourceAssetId === undefined) {
|
||||
return;
|
||||
}
|
||||
let active = true;
|
||||
const controller = new AbortController();
|
||||
let release: (() => void) | undefined;
|
||||
void acquireExternalContentRoot({
|
||||
projectId: project.id,
|
||||
assetId: sourceAssetId,
|
||||
blockInstanceId: instance.id,
|
||||
renderScope,
|
||||
includeUnresolvedTemplatePlaceholders,
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then((nextRelease) => {
|
||||
if (active === false) {
|
||||
nextRelease();
|
||||
return;
|
||||
}
|
||||
release = nextRelease;
|
||||
})
|
||||
.catch((error) => {
|
||||
if (active) {
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Unable to load MDX content"
|
||||
);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
controller.abort();
|
||||
release?.();
|
||||
};
|
||||
}, [
|
||||
includeUnresolvedTemplatePlaceholders,
|
||||
instance.id,
|
||||
project,
|
||||
renderScope,
|
||||
sourceAssetId,
|
||||
]);
|
||||
return null;
|
||||
};
|
||||
|
||||
const getExternalContentInstance = ({
|
||||
sourceInstance,
|
||||
sourceInstanceSelector,
|
||||
instances,
|
||||
}: {
|
||||
sourceInstance: Instance;
|
||||
sourceInstanceSelector: InstanceSelector;
|
||||
instances: Instances;
|
||||
}) => {
|
||||
const occurrence = resolveExternalContentOccurrence({
|
||||
sourceInstance,
|
||||
sourceSelector: sourceInstanceSelector,
|
||||
instances,
|
||||
roots: getExternalContentRoots(),
|
||||
});
|
||||
return occurrence === undefined
|
||||
? undefined
|
||||
: { instance: occurrence.instance, instanceSelector: occurrence.selector };
|
||||
};
|
||||
|
||||
const getUpdatedText = (
|
||||
rootInstanceId: Instance["id"],
|
||||
updates: readonly Instance[]
|
||||
) => {
|
||||
const byId = new Map(updates.map((instance) => [instance.id, instance]));
|
||||
const visit = (instanceId: Instance["id"]): string | undefined => {
|
||||
const instance = byId.get(instanceId);
|
||||
if (instance === undefined) {
|
||||
return;
|
||||
}
|
||||
let value = "";
|
||||
for (const child of instance.children) {
|
||||
if (child.type === "expression") {
|
||||
return;
|
||||
}
|
||||
if (child.type === "text") {
|
||||
value += child.value;
|
||||
continue;
|
||||
}
|
||||
const nested = visit(child.value);
|
||||
if (nested === undefined) {
|
||||
return;
|
||||
}
|
||||
value += nested;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
return visit(rootInstanceId);
|
||||
};
|
||||
|
||||
const WebstudioComponentCanvasInner = forwardRef<
|
||||
HTMLElement,
|
||||
WebstudioComponentProps
|
||||
>(({ instance, instanceSelector, components, ...restProps }, ref) => {
|
||||
const instanceId = instance.id;
|
||||
const instances = useStore($instances);
|
||||
const allProps = useStore($props);
|
||||
const externalContentRoots = useStore($externalContentRoots);
|
||||
const metas = useStore($registeredComponentMetas);
|
||||
|
||||
const textEditingInstanceSelector = useStore($textEditingInstanceSelector);
|
||||
@@ -576,6 +755,10 @@ export const WebstudioComponentCanvas = forwardRef<
|
||||
Component = Block;
|
||||
}
|
||||
|
||||
if (instance.component === blockBodyComponent) {
|
||||
Component = BlockBody;
|
||||
}
|
||||
|
||||
if (instance.component === blockTemplateComponent) {
|
||||
Component = BlockTemplate;
|
||||
}
|
||||
@@ -627,11 +810,37 @@ export const WebstudioComponentCanvas = forwardRef<
|
||||
return instanceElement;
|
||||
}
|
||||
|
||||
const contentModel = metas.get(instance.component)?.contentModel;
|
||||
const expressionChild =
|
||||
instance.children.length === 1 && instance.children[0].type === "expression"
|
||||
? instance.children[0]
|
||||
: undefined;
|
||||
const externalEntry = findExternalContentRootEntryBySelector(
|
||||
externalContentRoots,
|
||||
instanceSelector
|
||||
);
|
||||
const candidateRoot = externalEntry?.[1];
|
||||
const frontmatterPath =
|
||||
expressionChild === undefined
|
||||
? undefined
|
||||
: getSelectedContentBlockDocumentBindingPath({
|
||||
binding: expressionChild,
|
||||
instanceSelector,
|
||||
instances,
|
||||
props: allProps,
|
||||
sourceBlockInstanceId: candidateRoot?.sourceBlockInstanceId,
|
||||
renderedBlockInstanceId: candidateRoot?.blockInstanceId,
|
||||
});
|
||||
return (
|
||||
<TextEditor
|
||||
rootInstanceSelector={instanceSelector}
|
||||
instances={instances}
|
||||
props={allProps}
|
||||
plainText={
|
||||
frontmatterPath !== undefined ||
|
||||
(contentModel?.children.length === 1 &&
|
||||
contentModel.children[0] === "text")
|
||||
}
|
||||
contentEditable={
|
||||
<ContentEditable
|
||||
placeholder={getEditableComponentPlaceholder(
|
||||
@@ -642,13 +851,54 @@ export const WebstudioComponentCanvas = forwardRef<
|
||||
"editing"
|
||||
)}
|
||||
renderComponentWithRef={(elementRef) => (
|
||||
<Component {...props} ref={mergeRefs(ref, elementRef)}>
|
||||
<Component
|
||||
{...props}
|
||||
data-ws-text-editing=""
|
||||
ref={mergeRefs(ref, elementRef)}
|
||||
>
|
||||
{initialContentEditableContent.current}
|
||||
</Component>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
onChange={(instancesList) => {
|
||||
if (frontmatterPath !== undefined) {
|
||||
const value = getUpdatedText(instance.id, instancesList);
|
||||
if (externalEntry === undefined) {
|
||||
toast.error("The MDX content source is not ready for editing.");
|
||||
return;
|
||||
}
|
||||
const [rootKey, externalRoot] = externalEntry;
|
||||
if (externalRoot.document === undefined) {
|
||||
toast.error("The MDX content source is not ready for editing.");
|
||||
return;
|
||||
}
|
||||
if (
|
||||
isObjectPathWritable({
|
||||
value: externalRoot.document.frontmatter.properties,
|
||||
path: frontmatterPath,
|
||||
}) === false
|
||||
) {
|
||||
toast.error("Open the referenced file to edit this value.");
|
||||
return;
|
||||
}
|
||||
if (value === undefined) {
|
||||
toast.error("This frontmatter value must remain plain text.");
|
||||
return;
|
||||
}
|
||||
void updateExternalContentFrontmatter({
|
||||
rootKey,
|
||||
path: frontmatterPath,
|
||||
value,
|
||||
}).catch((error) => {
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Unable to update MDX frontmatter"
|
||||
);
|
||||
});
|
||||
return;
|
||||
}
|
||||
const result = executeRuntimeMutation({
|
||||
id: "instances.updateTextTree",
|
||||
input: {
|
||||
@@ -672,7 +922,43 @@ export const WebstudioComponentCanvas = forwardRef<
|
||||
);
|
||||
});
|
||||
|
||||
export const WebstudioComponentPreview = forwardRef<
|
||||
export const WebstudioComponentCanvas = forwardRef<
|
||||
HTMLElement,
|
||||
WebstudioComponentProps
|
||||
>(({ instance, instanceSelector, ...props }, ref) => {
|
||||
const instances = useStore($instances);
|
||||
const isDesignMode = useStore($isDesignMode);
|
||||
const externalContent = isDesignMode
|
||||
? undefined
|
||||
: getExternalContentInstance({
|
||||
sourceInstance: instance,
|
||||
sourceInstanceSelector: instanceSelector,
|
||||
instances,
|
||||
});
|
||||
const renderedInstance = externalContent?.instance ?? instance;
|
||||
const renderedInstanceSelector =
|
||||
externalContent?.instanceSelector ?? instanceSelector;
|
||||
return (
|
||||
<>
|
||||
{instance.component === blockComponent ? (
|
||||
<ExternalContentRootLoader
|
||||
instance={instance}
|
||||
instanceSelector={instanceSelector}
|
||||
includeUnresolvedTemplatePlaceholders={true}
|
||||
showDiagnostics={true}
|
||||
/>
|
||||
) : null}
|
||||
<WebstudioComponentCanvasInner
|
||||
{...props}
|
||||
ref={ref}
|
||||
instance={renderedInstance}
|
||||
instanceSelector={renderedInstanceSelector}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
const WebstudioComponentPreviewInner = forwardRef<
|
||||
HTMLElement,
|
||||
WebstudioComponentProps
|
||||
>(({ instance, instanceSelector, components, ...restProps }, ref) => {
|
||||
@@ -745,6 +1031,10 @@ export const WebstudioComponentPreview = forwardRef<
|
||||
Component = Block;
|
||||
}
|
||||
|
||||
if (instance.component === blockBodyComponent) {
|
||||
Component = BlockBody;
|
||||
}
|
||||
|
||||
if (instance.component === blockTemplateComponent) {
|
||||
Component = BlockTemplate;
|
||||
}
|
||||
@@ -778,3 +1068,36 @@ export const WebstudioComponentPreview = forwardRef<
|
||||
|
||||
return element;
|
||||
});
|
||||
|
||||
export const WebstudioComponentPreview = forwardRef<
|
||||
HTMLElement,
|
||||
WebstudioComponentProps
|
||||
>(({ instance, instanceSelector, ...props }, ref) => {
|
||||
const instances = useStore($instances);
|
||||
const externalContent = getExternalContentInstance({
|
||||
sourceInstance: instance,
|
||||
sourceInstanceSelector: instanceSelector,
|
||||
instances,
|
||||
});
|
||||
const renderedInstance = externalContent?.instance ?? instance;
|
||||
const renderedInstanceSelector =
|
||||
externalContent?.instanceSelector ?? instanceSelector;
|
||||
return (
|
||||
<>
|
||||
{instance.component === blockComponent ? (
|
||||
<ExternalContentRootLoader
|
||||
instance={instance}
|
||||
instanceSelector={instanceSelector}
|
||||
includeUnresolvedTemplatePlaceholders={false}
|
||||
showDiagnostics={false}
|
||||
/>
|
||||
) : null}
|
||||
<WebstudioComponentPreviewInner
|
||||
{...props}
|
||||
ref={ref}
|
||||
instance={renderedInstance}
|
||||
instanceSelector={renderedInstanceSelector}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -142,6 +142,7 @@ const handleEdit = (
|
||||
isContentMode: true,
|
||||
instanceSelector: editableInstanceSelector,
|
||||
instances,
|
||||
props: $props.get(),
|
||||
}) === false
|
||||
) {
|
||||
editableInstanceSelector = undefined;
|
||||
|
||||
@@ -101,6 +101,7 @@ export const { emitCommand, subscribeCommands } = createCommandsEmitter({
|
||||
isContentMode: $isContentMode.get(),
|
||||
instanceSelector: editableInstanceSelector,
|
||||
instances: $instances.get(),
|
||||
props: $props.get(),
|
||||
}) === false
|
||||
) {
|
||||
return;
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
blockBodyComponent,
|
||||
blockComponent,
|
||||
blockTemplateComponent,
|
||||
contentBlockDocumentProp,
|
||||
contentBlockSourceProp,
|
||||
encodeDataSourceVariable,
|
||||
elementComponent,
|
||||
type Instance,
|
||||
type Prop,
|
||||
} from "@webstudio-is/sdk";
|
||||
import { isTextEditableInContentMode } from "./content-mode";
|
||||
|
||||
@@ -29,6 +35,7 @@ describe("isTextEditableInContentMode", () => {
|
||||
isContentMode: true,
|
||||
instanceSelector: ["inside", "block", "body"],
|
||||
instances,
|
||||
props: new Map(),
|
||||
})
|
||||
).toBe(true);
|
||||
expect(
|
||||
@@ -36,6 +43,7 @@ describe("isTextEditableInContentMode", () => {
|
||||
isContentMode: true,
|
||||
instanceSelector: ["outside", "body"],
|
||||
instances,
|
||||
props: new Map(),
|
||||
})
|
||||
).toBe(false);
|
||||
expect(
|
||||
@@ -43,6 +51,7 @@ describe("isTextEditableInContentMode", () => {
|
||||
isContentMode: true,
|
||||
instanceSelector: ["source", "templates", "block", "body"],
|
||||
instances,
|
||||
props: new Map(),
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
@@ -53,7 +62,70 @@ describe("isTextEditableInContentMode", () => {
|
||||
isContentMode: false,
|
||||
instanceSelector: ["outside", "body"],
|
||||
instances,
|
||||
props: new Map(),
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("permits a direct frontmatter text binding outside the Body", () => {
|
||||
const document = encodeDataSourceVariable("document-source");
|
||||
const explicitInstances = new Map<string, Instance>([
|
||||
["block", instance("block", blockComponent, ["title", "content"])],
|
||||
[
|
||||
"title",
|
||||
{
|
||||
...instance("title", elementComponent),
|
||||
children: [
|
||||
{
|
||||
type: "expression" as const,
|
||||
value: `${document}.frontmatter.title`,
|
||||
mode: "readwrite" as const,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
["content", instance("content", blockBodyComponent)],
|
||||
]);
|
||||
const props = new Map<string, Prop>([
|
||||
[
|
||||
"source",
|
||||
{
|
||||
id: "source",
|
||||
instanceId: "block",
|
||||
name: contentBlockSourceProp,
|
||||
type: "asset",
|
||||
value: "article",
|
||||
},
|
||||
],
|
||||
[
|
||||
"document",
|
||||
{
|
||||
id: "document",
|
||||
instanceId: "block",
|
||||
name: contentBlockDocumentProp,
|
||||
type: "parameter",
|
||||
value: "document-source",
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
expect(
|
||||
isTextEditableInContentMode({
|
||||
isContentMode: true,
|
||||
instanceSelector: ["title", "block"],
|
||||
instances: explicitInstances,
|
||||
props,
|
||||
})
|
||||
).toBe(true);
|
||||
|
||||
props.delete("source");
|
||||
expect(
|
||||
isTextEditableInContentMode({
|
||||
isContentMode: true,
|
||||
instanceSelector: ["title", "block"],
|
||||
instances: explicitInstances,
|
||||
props,
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,20 +1,30 @@
|
||||
import { getContentModeEditableInstanceIds } from "@webstudio-is/project-build/runtime";
|
||||
import {
|
||||
getContentModeEditableInstanceIds,
|
||||
getContentModeFrontmatterBoundTargets,
|
||||
} from "@webstudio-is/project-build/runtime";
|
||||
import type { InstanceSelector } from "@webstudio-is/project-build/runtime";
|
||||
import type { Instances } from "@webstudio-is/sdk";
|
||||
import type { Instances, Props } from "@webstudio-is/sdk";
|
||||
|
||||
export const isTextEditableInContentMode = ({
|
||||
isContentMode,
|
||||
instanceSelector,
|
||||
instances,
|
||||
props,
|
||||
}: {
|
||||
isContentMode: boolean;
|
||||
instanceSelector: InstanceSelector;
|
||||
instances: Instances;
|
||||
props: Props;
|
||||
}) => {
|
||||
if (isContentMode === false) {
|
||||
return true;
|
||||
}
|
||||
return getContentModeEditableInstanceIds({ instances }).has(
|
||||
instanceSelector[0]
|
||||
const instanceId = instanceSelector[0];
|
||||
return (
|
||||
getContentModeEditableInstanceIds({ instances }).has(instanceId) ||
|
||||
getContentModeFrontmatterBoundTargets({
|
||||
instances,
|
||||
props,
|
||||
}).textInstanceIds.has(instanceId)
|
||||
);
|
||||
};
|
||||
@@ -40,7 +40,9 @@ import {
|
||||
loadBuilderAssetFieldCatalog,
|
||||
loadAssetDataByProject,
|
||||
loadAssetFoldersByProject,
|
||||
PostgresAssetRepository,
|
||||
} from "@webstudio-is/asset-uploader/server";
|
||||
import { createAssetContentSession } from "@webstudio-is/content-engine/asset-content-session";
|
||||
import { buildPatchTransaction } from "@webstudio-is/protocol/schema";
|
||||
import {
|
||||
publicApiContractVersion,
|
||||
@@ -54,10 +56,12 @@ import {
|
||||
loadApiToken,
|
||||
} from "./api-permits.server";
|
||||
import { componentMetas } from "~/shared/component-metas.server";
|
||||
import { type Asset, type AssetFolder } from "@webstudio-is/sdk";
|
||||
import type { Asset, AssetFolder } from "@webstudio-is/sdk";
|
||||
import {
|
||||
applyContentModeTransaction,
|
||||
createContentBlockApplication,
|
||||
getContentModeCapabilities,
|
||||
type BuilderRuntimeContext,
|
||||
} from "@webstudio-is/project-build/runtime";
|
||||
import type { CompactBuild } from "@webstudio-is/project-build";
|
||||
import {
|
||||
@@ -340,6 +344,7 @@ const commitRuntimeMutation = async <
|
||||
assetFolders,
|
||||
input,
|
||||
commit,
|
||||
context,
|
||||
}: {
|
||||
id: RuntimeOperationId;
|
||||
build: Awaited<ReturnType<typeof loadDevBuildByProjectId>>;
|
||||
@@ -347,6 +352,7 @@ const commitRuntimeMutation = async <
|
||||
assetFolders?: AssetFolder[];
|
||||
input: unknown;
|
||||
commit: BuildCommit;
|
||||
context?: Partial<BuilderRuntimeContext>;
|
||||
}) => {
|
||||
const mutation = await executeApiRuntimeMutation<Result>({
|
||||
id,
|
||||
@@ -354,6 +360,7 @@ const commitRuntimeMutation = async <
|
||||
assets,
|
||||
assetFolders,
|
||||
input,
|
||||
context,
|
||||
});
|
||||
if (mutation.noop || mutation.payload.length === 0) {
|
||||
return {
|
||||
@@ -464,18 +471,58 @@ const loadRuntimeAssetData = async (ctx: AppContext, projectId: string) => {
|
||||
});
|
||||
};
|
||||
|
||||
const createServerContentBlockApplication = ({
|
||||
ctx,
|
||||
projectId,
|
||||
build,
|
||||
assets,
|
||||
assetFolders,
|
||||
}: {
|
||||
ctx: AppContext;
|
||||
projectId: string;
|
||||
build: CompactBuild;
|
||||
assets: Asset[];
|
||||
assetFolders: AssetFolder[];
|
||||
}) => {
|
||||
const state = createBuilderRuntimeState(build, assets, assetFolders);
|
||||
const repository = new PostgresAssetRepository({
|
||||
projectId,
|
||||
context: ctx,
|
||||
assetStore: createAssetClient(),
|
||||
});
|
||||
const session = createAssetContentSession({
|
||||
repository,
|
||||
authorize: ({ assetId }) => state.assets?.has(assetId) === true,
|
||||
});
|
||||
return createContentBlockApplication({
|
||||
projectId,
|
||||
session,
|
||||
metas: componentMetas,
|
||||
});
|
||||
};
|
||||
|
||||
const runtimeAssetsQuery = <Result = unknown>(id: RuntimeOperationId) =>
|
||||
projectQuery(runtimeProjectInput(id), "view", async ({ ctx, input }) => {
|
||||
const [{ assets, assetFolders }, build] = await Promise.all([
|
||||
loadRuntimeAssetData(ctx, input.projectId),
|
||||
loadDevBuildByProjectId(ctx, input.projectId),
|
||||
]);
|
||||
const contentBlockApplication = id.startsWith("contentBlocks.")
|
||||
? createServerContentBlockApplication({
|
||||
ctx,
|
||||
projectId: input.projectId,
|
||||
build,
|
||||
assets,
|
||||
assetFolders,
|
||||
})
|
||||
: undefined;
|
||||
return executeApiRuntimeOperation<Result>({
|
||||
id,
|
||||
build,
|
||||
assets,
|
||||
assetFolders,
|
||||
input,
|
||||
context: { contentBlockApplication },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -500,6 +547,38 @@ const runtimeAssetsMutation = <Result extends Record<string, unknown> = {}>(
|
||||
}
|
||||
);
|
||||
|
||||
const runtimeContentBlockMutation = <
|
||||
Result extends Record<string, unknown> = {},
|
||||
>(
|
||||
id: RuntimeOperationId
|
||||
) =>
|
||||
contentOrBuildMutation(
|
||||
runtimeMutationInput(id, false),
|
||||
async ({ ctx, input, build, commit }) => {
|
||||
const { assets, assetFolders } = await loadRuntimeAssetData(
|
||||
ctx,
|
||||
input.projectId
|
||||
);
|
||||
return await commitRuntimeMutation<Result>({
|
||||
id,
|
||||
build,
|
||||
assets,
|
||||
assetFolders,
|
||||
input,
|
||||
commit,
|
||||
context: {
|
||||
contentBlockApplication: createServerContentBlockApplication({
|
||||
ctx,
|
||||
projectId: input.projectId,
|
||||
build,
|
||||
assets,
|
||||
assetFolders,
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
type RuntimeOperationRouteTree = Record<string, unknown>;
|
||||
type RouterRecord = Parameters<typeof router>[0];
|
||||
type RuntimeOperation = (typeof runtimeOperationContracts)[number];
|
||||
@@ -532,6 +611,8 @@ const createRuntimeOperationProcedure = (operation: RuntimeOperation) => {
|
||||
if (operation.requiresAssets) {
|
||||
return operation.kind === "read"
|
||||
? runtimeAssetsQuery(id)
|
||||
: id.startsWith("contentBlocks.")
|
||||
? runtimeContentBlockMutation(id)
|
||||
: runtimeAssetsMutation(id);
|
||||
}
|
||||
if (operation.kind === "read") {
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import type { CompactBuild } from "@webstudio-is/project-build";
|
||||
import type { RuntimeOperationId } from "@webstudio-is/project-build/contracts";
|
||||
import { executeBuilderRuntimeOperation } from "@webstudio-is/project-build/runtime";
|
||||
import { BuilderRuntimeError } from "@webstudio-is/project-build/runtime";
|
||||
import { builderRuntimeContext } from "@webstudio-is/project-build/runtime";
|
||||
import { type BuilderRuntimeMutation } from "@webstudio-is/project-build/runtime";
|
||||
import {
|
||||
BuilderRuntimeError,
|
||||
builderRuntimeContext,
|
||||
executeBuilderRuntimeOperation,
|
||||
type BuilderRuntimeContext,
|
||||
type BuilderRuntimeMutation,
|
||||
} from "@webstudio-is/project-build/runtime";
|
||||
import type { BuilderState } from "@webstudio-is/project-build/state";
|
||||
import { createBuilderStateFromCompactBuild } from "@webstudio-is/project-build/state";
|
||||
import { type Asset, type AssetFolder } from "@webstudio-is/sdk";
|
||||
@@ -26,12 +29,14 @@ export const executeApiRuntimeOperation = async <Result>({
|
||||
assets,
|
||||
assetFolders,
|
||||
input,
|
||||
context,
|
||||
}: {
|
||||
id: RuntimeOperationId;
|
||||
build: CompactBuild;
|
||||
assets?: Asset[];
|
||||
assetFolders?: AssetFolder[];
|
||||
input: unknown;
|
||||
context?: Partial<BuilderRuntimeContext>;
|
||||
}): Promise<Result> => {
|
||||
try {
|
||||
return await executeBuilderRuntimeOperation<Result>({
|
||||
@@ -42,6 +47,7 @@ export const executeApiRuntimeOperation = async <Result>({
|
||||
createId: builderRuntimeContext.createId,
|
||||
projectId: build.projectId,
|
||||
projectVersion: build.version,
|
||||
...context,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -67,4 +73,5 @@ export const executeApiRuntimeMutation = <
|
||||
assets?: Asset[];
|
||||
assetFolders?: AssetFolder[];
|
||||
input: unknown;
|
||||
context?: Partial<BuilderRuntimeContext>;
|
||||
}) => executeApiRuntimeOperation<BuilderRuntimeMutation<Result>>(args);
|
||||
@@ -7,8 +7,10 @@ import { Readable } from "node:stream";
|
||||
import parseRange from "range-parser";
|
||||
import { getAssetMime } from "@webstudio-is/sdk";
|
||||
import {
|
||||
assetContentDescriptorHeader,
|
||||
assetMetadataUpdate,
|
||||
assetResourceApiOperations,
|
||||
serializeAssetContentDescriptor,
|
||||
} from "@webstudio-is/protocol/asset-resource-api";
|
||||
import { privateNoStoreResponseHeaders } from "./cache-control.server";
|
||||
import { ensureApiCsrf } from "./api-auth.server";
|
||||
@@ -69,9 +71,13 @@ export const createAssetContentLoader =
|
||||
const range = parseRequestRange(request.headers.get("range"), asset.size);
|
||||
const content = await repository.readContent({ assetId, range, asset });
|
||||
const headers = new Headers(privateNoStoreResponseHeaders);
|
||||
headers.set(
|
||||
assetContentDescriptorHeader,
|
||||
serializeAssetContentDescriptor(content.asset)
|
||||
);
|
||||
headers.set(
|
||||
"content-type",
|
||||
getAssetMime(asset) ?? "application/octet-stream"
|
||||
getAssetMime(content.asset) ?? "application/octet-stream"
|
||||
);
|
||||
headers.set("accept-ranges", "bytes");
|
||||
headers.set(
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { expect, test, vi } from "vitest";
|
||||
import { contentEngineLimits } from "@webstudio-is/content-engine/limits";
|
||||
import { createAssetContentBridge } from "./asset-content-bridge.client";
|
||||
|
||||
const origin = "https://builder.example";
|
||||
const contentUrl = `${origin}/rest/assets/asset-1/content?projectId=project-1`;
|
||||
|
||||
const createBridge = ({
|
||||
authorized = true,
|
||||
}: {
|
||||
authorized?: boolean;
|
||||
} = {}) => {
|
||||
const request = vi.fn(async () => new Response("ok"));
|
||||
const authorize = vi.fn(() => authorized);
|
||||
const requireReload = vi.fn();
|
||||
return {
|
||||
bridge: createAssetContentBridge({
|
||||
origin,
|
||||
request,
|
||||
authorize,
|
||||
requireReload,
|
||||
}),
|
||||
request,
|
||||
authorize,
|
||||
requireReload,
|
||||
};
|
||||
};
|
||||
|
||||
test("delegates stale revision handling to the bridge owner", () => {
|
||||
const { bridge, requireReload } = createBridge();
|
||||
|
||||
bridge.requireReload("The file changed.");
|
||||
|
||||
expect(requireReload).toHaveBeenCalledWith("The file changed.");
|
||||
});
|
||||
|
||||
test("forwards only an authorized Asset content request", async () => {
|
||||
const { bridge, request, authorize } = createBridge();
|
||||
|
||||
await expect(bridge.request(contentUrl)).resolves.toBeInstanceOf(Response);
|
||||
|
||||
expect(authorize).toHaveBeenCalledWith({
|
||||
projectId: "project-1",
|
||||
assetId: "asset-1",
|
||||
operation: "read",
|
||||
});
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("forwards a bounded authorized write", async () => {
|
||||
const { bridge, request, authorize } = createBridge();
|
||||
|
||||
await expect(
|
||||
bridge.request(`${contentUrl}&expectedName=article.mdx`, {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/octet-stream" },
|
||||
body: new TextEncoder().encode("# Updated"),
|
||||
})
|
||||
).resolves.toBeInstanceOf(Response);
|
||||
|
||||
expect(authorize).toHaveBeenCalledWith({
|
||||
projectId: "project-1",
|
||||
assetId: "asset-1",
|
||||
operation: "write",
|
||||
});
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
const rejectedRequests: ReadonlyArray<{
|
||||
name: string;
|
||||
url: string;
|
||||
init?: RequestInit;
|
||||
}> = [
|
||||
{
|
||||
name: "another origin",
|
||||
url: "https://attacker.example/rest/assets/asset-1/content?projectId=project-1",
|
||||
},
|
||||
{
|
||||
name: "another route",
|
||||
url: `${origin}/rest/assets/asset-1?projectId=project-1`,
|
||||
},
|
||||
{
|
||||
name: "an unsupported method",
|
||||
url: contentUrl,
|
||||
init: { method: "DELETE" },
|
||||
},
|
||||
{
|
||||
name: "an unknown query",
|
||||
url: `${contentUrl}&redirect=https://attacker.example`,
|
||||
},
|
||||
];
|
||||
|
||||
test.each(rejectedRequests)("rejects $name", async ({ url, init }) => {
|
||||
const { bridge, request } = createBridge();
|
||||
|
||||
await expect(bridge.request(url, init)).rejects.toThrow();
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("enforces project and Asset authorization inside the request path", async () => {
|
||||
const { bridge, request } = createBridge({ authorized: false });
|
||||
|
||||
await expect(bridge.request(contentUrl)).rejects.toThrow("not authorized");
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("bounds Asset content writes before forwarding them", async () => {
|
||||
const { bridge, request } = createBridge();
|
||||
|
||||
await expect(
|
||||
bridge.request(`${contentUrl}&expectedName=article.mdx`, {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/octet-stream" },
|
||||
body: new Uint8Array(contentEngineLimits.hydratedFileBytes + 1),
|
||||
})
|
||||
).rejects.toThrow("exceeds");
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
import { contentEngineLimits } from "@webstudio-is/content-engine/limits";
|
||||
import type { AssetContentSession } from "@webstudio-is/content-engine/asset-content-session";
|
||||
|
||||
type AssetContentAuthorization = Readonly<{
|
||||
projectId: string;
|
||||
assetId: string;
|
||||
operation: "read" | "write";
|
||||
}>;
|
||||
|
||||
export type AssetContentBridge = Readonly<{
|
||||
request: (input: string, init?: RequestInit) => Promise<Response>;
|
||||
authorize: (input: AssetContentAuthorization) => boolean;
|
||||
requireReload: (error: string) => void;
|
||||
getContentSession?: (projectId: string) => AssetContentSession;
|
||||
}>;
|
||||
|
||||
const namespace = "__webstudio__$__assetContentBridge";
|
||||
|
||||
export const createAssetContentBridge = ({
|
||||
origin,
|
||||
request,
|
||||
authorize,
|
||||
requireReload,
|
||||
getContentSession,
|
||||
}: {
|
||||
origin: string;
|
||||
request: (input: string, init?: RequestInit) => Promise<Response>;
|
||||
authorize: (input: AssetContentAuthorization) => boolean;
|
||||
requireReload: (error: string) => void;
|
||||
getContentSession?: (projectId: string) => AssetContentSession;
|
||||
}): AssetContentBridge => ({
|
||||
authorize,
|
||||
requireReload,
|
||||
getContentSession,
|
||||
request: async (input, init) => {
|
||||
const url = new URL(input, origin);
|
||||
const method = init?.method?.toUpperCase() ?? "GET";
|
||||
const [, rest, assets, encodedAssetId, content, ...extraPath] =
|
||||
url.pathname.split("/");
|
||||
if (
|
||||
url.origin !== origin ||
|
||||
rest !== "rest" ||
|
||||
assets !== "assets" ||
|
||||
encodedAssetId === undefined ||
|
||||
encodedAssetId === "" ||
|
||||
content !== "content" ||
|
||||
extraPath.length !== 0 ||
|
||||
(method !== "GET" && method !== "PUT")
|
||||
) {
|
||||
throw new Error("Only same-origin Asset content requests are allowed");
|
||||
}
|
||||
let assetId: string;
|
||||
try {
|
||||
assetId = decodeURIComponent(encodedAssetId);
|
||||
} catch {
|
||||
throw new Error("Asset content request has an invalid Asset id");
|
||||
}
|
||||
const projectIds = url.searchParams.getAll("projectId");
|
||||
const expectedNames = url.searchParams.getAll("expectedName");
|
||||
const allowedQueryNames =
|
||||
method === "GET"
|
||||
? new Set(["projectId"])
|
||||
: new Set(["projectId", "expectedName"]);
|
||||
if (
|
||||
projectIds.length !== 1 ||
|
||||
projectIds[0] === "" ||
|
||||
expectedNames.length !== (method === "PUT" ? 1 : 0) ||
|
||||
(method === "PUT" && expectedNames[0] === "") ||
|
||||
[...url.searchParams.keys()].some(
|
||||
(name) => allowedQueryNames.has(name) === false
|
||||
)
|
||||
) {
|
||||
throw new Error("Asset content request has invalid parameters");
|
||||
}
|
||||
const operation = method === "PUT" ? "write" : "read";
|
||||
if (
|
||||
authorize({
|
||||
projectId: projectIds[0],
|
||||
assetId,
|
||||
operation,
|
||||
}) === false
|
||||
) {
|
||||
throw new Error("Asset content request is not authorized");
|
||||
}
|
||||
if (method === "GET") {
|
||||
if (init?.body != null) {
|
||||
throw new Error("Asset content reads cannot include a body");
|
||||
}
|
||||
return await request(url.href, init);
|
||||
}
|
||||
const headers = new Headers(init?.headers);
|
||||
if (headers.get("content-type") !== "application/octet-stream") {
|
||||
throw new Error("Asset content writes require binary content");
|
||||
}
|
||||
const body = init?.body;
|
||||
const byteLength =
|
||||
typeof body === "string"
|
||||
? new TextEncoder().encode(body).byteLength
|
||||
: body instanceof Blob
|
||||
? body.size
|
||||
: body instanceof ArrayBuffer
|
||||
? body.byteLength
|
||||
: ArrayBuffer.isView(body)
|
||||
? body.byteLength
|
||||
: undefined;
|
||||
if (byteLength === undefined) {
|
||||
throw new Error("Asset content writes require a bounded body");
|
||||
}
|
||||
if (byteLength > contentEngineLimits.hydratedFileBytes) {
|
||||
throw new Error("Asset content write exceeds the MDX editing limit");
|
||||
}
|
||||
return await request(url.href, init);
|
||||
},
|
||||
});
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
[namespace]?: AssetContentBridge;
|
||||
}
|
||||
}
|
||||
|
||||
export const initAssetContentBridge = (bridge: AssetContentBridge) => {
|
||||
window[namespace] = bridge;
|
||||
};
|
||||
|
||||
export const getAssetContentBridge = () => {
|
||||
const owner = window.self === window.top ? window : window.top;
|
||||
const bridge = owner?.[namespace];
|
||||
if (bridge === undefined) {
|
||||
throw new Error("Builder Asset content bridge is not available");
|
||||
}
|
||||
return bridge;
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
import { expect, test } from "vitest";
|
||||
import type { Asset } from "@webstudio-is/sdk";
|
||||
import { __testing__ } from "./builder-api";
|
||||
|
||||
const file = (format: string) =>
|
||||
({ type: "file", format }) satisfies Pick<Asset, "type" | "format">;
|
||||
|
||||
test("allows graph documents to be read but only MDX roots to be written", () => {
|
||||
for (const format of ["md", "mdx", "json"]) {
|
||||
expect(
|
||||
__testing__.canAccessAssetContent({
|
||||
asset: file(format),
|
||||
operation: "read",
|
||||
canWrite: false,
|
||||
})
|
||||
).toBe(true);
|
||||
}
|
||||
expect(
|
||||
__testing__.canAccessAssetContent({
|
||||
asset: file("png"),
|
||||
operation: "read",
|
||||
canWrite: true,
|
||||
})
|
||||
).toBe(false);
|
||||
expect(
|
||||
__testing__.canAccessAssetContent({
|
||||
asset: file("mdx"),
|
||||
operation: "write",
|
||||
canWrite: true,
|
||||
})
|
||||
).toBe(true);
|
||||
for (const format of ["md", "json"]) {
|
||||
expect(
|
||||
__testing__.canAccessAssetContent({
|
||||
asset: file(format),
|
||||
operation: "write",
|
||||
canWrite: true,
|
||||
})
|
||||
).toBe(false);
|
||||
}
|
||||
expect(
|
||||
__testing__.canAccessAssetContent({
|
||||
asset: file("mdx"),
|
||||
operation: "write",
|
||||
canWrite: false,
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
@@ -8,9 +8,43 @@ import {
|
||||
import { showTokenConflictDialog } from "./token-conflict-dialog";
|
||||
import { showRootStyleConflictDialog } from "./root-style-conflict-dialog";
|
||||
import { showDesignTokenImportDialog } from "./design-token-import-dialog";
|
||||
import { fetch as builderFetch } from "./fetch.client";
|
||||
import { $assets, $project } from "./sync/data-stores";
|
||||
import { $authPermit } from "./nano-states";
|
||||
import {
|
||||
createAssetContentBridge,
|
||||
initAssetContentBridge,
|
||||
} from "./asset-content-bridge.client";
|
||||
import { requireBuilderReload } from "./sync/reload-required";
|
||||
import { createAssetContentSession } from "@webstudio-is/content-engine/asset-content-session";
|
||||
import { createHttpAssetContentRepository } from "~/builder/shared/assets/mdx-content-repository";
|
||||
import { $authToken } from "./nano-states";
|
||||
import { isMdxFileAsset, type Asset } from "@webstudio-is/sdk";
|
||||
import { createTransactionFromBuilderPatchPayload } from "./sync/builder-patch";
|
||||
import { getWebstudioData } from "./instance-utils/data";
|
||||
import { invalidateAssets } from "./resources";
|
||||
import { onNextTransactionComplete } from "./sync/project-queue";
|
||||
import { disposeExternalContentProject } from "./external-content-roots";
|
||||
|
||||
const apiWindowNamespace = "__webstudio__$__builderApi";
|
||||
|
||||
const isContentDocumentAsset = (asset: Pick<Asset, "type" | "format">) =>
|
||||
asset.type === "file" &&
|
||||
["md", "mdx", "json"].includes(asset.format.toLowerCase());
|
||||
|
||||
const canAccessAssetContent = ({
|
||||
asset,
|
||||
operation,
|
||||
canWrite,
|
||||
}: {
|
||||
asset: Pick<Asset, "type" | "format">;
|
||||
operation: "read" | "write";
|
||||
canWrite: boolean;
|
||||
}) =>
|
||||
operation === "read"
|
||||
? isContentDocumentAsset(asset)
|
||||
: canWrite && isMdxFileAsset(asset);
|
||||
|
||||
type ToastHandler = (message: string) => void;
|
||||
|
||||
const isSafeMode = (() => {
|
||||
@@ -20,6 +54,28 @@ const isSafeMode = (() => {
|
||||
return new URLSearchParams(window.location.search).get("safemode") === "true";
|
||||
})();
|
||||
|
||||
const authorizeAssetContent = ({
|
||||
projectId,
|
||||
assetId,
|
||||
operation,
|
||||
}: {
|
||||
projectId: string;
|
||||
assetId: string;
|
||||
operation: "read" | "write";
|
||||
}) => {
|
||||
const asset = $assets.get().get(assetId);
|
||||
return (
|
||||
$project.get()?.id === projectId &&
|
||||
asset?.projectId === projectId &&
|
||||
asset !== undefined &&
|
||||
canAccessAssetContent({
|
||||
asset,
|
||||
operation,
|
||||
canWrite: $authPermit.get() !== "view",
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const _builderApi = {
|
||||
isInitialized: () => true,
|
||||
isSafeMode: () => isSafeMode,
|
||||
@@ -121,6 +177,197 @@ export const builderApi = createRecursiveProxy((options) => {
|
||||
export const initBuilderApi = () => {
|
||||
if (isInTop()) {
|
||||
window[apiWindowNamespace] = _builderApi;
|
||||
type ContentSessionEntry = {
|
||||
projectId: string;
|
||||
session: ReturnType<typeof createAssetContentSession>;
|
||||
active: boolean;
|
||||
retiring?: Promise<void>;
|
||||
};
|
||||
const contentSessions = new Map<string, ContentSessionEntry>();
|
||||
let activeContentSession: ContentSessionEntry | undefined;
|
||||
const retireContentSession = (contentSession: ContentSessionEntry) => {
|
||||
contentSession.active = false;
|
||||
if (activeContentSession === contentSession) {
|
||||
activeContentSession = undefined;
|
||||
}
|
||||
if (contentSession.retiring !== undefined) {
|
||||
return;
|
||||
}
|
||||
const retiring = disposeExternalContentProject({
|
||||
projectId: contentSession.projectId,
|
||||
session: contentSession.session,
|
||||
shouldCleanup: () => contentSession.active === false,
|
||||
})
|
||||
.then((cleanedUp) => {
|
||||
if (cleanedUp === false || contentSession.active) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
contentSessions.get(contentSession.projectId) === contentSession
|
||||
) {
|
||||
contentSessions.delete(contentSession.projectId);
|
||||
}
|
||||
contentSession.session.dispose();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(
|
||||
"Some MDX changes could not be saved. Return to this project to retry."
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
if (contentSession.retiring === retiring) {
|
||||
contentSession.retiring = undefined;
|
||||
}
|
||||
});
|
||||
contentSession.retiring = retiring;
|
||||
};
|
||||
const unlistenProject = $project.listen((project) => {
|
||||
if (
|
||||
activeContentSession !== undefined &&
|
||||
activeContentSession.projectId !== project?.id
|
||||
) {
|
||||
retireContentSession(activeContentSession);
|
||||
}
|
||||
});
|
||||
initAssetContentBridge(
|
||||
createAssetContentBridge({
|
||||
origin: window.location.origin,
|
||||
request: (input, init) => builderFetch(input, init),
|
||||
authorize: authorizeAssetContent,
|
||||
requireReload: (error) => requireBuilderReload({ error }),
|
||||
getContentSession: (projectId) => {
|
||||
if ($project.get()?.id !== projectId) {
|
||||
throw new Error("Asset content session belongs to another project");
|
||||
}
|
||||
const existing = contentSessions.get(projectId);
|
||||
if (existing !== undefined) {
|
||||
existing.active = true;
|
||||
activeContentSession = existing;
|
||||
return existing.session;
|
||||
}
|
||||
if (activeContentSession !== undefined) {
|
||||
retireContentSession(activeContentSession);
|
||||
}
|
||||
let sessionAuthToken = $authToken.get();
|
||||
let canWrite = $authPermit.get() !== "view";
|
||||
const authorizedReadAssetIds = new Set(
|
||||
Array.from($assets.get().values())
|
||||
.filter(
|
||||
(asset) =>
|
||||
asset.projectId === projectId &&
|
||||
canAccessAssetContent({
|
||||
asset,
|
||||
operation: "read",
|
||||
canWrite: true,
|
||||
})
|
||||
)
|
||||
.map((asset) => asset.id)
|
||||
);
|
||||
const authorizedWriteAssetIds = new Set(
|
||||
Array.from($assets.get().values())
|
||||
.filter(
|
||||
(asset) =>
|
||||
asset.projectId === projectId &&
|
||||
canAccessAssetContent({
|
||||
asset,
|
||||
operation: "write",
|
||||
canWrite: true,
|
||||
})
|
||||
)
|
||||
.map((asset) => asset.id)
|
||||
);
|
||||
const session = createAssetContentSession({
|
||||
repository: createHttpAssetContentRepository({
|
||||
projectId,
|
||||
origin: window.location.origin,
|
||||
authToken: () => {
|
||||
if ($project.get()?.id === projectId) {
|
||||
sessionAuthToken = $authToken.get();
|
||||
}
|
||||
return sessionAuthToken;
|
||||
},
|
||||
request: (input, init) => builderFetch(input, init),
|
||||
}),
|
||||
authorize: ({ assetId, operation }) => {
|
||||
if ($project.get()?.id === projectId) {
|
||||
canWrite = $authPermit.get() !== "view";
|
||||
const asset = $assets.get().get(assetId);
|
||||
if (
|
||||
asset?.projectId === projectId &&
|
||||
canAccessAssetContent({
|
||||
asset,
|
||||
operation: "read",
|
||||
canWrite: true,
|
||||
})
|
||||
) {
|
||||
authorizedReadAssetIds.add(assetId);
|
||||
}
|
||||
if (
|
||||
asset?.projectId === projectId &&
|
||||
canAccessAssetContent({
|
||||
asset,
|
||||
operation: "write",
|
||||
canWrite: true,
|
||||
})
|
||||
) {
|
||||
authorizedWriteAssetIds.add(assetId);
|
||||
}
|
||||
}
|
||||
return (
|
||||
(operation === "read"
|
||||
? authorizedReadAssetIds
|
||||
: authorizedWriteAssetIds
|
||||
).has(assetId) &&
|
||||
(operation === "read" || canWrite)
|
||||
);
|
||||
},
|
||||
});
|
||||
session.subscribe((_assetId, state) => {
|
||||
if (state.status !== "saved" || $project.get()?.id !== projectId) {
|
||||
return;
|
||||
}
|
||||
const current = $assets.get().get(state.asset.id);
|
||||
if (current === undefined) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
current.name === state.asset.name &&
|
||||
current.size === state.asset.size &&
|
||||
current.updatedAt === state.asset.updatedAt
|
||||
) {
|
||||
return;
|
||||
}
|
||||
createTransactionFromBuilderPatchPayload({
|
||||
data: getWebstudioData(),
|
||||
payload: [
|
||||
{
|
||||
namespace: "assets",
|
||||
patches: [
|
||||
{
|
||||
op: "replace",
|
||||
path: [current.id],
|
||||
value: { ...current, ...state.asset },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
onNextTransactionComplete(invalidateAssets);
|
||||
});
|
||||
activeContentSession = { projectId, session, active: true };
|
||||
contentSessions.set(projectId, activeContentSession);
|
||||
return session;
|
||||
},
|
||||
})
|
||||
);
|
||||
return () => {
|
||||
unlistenProject();
|
||||
for (const contentSession of contentSessions.values()) {
|
||||
retireContentSession(contentSession);
|
||||
}
|
||||
};
|
||||
}
|
||||
return () => {};
|
||||
};
|
||||
|
||||
export const __testing__ = { canAccessAssetContent };
|
||||
@@ -236,6 +236,28 @@ export const foldGutterExtension = foldGutter({
|
||||
},
|
||||
});
|
||||
|
||||
export const linterTooltipTheme = EditorView.theme({
|
||||
".cm-tooltip:has(.cm-tooltip-lint)": {
|
||||
backgroundColor: "transparent",
|
||||
borderWidth: 0,
|
||||
paddingTop: rawTheme.spacing[5],
|
||||
paddingBottom: rawTheme.spacing[5],
|
||||
pointerEvents: "none",
|
||||
},
|
||||
".cm-tooltip-lint": {
|
||||
backgroundColor: rawTheme.colors.backgroundTooltipMain,
|
||||
color: rawTheme.colors.foregroundContrastMain,
|
||||
borderRadius: rawTheme.borderRadius[7],
|
||||
padding: rawTheme.spacing[5],
|
||||
},
|
||||
".cm-tooltip-lint .cm-diagnostic": {
|
||||
borderWidth: 0,
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
...textVariants.regular,
|
||||
},
|
||||
});
|
||||
|
||||
export type EditorApi = {
|
||||
replaceSelection: (string: string) => void;
|
||||
insertTemplate: (template: {
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
EditorDialogControl,
|
||||
foldGutterExtension,
|
||||
getCodeEditorCssVars,
|
||||
linterTooltipTheme,
|
||||
} from "~/shared/code-editor-base";
|
||||
|
||||
const wrapperStyle = css({
|
||||
@@ -216,6 +217,7 @@ export const CodeEditor = forwardRef<
|
||||
...builtInExtensions,
|
||||
...dynamicLanguageExtensions,
|
||||
...languageExtensions,
|
||||
linterTooltipTheme,
|
||||
],
|
||||
[builtInExtensions, dynamicLanguageExtensions, languageExtensions]
|
||||
);
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { expect, test } from "vitest";
|
||||
import type { ContentBlockDiagnostic } from "@webstudio-is/sdk";
|
||||
import {
|
||||
deduplicateContentBlockDiagnostics,
|
||||
formatContentBlockDiagnostic,
|
||||
takeNewContentBlockDiagnostics,
|
||||
} from "./content-block-diagnostics";
|
||||
|
||||
const diagnostic: ContentBlockDiagnostic = {
|
||||
code: "ignored-template-prop",
|
||||
severity: "warning",
|
||||
blockInstanceId: "block",
|
||||
assetId: "article",
|
||||
contentRef: "article.mdx",
|
||||
renderScope: "scope",
|
||||
templateName: "Hero",
|
||||
propName: "tone",
|
||||
reason: "design-only",
|
||||
sourceRange: {
|
||||
start: { line: 2, column: 3 },
|
||||
end: { line: 2, column: 12 },
|
||||
},
|
||||
};
|
||||
|
||||
test("formats and deduplicates diagnostics by revision without losing detail", () => {
|
||||
expect(formatContentBlockDiagnostic(diagnostic)).toBe(
|
||||
'Property "tone" on template "Hero" was ignored because it is design only. Line 2, column 3.'
|
||||
);
|
||||
expect(deduplicateContentBlockDiagnostics([diagnostic, diagnostic])).toEqual([
|
||||
diagnostic,
|
||||
]);
|
||||
expect(takeNewContentBlockDiagnostics([diagnostic], "one")).toEqual([
|
||||
diagnostic,
|
||||
]);
|
||||
expect(takeNewContentBlockDiagnostics([diagnostic], "one")).toEqual([]);
|
||||
expect(takeNewContentBlockDiagnostics([diagnostic], "two")).toEqual([
|
||||
diagnostic,
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { ContentBlockDiagnostic } from "@webstudio-is/sdk";
|
||||
|
||||
const formatSourceLocation = (diagnostic: ContentBlockDiagnostic) => {
|
||||
const point = diagnostic.sourceRange?.start;
|
||||
return point === undefined
|
||||
? ""
|
||||
: ` Line ${point.line}, column ${point.column}.`;
|
||||
};
|
||||
|
||||
export const formatContentBlockDiagnostic = (
|
||||
diagnostic: ContentBlockDiagnostic
|
||||
) => {
|
||||
let message = "Invalid MDX content.";
|
||||
if (diagnostic.code === "invalid-mdx") {
|
||||
message = diagnostic.message;
|
||||
} else if (diagnostic.code === "unsafe-mdx") {
|
||||
message = diagnostic.reason;
|
||||
} else if (diagnostic.code === "unresolved-template") {
|
||||
message = `Template "${diagnostic.templateName}" is not available and was skipped.`;
|
||||
} else if (diagnostic.code === "ignored-template-prop") {
|
||||
message = `Property "${diagnostic.propName}" on template "${
|
||||
diagnostic.templateName
|
||||
}" was ignored because it is ${diagnostic.reason.replace("-", " ")}.`;
|
||||
}
|
||||
return `${message}${formatSourceLocation(diagnostic)}`;
|
||||
};
|
||||
|
||||
export const deduplicateContentBlockDiagnostics = (
|
||||
diagnostics: readonly ContentBlockDiagnostic[]
|
||||
) => {
|
||||
const keys = new Set<string>();
|
||||
return diagnostics.filter((diagnostic) => {
|
||||
const key = JSON.stringify(diagnostic);
|
||||
if (keys.has(key)) {
|
||||
return false;
|
||||
}
|
||||
keys.add(key);
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
const notifiedDiagnosticKeys = new Set<string>();
|
||||
const maxNotifiedDiagnosticKeys = 1000;
|
||||
|
||||
export const takeNewContentBlockDiagnostics = (
|
||||
diagnostics: readonly ContentBlockDiagnostic[],
|
||||
revision?: string
|
||||
) => {
|
||||
const fresh: ContentBlockDiagnostic[] = [];
|
||||
for (const diagnostic of deduplicateContentBlockDiagnostics(diagnostics)) {
|
||||
const key = JSON.stringify([revision, diagnostic]);
|
||||
if (notifiedDiagnosticKeys.has(key)) {
|
||||
continue;
|
||||
}
|
||||
notifiedDiagnosticKeys.add(key);
|
||||
if (notifiedDiagnosticKeys.size > maxNotifiedDiagnosticKeys) {
|
||||
const oldestKey = notifiedDiagnosticKeys.values().next().value;
|
||||
if (oldestKey !== undefined) {
|
||||
notifiedDiagnosticKeys.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
fresh.push(diagnostic);
|
||||
}
|
||||
return fresh;
|
||||
};
|
||||
@@ -0,0 +1,411 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
getSelectedContentBlockDocumentBindingPath,
|
||||
getSelectedContentBlockExpressionMode,
|
||||
isObjectPathWritable,
|
||||
setObjectPathValue,
|
||||
} from "./content-block-document";
|
||||
import {
|
||||
blockComponent,
|
||||
blockTemplateComponent,
|
||||
contentBlockDocumentProp,
|
||||
encodeDataSourceVariable,
|
||||
type Instance,
|
||||
type Prop,
|
||||
} from "@webstudio-is/sdk";
|
||||
|
||||
describe("Content Block document bindings", () => {
|
||||
test("uses the selected external root instead of an enclosing Content Block", () => {
|
||||
const instances = new Map<string, Instance>([
|
||||
[
|
||||
"inner",
|
||||
{
|
||||
type: "instance",
|
||||
id: "inner",
|
||||
component: blockComponent,
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
[
|
||||
"outer",
|
||||
{
|
||||
type: "instance",
|
||||
id: "outer",
|
||||
component: blockComponent,
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
]);
|
||||
const props = new Map<string, Prop>([
|
||||
[
|
||||
"outer-source",
|
||||
{
|
||||
id: "outer-source",
|
||||
instanceId: "outer",
|
||||
name: "src",
|
||||
type: "asset",
|
||||
value: "outer-article",
|
||||
},
|
||||
],
|
||||
[
|
||||
"inner-source",
|
||||
{
|
||||
id: "inner-source",
|
||||
instanceId: "inner",
|
||||
name: "src",
|
||||
type: "asset",
|
||||
value: "inner-article",
|
||||
},
|
||||
],
|
||||
[
|
||||
"outer-document",
|
||||
{
|
||||
id: "outer-document",
|
||||
instanceId: "outer",
|
||||
name: contentBlockDocumentProp,
|
||||
type: "parameter",
|
||||
value: "outerDocument",
|
||||
},
|
||||
],
|
||||
[
|
||||
"inner-document",
|
||||
{
|
||||
id: "inner-document",
|
||||
instanceId: "inner",
|
||||
name: contentBlockDocumentProp,
|
||||
type: "parameter",
|
||||
value: "innerDocument",
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
expect(
|
||||
getSelectedContentBlockDocumentBindingPath({
|
||||
binding: {
|
||||
type: "expression",
|
||||
value: `${encodeDataSourceVariable("innerDocument")}.frontmatter.title`,
|
||||
mode: "readwrite",
|
||||
},
|
||||
instanceSelector: ["heading", "outer"],
|
||||
instances,
|
||||
props,
|
||||
sourceBlockInstanceId: "inner",
|
||||
renderedBlockInstanceId: "outer",
|
||||
})
|
||||
).toEqual(["title"]);
|
||||
});
|
||||
|
||||
test("does not infer write access from a direct expression alone", () => {
|
||||
expect(
|
||||
getSelectedContentBlockDocumentBindingPath({
|
||||
binding: {
|
||||
type: "expression",
|
||||
value: `${encodeDataSourceVariable("innerDocument")}.frontmatter.title`,
|
||||
},
|
||||
instanceSelector: ["heading", "inner"],
|
||||
instances: new Map([
|
||||
[
|
||||
"inner",
|
||||
{
|
||||
type: "instance",
|
||||
id: "inner",
|
||||
component: blockComponent,
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
]),
|
||||
props: new Map<string, Prop>([
|
||||
[
|
||||
"inner-source",
|
||||
{
|
||||
id: "inner-source",
|
||||
instanceId: "inner",
|
||||
name: "src",
|
||||
type: "asset",
|
||||
value: "article",
|
||||
},
|
||||
],
|
||||
[
|
||||
"inner-document",
|
||||
{
|
||||
id: "inner-document",
|
||||
instanceId: "inner",
|
||||
name: contentBlockDocumentProp,
|
||||
type: "parameter",
|
||||
value: "innerDocument",
|
||||
},
|
||||
],
|
||||
]),
|
||||
})
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
test("does not expose a writable document target after disconnect", () => {
|
||||
expect(
|
||||
getSelectedContentBlockDocumentBindingPath({
|
||||
binding: {
|
||||
type: "expression",
|
||||
value: `${encodeDataSourceVariable("document")}.frontmatter.title`,
|
||||
mode: "readwrite",
|
||||
},
|
||||
instanceSelector: ["heading", "block"],
|
||||
instances: new Map([
|
||||
[
|
||||
"block",
|
||||
{
|
||||
type: "instance",
|
||||
id: "block",
|
||||
component: blockComponent,
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
]),
|
||||
props: new Map([
|
||||
[
|
||||
"document",
|
||||
{
|
||||
id: "document",
|
||||
instanceId: "block",
|
||||
name: contentBlockDocumentProp,
|
||||
type: "parameter",
|
||||
value: "document",
|
||||
},
|
||||
],
|
||||
]),
|
||||
})
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
test("classifies only connected direct document paths as readwrite", () => {
|
||||
const document = encodeDataSourceVariable("document");
|
||||
const block: Instance = {
|
||||
type: "instance",
|
||||
id: "block",
|
||||
component: blockComponent,
|
||||
children: [],
|
||||
};
|
||||
const instances = new Map([[block.id, block]]);
|
||||
const props = new Map<string, Prop>([
|
||||
[
|
||||
"source",
|
||||
{
|
||||
id: "source",
|
||||
instanceId: block.id,
|
||||
name: "src",
|
||||
type: "asset",
|
||||
value: "article",
|
||||
},
|
||||
],
|
||||
[
|
||||
"document",
|
||||
{
|
||||
id: "document",
|
||||
instanceId: block.id,
|
||||
name: contentBlockDocumentProp,
|
||||
type: "parameter",
|
||||
value: "document",
|
||||
},
|
||||
],
|
||||
]);
|
||||
const context = {
|
||||
instanceSelector: ["heading", block.id],
|
||||
instances,
|
||||
props,
|
||||
};
|
||||
|
||||
expect(
|
||||
getSelectedContentBlockExpressionMode({
|
||||
...context,
|
||||
expression: `${document}.frontmatter.title`,
|
||||
})
|
||||
).toBe("readwrite");
|
||||
expect(
|
||||
getSelectedContentBlockExpressionMode({
|
||||
...context,
|
||||
expression: `${document}.frontmatter.title + "!"`,
|
||||
})
|
||||
).toBe("read");
|
||||
});
|
||||
|
||||
test("keeps bindings inside Templates read-only", () => {
|
||||
const document = encodeDataSourceVariable("document");
|
||||
const instances = new Map<string, Instance>([
|
||||
[
|
||||
"block",
|
||||
{
|
||||
type: "instance",
|
||||
id: "block",
|
||||
component: blockComponent,
|
||||
children: [{ type: "id", value: "templates" }],
|
||||
},
|
||||
],
|
||||
[
|
||||
"templates",
|
||||
{
|
||||
type: "instance",
|
||||
id: "templates",
|
||||
component: blockTemplateComponent,
|
||||
children: [{ type: "id", value: "heading" }],
|
||||
},
|
||||
],
|
||||
]);
|
||||
const props = new Map<string, Prop>([
|
||||
[
|
||||
"source",
|
||||
{
|
||||
id: "source",
|
||||
instanceId: "block",
|
||||
name: "src",
|
||||
type: "asset",
|
||||
value: "article",
|
||||
},
|
||||
],
|
||||
[
|
||||
"document",
|
||||
{
|
||||
id: "document",
|
||||
instanceId: "block",
|
||||
name: contentBlockDocumentProp,
|
||||
type: "parameter",
|
||||
value: "document",
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
expect(
|
||||
getSelectedContentBlockExpressionMode({
|
||||
expression: `${document}.frontmatter.title`,
|
||||
instanceSelector: ["heading", "templates", "block"],
|
||||
instances,
|
||||
props,
|
||||
})
|
||||
).toBe("read");
|
||||
});
|
||||
|
||||
test("does not write an outer document through a nested Content Block", () => {
|
||||
const outerDocument = encodeDataSourceVariable("outerDocument");
|
||||
const instances = new Map<string, Instance>([
|
||||
[
|
||||
"inner",
|
||||
{
|
||||
type: "instance",
|
||||
id: "inner",
|
||||
component: blockComponent,
|
||||
children: [{ type: "id", value: "heading" }],
|
||||
},
|
||||
],
|
||||
[
|
||||
"outer",
|
||||
{
|
||||
type: "instance",
|
||||
id: "outer",
|
||||
component: blockComponent,
|
||||
children: [{ type: "id", value: "inner" }],
|
||||
},
|
||||
],
|
||||
]);
|
||||
const props = new Map<string, Prop>([
|
||||
[
|
||||
"outer-source",
|
||||
{
|
||||
id: "outer-source",
|
||||
instanceId: "outer",
|
||||
name: "src",
|
||||
type: "asset",
|
||||
value: "outer-article",
|
||||
},
|
||||
],
|
||||
[
|
||||
"outer-document",
|
||||
{
|
||||
id: "outer-document",
|
||||
instanceId: "outer",
|
||||
name: contentBlockDocumentProp,
|
||||
type: "parameter",
|
||||
value: "outerDocument",
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
expect(
|
||||
getSelectedContentBlockExpressionMode({
|
||||
expression: `${outerDocument}.frontmatter.title`,
|
||||
instanceSelector: ["heading", "inner", "outer"],
|
||||
instances,
|
||||
props,
|
||||
sourceBlockInstanceId: "outer",
|
||||
renderedBlockInstanceId: "outer",
|
||||
})
|
||||
).toBe("read");
|
||||
});
|
||||
|
||||
test("does not write through a referenced frontmatter value", () => {
|
||||
expect(
|
||||
isObjectPathWritable({
|
||||
value: { author: { $ref: "./author.mdx#frontmatter" } },
|
||||
path: ["author", "name"],
|
||||
})
|
||||
).toBe(false);
|
||||
expect(
|
||||
isObjectPathWritable({
|
||||
value: { featureImage: { $ref: "./hero.png" } },
|
||||
path: ["featureImage"],
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("updates a nested property without mutating its source", () => {
|
||||
const source = { author: { name: "Before", role: "Writer" } };
|
||||
expect(
|
||||
setObjectPathValue({
|
||||
value: source,
|
||||
path: ["author", "name"],
|
||||
nextValue: "After",
|
||||
})
|
||||
).toEqual({ author: { name: "After", role: "Writer" } });
|
||||
expect(source.author.name).toBe("Before");
|
||||
});
|
||||
|
||||
test("does not replace an existing scalar while traversing a nested path", () => {
|
||||
const source = { author: "Oleg" };
|
||||
|
||||
expect(
|
||||
isObjectPathWritable({ value: source, path: ["author", "name"] })
|
||||
).toBe(false);
|
||||
expect(() =>
|
||||
setObjectPathValue({
|
||||
value: source,
|
||||
path: ["author", "name"],
|
||||
nextValue: "Ada",
|
||||
})
|
||||
).toThrow("Frontmatter object path is invalid");
|
||||
expect(source).toEqual({ author: "Oleg" });
|
||||
});
|
||||
|
||||
test("updates an array item without changing the array shape", () => {
|
||||
const source = { authors: [{ name: "Before" }, { name: "Other" }] };
|
||||
const path = ["authors", "0", "name"];
|
||||
|
||||
expect(isObjectPathWritable({ value: source, path })).toBe(true);
|
||||
expect(
|
||||
setObjectPathValue({ value: source, path, nextValue: "After" })
|
||||
).toEqual({ authors: [{ name: "After" }, { name: "Other" }] });
|
||||
expect(source.authors[0].name).toBe("Before");
|
||||
});
|
||||
|
||||
test("rejects invalid array indexes and unsafe object paths", () => {
|
||||
expect(
|
||||
isObjectPathWritable({ value: { authors: [] }, path: ["authors", "0"] })
|
||||
).toBe(false);
|
||||
expect(
|
||||
isObjectPathWritable({ value: {}, path: ["__proto__", "polluted"] })
|
||||
).toBe(false);
|
||||
expect(() =>
|
||||
setObjectPathValue({
|
||||
value: {},
|
||||
path: ["__proto__", "polluted"],
|
||||
nextValue: true,
|
||||
})
|
||||
).toThrow("Frontmatter object path is invalid");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,198 @@
|
||||
import {
|
||||
blockComponent,
|
||||
blockTemplateComponent,
|
||||
contentBlockDocumentProp,
|
||||
getWritableContentBlockDocumentBinding,
|
||||
getContentBlockSource,
|
||||
isSafeContentBlockDocumentPath,
|
||||
type ExpressionBinding,
|
||||
type ExpressionBindingMode,
|
||||
type Instance,
|
||||
type Instances,
|
||||
type Props,
|
||||
} from "@webstudio-is/sdk";
|
||||
import type { InstanceSelector } from "@webstudio-is/project-build/runtime";
|
||||
|
||||
export const getSelectedContentBlockDocumentBindingPath = ({
|
||||
binding,
|
||||
instanceSelector,
|
||||
instances,
|
||||
props,
|
||||
sourceBlockInstanceId,
|
||||
renderedBlockInstanceId,
|
||||
}: {
|
||||
binding: ExpressionBinding;
|
||||
instanceSelector: InstanceSelector;
|
||||
instances: Pick<Instances, "get">;
|
||||
props: Props;
|
||||
sourceBlockInstanceId?: Instance["id"];
|
||||
renderedBlockInstanceId?: Instance["id"];
|
||||
}) => {
|
||||
if (
|
||||
instanceSelector.some(
|
||||
(instanceId) =>
|
||||
instances.get(instanceId)?.component === blockTemplateComponent
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const selectedBlockInstanceId = instanceSelector.find(
|
||||
(instanceId) => instances.get(instanceId)?.component === blockComponent
|
||||
);
|
||||
const owningBlockInstanceIds = [
|
||||
sourceBlockInstanceId,
|
||||
renderedBlockInstanceId,
|
||||
].filter((instanceId) => instanceId !== undefined);
|
||||
if (
|
||||
selectedBlockInstanceId !== undefined &&
|
||||
owningBlockInstanceIds.length > 0 &&
|
||||
owningBlockInstanceIds.includes(selectedBlockInstanceId) === false
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const blockInstanceIds =
|
||||
owningBlockInstanceIds.length > 0
|
||||
? owningBlockInstanceIds
|
||||
: selectedBlockInstanceId === undefined
|
||||
? []
|
||||
: [selectedBlockInstanceId];
|
||||
const allProps = Array.from(props.values());
|
||||
for (const [index, blockInstanceId] of blockInstanceIds.entries()) {
|
||||
if (blockInstanceIds.indexOf(blockInstanceId) !== index) {
|
||||
continue;
|
||||
}
|
||||
const blockProps = allProps.filter(
|
||||
(prop) => prop.instanceId === blockInstanceId
|
||||
);
|
||||
if (
|
||||
getContentBlockSource({
|
||||
blockInstanceId,
|
||||
props: blockProps,
|
||||
}) === undefined
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const documentProp = blockProps.find(
|
||||
(prop) =>
|
||||
prop.name === contentBlockDocumentProp && prop.type === "parameter"
|
||||
);
|
||||
if (documentProp?.type !== "parameter") {
|
||||
continue;
|
||||
}
|
||||
const target = getWritableContentBlockDocumentBinding({
|
||||
binding,
|
||||
documentDataSourceId: documentProp.value,
|
||||
});
|
||||
if (target !== undefined) {
|
||||
return target.frontmatterPath;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const getSelectedContentBlockExpressionMode = ({
|
||||
expression,
|
||||
...context
|
||||
}: Omit<
|
||||
Parameters<typeof getSelectedContentBlockDocumentBindingPath>[0],
|
||||
"binding"
|
||||
> & { expression: string }): ExpressionBindingMode =>
|
||||
getSelectedContentBlockDocumentBindingPath({
|
||||
...context,
|
||||
binding: { type: "expression", value: expression, mode: "readwrite" },
|
||||
}) === undefined
|
||||
? "read"
|
||||
: "readwrite";
|
||||
|
||||
export const setObjectPathValue = ({
|
||||
value,
|
||||
path,
|
||||
nextValue,
|
||||
}: {
|
||||
value: Readonly<Record<string, unknown>>;
|
||||
path: readonly string[];
|
||||
nextValue: unknown;
|
||||
}) => {
|
||||
if (path.length === 0) {
|
||||
return value;
|
||||
}
|
||||
if (isSafeContentBlockDocumentPath(path) === false) {
|
||||
throw new Error("Frontmatter object path is invalid");
|
||||
}
|
||||
const update = (current: unknown, index: number): unknown => {
|
||||
const segment = path[index];
|
||||
const isLast = index === path.length - 1;
|
||||
if (Array.isArray(current)) {
|
||||
const arrayIndex = getArrayIndex(segment);
|
||||
if (arrayIndex === undefined || arrayIndex >= current.length) {
|
||||
throw new Error("Frontmatter array path is invalid");
|
||||
}
|
||||
const result = [...current];
|
||||
result[arrayIndex] = isLast
|
||||
? nextValue
|
||||
: update(current[arrayIndex], index + 1);
|
||||
return result;
|
||||
}
|
||||
if (
|
||||
current !== undefined &&
|
||||
(typeof current !== "object" || current === null)
|
||||
) {
|
||||
throw new Error("Frontmatter object path is invalid");
|
||||
}
|
||||
const record =
|
||||
current === undefined
|
||||
? {}
|
||||
: (current as Readonly<Record<string, unknown>>);
|
||||
const result = { ...record };
|
||||
result[segment] = isLast ? nextValue : update(record[segment], index + 1);
|
||||
return result;
|
||||
};
|
||||
return update(value, 0) as Readonly<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
const getArrayIndex = (segment: string) => {
|
||||
const index = Number(segment);
|
||||
return Number.isSafeInteger(index) && index >= 0 && String(index) === segment
|
||||
? index
|
||||
: undefined;
|
||||
};
|
||||
|
||||
export const isObjectPathWritable = ({
|
||||
value,
|
||||
path,
|
||||
}: {
|
||||
value: Readonly<Record<string, unknown>>;
|
||||
path: readonly string[];
|
||||
}) => {
|
||||
if (isSafeContentBlockDocumentPath(path) === false) {
|
||||
return false;
|
||||
}
|
||||
let current: unknown = value;
|
||||
for (const [index, segment] of path.entries()) {
|
||||
const isLast = index === path.length - 1;
|
||||
if (Array.isArray(current)) {
|
||||
const arrayIndex = getArrayIndex(segment);
|
||||
if (arrayIndex === undefined || arrayIndex >= current.length) {
|
||||
return false;
|
||||
}
|
||||
if (isLast) {
|
||||
return true;
|
||||
}
|
||||
current = current[arrayIndex];
|
||||
continue;
|
||||
}
|
||||
if (current === undefined) {
|
||||
return true;
|
||||
}
|
||||
if (typeof current !== "object" || current === null) {
|
||||
return false;
|
||||
}
|
||||
if ("$ref" in current) {
|
||||
return false;
|
||||
}
|
||||
if (isLast) {
|
||||
return true;
|
||||
}
|
||||
current = (current as Record<string, unknown>)[segment];
|
||||
}
|
||||
return true;
|
||||
};
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { ROOT_INSTANCE_ID, type Instances } from "@webstudio-is/sdk";
|
||||
import {
|
||||
getContentBlockOccurrenceVariableValues,
|
||||
isRepeatedContentBlockOccurrence,
|
||||
parseContentBlockRenderScope,
|
||||
resolveContentBlockOccurrenceAssetId,
|
||||
} from "./content-block-source-utils";
|
||||
|
||||
const selector = ["block", "collection[item]", "collection", "body"];
|
||||
|
||||
describe("Content Block occurrence source", () => {
|
||||
test("parses only instance selectors", () => {
|
||||
expect(parseContentBlockRenderScope(JSON.stringify(selector))).toEqual(
|
||||
selector
|
||||
);
|
||||
expect(parseContentBlockRenderScope("{}")).toBeUndefined();
|
||||
expect(parseContentBlockRenderScope('["block",1]')).toBeUndefined();
|
||||
});
|
||||
|
||||
test("resolves the same direct and root-qualified variable scopes", () => {
|
||||
const values = new Map([["asset", "article"]]);
|
||||
const direct = new Map([[JSON.stringify(selector), values]]);
|
||||
const rootQualified = new Map([
|
||||
[JSON.stringify([...selector, ROOT_INSTANCE_ID]), values],
|
||||
]);
|
||||
expect(
|
||||
getContentBlockOccurrenceVariableValues({
|
||||
instanceSelector: selector,
|
||||
variableValuesByRenderScope: direct,
|
||||
})
|
||||
).toBe(values);
|
||||
expect(
|
||||
resolveContentBlockOccurrenceAssetId({
|
||||
source: { type: "expression", value: "asset" },
|
||||
instanceSelector: selector,
|
||||
variableValuesByRenderScope: rootQualified,
|
||||
})
|
||||
).toBe("article");
|
||||
});
|
||||
|
||||
test("resolves each bound Collection occurrence independently", () => {
|
||||
const first = ["block", "collection[first]", "collection", "body"];
|
||||
const second = ["block", "collection[second]", "collection", "body"];
|
||||
const values = new Map([
|
||||
[JSON.stringify(first), new Map([["asset", "a.mdx"]])],
|
||||
[JSON.stringify(second), new Map([["asset", "b.mdx"]])],
|
||||
]);
|
||||
|
||||
expect(
|
||||
[first, second].map((instanceSelector) =>
|
||||
resolveContentBlockOccurrenceAssetId({
|
||||
source: { type: "expression", value: "asset" },
|
||||
instanceSelector,
|
||||
variableValuesByRenderScope: values,
|
||||
})
|
||||
)
|
||||
).toEqual(["a.mdx", "b.mdx"]);
|
||||
});
|
||||
|
||||
test("detects repeated selectors consistently", () => {
|
||||
const instances = new Map(
|
||||
["block", "collection", "body"].map((id) => [
|
||||
id,
|
||||
{
|
||||
type: "instance" as const,
|
||||
id,
|
||||
component: "Box",
|
||||
children: [],
|
||||
},
|
||||
])
|
||||
) as Instances;
|
||||
expect(
|
||||
isRepeatedContentBlockOccurrence({
|
||||
instanceSelector: selector,
|
||||
instances,
|
||||
})
|
||||
).toBe(true);
|
||||
expect(
|
||||
isRepeatedContentBlockOccurrence({
|
||||
instanceSelector: ["block", "collection", "body"],
|
||||
instances,
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { resolveContentBlockSourceAssetId } from "@webstudio-is/project-build/runtime";
|
||||
import {
|
||||
ROOT_INSTANCE_ID,
|
||||
type ContentBlockSource,
|
||||
type Instances,
|
||||
} from "@webstudio-is/sdk";
|
||||
import type { InstanceSelector } from "@webstudio-is/project-build/runtime";
|
||||
|
||||
type VariableValuesByRenderScope = ReadonlyMap<
|
||||
string,
|
||||
ReadonlyMap<string, unknown>
|
||||
>;
|
||||
|
||||
export const parseContentBlockRenderScope = (renderScope: string) => {
|
||||
try {
|
||||
const value: unknown = JSON.parse(renderScope);
|
||||
if (
|
||||
Array.isArray(value) &&
|
||||
value.length > 0 &&
|
||||
value.every((instanceId) => typeof instanceId === "string")
|
||||
) {
|
||||
return value as InstanceSelector;
|
||||
}
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
export const getContentBlockOccurrenceVariableValues = ({
|
||||
instanceSelector,
|
||||
variableValuesByRenderScope,
|
||||
}: {
|
||||
instanceSelector: InstanceSelector;
|
||||
variableValuesByRenderScope: VariableValuesByRenderScope;
|
||||
}) =>
|
||||
variableValuesByRenderScope.get(JSON.stringify(instanceSelector)) ??
|
||||
variableValuesByRenderScope.get(
|
||||
JSON.stringify([...instanceSelector, ROOT_INSTANCE_ID])
|
||||
);
|
||||
|
||||
export const resolveContentBlockOccurrenceAssetId = ({
|
||||
source,
|
||||
instanceSelector,
|
||||
variableValuesByRenderScope,
|
||||
}: {
|
||||
source: ContentBlockSource;
|
||||
instanceSelector: InstanceSelector;
|
||||
variableValuesByRenderScope: VariableValuesByRenderScope;
|
||||
}) =>
|
||||
resolveContentBlockSourceAssetId({
|
||||
source,
|
||||
values: getContentBlockOccurrenceVariableValues({
|
||||
instanceSelector,
|
||||
variableValuesByRenderScope,
|
||||
}),
|
||||
});
|
||||
|
||||
export const isRepeatedContentBlockOccurrence = ({
|
||||
instanceSelector,
|
||||
instances,
|
||||
}: {
|
||||
instanceSelector: InstanceSelector;
|
||||
instances: Instances;
|
||||
}) =>
|
||||
instanceSelector.some(
|
||||
(instanceId) =>
|
||||
instanceId !== ROOT_INSTANCE_ID && instances.has(instanceId) === false
|
||||
);
|
||||
@@ -114,6 +114,12 @@ test("imports and remaps every fragment asset reference", async () => {
|
||||
if (result.success === false) {
|
||||
return;
|
||||
}
|
||||
expect(result.assets).toEqual(
|
||||
new Map<Asset["id"], Asset>([
|
||||
[imageAsset.id, importedImage],
|
||||
[fontAsset.id, importedFont],
|
||||
])
|
||||
);
|
||||
expect(result.fragments.get(fragment)).toEqual({
|
||||
...fragment,
|
||||
assets: [],
|
||||
|
||||
@@ -26,6 +26,7 @@ export const transferFragmentAssets = async ({
|
||||
success: true,
|
||||
fragments: new Map(fragments.map((fragment) => [fragment, fragment])),
|
||||
assetIds: new Map<Asset["id"], Asset["id"]>(),
|
||||
assets: new Map<Asset["id"], Asset>(),
|
||||
} as const;
|
||||
}
|
||||
|
||||
@@ -87,5 +88,6 @@ export const transferFragmentAssets = async ({
|
||||
success: true,
|
||||
fragments: transferredFragments,
|
||||
assetIds,
|
||||
assets: importedAssets,
|
||||
} as const;
|
||||
};
|
||||
@@ -0,0 +1,398 @@
|
||||
import { expect, test, vi } from "vitest";
|
||||
import {
|
||||
blockBodyComponent,
|
||||
blockComponent,
|
||||
blockTemplateComponent,
|
||||
type Asset,
|
||||
type AssetFolder,
|
||||
} from "@webstudio-is/sdk";
|
||||
import {
|
||||
includeMdxAssetDependencies,
|
||||
prepareConnectedContentBlockFragment,
|
||||
resolveRepeatedContentBlockSourcesForCopy,
|
||||
} from "./content-block-fragment";
|
||||
|
||||
const createFile = ({
|
||||
id,
|
||||
name,
|
||||
format,
|
||||
folderId,
|
||||
}: {
|
||||
id: string;
|
||||
name: string;
|
||||
format: string;
|
||||
folderId?: string;
|
||||
}) =>
|
||||
({
|
||||
id,
|
||||
projectId: "project",
|
||||
name,
|
||||
type: "file",
|
||||
format,
|
||||
folderId,
|
||||
size: 1,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
description: null,
|
||||
meta: {},
|
||||
}) satisfies Asset;
|
||||
|
||||
const source = createFile({
|
||||
id: "article",
|
||||
name: "article.mdx",
|
||||
format: "mdx",
|
||||
folderId: "articles",
|
||||
});
|
||||
const dependency = createFile({
|
||||
id: "hero",
|
||||
name: "hero.png",
|
||||
format: "png",
|
||||
folderId: "media",
|
||||
});
|
||||
const folders = new Map<string, AssetFolder>([
|
||||
[
|
||||
"articles",
|
||||
{
|
||||
id: "articles",
|
||||
projectId: "project",
|
||||
name: "articles",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
},
|
||||
],
|
||||
[
|
||||
"media",
|
||||
{
|
||||
id: "media",
|
||||
projectId: "project",
|
||||
name: "media",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
},
|
||||
],
|
||||
]);
|
||||
const fragment = {
|
||||
children: [],
|
||||
instances: [],
|
||||
props: [],
|
||||
assets: [source],
|
||||
dataSources: [],
|
||||
resources: [],
|
||||
breakpoints: [],
|
||||
styleSources: [],
|
||||
styleSourceSelections: [],
|
||||
styles: [],
|
||||
};
|
||||
|
||||
test("discovers dependencies for an unmounted MDX source", async () => {
|
||||
const readSource = vi.fn(async () => "");
|
||||
const discoveryCache = new Map<
|
||||
string,
|
||||
Promise<readonly string[] | undefined>
|
||||
>();
|
||||
|
||||
const result = await includeMdxAssetDependencies({
|
||||
fragment,
|
||||
projectId: "project",
|
||||
assets: new Map([
|
||||
[source.id, source],
|
||||
[dependency.id, dependency],
|
||||
]),
|
||||
assetFolders: folders,
|
||||
discoveryCache,
|
||||
readSource,
|
||||
});
|
||||
await includeMdxAssetDependencies({
|
||||
fragment,
|
||||
projectId: "project",
|
||||
assets: new Map([
|
||||
[source.id, source],
|
||||
[dependency.id, dependency],
|
||||
]),
|
||||
assetFolders: folders,
|
||||
discoveryCache,
|
||||
readSource,
|
||||
});
|
||||
|
||||
expect(readSource).toHaveBeenCalledOnce();
|
||||
expect(readSource).toHaveBeenCalledWith(source.id);
|
||||
expect(result.fragment.assets).toEqual([source, dependency]);
|
||||
expect(result.skippedAssetIds).toEqual([]);
|
||||
});
|
||||
|
||||
test("preserves an invalid MDX source and reports skipped discovery", async () => {
|
||||
const result = await includeMdxAssetDependencies({
|
||||
fragment,
|
||||
projectId: "project",
|
||||
assets: new Map([[source.id, source]]),
|
||||
assetFolders: folders,
|
||||
readSource: async () => "<broken",
|
||||
});
|
||||
|
||||
expect(result.fragment.assets).toEqual([source]);
|
||||
expect(result.skippedAssetIds).toEqual([source.id]);
|
||||
});
|
||||
|
||||
test("discovers transitive dependencies through Markdown documents", async () => {
|
||||
const author = createFile({
|
||||
id: "author",
|
||||
name: "author.md",
|
||||
format: "md",
|
||||
folderId: "articles",
|
||||
});
|
||||
|
||||
const result = await includeMdxAssetDependencies({
|
||||
fragment,
|
||||
projectId: "project",
|
||||
assets: new Map([
|
||||
[source.id, source],
|
||||
[author.id, author],
|
||||
[dependency.id, dependency],
|
||||
]),
|
||||
assetFolders: folders,
|
||||
readSource: async (assetId) =>
|
||||
assetId === source.id
|
||||
? "---\nauthor:\n $ref: ./author.md#frontmatter\n---\n"
|
||||
: "---\navatar: ../media/hero.png\n---\n",
|
||||
});
|
||||
|
||||
expect(result.fragment.assets).toEqual([source, author, dependency]);
|
||||
expect(result.skippedAssetIds).toEqual([]);
|
||||
});
|
||||
|
||||
test("discovers transitive dependencies through JSON documents", async () => {
|
||||
const author = createFile({
|
||||
id: "author",
|
||||
name: "author.json",
|
||||
format: "json",
|
||||
folderId: "articles",
|
||||
});
|
||||
|
||||
const result = await includeMdxAssetDependencies({
|
||||
fragment,
|
||||
projectId: "project",
|
||||
assets: new Map([
|
||||
[source.id, source],
|
||||
[author.id, author],
|
||||
[dependency.id, dependency],
|
||||
]),
|
||||
assetFolders: folders,
|
||||
readSource: async (assetId) =>
|
||||
assetId === source.id
|
||||
? "---\nauthor:\n $ref: ./author.json#\n---\n"
|
||||
: '{"avatar":"../media/hero.png"}',
|
||||
});
|
||||
|
||||
expect(result.fragment.assets).toEqual([source, author, dependency]);
|
||||
expect(result.skippedAssetIds).toEqual([]);
|
||||
});
|
||||
|
||||
test("includes a static expression source without a mounted Content Block", () => {
|
||||
const prepared = prepareConnectedContentBlockFragment({
|
||||
fragment: {
|
||||
...fragment,
|
||||
children: [{ type: "id", value: "block" }],
|
||||
instances: [
|
||||
{
|
||||
type: "instance",
|
||||
id: "block",
|
||||
component: blockComponent,
|
||||
children: [
|
||||
{ type: "id", value: "templates" },
|
||||
{ type: "id", value: "authored" },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "instance",
|
||||
id: "templates",
|
||||
component: blockTemplateComponent,
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
type: "instance",
|
||||
id: "authored",
|
||||
component: "Paragraph",
|
||||
children: [{ type: "text", value: "Loaded" }],
|
||||
},
|
||||
],
|
||||
props: [
|
||||
{
|
||||
id: "source",
|
||||
instanceId: "block",
|
||||
name: "src",
|
||||
type: "expression",
|
||||
value: JSON.stringify(source.id),
|
||||
},
|
||||
],
|
||||
assets: [],
|
||||
},
|
||||
projectId: undefined,
|
||||
assets: new Map([[source.id, source]]),
|
||||
});
|
||||
|
||||
expect(prepared.assets).toEqual([source]);
|
||||
expect(prepared.instances).not.toContainEqual(
|
||||
expect.objectContaining({ id: "authored" })
|
||||
);
|
||||
});
|
||||
|
||||
test("preserves the designed shell and clears only an explicit Body", () => {
|
||||
const prepared = prepareConnectedContentBlockFragment({
|
||||
fragment: {
|
||||
...fragment,
|
||||
children: [{ type: "id", value: "block" }],
|
||||
instances: [
|
||||
{
|
||||
type: "instance",
|
||||
id: "block",
|
||||
component: blockComponent,
|
||||
children: [
|
||||
{ type: "id", value: "shell" },
|
||||
{ type: "id", value: "templates" },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "instance",
|
||||
id: "shell",
|
||||
component: "Box",
|
||||
children: [{ type: "id", value: "content" }],
|
||||
},
|
||||
{
|
||||
type: "instance",
|
||||
id: "content",
|
||||
component: blockBodyComponent,
|
||||
children: [{ type: "id", value: "authored" }],
|
||||
},
|
||||
{
|
||||
type: "instance",
|
||||
id: "templates",
|
||||
component: blockTemplateComponent,
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
type: "instance",
|
||||
id: "authored",
|
||||
component: "Paragraph",
|
||||
children: [{ type: "text", value: "Loaded" }],
|
||||
},
|
||||
],
|
||||
props: [
|
||||
{
|
||||
id: "source",
|
||||
instanceId: "block",
|
||||
name: "src",
|
||||
type: "asset",
|
||||
value: source.id,
|
||||
},
|
||||
],
|
||||
},
|
||||
projectId: undefined,
|
||||
});
|
||||
|
||||
expect(prepared.instances).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: "block",
|
||||
children: [
|
||||
{ type: "id", value: "shell" },
|
||||
{ type: "id", value: "templates" },
|
||||
],
|
||||
}),
|
||||
expect.objectContaining({ id: "shell" }),
|
||||
expect.objectContaining({ id: "content", children: [] }),
|
||||
expect.objectContaining({ id: "templates" }),
|
||||
])
|
||||
);
|
||||
expect(prepared.instances).not.toContainEqual(
|
||||
expect.objectContaining({ id: "authored" })
|
||||
);
|
||||
});
|
||||
|
||||
test("makes a copied Collection item source independent from its Collection", () => {
|
||||
const dynamicFragment = {
|
||||
...fragment,
|
||||
children: [{ type: "id" as const, value: "item" }],
|
||||
instances: [
|
||||
{
|
||||
type: "instance" as const,
|
||||
id: "item",
|
||||
component: "Box",
|
||||
children: [{ type: "id" as const, value: "block" }],
|
||||
},
|
||||
{
|
||||
type: "instance" as const,
|
||||
id: "block",
|
||||
component: blockComponent,
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
props: [
|
||||
{
|
||||
id: "source",
|
||||
instanceId: "block",
|
||||
name: "src",
|
||||
type: "expression" as const,
|
||||
value: "$ws$dataSource$item.file",
|
||||
},
|
||||
],
|
||||
};
|
||||
const firstScope = '["block","item","collection[first]","collection"]';
|
||||
const secondScope = '["block","item","collection[second]","collection"]';
|
||||
const result = resolveRepeatedContentBlockSourcesForCopy({
|
||||
fragment: dynamicFragment,
|
||||
selectedInstanceSelector: ["item", "collection[first]", "collection"],
|
||||
occurrences: [
|
||||
{
|
||||
sourceBlockInstanceId: "block",
|
||||
sourceRenderScope: firstScope,
|
||||
sourceInstanceSelector: JSON.parse(firstScope),
|
||||
assetId: "first-article",
|
||||
},
|
||||
{
|
||||
sourceBlockInstanceId: "block",
|
||||
sourceRenderScope: secondScope,
|
||||
sourceInstanceSelector: JSON.parse(secondScope),
|
||||
assetId: "second-article",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.fragment.props).toEqual([
|
||||
expect.objectContaining({ type: "asset", value: "first-article" }),
|
||||
]);
|
||||
expect(result.renderScopes).toEqual(new Set([firstScope]));
|
||||
});
|
||||
|
||||
test("keeps a dynamic source when the copied item contains many occurrences", () => {
|
||||
const sourceProp = {
|
||||
id: "source",
|
||||
instanceId: "block",
|
||||
name: "src",
|
||||
type: "expression" as const,
|
||||
value: "$ws$dataSource$item.file",
|
||||
};
|
||||
const result = resolveRepeatedContentBlockSourcesForCopy({
|
||||
fragment: { ...fragment, props: [sourceProp] },
|
||||
selectedInstanceSelector: ["item", "collection[first]", "collection"],
|
||||
occurrences: ["nested[first]", "nested[second]"].map(
|
||||
(nestedInstanceId) => ({
|
||||
sourceBlockInstanceId: "block",
|
||||
sourceRenderScope: JSON.stringify([
|
||||
"block",
|
||||
nestedInstanceId,
|
||||
"item",
|
||||
"collection[first]",
|
||||
"collection",
|
||||
]),
|
||||
sourceInstanceSelector: [
|
||||
"block",
|
||||
nestedInstanceId,
|
||||
"item",
|
||||
"collection[first]",
|
||||
"collection",
|
||||
],
|
||||
assetId: nestedInstanceId,
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
expect(result.fragment.props).toEqual([sourceProp]);
|
||||
expect(result.renderScopes).toHaveLength(2);
|
||||
});
|
||||
@@ -0,0 +1,314 @@
|
||||
import {
|
||||
createCanonicalAssetPath,
|
||||
discoverNamedMdxAssetReferences,
|
||||
parseMdxDocumentRecovering,
|
||||
} from "@webstudio-is/content-engine/mdx";
|
||||
import { discoverNamedMarkdownDocumentAssetReferences } from "@webstudio-is/content-engine/markdown-assets";
|
||||
import { discoverNamedJsonAssetReferences } from "@webstudio-is/content-engine";
|
||||
import {
|
||||
createWebstudioDataFromFragment,
|
||||
extractWebstudioFragment,
|
||||
isDescendantOrSelf,
|
||||
mergeWebstudioFragments,
|
||||
} from "@webstudio-is/project-build/runtime";
|
||||
import {
|
||||
blockComponent,
|
||||
blockTemplateComponent,
|
||||
contentBlockSourceProp,
|
||||
createAssetFolderHierarchy,
|
||||
formatAssetName,
|
||||
findContentBlockBodyContainers,
|
||||
getContentBlockSource,
|
||||
getStaticContentBlockSourceAssetId,
|
||||
isMdxFileAsset,
|
||||
type Asset,
|
||||
type AssetFolders,
|
||||
type WebstudioFragment,
|
||||
} from "@webstudio-is/sdk";
|
||||
import {
|
||||
getExternalContentRootAssets,
|
||||
openExternalContentAsset,
|
||||
} from "../external-content-roots";
|
||||
|
||||
export const resolveRepeatedContentBlockSourcesForCopy = ({
|
||||
fragment,
|
||||
selectedInstanceSelector,
|
||||
occurrences,
|
||||
}: {
|
||||
fragment: WebstudioFragment;
|
||||
selectedInstanceSelector: readonly string[];
|
||||
occurrences: ReadonlyArray<
|
||||
Readonly<{
|
||||
sourceBlockInstanceId: string;
|
||||
sourceRenderScope: string;
|
||||
sourceInstanceSelector: readonly string[];
|
||||
assetId: string;
|
||||
}>
|
||||
>;
|
||||
}) => {
|
||||
const selectedOccurrences = occurrences.filter(({ sourceInstanceSelector }) =>
|
||||
isDescendantOrSelf(
|
||||
[...sourceInstanceSelector],
|
||||
[...selectedInstanceSelector]
|
||||
)
|
||||
);
|
||||
const occurrencesByBlockId = new Map<string, typeof selectedOccurrences>();
|
||||
for (const occurrence of selectedOccurrences) {
|
||||
const blockOccurrences =
|
||||
occurrencesByBlockId.get(occurrence.sourceBlockInstanceId) ?? [];
|
||||
blockOccurrences.push(occurrence);
|
||||
occurrencesByBlockId.set(
|
||||
occurrence.sourceBlockInstanceId,
|
||||
blockOccurrences
|
||||
);
|
||||
}
|
||||
return {
|
||||
fragment: {
|
||||
...fragment,
|
||||
props: fragment.props.map((prop) => {
|
||||
const blockOccurrences = occurrencesByBlockId.get(prop.instanceId);
|
||||
if (
|
||||
prop.name !== contentBlockSourceProp ||
|
||||
prop.type !== "expression" ||
|
||||
blockOccurrences?.length !== 1
|
||||
) {
|
||||
return prop;
|
||||
}
|
||||
return {
|
||||
...prop,
|
||||
type: "asset" as const,
|
||||
value: blockOccurrences[0].assetId,
|
||||
};
|
||||
}),
|
||||
},
|
||||
renderScopes: new Set(
|
||||
selectedOccurrences.map(({ sourceRenderScope }) => sourceRenderScope)
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
export const prepareConnectedContentBlockFragment = ({
|
||||
fragment,
|
||||
projectId,
|
||||
assets,
|
||||
renderScopes,
|
||||
}: {
|
||||
fragment: WebstudioFragment;
|
||||
projectId: string | undefined;
|
||||
assets?: ReadonlyMap<Asset["id"], Asset>;
|
||||
renderScopes?: ReadonlySet<string>;
|
||||
}) => {
|
||||
const data = createWebstudioDataFromFragment(fragment);
|
||||
let changed = false;
|
||||
const staticSourceAssetIds = new Set<string>();
|
||||
for (const instance of data.instances.values()) {
|
||||
if (instance.component !== blockComponent) {
|
||||
continue;
|
||||
}
|
||||
const source = getContentBlockSource({
|
||||
blockInstanceId: instance.id,
|
||||
props: data.props.values(),
|
||||
});
|
||||
if (source === undefined) {
|
||||
continue;
|
||||
}
|
||||
const staticAssetId = getStaticContentBlockSourceAssetId(source);
|
||||
if (staticAssetId !== undefined) {
|
||||
staticSourceAssetIds.add(staticAssetId);
|
||||
}
|
||||
const bodyContainers = findContentBlockBodyContainers({
|
||||
blockInstance: instance,
|
||||
instances: data.instances,
|
||||
});
|
||||
if (bodyContainers.length === 0) {
|
||||
data.instances.set(instance.id, {
|
||||
...instance,
|
||||
children: instance.children.filter(
|
||||
(child) =>
|
||||
child.type === "id" &&
|
||||
data.instances.get(child.value)?.component ===
|
||||
blockTemplateComponent
|
||||
),
|
||||
});
|
||||
} else {
|
||||
for (const body of bodyContainers) {
|
||||
data.instances.set(body.id, { ...body, children: [] });
|
||||
}
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
const prepared =
|
||||
changed === false
|
||||
? fragment
|
||||
: mergeWebstudioFragments(
|
||||
fragment.children.flatMap((child) =>
|
||||
child.type === "id" ? [child.value] : []
|
||||
),
|
||||
fragment.children.flatMap((child) =>
|
||||
child.type === "id"
|
||||
? [extractWebstudioFragment(data, child.value)]
|
||||
: []
|
||||
)
|
||||
);
|
||||
const assetIds = new Set(prepared.assets.map(({ id }) => id));
|
||||
for (const assetId of staticSourceAssetIds) {
|
||||
const asset = assets?.get(assetId);
|
||||
if (asset !== undefined && assetIds.has(asset.id) === false) {
|
||||
assetIds.add(asset.id);
|
||||
prepared.assets.push(asset);
|
||||
}
|
||||
}
|
||||
if (projectId === undefined) {
|
||||
return prepared;
|
||||
}
|
||||
const blockInstanceIds = new Set(
|
||||
prepared.instances
|
||||
.filter(({ component }) => component === blockComponent)
|
||||
.map(({ id }) => id)
|
||||
);
|
||||
for (const asset of getExternalContentRootAssets({
|
||||
projectId,
|
||||
blockInstanceIds,
|
||||
renderScopes,
|
||||
})) {
|
||||
if (assetIds.has(asset.id) === false) {
|
||||
assetIds.add(asset.id);
|
||||
prepared.assets.push(asset);
|
||||
}
|
||||
}
|
||||
return prepared;
|
||||
};
|
||||
|
||||
export const hasDynamicContentBlockSource = (fragment: WebstudioFragment) => {
|
||||
const data = createWebstudioDataFromFragment(fragment);
|
||||
return Array.from(data.instances.values()).some((instance) => {
|
||||
if (instance.component !== blockComponent) {
|
||||
return false;
|
||||
}
|
||||
const source = getContentBlockSource({
|
||||
blockInstanceId: instance.id,
|
||||
props: data.props.values(),
|
||||
});
|
||||
return (
|
||||
source?.type === "expression" &&
|
||||
getStaticContentBlockSourceAssetId(source) === undefined
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export const createClipboardAssetPaths = (
|
||||
assets: readonly Asset[],
|
||||
assetFolders: AssetFolders
|
||||
) => {
|
||||
const folderHierarchy = createAssetFolderHierarchy(assetFolders);
|
||||
return Object.fromEntries(
|
||||
assets.map((asset) => [
|
||||
asset.id,
|
||||
createCanonicalAssetPath({
|
||||
folderNames: folderHierarchy
|
||||
.getPath(asset.folderId)
|
||||
.map(({ name }) => name),
|
||||
name: formatAssetName(asset),
|
||||
}),
|
||||
])
|
||||
);
|
||||
};
|
||||
|
||||
export const includeMdxAssetDependencies = async ({
|
||||
fragment,
|
||||
projectId,
|
||||
assets,
|
||||
assetFolders,
|
||||
discoveryCache = new Map(),
|
||||
readSource = async (assetId: string) =>
|
||||
(await openExternalContentAsset({ projectId, assetId })).source,
|
||||
}: {
|
||||
fragment: WebstudioFragment;
|
||||
projectId: string;
|
||||
assets: ReadonlyMap<Asset["id"], Asset>;
|
||||
assetFolders: AssetFolders;
|
||||
discoveryCache?: Map<string, Promise<readonly string[] | undefined>>;
|
||||
readSource?: (assetId: string) => Promise<string>;
|
||||
}) => {
|
||||
const isContentDocument = (asset: Asset) =>
|
||||
isMdxFileAsset(asset) ||
|
||||
(asset.type === "file" &&
|
||||
["md", "json"].includes(asset.format.toLowerCase()));
|
||||
const included = new Map(fragment.assets.map((asset) => [asset.id, asset]));
|
||||
const pending = fragment.assets.filter(isContentDocument);
|
||||
const inspected = new Set<string>();
|
||||
const skippedAssetIds: string[] = [];
|
||||
const folderHierarchy = createAssetFolderHierarchy(assetFolders);
|
||||
const getNamedAsset = (asset: Asset) => ({
|
||||
id: asset.id,
|
||||
name: formatAssetName(asset),
|
||||
folderNames: folderHierarchy
|
||||
.getPath(asset.folderId)
|
||||
.map(({ name }) => name),
|
||||
});
|
||||
while (pending.length > 0) {
|
||||
const sourceAsset = pending.shift()!;
|
||||
if (inspected.has(sourceAsset.id)) {
|
||||
continue;
|
||||
}
|
||||
inspected.add(sourceAsset.id);
|
||||
let discovery = discoveryCache.get(sourceAsset.id);
|
||||
if (discovery === undefined) {
|
||||
discovery = (async () => {
|
||||
try {
|
||||
const source = await readSource(sourceAsset.id);
|
||||
const namedAssets = Array.from(assets.values(), getNamedAsset);
|
||||
if (sourceAsset.format.toLowerCase() === "json") {
|
||||
return (
|
||||
await discoverNamedJsonAssetReferences({
|
||||
source,
|
||||
asset: getNamedAsset(sourceAsset),
|
||||
assets: namedAssets,
|
||||
})
|
||||
).map(({ assetId }) => assetId);
|
||||
}
|
||||
if (isMdxFileAsset(sourceAsset) === false) {
|
||||
return (
|
||||
await discoverNamedMarkdownDocumentAssetReferences({
|
||||
markdown: source,
|
||||
source: getNamedAsset(sourceAsset),
|
||||
assets: namedAssets,
|
||||
})
|
||||
).map(({ assetId }) => assetId);
|
||||
}
|
||||
const parsed = await parseMdxDocumentRecovering({ source });
|
||||
if (parsed.status === "unrecoverable") {
|
||||
return;
|
||||
}
|
||||
return discoverNamedMdxAssetReferences({
|
||||
document: parsed.document,
|
||||
source: getNamedAsset(sourceAsset),
|
||||
assets: namedAssets,
|
||||
}).map(({ assetId }) => assetId);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
})();
|
||||
discoveryCache.set(sourceAsset.id, discovery);
|
||||
}
|
||||
const dependencyIds = await discovery;
|
||||
if (dependencyIds === undefined) {
|
||||
skippedAssetIds.push(sourceAsset.id);
|
||||
continue;
|
||||
}
|
||||
for (const assetId of dependencyIds) {
|
||||
const dependency = assets.get(assetId);
|
||||
if (dependency === undefined || included.has(assetId)) {
|
||||
continue;
|
||||
}
|
||||
included.set(assetId, dependency);
|
||||
if (isContentDocument(dependency)) {
|
||||
pending.push(dependency);
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
fragment: { ...fragment, assets: Array.from(included.values()) },
|
||||
skippedAssetIds,
|
||||
};
|
||||
};
|
||||
@@ -221,6 +221,30 @@ test("does not copy page to clipboard when copy permission is disabled", async (
|
||||
expect(writeText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("writes asynchronously prepared page data from a clipboard event", async () => {
|
||||
resetStores();
|
||||
setupPage();
|
||||
$project.set({ id: "project" } as Project);
|
||||
selectInstance(["body-id"]);
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
vi.stubGlobal("navigator", {
|
||||
...navigator,
|
||||
clipboard: { writeText },
|
||||
});
|
||||
const abortController = new AbortController();
|
||||
initCopyPaste({ signal: abortController.signal });
|
||||
const { event } = createClipboardEvent("copy");
|
||||
|
||||
document.dispatchEvent(event);
|
||||
await waitForClipboardEvent();
|
||||
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
expect(writeText).toHaveBeenCalledWith(
|
||||
expect.stringContaining('"@webstudio/page/v0.1"')
|
||||
);
|
||||
abortController.abort();
|
||||
});
|
||||
|
||||
test("does not copy instance to clipboard when no instance is selected", async () => {
|
||||
resetStores();
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
@@ -80,7 +80,7 @@ const validateCopyPermission = () => {
|
||||
export type Plugin = {
|
||||
name: string;
|
||||
mimeType: string;
|
||||
onCopy?: () => undefined | string;
|
||||
onCopy?: () => undefined | string | Promise<undefined | string>;
|
||||
onCut?: () => undefined | string;
|
||||
onPaste?: (data: string) => PasteResult | Promise<PasteResult>;
|
||||
};
|
||||
@@ -124,6 +124,17 @@ const initPlugins = ({
|
||||
for (const { mimeType, onCopy } of plugins) {
|
||||
const data = onCopy?.();
|
||||
|
||||
if (data instanceof Promise) {
|
||||
event.preventDefault();
|
||||
void data
|
||||
.then((value) => writeClipboardText(value))
|
||||
.catch(() =>
|
||||
builderApi.toast.error(
|
||||
"Could not prepare the selected content for copying."
|
||||
)
|
||||
);
|
||||
break;
|
||||
}
|
||||
if (data) {
|
||||
// must prevent default, otherwise setData() will not work
|
||||
event.preventDefault();
|
||||
@@ -269,11 +280,13 @@ export const initCopyPasteForContentEditMode = ({
|
||||
});
|
||||
};
|
||||
|
||||
const writeClipboardText = (data: string | undefined) => {
|
||||
const writeClipboardText = async (
|
||||
data: string | undefined | Promise<string | undefined>
|
||||
) => {
|
||||
data = await data;
|
||||
if (data && validateCopyPermission()) {
|
||||
return navigator.clipboard.writeText(data);
|
||||
await navigator.clipboard.writeText(data);
|
||||
}
|
||||
return Promise.resolve();
|
||||
};
|
||||
|
||||
// Public API for programmatic copy/paste/cut operations
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import { expect, test, vi } from "vitest";
|
||||
import type { Asset } from "@webstudio-is/sdk";
|
||||
import { rewriteTransferredDocumentAssetReferences } from "./mdx-asset-transfer";
|
||||
|
||||
const sourceMdx = {
|
||||
id: "article",
|
||||
projectId: "source",
|
||||
name: "article.mdx",
|
||||
type: "file",
|
||||
format: "mdx",
|
||||
size: 20,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
description: null,
|
||||
meta: {},
|
||||
} satisfies Asset;
|
||||
const sourceImage = {
|
||||
id: "hero",
|
||||
projectId: "source",
|
||||
name: "hero.png",
|
||||
type: "image",
|
||||
format: "png",
|
||||
size: 20,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
description: null,
|
||||
meta: { width: 100, height: 100 },
|
||||
} satisfies Asset;
|
||||
const sourceMarkdown = {
|
||||
id: "author",
|
||||
projectId: "source",
|
||||
name: "oleg.md",
|
||||
type: "file",
|
||||
format: "md",
|
||||
size: 20,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
description: null,
|
||||
meta: {},
|
||||
} satisfies Asset;
|
||||
const sourceJson = {
|
||||
...sourceMarkdown,
|
||||
id: "author-json",
|
||||
name: "oleg.json",
|
||||
format: "json",
|
||||
} satisfies Asset;
|
||||
|
||||
test("rewrites copied MDX to the imported Asset paths", async () => {
|
||||
const readSource = vi.fn(async () => "");
|
||||
const writeSource = vi.fn(async () => {});
|
||||
|
||||
await rewriteTransferredDocumentAssetReferences({
|
||||
sourceOrigin: "https://source.example.com",
|
||||
projectId: "target",
|
||||
sourceAssets: [sourceMdx, sourceImage],
|
||||
sourceAssetPaths: {
|
||||
article: "articles/article.mdx",
|
||||
hero: "media/hero.png",
|
||||
},
|
||||
importedAssets: new Map([
|
||||
[sourceMdx.id, { ...sourceMdx, id: "article-copy", projectId: "target" }],
|
||||
[
|
||||
sourceImage.id,
|
||||
{
|
||||
...sourceImage,
|
||||
id: "hero-copy",
|
||||
projectId: "target",
|
||||
filename: "hero_1",
|
||||
},
|
||||
],
|
||||
]),
|
||||
readSource,
|
||||
writeSource,
|
||||
});
|
||||
|
||||
expect(writeSource).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: "article-copy" }),
|
||||
"\n"
|
||||
);
|
||||
});
|
||||
|
||||
test("preserves invalid copied MDX for destination diagnostics", async () => {
|
||||
const writeSource = vi.fn(async () => {});
|
||||
const result = await rewriteTransferredDocumentAssetReferences({
|
||||
sourceOrigin: "https://source.example.com",
|
||||
projectId: "target",
|
||||
sourceAssets: [sourceMdx],
|
||||
importedAssets: new Map([
|
||||
[sourceMdx.id, { ...sourceMdx, id: "article-copy", projectId: "target" }],
|
||||
]),
|
||||
readSource: async () => "<broken",
|
||||
writeSource,
|
||||
});
|
||||
|
||||
expect(result.skippedInvalidAssetIds).toEqual([sourceMdx.id]);
|
||||
expect(writeSource).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("rewrites structured document references in copied MDX", async () => {
|
||||
const writeSource = vi.fn(async () => {});
|
||||
|
||||
await rewriteTransferredDocumentAssetReferences({
|
||||
sourceOrigin: "https://source.example.com",
|
||||
projectId: "target",
|
||||
sourceAssets: [sourceMdx, sourceMarkdown],
|
||||
sourceAssetPaths: {
|
||||
article: "posts/article.mdx",
|
||||
author: "authors/oleg.md",
|
||||
},
|
||||
importedAssets: new Map([
|
||||
[sourceMdx.id, { ...sourceMdx, id: "article-copy", projectId: "target" }],
|
||||
[
|
||||
sourceMarkdown.id,
|
||||
{
|
||||
...sourceMarkdown,
|
||||
id: "author-copy",
|
||||
projectId: "target",
|
||||
filename: "oleg_1",
|
||||
},
|
||||
],
|
||||
]),
|
||||
readSource: async () =>
|
||||
"---\nauthor:\n $ref: ../authors/oleg.md#frontmatter\n---\n",
|
||||
writeSource,
|
||||
});
|
||||
|
||||
expect(writeSource).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: "article-copy" }),
|
||||
"---\nauthor:\n $ref: oleg_1.md#frontmatter\n---\n\n"
|
||||
);
|
||||
});
|
||||
|
||||
test("rewrites references in copied Markdown dependencies", async () => {
|
||||
const writeSource = vi.fn(async () => {});
|
||||
|
||||
await rewriteTransferredDocumentAssetReferences({
|
||||
sourceOrigin: "https://source.example.com",
|
||||
projectId: "target",
|
||||
sourceAssets: [sourceMarkdown, sourceImage],
|
||||
sourceAssetPaths: {
|
||||
author: "authors/oleg.md",
|
||||
hero: "media/hero.png",
|
||||
},
|
||||
importedAssets: new Map([
|
||||
[
|
||||
sourceMarkdown.id,
|
||||
{ ...sourceMarkdown, id: "author-copy", projectId: "target" },
|
||||
],
|
||||
[
|
||||
sourceImage.id,
|
||||
{
|
||||
...sourceImage,
|
||||
id: "hero-copy",
|
||||
projectId: "target",
|
||||
filename: "hero_1",
|
||||
},
|
||||
],
|
||||
]),
|
||||
readSource: async () =>
|
||||
"---\navatar: ../media/hero.png#profile\n---\n\n<!-- HTML remains valid Markdown -->\n\n",
|
||||
writeSource,
|
||||
});
|
||||
|
||||
expect(writeSource).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: "author-copy" }),
|
||||
"---\navatar: hero_1.png#profile\n---\n\n<!-- HTML remains valid Markdown -->\n\n"
|
||||
);
|
||||
});
|
||||
|
||||
test("rewrites references in copied JSON dependencies", async () => {
|
||||
const writeSource = vi.fn(async () => {});
|
||||
|
||||
await rewriteTransferredDocumentAssetReferences({
|
||||
sourceOrigin: "https://source.example.com",
|
||||
projectId: "target",
|
||||
sourceAssets: [sourceJson, sourceImage],
|
||||
sourceAssetPaths: {
|
||||
"author-json": "authors/oleg.json",
|
||||
hero: "media/hero.png",
|
||||
},
|
||||
importedAssets: new Map([
|
||||
[
|
||||
sourceJson.id,
|
||||
{ ...sourceJson, id: "author-copy", projectId: "target" },
|
||||
],
|
||||
[
|
||||
sourceImage.id,
|
||||
{
|
||||
...sourceImage,
|
||||
id: "hero-copy",
|
||||
projectId: "target",
|
||||
filename: "hero_1",
|
||||
},
|
||||
],
|
||||
]),
|
||||
readSource: async () =>
|
||||
JSON.stringify({ avatar: "../media/hero.png#profile" }),
|
||||
writeSource,
|
||||
});
|
||||
|
||||
expect(writeSource).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: "author-copy" }),
|
||||
'{\n "avatar": "hero_1.png#profile"\n}\n'
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
import {
|
||||
createCanonicalAssetPath,
|
||||
MdxDocumentError,
|
||||
rewriteMdxAssetReferences,
|
||||
} from "@webstudio-is/content-engine/mdx";
|
||||
import {
|
||||
MarkdownAssetReferenceError,
|
||||
rewriteMarkdownAssetReferences,
|
||||
} from "@webstudio-is/content-engine/markdown-assets";
|
||||
import {
|
||||
JsonAssetReferenceError,
|
||||
rewriteJsonAssetReferences,
|
||||
} from "@webstudio-is/content-engine";
|
||||
import {
|
||||
formatAssetName,
|
||||
getAssetUrl,
|
||||
isMdxFileAsset,
|
||||
type Asset,
|
||||
} from "@webstudio-is/sdk";
|
||||
import { fetch as builderFetch } from "../fetch.client";
|
||||
import {
|
||||
flushExternalContentAsset,
|
||||
openExternalContentAsset,
|
||||
updateExternalContentAssetSource,
|
||||
} from "../external-content-roots";
|
||||
import { updateAssetContent } from "~/builder/shared/assets/update-asset-content";
|
||||
|
||||
const getRootAssetPath = (asset: Asset) =>
|
||||
createCanonicalAssetPath({
|
||||
folderNames: [],
|
||||
name: formatAssetName(asset),
|
||||
});
|
||||
|
||||
export const rewriteTransferredDocumentAssetReferences = async ({
|
||||
sourceOrigin,
|
||||
projectId,
|
||||
sourceAssets,
|
||||
sourceAssetPaths = {},
|
||||
importedAssets,
|
||||
readSource = async (asset: Asset) => {
|
||||
const response = await builderFetch(getAssetUrl(asset, sourceOrigin));
|
||||
if (response.ok === false) {
|
||||
throw new Error(
|
||||
`Unable to read copied content Asset "${formatAssetName(asset)}"`
|
||||
);
|
||||
}
|
||||
return response.text();
|
||||
},
|
||||
writeSource = async (asset: Asset, source: string) => {
|
||||
if (isMdxFileAsset(asset) === false) {
|
||||
await updateAssetContent({ asset, content: source });
|
||||
return;
|
||||
}
|
||||
await openExternalContentAsset({ projectId, assetId: asset.id });
|
||||
await updateExternalContentAssetSource({
|
||||
projectId,
|
||||
assetId: asset.id,
|
||||
update: () => source,
|
||||
});
|
||||
await flushExternalContentAsset({ projectId, assetId: asset.id });
|
||||
},
|
||||
}: {
|
||||
sourceOrigin: string;
|
||||
projectId: string;
|
||||
sourceAssets: readonly Asset[];
|
||||
sourceAssetPaths?: Readonly<Record<string, string>>;
|
||||
importedAssets: ReadonlyMap<Asset["id"], Asset>;
|
||||
readSource?: (asset: Asset) => Promise<string>;
|
||||
writeSource?: (asset: Asset, source: string) => Promise<void>;
|
||||
}) => {
|
||||
const assetPaths = new Map(
|
||||
sourceAssets.map((asset) => [
|
||||
asset.id,
|
||||
sourceAssetPaths[asset.id] ?? getRootAssetPath(asset),
|
||||
])
|
||||
);
|
||||
const replacementPaths = new Map(
|
||||
sourceAssets.flatMap((asset) => {
|
||||
const imported = importedAssets.get(asset.id);
|
||||
return imported === undefined
|
||||
? []
|
||||
: ([[asset.id, getRootAssetPath(imported)]] as const);
|
||||
})
|
||||
);
|
||||
const skippedInvalidAssetIds: string[] = [];
|
||||
for (const sourceAsset of sourceAssets) {
|
||||
const imported = importedAssets.get(sourceAsset.id);
|
||||
const format = sourceAsset.format.toLowerCase();
|
||||
if (
|
||||
(isMdxFileAsset(sourceAsset) === false &&
|
||||
(sourceAsset.type !== "file" ||
|
||||
(format !== "md" && format !== "json"))) ||
|
||||
imported === undefined ||
|
||||
imported.id === sourceAsset.id
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const source = await readSource(sourceAsset);
|
||||
let rewritten: string;
|
||||
try {
|
||||
const rewrite =
|
||||
format === "mdx"
|
||||
? rewriteMdxAssetReferences
|
||||
: format === "md"
|
||||
? rewriteMarkdownAssetReferences
|
||||
: rewriteJsonAssetReferences;
|
||||
rewritten = await rewrite({
|
||||
source,
|
||||
sourcePath: assetPaths.get(sourceAsset.id)!,
|
||||
assetPaths,
|
||||
replacementPaths,
|
||||
});
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof MdxDocumentError ||
|
||||
error instanceof MarkdownAssetReferenceError ||
|
||||
error instanceof JsonAssetReferenceError
|
||||
) {
|
||||
skippedInvalidAssetIds.push(sourceAsset.id);
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (rewritten !== source) {
|
||||
await writeSource(imported, rewritten);
|
||||
}
|
||||
}
|
||||
return { skippedInvalidAssetIds };
|
||||
};
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
coreMetas,
|
||||
elementComponent,
|
||||
blockTemplateComponent,
|
||||
blockComponent,
|
||||
portalComponent,
|
||||
} from "@webstudio-is/sdk";
|
||||
import type { Project } from "@webstudio-is/project";
|
||||
@@ -25,9 +26,10 @@ import * as baseComponentMetas from "@webstudio-is/sdk-components-react/metas";
|
||||
import { componentMetas } from "@webstudio-is/sdk-components-registry/metas";
|
||||
import { registerContainers } from "../sync/sync-stores";
|
||||
import { $registeredComponentMetas } from "../nano-states";
|
||||
import { $assets, $instances } from "~/shared/sync/data-stores";
|
||||
import {
|
||||
$dataSources,
|
||||
$assets,
|
||||
$instances,
|
||||
$pages,
|
||||
$project,
|
||||
$props,
|
||||
@@ -45,6 +47,7 @@ import {
|
||||
} from "../slot-test-utils";
|
||||
import { pasteHandled } from "./copy-paste";
|
||||
import { initBuilderApi } from "../builder-api";
|
||||
import { $externalContentRoots } from "../external-content-mutations";
|
||||
|
||||
const expectString = expect.any(String) as unknown as string;
|
||||
|
||||
@@ -169,6 +172,163 @@ describe("copy and cut guards", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("copies and pastes one rendered Collection Content Block with its resolved direct MDX source", async () => {
|
||||
setPageRoot("body");
|
||||
$instances.set(
|
||||
toMap([
|
||||
createInstance("body", "Body", [{ type: "id", value: "collection" }]),
|
||||
createInstance("collection", collectionComponent, [
|
||||
{ type: "id", value: "item" },
|
||||
]),
|
||||
createInstance("item", "Box", [{ type: "id", value: "block" }]),
|
||||
createInstance("block", blockComponent, [
|
||||
{ type: "id", value: "templates" },
|
||||
{ type: "id", value: "mdx-heading" },
|
||||
]),
|
||||
createInstance("templates", blockTemplateComponent, []),
|
||||
createInstance("mdx-heading", elementComponent, [
|
||||
{ type: "text", value: "Loaded from MDX" },
|
||||
]),
|
||||
])
|
||||
);
|
||||
$dataSources.set(
|
||||
toMap([
|
||||
{
|
||||
id: "item-source",
|
||||
scopeInstanceId: "collection",
|
||||
type: "parameter",
|
||||
name: "item",
|
||||
},
|
||||
])
|
||||
);
|
||||
$props.set(
|
||||
toMap([
|
||||
{
|
||||
id: "collection-data",
|
||||
instanceId: "collection",
|
||||
name: "data",
|
||||
type: "json",
|
||||
value: [{ mdx: "post" }],
|
||||
},
|
||||
{
|
||||
id: "collection-item",
|
||||
instanceId: "collection",
|
||||
name: "item",
|
||||
type: "parameter",
|
||||
value: "item-source",
|
||||
},
|
||||
{
|
||||
id: "block-source",
|
||||
instanceId: "block",
|
||||
name: "src",
|
||||
type: "expression",
|
||||
value: `${encodeDataSourceVariable("item-source")}.mdx`,
|
||||
},
|
||||
] satisfies Prop[])
|
||||
);
|
||||
$assets.set(
|
||||
toMap([
|
||||
{
|
||||
id: "post",
|
||||
projectId: "my-project",
|
||||
type: "file",
|
||||
format: "mdx",
|
||||
name: "post.mdx",
|
||||
size: 0,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
meta: {},
|
||||
},
|
||||
])
|
||||
);
|
||||
const renderScope = '["block","item","collection[0]","collection","body"]';
|
||||
$externalContentRoots.set(
|
||||
new Map([
|
||||
[
|
||||
"post-root",
|
||||
{
|
||||
sourceBlockInstanceId: "block",
|
||||
sourceRenderScope: renderScope,
|
||||
blockInstanceId: "runtime-block",
|
||||
renderScope,
|
||||
instanceIds: new Set<string>(),
|
||||
mutationRevision: 0,
|
||||
identity: {
|
||||
blockInstanceId: "block",
|
||||
assetId: "post",
|
||||
revision: "revision",
|
||||
contentRef: "post.mdx",
|
||||
format: "mdx",
|
||||
renderScope,
|
||||
},
|
||||
},
|
||||
],
|
||||
])
|
||||
);
|
||||
selectInstance(["item", "collection[0]", "collection", "body"]);
|
||||
|
||||
const clipboardData = instanceText.onCopy?.();
|
||||
const fragment = JSON.parse(clipboardData ?? "")[
|
||||
"@webstudio/instance/v0.1"
|
||||
];
|
||||
|
||||
expect(fragment.props).toContainEqual({
|
||||
id: "block-source",
|
||||
instanceId: "block",
|
||||
name: "src",
|
||||
type: "asset",
|
||||
value: "post",
|
||||
});
|
||||
expect(fragment.assets).toContainEqual(
|
||||
expect.objectContaining({ id: "post", format: "mdx" })
|
||||
);
|
||||
expect(fragment.assetPaths).toEqual({ post: "post.mdx" });
|
||||
expect(fragment.instances).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ id: "block" }),
|
||||
expect.objectContaining({ id: "item" }),
|
||||
expect.objectContaining({ id: "templates" }),
|
||||
])
|
||||
);
|
||||
expect(fragment.instances).not.toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ id: "mdx-heading" })])
|
||||
);
|
||||
initBuilderApi();
|
||||
const importAssets = vi
|
||||
.spyOn(window.__webstudio__$__builderApi, "importAssets")
|
||||
.mockResolvedValue(new Map([["post", $assets.get().get("post")!]]));
|
||||
$instances.set(
|
||||
toMap([createInstance("target-body", "Body", [])] satisfies Instance[])
|
||||
);
|
||||
$props.set(new Map());
|
||||
$dataSources.set(new Map());
|
||||
setPageRoot("target-body");
|
||||
selectInstance(["target-body"]);
|
||||
|
||||
expect(await instanceText.onPaste?.(clipboardData ?? "")).toEqual(
|
||||
pasteHandled
|
||||
);
|
||||
const pastedBlock = Array.from($instances.get().values()).find(
|
||||
(instance) => instance.component === blockComponent
|
||||
);
|
||||
expect(pastedBlock).toBeDefined();
|
||||
expect(
|
||||
Array.from($props.get().values()).find(
|
||||
(prop) => prop.instanceId === pastedBlock?.id && prop.name === "src"
|
||||
)
|
||||
).toEqual(expect.objectContaining({ type: "asset", value: "post" }));
|
||||
expect(
|
||||
Array.from($instances.get().values()).some(
|
||||
(instance) => instance.id === "mdx-heading"
|
||||
)
|
||||
).toBe(false);
|
||||
|
||||
importAssets.mockRestore();
|
||||
$externalContentRoots.set(new Map());
|
||||
$props.set(new Map());
|
||||
$assets.set(new Map());
|
||||
setPageRoot("body0");
|
||||
});
|
||||
|
||||
test("pastes multiple copied roots in order and selects pasted roots", async () => {
|
||||
$instances.set(
|
||||
toMap([
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import {
|
||||
breakpointPasteLimitWarning,
|
||||
extractWebstudioFragment,
|
||||
findChildReferenceIndex,
|
||||
findSafeFragmentPasteTarget,
|
||||
getCommonAncestorSelector,
|
||||
getInstancePath,
|
||||
getPasteRootInstanceIds,
|
||||
getFragmentContentModelWarnings,
|
||||
mergeWebstudioFragments,
|
||||
type InstanceSelector,
|
||||
sortInstancePathsForChildMutation,
|
||||
} from "@webstudio-is/project-build/runtime";
|
||||
import {
|
||||
instanceTransferDataVersion,
|
||||
@@ -22,16 +27,15 @@ import { type Insertable } from "../instance-utils/insert";
|
||||
import { shallowEqual } from "shallow-equal";
|
||||
import { toast } from "@webstudio-is/design-system";
|
||||
import {
|
||||
blockComponent,
|
||||
contentBlockSourceProp,
|
||||
getContentBlockSource,
|
||||
isMdxFileAsset,
|
||||
type Instance,
|
||||
type WebstudioFragment,
|
||||
isComponentDetachable,
|
||||
} from "@webstudio-is/sdk";
|
||||
import { $instances, $project } from "~/shared/sync/data-stores";
|
||||
import {
|
||||
type InstanceSelector,
|
||||
sortInstancePathsForChildMutation,
|
||||
} from "@webstudio-is/project-build/runtime";
|
||||
import { findChildReferenceIndex } from "@webstudio-is/project-build/runtime";
|
||||
import { $assetFolders, $instances, $project } from "~/shared/sync/data-stores";
|
||||
import { deleteInstanceBySelector } from "../instance-utils/mutation";
|
||||
import {
|
||||
$allSelectedInstanceSelectors,
|
||||
@@ -39,15 +43,30 @@ import {
|
||||
$selectedInstancePath,
|
||||
$selectedInstanceSelector,
|
||||
$registeredComponentMetas,
|
||||
$variableValuesByInstanceSelector,
|
||||
selectInstances,
|
||||
} from "~/shared/nano-states";
|
||||
import { getInstancePath } from "@webstudio-is/project-build/runtime";
|
||||
import { builderApi } from "../builder-api";
|
||||
import { pasteHandled, pasteIgnored, type Plugin } from "./copy-paste";
|
||||
import { breakpointPasteLimitWarning } from "@webstudio-is/project-build/runtime";
|
||||
import { resolveFragmentTokenConflicts } from "../resolve-token-conflicts";
|
||||
import { reportFragmentContentModelWarnings } from "./fragment-utils";
|
||||
import { transferFragmentAssets } from "./asset-transfer-utils";
|
||||
import {
|
||||
isRepeatedContentBlockOccurrence,
|
||||
parseContentBlockRenderScope,
|
||||
resolveContentBlockOccurrenceAssetId,
|
||||
} from "../content-block-source-utils";
|
||||
import { rewriteTransferredDocumentAssetReferences } from "./mdx-asset-transfer";
|
||||
import {
|
||||
createClipboardAssetPaths,
|
||||
hasDynamicContentBlockSource,
|
||||
prepareConnectedContentBlockFragment,
|
||||
resolveRepeatedContentBlockSourcesForCopy,
|
||||
} from "./content-block-fragment";
|
||||
import {
|
||||
$externalContentRoots,
|
||||
getExternalContentSourceSelector,
|
||||
} from "../external-content-mutations";
|
||||
|
||||
const invalidPasteDataMessage =
|
||||
"Could not paste Webstudio instance data. The clipboard data appears to be incomplete or invalid.";
|
||||
@@ -73,10 +92,102 @@ const getTreeData = (
|
||||
return;
|
||||
}
|
||||
|
||||
const data = getWebstudioData();
|
||||
const project = $project.get();
|
||||
const sourceInstanceSelector =
|
||||
instance?.component === blockComponent
|
||||
? getExternalContentSourceSelector({
|
||||
selector: instanceSelector,
|
||||
roots: $externalContentRoots.get(),
|
||||
})?.sourceSelector
|
||||
: undefined;
|
||||
const copyInstanceSelector = sourceInstanceSelector ?? instanceSelector;
|
||||
let fragment = extractWebstudioFragment(data, copyInstanceSelector[0]);
|
||||
let renderScopes: ReadonlySet<string> | undefined;
|
||||
const isRepeatedOccurrence = isRepeatedContentBlockOccurrence({
|
||||
instanceSelector,
|
||||
instances,
|
||||
});
|
||||
if (isRepeatedOccurrence) {
|
||||
const resolved = resolveRepeatedContentBlockSourcesForCopy({
|
||||
fragment,
|
||||
selectedInstanceSelector: copyInstanceSelector,
|
||||
occurrences: Array.from($externalContentRoots.get().values()).flatMap(
|
||||
(root) => {
|
||||
const sourceInstanceSelector = parseContentBlockRenderScope(
|
||||
root.sourceRenderScope ?? root.renderScope ?? ""
|
||||
);
|
||||
const sourceBlockInstanceId =
|
||||
root.sourceBlockInstanceId ?? root.blockInstanceId;
|
||||
return root.identity === undefined ||
|
||||
sourceInstanceSelector === undefined
|
||||
? []
|
||||
: [
|
||||
{
|
||||
sourceBlockInstanceId,
|
||||
sourceRenderScope:
|
||||
root.sourceRenderScope ?? root.renderScope ?? "",
|
||||
sourceInstanceSelector,
|
||||
assetId: root.identity.assetId,
|
||||
},
|
||||
];
|
||||
}
|
||||
),
|
||||
});
|
||||
fragment = resolved.fragment;
|
||||
renderScopes = resolved.renderScopes;
|
||||
}
|
||||
if (instance?.component === blockComponent) {
|
||||
const source = getContentBlockSource({
|
||||
blockInstanceId: copyInstanceSelector[0],
|
||||
props: data.props.values(),
|
||||
});
|
||||
const resolvedAssetId =
|
||||
source?.type === "expression"
|
||||
? resolveContentBlockOccurrenceAssetId({
|
||||
source,
|
||||
instanceSelector,
|
||||
variableValuesByRenderScope:
|
||||
$variableValuesByInstanceSelector.get(),
|
||||
})
|
||||
: undefined;
|
||||
const resolvedAsset =
|
||||
resolvedAssetId === undefined
|
||||
? undefined
|
||||
: data.assets.get(resolvedAssetId);
|
||||
if (resolvedAsset !== undefined && isMdxFileAsset(resolvedAsset)) {
|
||||
fragment = {
|
||||
...fragment,
|
||||
props: fragment.props.map((prop) =>
|
||||
prop.instanceId === copyInstanceSelector[0] &&
|
||||
prop.name === contentBlockSourceProp
|
||||
? { ...prop, type: "asset" as const, value: resolvedAsset.id }
|
||||
: prop
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
fragment = prepareConnectedContentBlockFragment({
|
||||
fragment,
|
||||
projectId: project?.id,
|
||||
assets: data.assets,
|
||||
renderScopes,
|
||||
});
|
||||
const hasDynamicContentSource = hasDynamicContentBlockSource(fragment);
|
||||
if (
|
||||
showToast &&
|
||||
instance?.component !== blockComponent &&
|
||||
hasDynamicContentSource
|
||||
) {
|
||||
toast.warn(
|
||||
"Dynamic MDX sources are copied from the Collection items currently rendered on the canvas."
|
||||
);
|
||||
}
|
||||
return {
|
||||
sourceOrigin: window.location.origin,
|
||||
instanceSelector,
|
||||
...extractWebstudioFragment(getWebstudioData(), targetInstanceId),
|
||||
assetPaths: createClipboardAssetPaths(fragment.assets, $assetFolders.get()),
|
||||
instanceSelector: copyInstanceSelector,
|
||||
...fragment,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -96,6 +207,10 @@ const stringifyMultiRootSelection = (selectedData: InstanceTransferData[]) => {
|
||||
const rootInstanceIds = selectedData.map((data) => data.instanceSelector[0]);
|
||||
return stringifyMultiRoot({
|
||||
sourceOrigin: firstData.sourceOrigin,
|
||||
assetPaths: Object.assign(
|
||||
{},
|
||||
...selectedData.map(({ assetPaths }) => assetPaths ?? {})
|
||||
),
|
||||
rootInstanceIds,
|
||||
fragment: mergeWebstudioFragments(rootInstanceIds, selectedData),
|
||||
});
|
||||
@@ -185,11 +300,21 @@ const findPasteTarget = (
|
||||
const instances = $instances.get();
|
||||
|
||||
const instanceSelector = $selectedInstanceSelector.get();
|
||||
const selectedSourceSelector =
|
||||
instanceSelector === undefined
|
||||
? undefined
|
||||
: getExternalContentSourceSelector({
|
||||
selector: instanceSelector,
|
||||
roots: $externalContentRoots.get(),
|
||||
})?.sourceSelector;
|
||||
|
||||
// paste after selected instance
|
||||
if (
|
||||
instanceSelector &&
|
||||
shallowEqual(instanceSelector, data.instanceSelector)
|
||||
shallowEqual(
|
||||
selectedSourceSelector ?? instanceSelector,
|
||||
data.instanceSelector
|
||||
)
|
||||
) {
|
||||
// body is not allowed to copy
|
||||
// so clipboard always have at least two level instance selector
|
||||
@@ -215,12 +340,14 @@ const insertPastedFragment = async ({
|
||||
pasteTarget,
|
||||
selectRootInstances,
|
||||
sourceOrigin,
|
||||
assetPaths,
|
||||
projectId,
|
||||
}: {
|
||||
fragment: WebstudioFragment;
|
||||
pasteTarget: Insertable;
|
||||
selectRootInstances: (rootInstanceIds: Instance["id"][]) => void;
|
||||
sourceOrigin: string | undefined;
|
||||
assetPaths?: Readonly<Record<string, string>>;
|
||||
projectId: string;
|
||||
}) => {
|
||||
try {
|
||||
@@ -246,6 +373,35 @@ const insertPastedFragment = async ({
|
||||
error: "Project changed while pasting.",
|
||||
} as const;
|
||||
}
|
||||
if (sourceOrigin !== undefined) {
|
||||
try {
|
||||
const { skippedInvalidAssetIds } =
|
||||
await rewriteTransferredDocumentAssetReferences({
|
||||
sourceOrigin,
|
||||
projectId,
|
||||
sourceAssets: fragment.assets,
|
||||
sourceAssetPaths: assetPaths,
|
||||
importedAssets: transferred.assets,
|
||||
});
|
||||
if (skippedInvalidAssetIds.length > 0) {
|
||||
toast.warn(
|
||||
"Some invalid content files were copied unchanged. Open them to review their diagnostics."
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
"Could not update Asset references in the copied content files.",
|
||||
} as const;
|
||||
}
|
||||
}
|
||||
if ($project.get()?.id !== projectId) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Project changed while pasting.",
|
||||
} as const;
|
||||
}
|
||||
const transferredFragment = transferred.fragments.get(fragment);
|
||||
if (transferredFragment === undefined) {
|
||||
return pasteHandled;
|
||||
@@ -310,6 +466,7 @@ const handlePasteInstance = async (clipboardData: string) => {
|
||||
fragment,
|
||||
pasteTarget,
|
||||
sourceOrigin: transferData.data.sourceOrigin ?? window.location.origin,
|
||||
assetPaths: transferData.data.assetPaths,
|
||||
projectId,
|
||||
selectRootInstances: (rootInstanceIds) => {
|
||||
selectInstances(
|
||||
@@ -331,6 +488,7 @@ const handlePasteInstance = async (clipboardData: string) => {
|
||||
fragment,
|
||||
pasteTarget,
|
||||
sourceOrigin: transferData.data.sourceOrigin ?? window.location.origin,
|
||||
assetPaths: transferData.data.assetPaths,
|
||||
projectId,
|
||||
selectRootInstances: (rootInstanceIds) => {
|
||||
const newRootInstanceId = rootInstanceIds[0];
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import { enableMapSet } from "immer";
|
||||
import { createDefaultPages } from "@webstudio-is/project-build";
|
||||
import type { PageTransferItem } from "@webstudio-is/project-build/transfer";
|
||||
import {
|
||||
blockComponent,
|
||||
blockTemplateComponent,
|
||||
encodeDataSourceVariable,
|
||||
ROOT_FOLDER_ID,
|
||||
ROOT_INSTANCE_ID,
|
||||
@@ -36,6 +39,7 @@ import {
|
||||
copyTemplateData,
|
||||
handlePastePage,
|
||||
pageText,
|
||||
__testing__,
|
||||
} from "./plugin-page";
|
||||
import { pasteHandled, pasteIgnored } from "./copy-paste";
|
||||
import { initBuilderApi } from "../builder-api";
|
||||
@@ -43,6 +47,7 @@ import { initBuilderApi } from "../builder-api";
|
||||
enableMapSet();
|
||||
registerContainers();
|
||||
initBuilderApi();
|
||||
const { preparePageTransferItem } = __testing__;
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
@@ -196,7 +201,7 @@ const setupProjectWithRootStyle = ({
|
||||
return pages;
|
||||
};
|
||||
|
||||
test("copies the selected page root from the copy plugin", () => {
|
||||
test("copies the selected page root from the copy plugin", async () => {
|
||||
$project.set({ id: "source-project" } as Project);
|
||||
resetBuildStores();
|
||||
|
||||
@@ -226,11 +231,11 @@ test("copies the selected page root from the copy plugin", () => {
|
||||
])
|
||||
);
|
||||
|
||||
expect(pageText.onCopy?.()).toContain('"type":"page"');
|
||||
expect(pageText.onCopy?.()).toContain('"name":"Selected Page"');
|
||||
expect(await pageText.onCopy?.()).toContain('"type":"page"');
|
||||
expect(await pageText.onCopy?.()).toContain('"name":"Selected Page"');
|
||||
});
|
||||
|
||||
test("copies the edited folder from the copy plugin", () => {
|
||||
test("copies the edited folder from the copy plugin", async () => {
|
||||
$project.set({ id: "source-project" } as Project);
|
||||
resetBuildStores();
|
||||
|
||||
@@ -260,8 +265,8 @@ test("copies the edited folder from the copy plugin", () => {
|
||||
])
|
||||
);
|
||||
|
||||
expect(pageText.onCopy?.()).toContain('"type":"folder"');
|
||||
expect(pageText.onCopy?.()).toContain('"name":"Edited Folder"');
|
||||
expect(await pageText.onCopy?.()).toContain('"type":"folder"');
|
||||
expect(await pageText.onCopy?.()).toContain('"name":"Edited Folder"');
|
||||
});
|
||||
|
||||
test("copies page data across projects", async () => {
|
||||
@@ -306,7 +311,7 @@ test("copies page data across projects", async () => {
|
||||
);
|
||||
$dataSources.set(new Map([[variable.id, variable]]));
|
||||
|
||||
const clipboardData = copyPageData("source-page");
|
||||
const clipboardData = await copyPageData("source-page");
|
||||
expect(clipboardData).toBeDefined();
|
||||
|
||||
$project.set({ id: "target-project" } as Project);
|
||||
@@ -396,10 +401,13 @@ test("transfers assets copied from another deployment before inserting the page"
|
||||
])
|
||||
);
|
||||
$assets.set(new Map([[sourceAsset.id, sourceAsset]]));
|
||||
const clipboardData = JSON.parse(copyPageData("source-page") ?? "");
|
||||
const clipboardData = JSON.parse((await copyPageData("source-page")) ?? "");
|
||||
expect(clipboardData["@webstudio/page/v0.1"].sourceOrigin).toBe(
|
||||
window.location.origin
|
||||
);
|
||||
expect(clipboardData["@webstudio/page/v0.1"].assetPaths).toEqual({
|
||||
"source-asset": "hero.png",
|
||||
});
|
||||
clipboardData["@webstudio/page/v0.1"].sourceOrigin =
|
||||
"https://source.example.com";
|
||||
|
||||
@@ -503,7 +511,7 @@ test("preserves legacy page metadata assets that exist in the destination", asyn
|
||||
])
|
||||
);
|
||||
$assets.set(new Map([[sourceAsset.id, sourceAsset]]));
|
||||
const currentData = JSON.parse(copyPageData("source-page") ?? "")[
|
||||
const currentData = JSON.parse((await copyPageData("source-page")) ?? "")[
|
||||
"@webstudio/page/v0.1"
|
||||
];
|
||||
delete currentData.sourceOrigin;
|
||||
@@ -571,7 +579,7 @@ test.each([
|
||||
property: "fontSize",
|
||||
value: { type: "keyword", value: "medium" },
|
||||
});
|
||||
const clipboardData = copyPageData("source-page");
|
||||
const clipboardData = await copyPageData("source-page");
|
||||
|
||||
setupProjectWithRootStyle({
|
||||
projectId: "target-project",
|
||||
@@ -630,7 +638,7 @@ test("cancels page paste when global root style resolution is cancelled", async
|
||||
}
|
||||
sourcePage.meta.socialImageAssetId = sourceAsset.id;
|
||||
$assets.set(new Map([[sourceAsset.id, sourceAsset]]));
|
||||
const clipboardData = copyPageData("source-page");
|
||||
const clipboardData = await copyPageData("source-page");
|
||||
|
||||
const targetPages = setupProjectWithRootStyle({
|
||||
projectId: "target-project",
|
||||
@@ -668,7 +676,7 @@ test("checks current root styles after resolving token conflicts", async () => {
|
||||
color: "red",
|
||||
});
|
||||
addRootTokenStyle({ styleSourceId: "source-token", value: "red" });
|
||||
const clipboardData = copyPageData("source-page");
|
||||
const clipboardData = await copyPageData("source-page");
|
||||
|
||||
setupProjectWithRootStyle({
|
||||
projectId: "target-project",
|
||||
@@ -722,7 +730,7 @@ test("handles page paste data without falling through when insertion cannot comp
|
||||
])
|
||||
);
|
||||
|
||||
const clipboardData = copyPageData("source-page");
|
||||
const clipboardData = await copyPageData("source-page");
|
||||
expect(clipboardData).toBeDefined();
|
||||
|
||||
resetBuildStores();
|
||||
@@ -819,7 +827,7 @@ test("copies folder data with nested pages across projects", async () => {
|
||||
);
|
||||
setRootLocalStyle({ styleSourceId: "source-local", value: "red" });
|
||||
|
||||
const clipboardData = copyFolderData("source-folder");
|
||||
const clipboardData = await copyFolderData("source-folder");
|
||||
expect(clipboardData).toBeDefined();
|
||||
|
||||
$project.set({ id: "target-project" } as Project);
|
||||
@@ -906,7 +914,7 @@ test("preserves empty folder slugs when pasting folder data", async () => {
|
||||
])
|
||||
);
|
||||
|
||||
const clipboardData = copyFolderData("source-folder");
|
||||
const clipboardData = await copyFolderData("source-folder");
|
||||
expect(clipboardData).toBeDefined();
|
||||
|
||||
$project.set({ id: "target-project" } as Project);
|
||||
@@ -985,7 +993,7 @@ test("copies template data as a template", async () => {
|
||||
);
|
||||
setRootLocalStyle({ styleSourceId: "source-local", value: "red" });
|
||||
|
||||
const clipboardData = copyTemplateData("template-id");
|
||||
const clipboardData = await copyTemplateData("template-id");
|
||||
expect(clipboardData).toBeDefined();
|
||||
|
||||
$project.set({ id: "target-project" } as Project);
|
||||
@@ -1038,3 +1046,152 @@ test("copies template data as a template", async () => {
|
||||
);
|
||||
expect(pastedPage).toBeUndefined();
|
||||
});
|
||||
|
||||
test.each(["page", "template", "folder"] as const)(
|
||||
"prepares connected MDX dependencies when copying a %s",
|
||||
async (type) => {
|
||||
const sourceAsset = {
|
||||
id: "article",
|
||||
projectId: "source-project",
|
||||
name: "article.mdx",
|
||||
type: "file",
|
||||
format: "mdx",
|
||||
size: 1,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
description: null,
|
||||
meta: {},
|
||||
} satisfies Asset;
|
||||
const dependency = {
|
||||
...sourceAsset,
|
||||
id: "hero",
|
||||
name: "hero.png",
|
||||
format: "png",
|
||||
} satisfies Asset;
|
||||
$assets.set(
|
||||
new Map([
|
||||
[sourceAsset.id, sourceAsset],
|
||||
[dependency.id, dependency],
|
||||
])
|
||||
);
|
||||
const emptyFragment = {
|
||||
children: [],
|
||||
instances: [],
|
||||
props: [],
|
||||
assets: [],
|
||||
dataSources: [],
|
||||
resources: [],
|
||||
breakpoints: [],
|
||||
styleSources: [],
|
||||
styleSourceSelections: [],
|
||||
styles: [],
|
||||
};
|
||||
const bodyFragment = {
|
||||
...emptyFragment,
|
||||
children: [{ type: "id" as const, value: "body" }],
|
||||
instances: [
|
||||
{
|
||||
type: "instance" as const,
|
||||
id: "body",
|
||||
component: "Body",
|
||||
children: [{ type: "id" as const, value: "block" }],
|
||||
},
|
||||
{
|
||||
type: "instance" as const,
|
||||
id: "block",
|
||||
component: blockComponent,
|
||||
children: [
|
||||
{ type: "id" as const, value: "templates" },
|
||||
{ type: "id" as const, value: "authored" },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "instance" as const,
|
||||
id: "templates",
|
||||
component: blockTemplateComponent,
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
type: "instance" as const,
|
||||
id: "authored",
|
||||
component: "Paragraph",
|
||||
children: [{ type: "text" as const, value: "Loaded" }],
|
||||
},
|
||||
],
|
||||
props: [
|
||||
{
|
||||
id: "source",
|
||||
instanceId: "block",
|
||||
name: "src",
|
||||
type: "asset" as const,
|
||||
value: sourceAsset.id,
|
||||
},
|
||||
],
|
||||
assets: [sourceAsset],
|
||||
};
|
||||
const page = {
|
||||
type: "page" as const,
|
||||
page: {
|
||||
id: "page",
|
||||
name: "Page",
|
||||
path: "/page",
|
||||
title: "Page",
|
||||
rootInstanceId: "body",
|
||||
meta: {},
|
||||
},
|
||||
rootFragment: emptyFragment,
|
||||
bodyFragment,
|
||||
};
|
||||
const template = {
|
||||
type: "template" as const,
|
||||
template: {
|
||||
id: "template",
|
||||
name: "Template",
|
||||
title: "Template",
|
||||
rootInstanceId: "body",
|
||||
meta: {},
|
||||
},
|
||||
rootFragment: emptyFragment,
|
||||
bodyFragment,
|
||||
};
|
||||
const item: PageTransferItem =
|
||||
type === "page"
|
||||
? page
|
||||
: type === "template"
|
||||
? template
|
||||
: {
|
||||
type: "folder",
|
||||
folder: {
|
||||
id: "folder",
|
||||
name: "Folder",
|
||||
slug: "folder",
|
||||
children: ["page"],
|
||||
},
|
||||
children: [page],
|
||||
};
|
||||
const includeDependencies = vi.fn(async ({ fragment }) => ({
|
||||
fragment:
|
||||
fragment.assets.length === 0
|
||||
? fragment
|
||||
: { ...fragment, assets: [...fragment.assets, dependency] },
|
||||
skippedAssetIds: [],
|
||||
}));
|
||||
|
||||
const prepared = await preparePageTransferItem({
|
||||
item,
|
||||
projectId: "source-project",
|
||||
assets: $assets.get(),
|
||||
assetFolders: new Map(),
|
||||
includeDependencies,
|
||||
});
|
||||
const preparedItem =
|
||||
prepared.type === "folder" ? prepared.children[0] : prepared;
|
||||
if (preparedItem === undefined || preparedItem.type === "folder") {
|
||||
throw new Error("Expected a prepared page-like item");
|
||||
}
|
||||
|
||||
expect(preparedItem.bodyFragment.assets).toEqual([sourceAsset, dependency]);
|
||||
expect(preparedItem.bodyFragment.instances).not.toContainEqual(
|
||||
expect.objectContaining({ id: "authored" })
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -11,7 +11,12 @@ import {
|
||||
type Page,
|
||||
type PageTemplate,
|
||||
} from "@webstudio-is/sdk";
|
||||
import { $pages, $project } from "~/shared/sync/data-stores";
|
||||
import {
|
||||
$assetFolders,
|
||||
$assets,
|
||||
$pages,
|
||||
$project,
|
||||
} from "~/shared/sync/data-stores";
|
||||
import { detectFragmentTokenConflicts } from "@webstudio-is/project-build/runtime";
|
||||
import { resolveTokenConflicts } from "../resolve-token-conflicts";
|
||||
import { resolveRootStyleConflicts } from "../resolve-root-style-conflicts";
|
||||
@@ -34,19 +39,152 @@ import { getPageActionTarget } from "../page-action-target";
|
||||
import { pasteHandled, pasteIgnored, type Plugin } from "./copy-paste";
|
||||
import { breakpointPasteLimitWarning } from "@webstudio-is/project-build/runtime";
|
||||
import { transferFragmentAssets } from "./asset-transfer-utils";
|
||||
import { rewriteTransferredDocumentAssetReferences } from "./mdx-asset-transfer";
|
||||
import {
|
||||
createClipboardAssetPaths,
|
||||
hasDynamicContentBlockSource,
|
||||
includeMdxAssetDependencies,
|
||||
prepareConnectedContentBlockFragment,
|
||||
} from "./content-block-fragment";
|
||||
|
||||
const invalidPasteDataMessage =
|
||||
"Could not paste Webstudio page data. The clipboard data appears to be incomplete or invalid.";
|
||||
|
||||
const stringify = (data: PageTransferItem) => {
|
||||
const stringify = (
|
||||
data: PageTransferItem,
|
||||
assetFolders: ReturnType<typeof $assetFolders.get>
|
||||
) => {
|
||||
const assets = new Map(
|
||||
collectPageTransferItems(data).flatMap((item) =>
|
||||
[item.rootFragment, item.bodyFragment].flatMap((fragment) =>
|
||||
fragment.assets.map((asset) => [asset.id, asset] as const)
|
||||
)
|
||||
)
|
||||
);
|
||||
return JSON.stringify({
|
||||
[pageTransferDataVersion]: {
|
||||
...data,
|
||||
sourceOrigin: window.location.origin,
|
||||
assetPaths: createClipboardAssetPaths(
|
||||
Array.from(assets.values()),
|
||||
assetFolders
|
||||
),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const preparePageTransferItemRecursive = async ({
|
||||
item,
|
||||
projectId,
|
||||
assets,
|
||||
assetFolders,
|
||||
includeDependencies,
|
||||
discoveryCache,
|
||||
warnings,
|
||||
}: {
|
||||
item: PageTransferItem;
|
||||
projectId: string | undefined;
|
||||
assets: ReturnType<typeof $assets.get>;
|
||||
assetFolders: ReturnType<typeof $assetFolders.get>;
|
||||
includeDependencies: typeof includeMdxAssetDependencies;
|
||||
discoveryCache: Map<string, Promise<readonly string[] | undefined>>;
|
||||
warnings: { skippedDependencies: boolean };
|
||||
}): Promise<PageTransferItem> => {
|
||||
if (item.type === "folder") {
|
||||
return {
|
||||
...item,
|
||||
children: (await Promise.all(
|
||||
item.children.map((child) =>
|
||||
preparePageTransferItemRecursive({
|
||||
item: child,
|
||||
projectId,
|
||||
assets,
|
||||
assetFolders,
|
||||
includeDependencies,
|
||||
discoveryCache,
|
||||
warnings,
|
||||
})
|
||||
)
|
||||
)) as FolderTransferData["children"],
|
||||
};
|
||||
}
|
||||
const prepared = {
|
||||
...item,
|
||||
rootFragment: prepareConnectedContentBlockFragment({
|
||||
fragment: item.rootFragment,
|
||||
projectId,
|
||||
assets,
|
||||
}),
|
||||
bodyFragment: prepareConnectedContentBlockFragment({
|
||||
fragment: item.bodyFragment,
|
||||
projectId,
|
||||
assets,
|
||||
}),
|
||||
};
|
||||
if (projectId === undefined) {
|
||||
return prepared;
|
||||
}
|
||||
const [root, body] = await Promise.all(
|
||||
[prepared.rootFragment, prepared.bodyFragment].map((fragment) =>
|
||||
includeDependencies({
|
||||
fragment,
|
||||
projectId,
|
||||
assets,
|
||||
assetFolders,
|
||||
discoveryCache,
|
||||
})
|
||||
)
|
||||
);
|
||||
if (root.skippedAssetIds.length > 0 || body.skippedAssetIds.length > 0) {
|
||||
warnings.skippedDependencies = true;
|
||||
}
|
||||
return {
|
||||
...prepared,
|
||||
rootFragment: root.fragment,
|
||||
bodyFragment: body.fragment,
|
||||
};
|
||||
};
|
||||
|
||||
const preparePageTransferItem = async ({
|
||||
item,
|
||||
projectId,
|
||||
assets,
|
||||
assetFolders,
|
||||
includeDependencies = includeMdxAssetDependencies,
|
||||
}: {
|
||||
item: PageTransferItem;
|
||||
projectId: string | undefined;
|
||||
assets: ReturnType<typeof $assets.get>;
|
||||
assetFolders: ReturnType<typeof $assetFolders.get>;
|
||||
includeDependencies?: typeof includeMdxAssetDependencies;
|
||||
}) => {
|
||||
if (
|
||||
collectPageTransferItems(item).some(({ rootFragment, bodyFragment }) =>
|
||||
[rootFragment, bodyFragment].some(hasDynamicContentBlockSource)
|
||||
)
|
||||
) {
|
||||
toast.warn(
|
||||
"Dynamic MDX sources are copied from the Collection items currently rendered on the canvas."
|
||||
);
|
||||
}
|
||||
const warnings = { skippedDependencies: false };
|
||||
const prepared = await preparePageTransferItemRecursive({
|
||||
item,
|
||||
projectId,
|
||||
assets,
|
||||
assetFolders,
|
||||
includeDependencies,
|
||||
discoveryCache: new Map(),
|
||||
warnings,
|
||||
});
|
||||
if (warnings.skippedDependencies) {
|
||||
toast.warn(
|
||||
"Some MDX dependencies could not be inspected and were skipped while copying."
|
||||
);
|
||||
}
|
||||
return prepared;
|
||||
};
|
||||
|
||||
const getDefaultTargetFolderId = () => {
|
||||
const pages = $pages.get();
|
||||
if (pages === undefined) {
|
||||
@@ -220,33 +358,58 @@ const handleCopyPage = () => {
|
||||
}
|
||||
};
|
||||
|
||||
export const copyPageData = (pageId: Page["id"]) => {
|
||||
export const copyPageData = async (pageId: Page["id"]) => {
|
||||
const data = getWebstudioData();
|
||||
const pageData = getPageCopyData(data, pageId);
|
||||
if (pageData === undefined) {
|
||||
return;
|
||||
}
|
||||
return stringify(pageData);
|
||||
return stringify(
|
||||
await preparePageTransferItem({
|
||||
item: pageData,
|
||||
projectId: $project.get()?.id,
|
||||
assets: data.assets,
|
||||
assetFolders: data.assetFolders,
|
||||
}),
|
||||
data.assetFolders
|
||||
);
|
||||
};
|
||||
|
||||
export const copyTemplateData = (templateId: PageTemplate["id"]) => {
|
||||
export const copyTemplateData = async (templateId: PageTemplate["id"]) => {
|
||||
const data = getWebstudioData();
|
||||
const template = data.pages.pageTemplates?.get(templateId);
|
||||
if (template === undefined) {
|
||||
return;
|
||||
}
|
||||
return stringify(
|
||||
addTemplateType(createTemplateCopyData({ data, template }), data.assets)
|
||||
await preparePageTransferItem({
|
||||
item: addTemplateType(
|
||||
createTemplateCopyData({ data, template }),
|
||||
data.assets
|
||||
),
|
||||
projectId: $project.get()?.id,
|
||||
assets: data.assets,
|
||||
assetFolders: data.assetFolders,
|
||||
}),
|
||||
data.assetFolders
|
||||
);
|
||||
};
|
||||
|
||||
export const copyFolderData = (folderId: Folder["id"]) => {
|
||||
export const copyFolderData = async (folderId: Folder["id"]) => {
|
||||
const data = getWebstudioData();
|
||||
const folderData = createFolderCopyData({ data, folderId });
|
||||
if (folderData === undefined) {
|
||||
return;
|
||||
}
|
||||
return stringify(addFolderType(folderData, data.assets));
|
||||
return stringify(
|
||||
await preparePageTransferItem({
|
||||
item: addFolderType(folderData, data.assets),
|
||||
projectId: $project.get()?.id,
|
||||
assets: data.assets,
|
||||
assetFolders: data.assetFolders,
|
||||
}),
|
||||
data.assetFolders
|
||||
);
|
||||
};
|
||||
|
||||
export const handlePastePage = async (
|
||||
@@ -260,7 +423,7 @@ export const handlePastePage = async (
|
||||
if (transferData.valid === false) {
|
||||
return { success: false, error: invalidPasteDataMessage } as const;
|
||||
}
|
||||
const { sourceOrigin, ...transferItem } = transferData.data;
|
||||
const { sourceOrigin, assetPaths, ...transferItem } = transferData.data;
|
||||
let item: PageTransferItem = transferItem;
|
||||
|
||||
const pages = $pages.get();
|
||||
@@ -342,6 +505,42 @@ export const handlePastePage = async (
|
||||
error: "Project changed while pasting.",
|
||||
} as const;
|
||||
}
|
||||
if (sourceOrigin !== undefined) {
|
||||
const sourceAssets = new Map(
|
||||
sourcePageItems.flatMap((item) =>
|
||||
[item.rootFragment, item.bodyFragment].flatMap((fragment) =>
|
||||
fragment.assets.map((asset) => [asset.id, asset] as const)
|
||||
)
|
||||
)
|
||||
);
|
||||
try {
|
||||
const { skippedInvalidAssetIds } =
|
||||
await rewriteTransferredDocumentAssetReferences({
|
||||
sourceOrigin,
|
||||
projectId,
|
||||
sourceAssets: Array.from(sourceAssets.values()),
|
||||
sourceAssetPaths: assetPaths,
|
||||
importedAssets: transferred.assets,
|
||||
});
|
||||
if (skippedInvalidAssetIds.length > 0) {
|
||||
toast.warn(
|
||||
"Some invalid content files were copied unchanged. Open them to review their diagnostics."
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
"Could not update Asset references in the copied content files.",
|
||||
} as const;
|
||||
}
|
||||
}
|
||||
if ($project.get()?.id !== projectId) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Project changed while pasting.",
|
||||
} as const;
|
||||
}
|
||||
item = remapPageTransferAssets(
|
||||
item,
|
||||
transferred.fragments,
|
||||
@@ -384,3 +583,5 @@ export const pageText: Plugin = {
|
||||
onCopy: handleCopyPage,
|
||||
onPaste: (clipboardData) => handlePastePage(clipboardData),
|
||||
};
|
||||
|
||||
export const __testing__ = { preparePageTransferItem };
|
||||
@@ -1103,7 +1103,7 @@ test("CodeBlock", async () => {
|
||||
},
|
||||
});
|
||||
|
||||
equalFragment(fragment, <$.CodeText lang="javascript" code="test" />);
|
||||
equalFragment(fragment, <$.CodeText lang="javascript">test</$.CodeText>);
|
||||
expect(toCss(fragment)).toMatchInlineSnapshot(`
|
||||
"@media all {
|
||||
w-code-block {
|
||||
@@ -1988,7 +1988,10 @@ test("FormBlockLabel", async () => {
|
||||
},
|
||||
});
|
||||
|
||||
equalFragment(fragment, <$.Label htmlFor="email">Email Address</$.Label>);
|
||||
equalFragment(
|
||||
fragment,
|
||||
<$.Label {...{ for: "email" }}>Email Address</$.Label>
|
||||
);
|
||||
expect(toCss(fragment)).toMatchInlineSnapshot(`
|
||||
"@media all {
|
||||
label {
|
||||
@@ -2122,7 +2125,10 @@ test("FormBlockLabel", async () => {
|
||||
},
|
||||
});
|
||||
|
||||
equalFragment(fragment, <$.Label htmlFor="email">Email Address</$.Label>);
|
||||
equalFragment(
|
||||
fragment,
|
||||
<$.Label {...{ for: "email" }}>Email Address</$.Label>
|
||||
);
|
||||
expect(toCss(fragment)).toMatchInlineSnapshot(`
|
||||
"@media all {
|
||||
label {
|
||||
|
||||
Loaded 100 of 422 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user