-
Notifications
You must be signed in to change notification settings - Fork 1
Structure intrinsic MDX elements in Studio #155
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,257 @@ | ||
| # Structured Intrinsic MDX Elements Implementation Plan | ||
|
|
||
| > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. | ||
|
|
||
| **Goal:** Parse lowercase intrinsic MDX/HTML as structured Studio blocks so native wrappers do not hide registered child components. | ||
|
|
||
| **Architecture:** Add a `mdxIntrinsicElement` TipTap node beside `mdxComponent` and keep `mdxRawJsx` only as the unsupported fallback. The MDX AST parser will convert lowercase elements into intrinsic nodes when attributes are representable, the runtime editor will render them with block chrome, and the Markdown serializer will emit lowercase HTML tags. | ||
|
|
||
| **Tech Stack:** Bun, TypeScript, TipTap 3, MDX mdast parser, React node views. | ||
|
|
||
| --- | ||
|
|
||
| ### Task 1: Parser Contract Tests | ||
|
|
||
| **Files:** | ||
| - Modify: `packages/studio/src/lib/markdown-pipeline.test.ts` | ||
|
|
||
| - [ ] **Step 1: Add a failing test for lowercase wrappers containing components** | ||
|
|
||
| Add a test that parses: | ||
|
|
||
| ```mdx | ||
| <div style={{display: "flex", gap: "2rem"}}> | ||
| <Hero headlineLead="AI-native CMS for" /> | ||
| <FeatureGrid headingLead="Built for teams that" /> | ||
| </div> | ||
| ``` | ||
|
|
||
| Expected JSON shape: | ||
|
|
||
| ```ts | ||
| assert.equal(parsed.content?.[0]?.type, "mdxIntrinsicElement"); | ||
| assert.equal(parsed.content?.[0]?.attrs?.tagName, "div"); | ||
| assert.equal(parsed.content?.[0]?.content?.[0]?.type, "mdxComponent"); | ||
| assert.equal(parsed.content?.[0]?.content?.[0]?.attrs?.componentName, "Hero"); | ||
| assert.equal(parsed.content?.[0]?.content?.[1]?.type, "mdxComponent"); | ||
| assert.equal( | ||
| parsed.content?.[0]?.content?.[1]?.attrs?.componentName, | ||
| "FeatureGrid", | ||
| ); | ||
| ``` | ||
|
|
||
| - [ ] **Step 2: Add round-trip coverage for native form elements** | ||
|
|
||
| Add a test that round-trips: | ||
|
|
||
| ```mdx | ||
| <form name="contact"> | ||
| <label> | ||
| Name | ||
| <input type="text" name="name" required /> | ||
| </label> | ||
| <button type="submit">Send</button> | ||
| </form> | ||
| ``` | ||
|
|
||
| Expected: `roundTripMarkdown(source).markdown` contains `<form`, `<label>`, `<input`, and `<button`, and does not contain `mdxRawJsx`. | ||
|
|
||
| - [ ] **Step 3: Run the focused tests and verify failure** | ||
|
|
||
| Run: | ||
|
|
||
| ```bash | ||
| bun test packages/studio/src/lib/markdown-pipeline.test.ts | ||
| ``` | ||
|
|
||
| Expected: the new intrinsic-element tests fail because lowercase elements still parse as `mdxRawJsx`. | ||
|
|
||
| ### Task 2: Intrinsic Node Extension | ||
|
|
||
| **Files:** | ||
| - Create: `packages/studio/src/lib/mdx-intrinsic-element-extension.ts` | ||
| - Modify: `packages/studio/src/lib/editor-extensions.ts` | ||
|
|
||
| - [ ] **Step 1: Implement `MdxIntrinsicElementExtension`** | ||
|
|
||
| Create a TipTap node named `mdxIntrinsicElement` with: | ||
|
|
||
| ```ts | ||
| group: "block"; | ||
| content: "block*"; | ||
| isolating: true; | ||
| selectable: true; | ||
| draggable: true; | ||
| ``` | ||
|
|
||
| Attributes: | ||
|
|
||
| ```ts | ||
| tagName: { default: "" }; | ||
| props: { default: {} }; | ||
| isVoid: { default: false }; | ||
| ``` | ||
|
|
||
| Markdown rendering should serialize: | ||
|
|
||
| ```mdx | ||
| <tagName prop="value" /> | ||
| ``` | ||
|
|
||
| for void nodes and: | ||
|
|
||
| ```mdx | ||
| <tagName prop="value"> | ||
| children | ||
| </tagName> | ||
| ``` | ||
|
|
||
| for wrapper nodes, using the existing `serializeMdxJsxAttributes` helper. | ||
|
|
||
| - [ ] **Step 2: Register the extension** | ||
|
|
||
| Update `createEditorExtensions` so it includes `MdxIntrinsicElementExtension` before `MdxRawJsxExtension` and exposes an override slot like the existing `mdxComponent` and `mdxRawJsx` slots. | ||
|
|
||
| - [ ] **Step 3: Run the focused tests** | ||
|
|
||
| Run: | ||
|
|
||
| ```bash | ||
| bun test packages/studio/src/lib/markdown-pipeline.test.ts | ||
| ``` | ||
|
|
||
| Expected: tests still fail until the parser converts lowercase AST nodes to the new node type. | ||
|
|
||
| ### Task 3: Parser Conversion | ||
|
|
||
| **Files:** | ||
| - Modify: `packages/studio/src/lib/mdx-markdown-parser.ts` | ||
|
|
||
| - [ ] **Step 1: Convert lowercase MDX AST elements to intrinsic nodes** | ||
|
|
||
| Change the non-uppercase branch in `convertMdxJsxElementNode` so lowercase element names with parseable attributes return: | ||
|
|
||
| ```ts | ||
| { | ||
| type: "mdxIntrinsicElement", | ||
| attrs: { | ||
| tagName: name, | ||
| props, | ||
| isVoid, | ||
| }, | ||
| content, | ||
| } | ||
| ``` | ||
|
|
||
| Continue returning `mdxRawJsx` when attribute parsing fails or the name is neither uppercase nor lowercase intrinsic syntax. | ||
|
|
||
| - [ ] **Step 2: Preserve void semantics** | ||
|
|
||
| Treat MDX self-closing syntax and known HTML void elements as void. Void intrinsic nodes must not carry parsed children. | ||
|
|
||
| - [ ] **Step 3: Run the focused tests** | ||
|
|
||
| Run: | ||
|
|
||
| ```bash | ||
| bun test packages/studio/src/lib/markdown-pipeline.test.ts | ||
| ``` | ||
|
|
||
| Expected: parser and round-trip tests pass. | ||
|
|
||
| ### Task 4: Runtime Editor Rendering | ||
|
|
||
| **Files:** | ||
| - Create: `packages/studio/src/lib/runtime-ui/components/editor/mdx-intrinsic-element-node-view.tsx` | ||
| - Modify: `packages/studio/src/lib/runtime-ui/components/editor/tiptap-editor.tsx` | ||
| - Modify: `packages/studio/src/lib/runtime-ui/components/editor/mdx-component-node-view.tsx` | ||
|
|
||
| - [ ] **Step 1: Add an intrinsic node view** | ||
|
|
||
| Render intrinsic nodes with the existing `MdxComponentNodeFrame`, using the label `tagName`, the same collapse/duplicate/delete chrome, and `NodeViewContent` for non-void children. | ||
|
|
||
| - [ ] **Step 2: Render parseable intrinsic HTML as inert native preview** | ||
|
|
||
| For non-void intrinsic elements, wrap the editable content in `createElement(tagName, safeProps, editableSlot)` when safe to do so. For void elements, render `createElement(tagName, safeProps)` inside a `contentEditable={false}` preview surface. | ||
|
|
||
| - [ ] **Step 3: Register the node view** | ||
|
|
||
| Update `TipTapEditor` so `mdxIntrinsicElement` uses `ReactNodeViewRenderer(MdxIntrinsicElementNodeView)`. | ||
|
|
||
| - [ ] **Step 4: Run runtime UI tests affected by component node views** | ||
|
|
||
| Run: | ||
|
|
||
| ```bash | ||
| bun test packages/studio/src/lib/runtime-ui/components/editor/mdx-component-collapse.test.tsx packages/studio/src/lib/runtime-ui/components/editor/mdx-component-selection.test.ts packages/studio/src/lib/markdown-pipeline.test.ts | ||
| ``` | ||
|
|
||
| Expected: tests pass. | ||
|
|
||
| ### Task 5: Selection and Props Panel | ||
|
|
||
| **Files:** | ||
| - Modify: `packages/studio/src/lib/runtime-ui/components/editor/mdx-component-selection.ts` | ||
| - Modify: `packages/studio/src/lib/runtime-ui/components/editor/mdx-props-panel.tsx` | ||
| - Modify: `packages/studio/src/lib/runtime-ui/components/editor/visual-composition-commands.ts` | ||
|
|
||
| - [ ] **Step 1: Generalize selected block metadata** | ||
|
|
||
| Allow `getSelectedMdxComponent` and `updateSelectedMdxComponentProps` to work with `mdxIntrinsicElement` nodes by returning a selection with `kind: "component" | "intrinsic"`. | ||
|
|
||
| - [ ] **Step 2: Show intrinsic props in the side panel** | ||
|
|
||
| Update `MdxPropsPanel` so intrinsic selections show the lowercase tag name and a `VisualStyleInspector`-compatible style editor when the `style` prop exists or can be added. Avoid the unregistered component warning for intrinsic nodes. | ||
|
|
||
| - [ ] **Step 3: Keep existing component commands scoped** | ||
|
|
||
| Only `mdxComponent` nodes should use catalog-only operations such as wrap-in-Box and host component validation. Intrinsic nodes may support duplicate/delete/collapse, but not catalog validation. | ||
|
|
||
| - [ ] **Step 4: Run targeted selection and panel tests** | ||
|
|
||
| Run: | ||
|
|
||
| ```bash | ||
| bun test packages/studio/src/lib/runtime-ui/components/editor/mdx-component-selection.test.ts packages/studio/src/lib/runtime-ui/components/editor/visual-style-inspector.test.ts packages/studio/src/lib/runtime-ui/pages/content-document-page.test.tsx | ||
| ``` | ||
|
|
||
| Expected: tests pass after updating expectations for intrinsic selections. | ||
|
|
||
| ### Task 6: Changeset, Quality, Commit, Push | ||
|
|
||
| **Files:** | ||
| - Create via CLI: `.changeset/*.md` | ||
|
|
||
| - [ ] **Step 1: Create a changeset** | ||
|
|
||
| Run: | ||
|
|
||
| ```bash | ||
| bun run changeset | ||
| ``` | ||
|
|
||
| Select `@mdcms/studio`, patch release, and describe the Studio intrinsic HTML editor support. | ||
|
|
||
| - [ ] **Step 2: Run validation** | ||
|
|
||
| Run: | ||
|
|
||
| ```bash | ||
| bun run format:check | ||
| bun run check | ||
| bun test packages/studio/src/lib/markdown-pipeline.test.ts | ||
| ``` | ||
|
|
||
| Expected: all commands pass. | ||
|
|
||
| - [ ] **Step 3: Commit and push** | ||
|
|
||
| Run: | ||
|
|
||
| ```bash | ||
| git add docs/specs/SPEC-007-editor-mdx-and-collaboration.md docs/specs/SPEC-014-ai-assisted-studio-editing.md .ai/research/2026-05-28-structured-intrinsic-mdx-elements-design.md .ai/plans/2026-05-28-structured-intrinsic-mdx-elements.md packages/studio/src .changeset | ||
| git commit -m "feat(studio): structure intrinsic mdx elements" | ||
| git push -u origin codex/structured-intrinsic-mdx-elements | ||
| ``` | ||
|
|
||
| Expected: branch is pushed with the spec, implementation, tests, and changeset. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,63 @@ | ||
| # Structured Intrinsic MDX Elements Design | ||
|
|
||
| ## Decision | ||
|
|
||
| Studio will treat parseable lowercase intrinsic MDX/HTML elements as structured | ||
| visual blocks instead of raw JSX preview islands. Tags such as `div`, `section`, | ||
| `form`, `label`, `input`, and `button` remain valid MDX, but the editor surface | ||
| represents them as block nodes with a tag name, props, void status, and editable | ||
| children. | ||
|
|
||
| Raw JSX preservation remains only for syntax Studio cannot safely represent as | ||
| structured data. The fallback must be explicit unsupported content, not a | ||
| sanitized partial HTML preview that hides the wrapper tag while showing escaped | ||
| children. | ||
|
|
||
| ## Rationale | ||
|
|
||
| The current raw JSX island behavior preserves valid MDX but creates a confusing | ||
| editing model. A lowercase wrapper like `<div>` can swallow nested registered | ||
| components, causing the wrapper to render as invisible HTML while nested | ||
| PascalCase components appear as escaped text. Editors should see all document | ||
| structure as blocks when it is parseable, including native HTML elements. | ||
|
|
||
| ## Editor Contract | ||
|
|
||
| - Uppercase MDX elements continue to parse as `mdxComponent` nodes. | ||
| - Parseable lowercase MDX/HTML elements parse as `mdxIntrinsicElement` nodes. | ||
| - Intrinsic nodes store `tagName`, `props`, and `isVoid`. | ||
| - Intrinsic nodes can contain parsed Markdown, registered components, built-ins, | ||
| and other intrinsic nodes when the source element is not void. | ||
| - Intrinsic nodes serialize back to lowercase MDX/HTML tags. | ||
| - Intrinsic nodes are not inserted from the default component palette in this | ||
| pass. | ||
| - Intrinsic nodes do not use catalog validation because their contract is native | ||
| HTML syntax, not host component metadata. | ||
| - Unparseable JSX remains preserved as unsupported raw content. | ||
|
|
||
| ## Implementation Shape | ||
|
|
||
| The parser should use the MDX AST that already exposes lowercase elements and | ||
| their children. Instead of returning `mdxRawJsx` for every non-uppercase element, | ||
| it should parse attributes and return `mdxIntrinsicElement` for lowercase tag | ||
| names when attributes are representable. The existing raw JSX extension stays as | ||
| the fallback for cases that cannot be represented. | ||
|
|
||
| The new node extension should mirror the block affordances of `mdxComponent` | ||
| where practical: block group, selectable, isolating, optional content for | ||
| non-void elements, Markdown parse/render hooks, and an editor node view that | ||
| shows the tag label and nested content. The side panel can initially reuse the | ||
| existing prop editing path by exposing the selected node's props and tag name. | ||
|
|
||
| ## Validation | ||
|
|
||
| Targeted tests should prove that: | ||
|
|
||
| - `<div><Hero /></div>` parses into an intrinsic `div` node containing an | ||
| `mdxComponent` `Hero` child. | ||
| - Nested intrinsic elements round-trip through Markdown without becoming raw | ||
| preview text. | ||
| - Native form syntax still round-trips, including void controls such as | ||
| `<input />`. | ||
| - Existing raw fallback tests continue to pass for unsupported/unparseable | ||
| syntax. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "@mdcms/studio": patch | ||
| --- | ||
|
|
||
| Render lowercase MDX/HTML elements as structured Studio blocks. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -256,13 +256,24 @@ deterministic error when `config.components` declares `Box`, `Text`, `Image`, or | |
| `style` prop in the extracted catalog; built-in support does not imply a | ||
| universal wrapper or style injection layer for host components. | ||
|
|
||
| Raw lowercase MDX/HTML elements such as `<div>`, `<form>`, `<label>`, | ||
| `<input>`, and `<button>` remain valid advanced MDX authoring syntax. They are | ||
| not catalog components and are not shown as first-class visual composition | ||
| blocks. Studio preserves them as raw MDX islands, renders an inert preview where | ||
| possible, and serializes them back to their original MDX source. Built-ins are | ||
| therefore the supported visual-editing primitives, not the only HTML that can | ||
| exist in a document. | ||
| Lowercase intrinsic MDX/HTML elements such as `<div>`, `<form>`, `<label>`, | ||
| `<input>`, and `<button>` remain valid advanced MDX authoring syntax. When their | ||
| attributes can be represented as Studio props, Studio parses them into | ||
| first-class visual composition blocks with their tag name, props, void status, | ||
| and editable children. Intrinsic blocks are not catalog components, are not | ||
| shown in the insert component palette by default, and do not participate in host | ||
| component prop-schema validation. They still render in the canvas with the same | ||
| block affordances as MDX components so editors never see a partial raw HTML | ||
| preview as the normal editing surface. | ||
|
|
||
| Studio preserves intrinsic HTML semantics during serialization: an intrinsic | ||
| `div` block serializes as `<div>`, a `form` block serializes as `<form>`, and | ||
| void intrinsic elements such as `<input />` serialize as self-closing lowercase | ||
| HTML. Built-ins are the supported visual-editing primitives for common layout | ||
| and inline content, while intrinsic blocks cover valid native HTML semantics and | ||
| legacy MDX content. Raw MDX islands are a last-resort preservation fallback only | ||
| for unparseable or unsupported syntax; they must be clearly labeled as | ||
| unsupported content rather than rendered as a half-raw inline preview. | ||
|
Comment on lines
+259
to
+276
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Resolve contract conflict on lowercase intrinsic handling. This new section says parseable lowercase intrinsic HTML should be structured blocks, but the later “Children / Nested Content” section still states raw lowercase HTML remains raw-island content. Please align both sections to one behavior to avoid conflicting implementation guidance. 🤖 Prompt for AI Agents |
||
|
|
||
| The legacy `/` slash menu lists only host-registered components and is | ||
| positioned inline near the active cursor. Built-ins remain hidden from that | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fix heading level jump to keep markdown lint-clean.
Line 13 jumps from an H1 section to H3 (
###) without an intermediate H2, which triggers MD001. Change it to##(or insert a missing##parent heading).🧰 Tools
🪛 markdownlint-cli2 (0.22.1)
[warning] 13-13: Heading levels should only increment by one level at a time
Expected: h2; Actual: h3
(MD001, heading-increment)
🤖 Prompt for AI Agents