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:
Oleg Isonen authored and GitHub committed 2026-08-29 20:15:20 +02:00
1 parent 7e7c14868a
commit f9681dc30f
422 files changed
+45686 -1712

No files matched your search

+2
View File
@@ -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) => {
executeRuntimeMutation({
id: "instances.setLabel",
input: { instanceId: instance.id, label: value },
});
editableRef.current?.closest("button")?.focus();
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} />}>
<EditableTreeNodeLabel
ref={mergeRefs(editableRef, ref)}
{...handlers}
isEditing={isEditing}
<Tooltip
content={isNameEditable ? error : externalContentInstanceNameMessage}
delayDuration={0}
>
{label}
</EditableTreeNodeLabel>
<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}
@@ -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" },
]);
});
@@ -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],
}),
});
@@ -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>
);
};
@@ -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),
});
@@ -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);
}}
>
@@ -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;
@@ -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>}>
<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)}
onBlur={localValue.save}
/>
<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}
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>
@@ -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
? "auto minmax(0, 1fr)"
? 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">) =>
@@ -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;
}
@@ -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",
@@ -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} />
@@ -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");
});
});
@@ -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"
? {
dimensions: {
width: source.asset.meta.width,
height: source.asset.meta.height,
},
}
: {};
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"]);
+33 -3
View File
@@ -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;
}
menuState = "closed";
if (menuState !== "closed") {
menuState = "closed";
handleOpen(editor.getEditorState(), undefined);
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(
treeRootInstance,
refs,
newLinkKeyToInstanceId,
builderRuntimeContext.createId
)
);
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,14 +1753,16 @@ export const TextEditor = ({
<LexicalComposer initialConfig={initialConfig}>
<RemoveParagaphsPlugin />
<CaretColorPlugin />
<ToolbarConnectorPlugin
onSelectNode={(nodeKey) => {
const instanceId = refs.get(`${nodeKey}:span`);
if (instanceId !== undefined) {
onSelectInstance(instanceId);
}
}}
/>
{plainText === false && (
<ToolbarConnectorPlugin
onSelectNode={(nodeKey) => {
const instanceId = refs.get(`${nodeKey}:span`);
if (instanceId !== undefined) {
onSelectInstance(instanceId);
}
}}
/>
)}
<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} />
<RichTextContentPlugin
onOpen={handleContextMenuOpen}
rootInstanceSelector={rootInstanceSelector}
// oxlint-disable-next-line react-hooks/rules-of-hooks -- our useEffectEvent is a stable callback
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 />
<LinkSelectionPlugin
rootInstanceSelector={rootInstanceSelector}
registerNewLink={registerNewLink}
/>
{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);
});
});
+14 -4
View File
@@ -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)
);
};
+83 -2
View File
@@ -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,7 +611,9 @@ const createRuntimeOperationProcedure = (operation: RuntimeOperation) => {
if (operation.requiresAssets) {
return operation.kind === "read"
? runtimeAssetsQuery(id)
: runtimeAssetsMutation(id);
: id.startsWith("contentBlocks.")
? runtimeContentBlockMutation(id)
: runtimeAssetsMutation(id);
}
if (operation.kind === "read") {
return runtimeBuildQuery(id);
@@ -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);
});
+247
View File
@@ -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: {
+2
View File
@@ -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 () => "![Hero](../media/hero.png)");
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 () => "![Hero](../media/hero.png)");
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" }),
"![Hero](hero_1.png)\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![Avatar](../media/hero.png)",
writeSource,
});
expect(writeSource).toHaveBeenCalledWith(
expect.objectContaining({ id: "author-copy" }),
"---\navatar: hero_1.png#profile\n---\n\n<!-- HTML remains valid Markdown -->\n\n![Avatar](hero_1.png)"
);
});
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