diff --git a/packages/core/src/spec-validator.test.ts b/packages/core/src/spec-validator.test.ts index e5ee6ca2..9117c97b 100644 --- a/packages/core/src/spec-validator.test.ts +++ b/packages/core/src/spec-validator.test.ts @@ -129,6 +129,80 @@ describe("validateSpec", () => { expect(watchIssue!.elementKey).toBe("root"); }); + it("detects action_in_props (legacy pattern)", () => { + const spec: Spec = { + root: "root", + elements: { + root: { + type: "Button", + props: { label: "Submit", action: "submitForm" }, + children: [], + }, + }, + }; + const result = validateSpec(spec); + expect(result.valid).toBe(false); + expect(result.issues.some((i) => i.code === "action_in_props")).toBe(true); + }); + + it("detects actionParams_in_props (legacy pattern)", () => { + const spec: Spec = { + root: "root", + elements: { + root: { + type: "Button", + props: { + label: "Submit", + action: "submitForm", + actionParams: { formId: "main" }, + }, + children: [], + }, + }, + }; + const result = validateSpec(spec); + expect(result.valid).toBe(false); + expect(result.issues.some((i) => i.code === "actionParams_in_props")).toBe( + true, + ); + }); + + it("detects children_in_props", () => { + const spec: Spec = { + root: "root", + elements: { + root: { + type: "Stack", + props: { children: ["child1", "child2"] }, + children: [], + }, + child1: { type: "Text", props: {}, children: [] }, + child2: { type: "Text", props: {}, children: [] }, + }, + }; + const result = validateSpec(spec); + expect(result.valid).toBe(false); + expect(result.issues.some((i) => i.code === "children_in_props")).toBe( + true, + ); + }); + + it("detects state_in_props", () => { + const spec: Spec = { + root: "root", + elements: { + root: { + type: "Stack", + props: { state: { count: 0 } }, + children: [], + }, + }, + }; + const result = validateSpec(spec); + expect(result.valid).toBe(false); + expect(result.issues.some((i) => i.code === "state_in_props")).toBe(true); + }); + it("detects orphaned elements when checkOrphans is true", () => { const spec: Spec = { root: "root", @@ -247,4 +321,71 @@ describe("autoFixSpec", () => { const { fixes } = autoFixSpec(spec); expect(fixes).toHaveLength(0); }); + + it("moves children from props to element level", () => { + const spec: Spec = { + root: "root", + elements: { + root: { + type: "Stack", + props: { children: ["child1", "child2"] }, + children: [], + }, + child1: { type: "Text", props: {}, children: [] }, + child2: { type: "Text", props: {}, children: [] }, + }, + }; + const { spec: fixed, fixes } = autoFixSpec(spec); + expect( + (fixed.elements.root.props as Record).children, + ).toBeUndefined(); + expect(fixed.elements.root.children).toEqual(["child1", "child2"]); + expect(fixes.some((f) => f.includes('"children"'))).toBe(true); + }); + + it("converts legacy action/actionParams to on.press", () => { + const spec: Spec = { + root: "root", + elements: { + root: { + type: "Button", + props: { + label: "Submit", + action: "submitForm", + actionParams: { formId: "main" }, + }, + children: [], + }, + }, + }; + const { spec: fixed, fixes } = autoFixSpec(spec); + const props = fixed.elements.root.props as Record; + expect(props.action).toBeUndefined(); + expect(props.actionParams).toBeUndefined(); + expect(fixed.elements.root.on).toEqual({ + press: { + action: "submitForm", + params: { formId: "main" }, + }, + }); + expect(fixes.some((f) => f.includes("legacy"))).toBe(true); + }); + + it("converts legacy action without params to on.press", () => { + const spec: Spec = { + root: "root", + elements: { + root: { + type: "Button", + props: { label: "OK", action: "confirm" }, + children: [], + }, + }, + }; + const { spec: fixed, fixes } = autoFixSpec(spec); + expect(fixed.elements.root.on).toEqual({ + press: { action: "confirm" }, + }); + expect(fixes.length).toBeGreaterThan(0); + }); }); diff --git a/packages/core/src/spec-validator.ts b/packages/core/src/spec-validator.ts index a04d2f2d..c6f1217b 100644 --- a/packages/core/src/spec-validator.ts +++ b/packages/core/src/spec-validator.ts @@ -29,7 +29,11 @@ export interface SpecIssue { | "empty_spec" | "on_in_props" | "repeat_in_props" - | "watch_in_props"; + | "watch_in_props" + | "action_in_props" + | "actionParams_in_props" + | "children_in_props" + | "state_in_props"; } /** @@ -162,6 +166,46 @@ export function validateSpec( code: "watch_in_props", }); } + + // 3f. `action` inside props (legacy pattern, should use `on` field) + if (props && "action" in props && props.action !== undefined) { + issues.push({ + severity: "error", + message: `Element "${key}" has "action" inside "props". Use the "on" field instead: { "on": { "press": { "action": "...", "params": {...} } } }`, + elementKey: key, + code: "action_in_props", + }); + } + + // 3g. `actionParams` inside props (legacy pattern, should use `on` field) + if (props && "actionParams" in props && props.actionParams !== undefined) { + issues.push({ + severity: "error", + message: `Element "${key}" has "actionParams" inside "props". Use the "on" field instead: { "on": { "press": { "action": "...", "params": {...} } } }`, + elementKey: key, + code: "actionParams_in_props", + }); + } + + // 3h. `children` inside props (should be a top-level field) + if (props && "children" in props && props.children !== undefined) { + issues.push({ + severity: "error", + message: `Element "${key}" has "children" inside "props". It should be a top-level field on the element (sibling of type/props).`, + elementKey: key, + code: "children_in_props", + }); + } + + // 3i. `state` inside props (should be at spec level or use $state/$bindState) + if (props && "state" in props && props.state !== undefined) { + issues.push({ + severity: "error", + message: `Element "${key}" has "state" inside "props". State should be defined at spec.state level, not inside element props. Use { "$state": "/path" } or { "$bindState": "/path" } for state references.`, + elementKey: key, + code: "state_in_props", + }); + } } // 4. Orphaned elements (optional) @@ -274,6 +318,53 @@ export function autoFixSpec(spec: Spec): { fixes.push(`Moved "watch" from props to element level on "${key}".`); } + // Fix children inside props → move to element level + currentProps = fixed.props as Record | undefined; + if ( + currentProps && + "children" in currentProps && + currentProps.children !== undefined + ) { + const { children, ...restProps } = currentProps; + // Only move if it's an array of strings (element keys) + if ( + Array.isArray(children) && + children.every((c) => typeof c === "string") + ) { + fixed = { + ...fixed, + props: restProps, + children: children as string[], + }; + fixes.push(`Moved "children" from props to element level on "${key}".`); + } + } + + // Fix legacy action/actionParams → convert to on.press + currentProps = fixed.props as Record | undefined; + if ( + currentProps && + "action" in currentProps && + currentProps.action !== undefined + ) { + const { action, actionParams, ...restProps } = currentProps; + const actionBinding = { + action: action as string, + ...(actionParams ? { params: actionParams as Record } : {}), + }; + fixed = { + ...fixed, + props: restProps, + on: { + ...(fixed.on ?? {}), + press: actionBinding, + }, + }; + fixes.push( + `Converted legacy "action"/"actionParams" to "on.press" on "${key}".`, + ); + } + fixedElements[key] = fixed; }