Files
webstudio/apps/builder/app/shared/tailwind/tailwind.ts
Oleg Isonen f9681dc30f 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.
2026-08-29 20:15:20 +02:00

900 lines
28 KiB
TypeScript

import { createGenerator } from "@unocss/core";
import { presetWind4 } from "@unocss/preset-wind4";
import {
camelCaseProperty,
extractCssCustomProperties,
parseCss,
parseMediaQuery,
type ParsedStyleDecl,
} from "@webstudio-is/css-data";
import type { StyleProperty } from "@webstudio-is/css-engine";
import {
getStyleDeclKey,
type Breakpoint,
type Instance,
type Prop,
type WebstudioFragment,
} from "@webstudio-is/sdk";
import {
createWebstudioDataFromFragment,
isBaseBreakpoint,
} from "@webstudio-is/project-build/runtime";
import { preflight } from "./__generated__/preflight";
// breakpoints used to map tailwind classes to webstudio breakpoints
// includes both min-width (desktop-first) and max-width (mobile-first) breakpoints
const tailwindBreakpoints: Breakpoint[] = [
{ id: "1536", label: "1536", minWidth: 1536 },
{ id: "1280", label: "1280", minWidth: 1280 },
{ id: "base", label: "" },
{ id: "1023", label: "1023", maxWidth: 1023 },
{ id: "767", label: "767", maxWidth: 767 },
{ id: "639", label: "639", maxWidth: 639 },
];
type StyleDecl = Omit<ParsedStyleDecl, "selector">;
type StyleBreakpoint = {
styleDecl: StyleDecl;
minWidth?: number;
maxWidth?: number;
};
type Range = {
styleDecl: StyleDecl;
start: number;
end: number;
};
const serializeStyleBreakpoint = (breakpoint: StyleBreakpoint) => {
if (breakpoint?.minWidth !== undefined) {
return `(min-width: ${breakpoint.minWidth}px)`;
}
if (breakpoint?.maxWidth !== undefined) {
return `(max-width: ${breakpoint.maxWidth}px)`;
}
};
const UPPER_BOUND = Number.MAX_SAFE_INTEGER;
const breakpointsToRanges = (breakpoints: StyleBreakpoint[]) => {
// collect lower bounds and ids
const values = new Set<number>([0]);
const styles = new Map<undefined | number, StyleDecl>();
for (const breakpoint of breakpoints) {
if (breakpoint.minWidth !== undefined) {
values.add(breakpoint.minWidth);
styles.set(breakpoint.minWidth, breakpoint.styleDecl);
} else if (breakpoint.maxWidth !== undefined) {
values.add(breakpoint.maxWidth + 1);
styles.set(breakpoint.maxWidth, breakpoint.styleDecl);
} else {
// base breakpoint
styles.set(undefined, breakpoint.styleDecl);
}
}
const sortedValues = Array.from(values).sort((left, right) => left - right);
const ranges: Range[] = [];
for (let index = 0; index < sortedValues.length; index += 1) {
const start = sortedValues[index];
let end;
if (index === sortedValues.length - 1) {
end = UPPER_BOUND;
} else {
end = sortedValues[index + 1] - 1;
}
const styleDecl =
styles.get(start) ?? styles.get(end) ?? styles.get(undefined);
if (styleDecl) {
ranges.push({ styleDecl, start, end });
continue;
}
// when declaration is missing add new one with unset value
// to fill the hole in breakpoints
// for example
// "sm:opacity-20" has a hole at the start
// "max-sm:opacity-10 md:opacity-20" has a whole in the middle
const example = Array.from(styles.values())[0];
if (example) {
const newStyleDecl: StyleDecl = {
...example,
value: { type: "keyword", value: "unset" },
};
ranges.push({ styleDecl: newStyleDecl, start, end });
}
}
return ranges;
};
const rangesToBreakpoints = (
ranges: Range[],
userBreakpoints: Breakpoint[]
) => {
const breakpoints: StyleBreakpoint[] = [];
for (const { styleDecl, start, end } of ranges) {
let matchedBreakpoint;
for (const breakpoint of userBreakpoints) {
if (breakpoint.minWidth === start) {
matchedBreakpoint = { styleDecl, minWidth: start };
}
if (breakpoint.maxWidth === end) {
matchedBreakpoint = { styleDecl, maxWidth: end };
}
if (
breakpoint.minWidth === undefined &&
breakpoint.maxWidth === undefined
) {
matchedBreakpoint ??= { styleDecl };
}
}
if (matchedBreakpoint) {
styleDecl.breakpoint = serializeStyleBreakpoint(matchedBreakpoint);
breakpoints.push(matchedBreakpoint);
}
}
return breakpoints;
};
const normalizeMediaQueryWidth = (value: number, direction: "min" | "max") => {
return direction === "min" ? Math.ceil(value) : Math.floor(value);
};
const adaptBreakpoints = (
parsedStyles: StyleDecl[],
userBreakpoints: Breakpoint[]
) => {
const breakpointGroups = new Map<string, StyleBreakpoint[]>();
for (const styleDecl of parsedStyles) {
const mediaQuery = styleDecl.breakpoint
? parseMediaQuery(styleDecl.breakpoint)
: undefined;
// Skip condition-only breakpoints (e.g. dark mode prefers-color-scheme)
// and combined min+max width breakpoints (e.g. md:max-xl:)
// @todo support composite breakpoints by splitting into range-based breakpoints
if (mediaQuery?.condition !== undefined) {
continue;
}
if (
mediaQuery?.minWidth !== undefined &&
mediaQuery?.maxWidth !== undefined
) {
continue;
}
if (mediaQuery?.minWidth !== undefined) {
mediaQuery.minWidth = normalizeMediaQueryWidth(
mediaQuery.minWidth,
"min"
);
}
if (mediaQuery?.maxWidth !== undefined) {
mediaQuery.maxWidth = normalizeMediaQueryWidth(
mediaQuery.maxWidth,
"max"
);
}
const groupKey = `${styleDecl.property}:${styleDecl.state ?? ""}`;
let group = breakpointGroups.get(groupKey);
if (group === undefined) {
group = [];
breakpointGroups.set(groupKey, group);
}
group.push({ styleDecl, ...mediaQuery });
}
const newStyles: typeof parsedStyles = [];
for (const group of breakpointGroups.values()) {
const ranges = breakpointsToRanges(group);
const newGroup = rangesToBreakpoints(ranges, userBreakpoints);
for (const { styleDecl } of newGroup) {
newStyles.push(styleDecl);
}
}
return newStyles;
};
const createUnoGenerator = async () => {
return await createGenerator({
presets: [
presetWind4({
// css variables are defined on the same element as styleDecl
preflights: { theme: "on-demand", reset: false, property: false },
// dark mode will be ignored by parser
dark: "media",
}),
],
});
};
const percentToNumber = (value: string) => {
const trimmed = value.trim();
if (trimmed.endsWith("%") === false) {
return trimmed;
}
const parsed = Number.parseFloat(trimmed.slice(0, -1));
if (Number.isNaN(parsed)) {
return trimmed;
}
return (parsed / 100).toString();
};
const appendAlphaToOklch = (oklch: string, alpha: string) => {
const match = oklch.match(/^oklch\((.*)\)$/i);
if (match === null) {
return oklch;
}
return `oklch(${match[1]} / ${alpha})`;
};
const hexToRgb = (hex: string) => {
const normalized =
hex.length === 4
? `#${hex[1]}${hex[1]}${hex[2]}${hex[2]}${hex[3]}${hex[3]}`
: hex;
const r = Number.parseInt(normalized.slice(1, 3), 16);
const g = Number.parseInt(normalized.slice(3, 5), 16);
const b = Number.parseInt(normalized.slice(5, 7), 16);
return `${r} ${g} ${b}`;
};
const extractPropertyInitialValues = (css: string) => {
const values = new Map<string, string>();
for (const match of css.matchAll(/@property\s+(--[\w-]+)\s*\{([^{}]*)\}/g)) {
const initialValue = match[2].match(/initial-value\s*:\s*([^;]+)\s*;?/);
if (initialValue) {
values.set(match[1], initialValue[1].trim());
}
}
return values;
};
const findMatchingParen = (text: string, openIndex: number) => {
let depth = 0;
for (let index = openIndex; index < text.length; index += 1) {
const char = text[index];
if (char === "(") {
depth += 1;
} else if (char === ")") {
depth -= 1;
if (depth === 0) {
return index;
}
}
}
};
const splitCssVarArguments = (args: string) => {
let depth = 0;
for (let index = 0; index < args.length; index += 1) {
const char = args[index];
if (char === "(") {
depth += 1;
} else if (char === ")") {
depth -= 1;
} else if (char === "," && depth === 0) {
return [args.slice(0, index).trim(), args.slice(index + 1).trim()];
}
}
return [args.trim()];
};
const resolveCssVars = (
value: string,
vars: Map<string, string>,
seen = new Set<string>()
): string => {
let result = "";
let index = 0;
while (index < value.length) {
const varIndex = value.indexOf("var(", index);
if (varIndex === -1) {
result += value.slice(index);
break;
}
result += value.slice(index, varIndex);
const openIndex = varIndex + "var".length;
const closeIndex = findMatchingParen(value, openIndex);
if (closeIndex === undefined) {
result += value.slice(varIndex);
break;
}
const args = value.slice(openIndex + 1, closeIndex);
const [name, fallback] = splitCssVarArguments(args);
const replacement = vars.get(name);
if (replacement !== undefined && seen.has(name) === false) {
seen.add(name);
result += resolveCssVars(replacement, vars, seen);
seen.delete(name);
} else if (fallback !== undefined) {
result += resolveCssVars(fallback, vars, seen);
} else {
result += value.slice(varIndex, closeIndex + 1);
}
index = closeIndex + 1;
}
return result;
};
const normalizeUnoCssValues = (css: string, finalVars: Map<string, string>) => {
// Wind4 emits rem media queries (e.g. 40rem). Convert to px so existing
// breakpoint mapping code can keep working unchanged.
let normalized = css.replace(
/(min|max)-width:\s*(-?\d*\.?\d+)rem/g,
(_match, range, rem) => {
const px = Number.parseFloat(rem) * 16;
return `${range}-width: ${px}px`;
}
);
normalized = normalized.replace(
/(min|max)-width:\s*calc\((-?\d*\.?\d+)rem\s*-\s*0\.1px\)/g,
(_match, range, rem) => {
const px = Number.parseFloat(rem) * 16 - 0.1;
return `${range}-width: ${px}px`;
}
);
// Inline tracked theme and utility variables so parseCss can resolve computed
// values like calc(var(--spacing) * 2), gradients, and shadow fallbacks.
normalized = resolveCssVars(normalized, finalVars);
// Wind4 uses a leading utility var fallback for typography.
normalized = normalized.replace(
/var\(--tw-leading,\s*([^\)]+)\)/g,
(_match, fallback) => fallback.trim()
);
normalized = normalized.replaceAll("calc(infinity * 1px)", "9999px");
// Resolve wind4's color-mix based opacity pipeline into concrete colors that
// parseCss can read as typed color values.
normalized = normalized.replace(
/color-mix\(in\s+(?:srgb|oklab),\s*var\((--colors-[\w-]+)\)\s+([^,]+),\s*transparent\)/g,
(_match, colorVar: string, opacityExpr: string) => {
const color = finalVars.get(colorVar);
if (color === undefined) {
return _match;
}
const trimmedOpacityExpr = opacityExpr.trim();
let alpha = trimmedOpacityExpr;
const varMatch = trimmedOpacityExpr.match(/^var\((--[\w-]+)\)$/);
if (varMatch) {
alpha = finalVars.get(varMatch[1]) ?? "1";
}
alpha = percentToNumber(alpha);
if (alpha === "1") {
return color;
}
if (color.startsWith("oklch(")) {
return appendAlphaToOklch(color, alpha);
}
if (color.startsWith("#")) {
return `rgb(from ${color} r g b / ${alpha})`;
}
return _match;
}
);
// After variable inlining, remaining color-mix declarations may already have
// concrete colors as the first argument (e.g. #fff or oklch(...)).
normalized = normalized.replace(
/color-mix\(in\s+(?:srgb|oklab),\s*(oklch\([^\)]+\)|#[0-9a-fA-F]{3,8})\s+([^,]+),\s*transparent\)/g,
(_match, color: string, opacityExpr: string) => {
const alpha = percentToNumber(opacityExpr.trim());
if (alpha === "1") {
return color;
}
if (color.startsWith("oklch(")) {
return appendAlphaToOklch(color, alpha);
}
if (color.startsWith("#")) {
return `rgb(from ${color} r g b / ${alpha})`;
}
return _match;
}
);
normalized = normalized.replace(
/rgb\(from\s+(#[0-9a-fA-F]{3,8})\s+r\s+g\s+b\s*\/\s*([^\)]+)\)/g,
(_match, hex: string, alpha: string) => {
const rgb = hexToRgb(hex);
return `rgb(${rgb} / ${alpha.trim()})`;
}
);
// Tailwind v4 emits gradients like `linear-gradient(to bottom right in oklab, ...)`.
// Keep imported gradients broadly renderable and parseable by dropping the
// interpolation color space from the direction argument.
normalized = normalized.replace(
/linear-gradient\((to\s+(?:top|bottom|left|right)(?:\s+(?:top|bottom|left|right))?|[-+]?\d*\.?\d+(?:deg|rad|grad|turn))\s+in\s+(?:srgb|srgb-linear|display-p3|a98-rgb|prophoto-rgb|rec2020|lab|oklab|lch|oklch|xyz(?:-d50|-d65)?),/gi,
"linear-gradient($1,"
);
return normalized;
};
const normalizeUnoCssForWebstudio = (generatedCss: string) => {
// UnoCSS uses the --un-* namespace. Keep generated CSS in Tailwind's
// namespace so custom properties match familiar Tailwind output and existing
// Webstudio styles.
const css = generatedCss.replaceAll("--un-", "--tw-");
// Normalize CSS custom property values: when the same var is declared in
// multiple utility-class rules, replace every occurrence with the value from
// the LAST declaration (the final cascaded value). This allows per-rule
// two-pass pre-collection to see the correct final value regardless of which
// rule a shorthand (e.g. border-color) lives in.
const finalVars = new Map([
...extractPropertyInitialValues(css),
...extractCssCustomProperties(css),
]);
let normalizedCss = normalizeUnoCssValues(css, finalVars);
if (finalVars.size > 0) {
normalizedCss = normalizedCss.replace(/--[\w-]+\s*:[^;{}\n]*/g, (match) => {
const colonIdx = match.indexOf(":");
const propName = match.slice(0, colonIdx).trim();
const finalValue = finalVars.get(propName);
return finalValue !== undefined
? `${propName}: ${resolveCssVars(finalValue, finalVars)}`
: match;
});
}
return { css: normalizedCss, vars: finalVars };
};
export const __testing__ = {
normalizeUnoCssForWebstudio,
};
const isTailwindDefaultBorderColorStyle = (styleDecl: StyleDecl): boolean => {
if (
styleDecl.property.startsWith("border-") === false ||
styleDecl.property.endsWith("-color") === false
) {
return false;
}
const value = styleDecl.value as {
type?: string;
alpha?: number;
components?: number[];
};
if (
value?.type !== "color" ||
value.alpha !== 1 ||
Array.isArray(value.components) === false ||
value.components.length < 3
) {
return false;
}
const [r, g, b] = value.components;
return (
Math.abs(r - 229 / 255) < 0.001 &&
Math.abs(g - 231 / 255) < 0.001 &&
Math.abs(b - 235 / 255) < 0.001
);
};
const stylePropertyGroups: Record<string, Set<StyleProperty>> = {
margin: new Set([
"marginTop",
"marginRight",
"marginBottom",
"marginLeft",
"marginBlockStart",
"marginBlockEnd",
"marginInlineStart",
"marginInlineEnd",
]),
padding: new Set([
"paddingTop",
"paddingRight",
"paddingBottom",
"paddingLeft",
"paddingBlockStart",
"paddingBlockEnd",
"paddingInlineStart",
"paddingInlineEnd",
]),
};
const getStylePropertyGroup = (property: StyleProperty) => {
for (const [groupName, properties] of Object.entries(stylePropertyGroups)) {
if (properties.has(property)) {
return groupName;
}
}
};
const parseTailwindClasses = async (
classes: string,
userBreakpoints: Breakpoint[],
fragmentBreakpoints: Breakpoint[]
) => {
// avoid caching uno generator instance
// to prevent bloating css with preflights from previous calls
const generator = await createUnoGenerator();
let hasColumnGaps = false;
let hasRowGaps = false;
let hasFlexOrGrid = false;
let hasContainer = false;
classes = classes
.split(" ")
.map((item) => {
// Tailwind v4 css variable shorthand: border-(--x)
// UnoCSS doesn't parse this alias directly, so normalize it to
// an explicit arbitrary property utility it understands.
// TODO: remove workaround once fixed https://github.com/unocss/unocss/issues/5188
if (/^border-\(--[\w-]+\)$/.test(item)) {
const varName = item.slice("border-(".length, -1);
return `[border-color:var(${varName})]`;
}
// Tailwind v4 typed arbitrary border color: border-[color:value]
// UnoCSS wind4 incorrectly maps this to border-width. Rewrite to the
// explicit arbitrary property form so it resolves to border-color.
// TODO: remove workaround once fixed https://github.com/unocss/unocss/issues/5188
const borderColorMatch = item.match(/^border-\[color:(.+)\]$/);
if (borderColorMatch) {
return `[border-color:${borderColorMatch[1]}]`;
}
// styles data cannot express space-x and space-y selectors
// with lobotomized owl so replace with gaps
if (item.includes("space-x-")) {
hasColumnGaps = true;
return item.replace("space-x-", "gap-x-");
}
if (item.includes("space-y-")) {
hasRowGaps = true;
return item.replace("space-y-", "gap-y-");
}
hasFlexOrGrid ||= item.endsWith("flex") || item.endsWith("grid");
hasContainer ||= item === "container";
return item;
})
.join(" ");
const generated = await generator.generate(classes);
const { css: normalizedCss, vars: finalVars } = normalizeUnoCssForWebstudio(
generated.css
);
let parsedStyles: StyleDecl[] = [];
// @todo probably builtin in v4
if (normalizedCss.includes("border")) {
// Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116)
// [UnoCSS]: allow to override the default border color with css var `--un-default-border-color`
const reset = `.styles {
border-style: solid;
border-color: var(--tw-default-border-color, #e5e7eb);
border-width: 0;
}`;
parsedStyles.push(...parseCss(reset, new Map()).styles);
}
parsedStyles.push(...parseCss(normalizedCss, finalVars).styles);
parsedStyles = parsedStyles.map((styleDecl) => {
const value = styleDecl.value as {
type?: string;
unit?: string;
value?: number | string;
fallback?: { type?: string; value?: string };
};
const isOpacityProperty =
styleDecl.property === "opacity" ||
styleDecl.property.endsWith("opacity");
if (
isOpacityProperty &&
value.type === "unit" &&
value.unit === "%" &&
typeof value.value === "number"
) {
return {
...styleDecl,
value: {
type: "unit" as const,
unit: "number" as const,
value: value.value / 100,
},
};
}
if (value.type === "unparsed" && typeof value.value === "string") {
const calcMatch = value.value.match(
/^calc\((-?\d*\.?\d+)rem\*(-?\d*\.?\d+)\)$/
);
if (calcMatch) {
return {
...styleDecl,
value: {
type: "unit",
unit: "rem",
value:
Number.parseFloat(calcMatch[1]) * Number.parseFloat(calcMatch[2]),
},
};
}
}
if (
value.type === "var" &&
value.value === "tw-leading" &&
value.fallback?.type === "unparsed" &&
typeof value.fallback.value === "string"
) {
const fallbackMatch = value.fallback.value.match(/^(-?\d*\.?\d+)rem$/);
if (fallbackMatch) {
return {
...styleDecl,
value: {
type: "unit",
unit: "rem",
value: Number.parseFloat(fallbackMatch[1]),
},
};
}
}
return styleDecl;
});
// skip preflights with ::before, ::after and ::backdrop
parsedStyles = parsedStyles.filter(
(styleDecl) =>
!styleDecl.state?.startsWith("::") &&
// Wind4 emits design-token variables in the theme layer. We inline them
// into declarations above, so they don't need to be persisted as styles.
(styleDecl.property.startsWith("--") === false ||
styleDecl.property.startsWith("--tw-"))
);
// setup base breakpoint for container class to avoid hole in ranges
if (hasContainer) {
parsedStyles.unshift({
property: "max-width",
value: { type: "keyword", value: "none" },
});
}
// gaps work only with flex and grid
// so try to use one or another for different axes
if (hasColumnGaps && !hasFlexOrGrid) {
parsedStyles.unshift({
property: "display",
value: { type: "keyword", value: "flex" },
});
}
if (hasRowGaps && !hasFlexOrGrid) {
parsedStyles.unshift(
{
property: "display",
value: { type: "keyword", value: "flex" },
},
{
property: "flex-direction",
value: { type: "keyword", value: "column" },
}
);
}
parsedStyles = adaptBreakpoints(parsedStyles, userBreakpoints);
// container class generates max-width styles for all Tailwind breakpoints
// filter out min-width ones only if user doesn't have min-width breakpoints
const hasUserMinWidthBreakpoints = fragmentBreakpoints.some(
(bp) => bp.minWidth !== undefined
);
if (hasContainer && !hasUserMinWidthBreakpoints) {
parsedStyles = parsedStyles.filter(
(styleDecl) =>
styleDecl.property !== "max-width" ||
!styleDecl.breakpoint?.includes("min-width")
);
}
const newClasses = classes
.split(" ")
.filter((item) => !generated.matched.has(item))
.join(" ");
return { newClasses, parsedStyles };
};
const getUniqueIdForList = <List extends Array<{ id: string }>>(list: List) => {
const existingIds = list.map((item) => item.id);
let index = 0;
while (existingIds.includes(index.toString())) {
index += 1;
}
return index.toString();
};
export const generateFragmentFromTailwind = async (
fragment: WebstudioFragment
): Promise<WebstudioFragment> => {
// lazily create breakpoint
let breakpoints = fragment.breakpoints;
const getBreakpointId = (mediaQuery: undefined | string) => {
if (mediaQuery === undefined) {
let baseBreakpoint = breakpoints.find(isBaseBreakpoint);
if (baseBreakpoint === undefined) {
baseBreakpoint = { id: "base", label: "" };
breakpoints = [...breakpoints];
breakpoints.push(baseBreakpoint);
}
return baseBreakpoint.id;
}
const parsedMediaQuery = parseMediaQuery(mediaQuery);
// unknown breakpoint
if (parsedMediaQuery === undefined) {
return;
}
let breakpoint = breakpoints.find(
(item) =>
item.minWidth === parsedMediaQuery.minWidth &&
item.maxWidth === parsedMediaQuery.maxWidth
);
if (breakpoint === undefined) {
const label = `${parsedMediaQuery.minWidth ?? parsedMediaQuery.maxWidth}`;
breakpoint = {
// make sure new breakpoint id is not conflicted with already defined by fragment
id: getUniqueIdForList(breakpoints),
label,
...parsedMediaQuery,
};
breakpoints = [...breakpoints];
breakpoints.push(breakpoint);
}
return breakpoint.id;
};
const { styleSourceSelections, styleSources, styles } =
createWebstudioDataFromFragment(fragment);
const preflightStyleDeclKeys = new Set<string>();
const getLocalStyleSource = (instanceId: Instance["id"]) => {
const styleSourceSelection = styleSourceSelections.get(instanceId);
const lastStyleSourceId = styleSourceSelection?.values.at(-1);
const lastStyleSource = styleSources.get(lastStyleSourceId ?? "");
return lastStyleSource?.type === "local" ? lastStyleSource : undefined;
};
const createLocalStyleSource = (instanceId: Instance["id"]) => {
const localStyleSource = {
type: "local" as const,
id: `${instanceId}:ws:style`,
};
let styleSourceSelection = structuredClone(
styleSourceSelections.get(instanceId)
);
if (styleSourceSelection === undefined) {
styleSourceSelection = { instanceId, values: [] };
styleSourceSelections.set(instanceId, styleSourceSelection);
}
styleSources.set(localStyleSource.id, localStyleSource);
styleSourceSelection.values.push(localStyleSource.id);
return localStyleSource;
};
const createOrMergeLocalStyles = (
instanceId: Instance["id"],
newStyles: StyleDecl[],
{ skipExisting = false }: { skipExisting?: boolean } = {}
) => {
const localStyleSource =
getLocalStyleSource(instanceId) ?? createLocalStyleSource(instanceId);
const clearedPropertyGroups = new Set<string>();
for (const parsedStyleDecl of newStyles) {
const breakpointId = getBreakpointId(parsedStyleDecl.breakpoint);
// ignore unknown breakpoints
if (breakpointId === undefined) {
continue;
}
const styleDecl = {
breakpointId,
styleSourceId: localStyleSource.id,
state: parsedStyleDecl.state,
property: camelCaseProperty(parsedStyleDecl.property),
value: parsedStyleDecl.value,
};
const styleDeclKey = getStyleDeclKey(styleDecl);
// preflight is a browser reset baseline and must not overwrite
// explicit styles already set from inline style attributes
if (skipExisting && styles.has(styleDeclKey)) {
continue;
}
if (skipExisting === false) {
const propertyGroup = getStylePropertyGroup(styleDecl.property);
const groupProperties =
propertyGroup === undefined
? undefined
: stylePropertyGroups[propertyGroup];
const groupKey =
propertyGroup === undefined
? undefined
: `${styleDecl.styleSourceId}:${styleDecl.breakpointId}:${
styleDecl.state ?? ""
}:${propertyGroup}`;
if (
groupProperties !== undefined &&
groupKey !== undefined &&
clearedPropertyGroups.has(groupKey) === false
) {
clearedPropertyGroups.add(groupKey);
for (const property of groupProperties) {
const groupStyleDeclKey = getStyleDeclKey({
...styleDecl,
property,
});
if (preflightStyleDeclKeys.has(groupStyleDeclKey)) {
styles.delete(groupStyleDeclKey);
preflightStyleDeclKeys.delete(groupStyleDeclKey);
}
}
}
}
styles.delete(styleDeclKey);
styles.set(styleDeclKey, styleDecl);
if (skipExisting) {
preflightStyleDeclKeys.add(styleDeclKey);
}
}
};
for (const instance of fragment.instances) {
const tag = instance.tag;
if (tag && preflight[tag]) {
createOrMergeLocalStyles(instance.id, preflight[tag], {
skipExisting: true,
});
}
}
const props: Prop[] = [];
await Promise.all(
fragment.props.map(async (prop) => {
if (prop.name === "class" && prop.type === "string") {
// always use tailwindBreakpoints for parsing to support all Tailwind classes
// new breakpoints will be created as needed via getBreakpointId
const { newClasses, parsedStyles } = await parseTailwindClasses(
prop.value,
tailwindBreakpoints,
fragment.breakpoints
);
if (parsedStyles.length > 0) {
const localStyleSource = getLocalStyleSource(prop.instanceId);
const filteredStyles = parsedStyles.filter((parsedStyleDecl) => {
if (
localStyleSource === undefined ||
isTailwindDefaultBorderColorStyle(parsedStyleDecl) === false
) {
return true;
}
const breakpointId = getBreakpointId(parsedStyleDecl.breakpoint);
if (breakpointId === undefined) {
return true;
}
const existingStyleKey = getStyleDeclKey({
breakpointId,
styleSourceId: localStyleSource.id,
state: parsedStyleDecl.state,
property: camelCaseProperty(parsedStyleDecl.property),
});
// Keep authored inline border colors if present.
return styles.has(existingStyleKey) === false;
});
createOrMergeLocalStyles(prop.instanceId, filteredStyles);
if (newClasses.length > 0) {
props.push({ ...prop, value: newClasses });
}
}
return;
}
props.push(prop);
})
);
return {
...fragment,
props,
breakpoints,
styleSources: Array.from(styleSources.values()),
styleSourceSelections: Array.from(styleSourceSelections.values()),
styles: Array.from(styles.values()),
};
};