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