Files
Oleg Isonen 05706b5425 feat: Make Content Block templates the MDX authoring interface (#6307)
Closes #6306

## Outcome

Content Block templates are now the public component model for MDX
authoring.

- Standard Markdown resolves through matching Content Block templates.
- PascalCase JSX resolves to a named template first, then to a
registered component.
- Adding or changing a template immediately affects matching MDX,
including content authored before that template existed.
- Every supported standard MDX element is present in new Content Blocks
by default.
- Template `name` is a stable PascalCase JSX identifier. `label` remains
optional display text only.
- Lowercase JSX represents native HTML and SVG without exposing
`ws.element` in authored syntax.
- Unknown, ambiguous, and unsafe JSX stays in source, produces a
source-ranged Builder diagnostic, and is omitted from published output.
- Frontmatter images preserve exact `$ref` values and use the Asset
description for `alt` text.
- Keyboard and mouse insertion from the slash menu remove the trigger
slash and preserve the selected template.

## Public MDX contract

- Markdown is canonical for standard prose.
- Lowercase JSX creates native elements:
`<section><p>Text</p></section>`.
- PascalCase JSX uses templates and registered components:
`<PromotionCard />`, `<Heading tag="h1">Title</Heading>`, and `<YouTube
src="https://youtu.be/KI5JHpzBK1s" />`.
- Component-name collisions use stable qualified identifiers such as
`Checkbox` and `RadixCheckbox`.
- A self-closing template keeps its designed default subtree. An
explicitly paired empty template clears it.
- Static quoted values and bare booleans are supported.
- Imports, exports, expressions, spreads, functions, executable
JavaScript, unsafe attributes, `<$.*>`, and authored `<ws.element>` are
rejected.
- `ws` remains only for compiler primitives with no React component.

## Implementation

- [x] Add stable, unique template names and keep them separate from
labels.
- [x] Parse and validate Markdown and JSX through their ASTs with
precise source ranges.
- [x] Share component identity between template JSX, Content MDX,
Builder materialization, reload, copy, and publication.
- [x] Resolve templates live after add, edit, rename, reorder, removal,
undo/redo, collaboration updates, and reload.
- [x] Preserve authored props, children, Asset references, template
defaults, and the exact slash-menu template choice.
- [x] Make one typed MDX semantic inventory drive default templates and
slash-menu eligibility.
- [x] Move component-dependent template assembly into the component
registry.
- [x] Replace production `$`, `animation`, and `radix` JSX namespaces
with direct component imports.
- [x] Keep only explicit compiler primitives in `ws` template JSX.
- [x] Migrate repository templates, fixtures, stories, examples, MCP
guidance, CLI guidance, and generated outputs.
- [x] Add Builder unit and Playwright coverage for authoring,
diagnostics, insertion, synchronization, reload, and publication.
- [x] Review and update the authoritative Content Block and generated
MCP documentation.

## Compatibility

- Persisted project data continues to use namespaced component IDs and
`ws:element` for generic HTML elements. No project-data migration is
required.
- Legacy `<ws.element ws:tag="…">`, `<ws.element ws:name="…">`, `<$.*>`,
`<animation.*>`, and `<radix.*>` input remains readable during the
compatibility window.
- Current serializers and repository-owned sources emit only canonical
Markdown, lowercase intrinsic JSX, or PascalCase identifiers.
- Untouched user MDX is not rewritten automatically. Legacy syntax is
canonicalized when its owning source is edited.
- Deferred follow-up: remove legacy namespace readers and proxy support
only after the canonical writer is deployed and existing projects are
backfilled. This is intentionally outside this PR.

## Verification

- [x] CI package tests, typechecks, lint, package boundaries, generated
assets, migrations, and size checks pass.
- [x] All six Builder E2E shards pass.
- [x] Fixture checks pass on Ubuntu and Windows.
- [x] Targeted local Content Engine, Template, SDK, registry, Project
Build, migration, Builder, component, CLI, and Playwright tests pass.
- [x] Runtime contracts, CLI docs, and MCP docs were regenerated from
their owning sources.
- [x] Local fixture generation completed without an unexpected fixture
diff.
- [x] Documentation impact reviewed and included in this PR.
- [x] Review and approve the intentional List visual snapshot change.
- [x] Run the complete agent evaluation suite after explicit approval.
- [x] Run the network fixture link/sync pipeline against the matching PR
deployment after explicit permission.

## Known visual change

The generated List story now targets its actual default `ul` element
instead of `ol`, so the default list styling appears in the visual
snapshot. The visual change was reviewed and the approved visual check
passes.
2026-09-06 15:19:54 +02:00

245 lines
5.4 KiB
TypeScript

import { test, expect } from "vitest";
import {
createTemplateComponentFixture,
renderTemplate,
ws,
} from "@webstudio-is/template";
import { __testing__ } from "./plugin-markdown";
const Image = createTemplateComponentFixture("Image");
const { parse } = __testing__;
test("paragraph", () => {
expect(parse("xyz")).toEqual(
renderTemplate(<ws.element ws:tag="p">xyz</ws.element>)
);
});
test("h1", () => {
expect(parse("# heading")).toEqual(
renderTemplate(<ws.element ws:tag="h1">heading</ws.element>)
);
});
test("h6", () => {
expect(parse("###### heading")).toEqual(
renderTemplate(<ws.element ws:tag="h6">heading</ws.element>)
);
});
test("bold 1", () => {
expect(parse("__bold__")).toEqual(
renderTemplate(
<ws.element ws:tag="p">
<ws.element ws:tag="strong">bold</ws.element>
</ws.element>
)
);
});
test("bold 2", () => {
expect(parse("**bold**")).toEqual(
renderTemplate(
<ws.element ws:tag="p">
<ws.element ws:tag="strong">bold</ws.element>
</ws.element>
)
);
});
test("italic 1", () => {
expect(parse("_italic_")).toEqual(
renderTemplate(
<ws.element ws:tag="p">
<ws.element ws:tag="em">italic</ws.element>
</ws.element>
)
);
});
test("italic 2", () => {
expect(parse("*italic*")).toEqual(
renderTemplate(
<ws.element ws:tag="p">
<ws.element ws:tag="em">italic</ws.element>
</ws.element>
)
);
});
test("link", () => {
expect(parse('[link](/uri "Title")')).toEqual(
renderTemplate(
<ws.element ws:tag="p">
<ws.element ws:tag="a" href="/uri" title="Title">
link
</ws.element>
</ws.element>
)
);
});
test("autolink", () => {
expect(parse("https://github.com")).toEqual(
renderTemplate(
<ws.element ws:tag="p">
<ws.element ws:tag="a" href="https://github.com">
https://github.com
</ws.element>
</ws.element>
)
);
});
test("image", () => {
expect(parse('![foo](/url "title")')).toEqual(
renderTemplate(
<ws.element ws:tag="p">
<Image src="/url" alt="foo" title="title" />
</ws.element>
)
);
});
test("hard line break", () => {
expect(
parse(`foo
baz`)
).toEqual(
renderTemplate(
<ws.element ws:tag="p">
<ws.element ws:tag="span">foo</ws.element>
<ws.element ws:tag="br" />
<ws.element ws:tag="span"> baz</ws.element>
</ws.element>
)
);
});
test("soft line break", () => {
expect(
parse(`foo
baz`)
).toEqual(renderTemplate(<ws.element ws:tag="p">foo baz</ws.element>));
});
test("blockquote", () => {
expect(parse("> bar")).toEqual(
renderTemplate(
<ws.element ws:tag="blockquote">
<ws.element ws:tag="p">bar</ws.element>
</ws.element>
)
);
});
test("inline code", () => {
expect(parse("`foo` and `bar`")).toEqual(
renderTemplate(
<ws.element ws:tag="p">
<ws.element ws:tag="code">foo</ws.element>
<ws.element ws:tag="span"> and </ws.element>
<ws.element ws:tag="code">bar</ws.element>
</ws.element>
)
);
});
test("code", () => {
expect(parse("```js meta\nfoo\nbar\n```")).toEqual(
renderTemplate(
<ws.element ws:tag="pre">
<ws.element ws:tag="code" class="language-js">
{"foo\nbar\n"}
</ws.element>
</ws.element>
)
);
});
test("list unordered", () => {
expect(parse("- one")).toEqual(
renderTemplate(
<ws.element ws:tag="ul">
<ws.element ws:tag="li">one</ws.element>
</ws.element>
)
);
});
test("list ordered", () => {
expect(parse("3. one")).toEqual(
renderTemplate(
<ws.element ws:tag="ol" start={3}>
<ws.element ws:tag="li">one</ws.element>
</ws.element>
)
);
});
test("thematic break | separator", () => {
expect(parse("---")).toEqual(renderTemplate(<ws.element ws:tag="hr" />));
});
test("strikethrough", () => {
expect(parse("~One~\n\n~~two~~")).toEqual(
renderTemplate(
<>
<ws.element ws:tag="p">
<ws.element ws:tag="del">One</ws.element>
</ws.element>
<ws.element ws:tag="p">
<ws.element ws:tag="del">two</ws.element>
</ws.element>
</>
)
);
});
test("preserve spaces between strong and em", () => {
expect(parse("**One** *two* text")).toEqual(
renderTemplate(
<>
<ws.element ws:tag="p">
<ws.element ws:tag="strong">{"One "}</ws.element>
<ws.element ws:tag="em">two</ws.element>
{" text"}
</ws.element>
</>
)
);
});
test("table", () => {
expect(
parse(`
| Header 1 | Header 2 |
|------------|------------|
| Cell 1.1 | Cell 1.2 |
| Cell 2.1 | Cell 2.2 |
`)
).toEqual(
renderTemplate(
<ws.element ws:tag="table">
<ws.element ws:tag="thead">
<ws.element ws:tag="tr">
<ws.element ws:tag="th">Header 1</ws.element>
<ws.element ws:tag="th">Header 2</ws.element>
</ws.element>
</ws.element>
<ws.element ws:tag="tbody">
<ws.element ws:tag="tr">
<ws.element ws:tag="td">Cell 1.1</ws.element>
<ws.element ws:tag="td">Cell 1.2</ws.element>
</ws.element>
<ws.element ws:tag="tr">
<ws.element ws:tag="td">Cell 2.1</ws.element>
<ws.element ws:tag="td">Cell 2.2</ws.element>
</ws.element>
</ws.element>
</ws.element>
)
);
});