mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-10-27 15:16:53 +09:00
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.
434 lines
14 KiB
TypeScript
434 lines
14 KiB
TypeScript
import { describe, expect, test, vi } from "vitest";
|
|
import { atom } from "nanostores";
|
|
import { Store } from "immerhin";
|
|
import { enableMapSet } from "immer";
|
|
import {
|
|
ImmerhinSyncObject,
|
|
NanostoresSyncObject,
|
|
NanoEventsSyncEmitter,
|
|
SyncClient,
|
|
SyncObjectPool,
|
|
type SyncStorage,
|
|
} from "./sync-client";
|
|
|
|
enableMapSet();
|
|
|
|
const createFollowerStore = () => {
|
|
const followerStore = new Store();
|
|
followerStore.register("users", atom(new Map()));
|
|
return followerStore;
|
|
};
|
|
|
|
const createLeaderStore = () => {
|
|
const leaderStore = new Store();
|
|
leaderStore.register(
|
|
"users",
|
|
atom(
|
|
new Map([
|
|
["john", "John Johnson"],
|
|
["frank", "Frank Frankson"],
|
|
])
|
|
)
|
|
);
|
|
return leaderStore;
|
|
};
|
|
|
|
test("synchronize initial state when follower is connected", () => {
|
|
const emitter = new NanoEventsSyncEmitter();
|
|
const leaderStore = createLeaderStore();
|
|
const followerStore = createFollowerStore();
|
|
const leader = new SyncClient({
|
|
role: "leader",
|
|
object: new ImmerhinSyncObject("my-data", leaderStore),
|
|
emitter,
|
|
});
|
|
const follower = new SyncClient({
|
|
role: "follower",
|
|
object: new ImmerhinSyncObject("my-data", followerStore),
|
|
emitter,
|
|
});
|
|
expect(leaderStore.containers.get("users")?.get().size).toEqual(2);
|
|
expect(followerStore.containers.get("users")?.get().size).toEqual(0);
|
|
expect(leaderStore.containers.get("users")?.get()).not.toEqual(
|
|
followerStore.containers.get("users")?.get()
|
|
);
|
|
// first leader and follow is connected later
|
|
leader.connect({ signal: new AbortController().signal });
|
|
follower.connect({ signal: new AbortController().signal });
|
|
expect(leaderStore.containers.get("users")?.get().size).toEqual(2);
|
|
expect(followerStore.containers.get("users")?.get().size).toEqual(2);
|
|
expect(leaderStore.containers.get("users")?.get()).toEqual(
|
|
followerStore.containers.get("users")?.get()
|
|
);
|
|
});
|
|
|
|
test("synchronize initial state when leader is connected", () => {
|
|
const emitter = new NanoEventsSyncEmitter();
|
|
const leaderStore = createLeaderStore();
|
|
const followerStore = createFollowerStore();
|
|
const leader = new SyncClient({
|
|
role: "leader",
|
|
object: new ImmerhinSyncObject("my-data", leaderStore),
|
|
emitter,
|
|
});
|
|
const follower = new SyncClient({
|
|
role: "follower",
|
|
object: new ImmerhinSyncObject("my-data", followerStore),
|
|
emitter,
|
|
});
|
|
expect(leaderStore.containers.get("users")?.get().size).toEqual(2);
|
|
expect(followerStore.containers.get("users")?.get().size).toEqual(0);
|
|
expect(leaderStore.containers.get("users")?.get()).not.toEqual(
|
|
followerStore.containers.get("users")?.get()
|
|
);
|
|
// first follower and leader is connected later
|
|
follower.connect({ signal: new AbortController().signal });
|
|
leader.connect({ signal: new AbortController().signal });
|
|
expect(leaderStore.containers.get("users")?.get().size).toEqual(2);
|
|
expect(followerStore.containers.get("users")?.get().size).toEqual(2);
|
|
expect(leaderStore.containers.get("users")?.get()).toEqual(
|
|
followerStore.containers.get("users")?.get()
|
|
);
|
|
});
|
|
|
|
test("synchronize initial state when one of followers become leader", () => {
|
|
const emitter = new NanoEventsSyncEmitter();
|
|
const leaderStore = createLeaderStore();
|
|
const followerStore = createFollowerStore();
|
|
const leader = new SyncClient({
|
|
role: "follower",
|
|
object: new ImmerhinSyncObject("my-data", leaderStore),
|
|
emitter,
|
|
});
|
|
const follower = new SyncClient({
|
|
role: "follower",
|
|
object: new ImmerhinSyncObject("my-data", followerStore),
|
|
emitter,
|
|
});
|
|
leader.connect({ signal: new AbortController().signal });
|
|
follower.connect({ signal: new AbortController().signal });
|
|
expect(leader.role).toEqual("follower");
|
|
expect(leaderStore.containers.get("users")?.get().size).toEqual(2);
|
|
expect(followerStore.containers.get("users")?.get().size).toEqual(0);
|
|
leader.lead();
|
|
expect(leader.role).toEqual("leader");
|
|
expect(leaderStore.containers.get("users")?.get().size).toEqual(2);
|
|
expect(followerStore.containers.get("users")?.get().size).toEqual(2);
|
|
expect(leaderStore.containers.get("users")?.get()).toEqual(
|
|
followerStore.containers.get("users")?.get()
|
|
);
|
|
});
|
|
|
|
test("exchange transactions between leader and follower", async () => {
|
|
const emitter = new NanoEventsSyncEmitter();
|
|
const leaderStore = createLeaderStore();
|
|
const followerStore = createFollowerStore();
|
|
const leader = new SyncClient({
|
|
role: "leader",
|
|
object: new ImmerhinSyncObject("my-data", leaderStore),
|
|
emitter,
|
|
});
|
|
const follower = new SyncClient({
|
|
role: "follower",
|
|
object: new ImmerhinSyncObject("my-data", followerStore),
|
|
emitter,
|
|
});
|
|
leader.connect({ signal: new AbortController().signal });
|
|
follower.connect({ signal: new AbortController().signal });
|
|
leaderStore.createTransaction(
|
|
[leaderStore.containers.get("users")!],
|
|
(users) => {
|
|
users.set("james", "James Jameson");
|
|
}
|
|
);
|
|
expect(leaderStore.containers.get("users")?.get().size).toEqual(3);
|
|
expect(followerStore.containers.get("users")?.get().size).toEqual(3);
|
|
followerStore.createTransaction(
|
|
[followerStore.containers.get("users")!],
|
|
(users) => {
|
|
users.set("mark", "Mark Grayson");
|
|
}
|
|
);
|
|
expect(leaderStore.containers.get("users")?.get().size).toEqual(4);
|
|
expect(followerStore.containers.get("users")?.get().size).toEqual(4);
|
|
expect(leaderStore.containers.get("users")?.get()).toEqual(
|
|
followerStore.containers.get("users")?.get()
|
|
);
|
|
});
|
|
|
|
test("recreates transaction values in the receiving realm", () => {
|
|
const store = createFollowerStore();
|
|
const object = new ImmerhinSyncObject("my-data", store);
|
|
const foreignValue = Object.assign(Object.create({}), {
|
|
"@context": "https://schema.org",
|
|
});
|
|
|
|
object.applyTransaction({
|
|
id: "transaction-id",
|
|
object: "my-data",
|
|
payload: [
|
|
{
|
|
namespace: "users",
|
|
patches: [{ op: "add", path: ["jsonLd"], value: foreignValue }],
|
|
revisePatches: [{ op: "remove", path: ["jsonLd"] }],
|
|
},
|
|
],
|
|
});
|
|
|
|
const receivedValue = store.containers.get("users")?.get().get("jsonLd");
|
|
expect(receivedValue).toEqual({ "@context": "https://schema.org" });
|
|
expect(Object.getPrototypeOf(receivedValue)).toBe(Object.prototype);
|
|
});
|
|
|
|
test("reports the rejected changes when a transaction is reverted", () => {
|
|
const store = createFollowerStore();
|
|
const onRevert = vi.fn();
|
|
const object = new ImmerhinSyncObject("my-data", store, { onRevert });
|
|
object.applyTransaction({
|
|
id: "transaction-id",
|
|
object: "my-data",
|
|
payload: [
|
|
{
|
|
namespace: "users",
|
|
patches: [{ op: "add", path: ["james"], value: "James Jameson" }],
|
|
revisePatches: [{ op: "remove", path: ["james"] }],
|
|
},
|
|
],
|
|
});
|
|
|
|
object.revertTransaction({ id: "transaction-id", object: "my-data" });
|
|
|
|
expect(store.containers.get("users")?.get().has("james")).toBe(false);
|
|
expect(onRevert).toHaveBeenCalledExactlyOnceWith([
|
|
{
|
|
namespace: "users",
|
|
patches: [{ op: "add", path: ["james"], value: "James Jameson" }],
|
|
revisePatches: [{ op: "remove", path: ["james"] }],
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("exchange transactions between followers", async () => {
|
|
const emitter = new NanoEventsSyncEmitter();
|
|
const follower1Store = createFollowerStore();
|
|
const follower2Store = createFollowerStore();
|
|
const follower1 = new SyncClient({
|
|
role: "follower",
|
|
object: new ImmerhinSyncObject("my-data", follower1Store),
|
|
emitter,
|
|
});
|
|
const follower2 = new SyncClient({
|
|
role: "follower",
|
|
object: new ImmerhinSyncObject("my-data", follower2Store),
|
|
emitter,
|
|
});
|
|
follower1.connect({ signal: new AbortController().signal });
|
|
follower2.connect({ signal: new AbortController().signal });
|
|
follower1Store.createTransaction(
|
|
[follower1Store.containers.get("users")!],
|
|
(users) => {
|
|
users.set("james", "James Jameson");
|
|
}
|
|
);
|
|
expect(follower1Store.containers.get("users")?.get().size).toEqual(1);
|
|
expect(follower2Store.containers.get("users")?.get().size).toEqual(1);
|
|
follower2Store.createTransaction(
|
|
[follower2Store.containers.get("users")!],
|
|
(users) => {
|
|
users.set("mark", "Mark Grayson");
|
|
}
|
|
);
|
|
expect(follower1Store.containers.get("users")?.get().size).toEqual(2);
|
|
expect(follower2Store.containers.get("users")?.get().size).toEqual(2);
|
|
expect(follower1Store.containers.get("users")?.get()).toEqual(
|
|
follower2Store.containers.get("users")?.get()
|
|
);
|
|
});
|
|
|
|
test("support pool of objects", () => {
|
|
const store1 = createFollowerStore();
|
|
const store2 = createFollowerStore();
|
|
const leader = new SyncClient({
|
|
role: "leader",
|
|
object: new SyncObjectPool([
|
|
new ImmerhinSyncObject("store1", store1),
|
|
new ImmerhinSyncObject("store2", store2),
|
|
]),
|
|
});
|
|
leader.connect({ signal: new AbortController().signal });
|
|
store1.createTransaction([store1.containers.get("users")!], (users) => {
|
|
users.set("james", "James Jameson");
|
|
});
|
|
store2.createTransaction([store2.containers.get("users")!], (users) => {
|
|
users.set("mark", "Mark Grayson");
|
|
});
|
|
expect(store1.containers.get("users")?.get()).toEqual(
|
|
new Map([["james", "James Jameson"]])
|
|
);
|
|
expect(store2.containers.get("users")?.get()).toEqual(
|
|
new Map([["mark", "Mark Grayson"]])
|
|
);
|
|
});
|
|
|
|
test("merge state in pool object to partially restore from storage", () => {
|
|
const store1 = atom(0);
|
|
const store2 = atom(1);
|
|
const objectPool = new SyncObjectPool([
|
|
new NanostoresSyncObject("store1", store1),
|
|
new NanostoresSyncObject("store2", store2),
|
|
]);
|
|
objectPool.setState(new Map([["store1", 2]]));
|
|
expect(store1.get()).toEqual(2);
|
|
expect(store2.get()).toEqual(1);
|
|
});
|
|
|
|
describe("nanostores sync object", () => {
|
|
test("sync initial state and exchange transactions", () => {
|
|
const emitter = new NanoEventsSyncEmitter();
|
|
const $leader = atom(1);
|
|
const $follower = atom(2);
|
|
new SyncClient({
|
|
role: "leader",
|
|
object: new NanostoresSyncObject("my-data", $leader),
|
|
emitter,
|
|
}).connect({ signal: new AbortController().signal });
|
|
new SyncClient({
|
|
role: "follower",
|
|
object: new NanostoresSyncObject("my-data", $follower),
|
|
emitter,
|
|
}).connect({ signal: new AbortController().signal });
|
|
expect($leader.get()).toEqual(1);
|
|
expect($follower.get()).toEqual(1);
|
|
$leader.set(3);
|
|
expect($leader.get()).toEqual(3);
|
|
expect($follower.get()).toEqual(3);
|
|
$follower.set(5);
|
|
expect($leader.get()).toEqual(5);
|
|
expect($follower.get()).toEqual(5);
|
|
});
|
|
|
|
test("set received state and access it", () => {
|
|
const $store = atom(1);
|
|
const object = new NanostoresSyncObject("nanostores", $store);
|
|
expect($store.get()).toEqual(1);
|
|
expect(object.getState()).toEqual(1);
|
|
object.setState(2);
|
|
expect($store.get()).toEqual(2);
|
|
expect(object.getState()).toEqual(2);
|
|
});
|
|
|
|
test("send new store value as transaction", () => {
|
|
const $store = atom(1);
|
|
const object = new NanostoresSyncObject("nanostores", $store);
|
|
const sendTransaction = vi.fn();
|
|
object.subscribe(sendTransaction, new AbortController().signal);
|
|
$store.set(2);
|
|
expect(sendTransaction).toBeCalledTimes(1);
|
|
expect(sendTransaction).toBeCalledWith({
|
|
id: expect.any(String),
|
|
object: "nanostores",
|
|
payload: 2,
|
|
});
|
|
});
|
|
|
|
test("prevent sending back received state", () => {
|
|
const $store = atom(1);
|
|
const object = new NanostoresSyncObject("nanostores", $store);
|
|
const sendTransaction = vi.fn();
|
|
object.subscribe(sendTransaction, new AbortController().signal);
|
|
expect(sendTransaction).toBeCalledTimes(0);
|
|
object.setState(2);
|
|
expect(sendTransaction).toBeCalledTimes(0);
|
|
});
|
|
|
|
test("prevent sending back applied transactions", () => {
|
|
const $store = atom(1);
|
|
const object = new NanostoresSyncObject("nanostores", $store);
|
|
const sendTransaction = vi.fn();
|
|
object.subscribe(sendTransaction, new AbortController().signal);
|
|
expect(sendTransaction).toBeCalledTimes(0);
|
|
object.applyTransaction({
|
|
id: "my-transaction",
|
|
object: "nanostores",
|
|
payload: 2,
|
|
});
|
|
expect(sendTransaction).toBeCalledTimes(0);
|
|
});
|
|
});
|
|
|
|
describe("storages", () => {
|
|
class TestStorage implements SyncStorage {
|
|
name = "TestStorage";
|
|
value: undefined | number;
|
|
constructor(value: undefined | number) {
|
|
this.value = value;
|
|
}
|
|
sendTransaction = vi.fn();
|
|
subscribe(setState: (state: unknown) => void) {
|
|
setState(this.value);
|
|
}
|
|
}
|
|
|
|
test("get initial state from storage", () => {
|
|
const $store = atom(0);
|
|
const client = new SyncClient({
|
|
role: "leader",
|
|
object: new NanostoresSyncObject("nanostores", $store),
|
|
storages: [new TestStorage(1)],
|
|
});
|
|
client.connect({ signal: new AbortController().signal });
|
|
expect($store.get()).toEqual(1);
|
|
});
|
|
|
|
test("fallback to current state when storage is empty", () => {
|
|
const $store = atom(0);
|
|
const client = new SyncClient({
|
|
role: "leader",
|
|
object: new NanostoresSyncObject("nanostores", $store),
|
|
storages: [new TestStorage(undefined)],
|
|
});
|
|
client.connect({ signal: new AbortController().signal });
|
|
expect($store.get()).toEqual(0);
|
|
});
|
|
|
|
test("get state from the first non-empty storage", () => {
|
|
const $store = atom(0);
|
|
const client = new SyncClient({
|
|
role: "leader",
|
|
object: new NanostoresSyncObject("nanostores", $store),
|
|
storages: [
|
|
new TestStorage(undefined),
|
|
new TestStorage(1),
|
|
new TestStorage(2),
|
|
],
|
|
});
|
|
client.connect({ signal: new AbortController().signal });
|
|
expect($store.get()).toEqual(1);
|
|
});
|
|
|
|
test("send transactions to all provided storages", () => {
|
|
const $store = atom(0);
|
|
const storage1 = new TestStorage(undefined);
|
|
const storage2 = new TestStorage(undefined);
|
|
const client = new SyncClient({
|
|
role: "leader",
|
|
object: new NanostoresSyncObject("nanostores", $store),
|
|
storages: [storage1, storage2],
|
|
});
|
|
client.connect({ signal: new AbortController().signal });
|
|
$store.set(1);
|
|
expect(storage1.sendTransaction).toBeCalledTimes(1);
|
|
expect(storage1.sendTransaction).toHaveBeenCalledWith({
|
|
id: expect.any(String),
|
|
object: "nanostores",
|
|
payload: 1,
|
|
});
|
|
expect(storage2.sendTransaction).toBeCalledTimes(1);
|
|
expect(storage2.sendTransaction).toHaveBeenCalledWith({
|
|
id: expect.any(String),
|
|
object: "nanostores",
|
|
payload: 1,
|
|
});
|
|
});
|
|
});
|