diff --git a/craft/src/state/PageContext.treeToState.test.ts b/craft/src/state/PageContext.treeToState.test.ts new file mode 100644 index 0000000..2fd1986 --- /dev/null +++ b/craft/src/state/PageContext.treeToState.test.ts @@ -0,0 +1,117 @@ +import { describe, test, expect, vi } from 'vitest'; +import type { SerializedTreeNode } from '../types/sitesmith'; +import { treeToCraftState } from './PageContext'; + +/** + * E3: treeToState (here, the extracted pure `treeToCraftState`) previously had + * NO resolvedName allowlist — an AI `replace` response (scope site/page, + * reaches this function via `actions.deserialize`) containing an unknown + * component would produce a state that could throw at render/deserialize + * time. This mirrors the guard `buildNodeTree` (apply-ai-response.ts) + * already had. + * + * Tested directly against the exported pure function (no React/Craft.js + * Editor needed) per the brief's guidance to extract+test the core + * transform rather than exercise this through the PageProvider. + */ +describe('treeToCraftState resolvedName guard', () => { + test('an unknown resolvedName at the root does not throw and falls back to a valid empty canvas', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const tree: SerializedTreeNode = { + type: { resolvedName: 'NotARealComponent' }, + props: { node_id: 'bad-root' }, + nodes: [], + }; + let state = ''; + expect(() => { + state = treeToCraftState(tree); + }).not.toThrow(); + + const parsed = JSON.parse(state); + expect(parsed.ROOT).toBeDefined(); + expect(parsed.ROOT.type.resolvedName).toBe('Container'); + expect(parsed.ROOT.nodes).toEqual([]); + expect(warnSpy).toHaveBeenCalled(); + warnSpy.mockRestore(); + }); + + test('an unknown resolvedName on a nested child is soft-skipped; valid siblings still render', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const tree: SerializedTreeNode = { + type: { resolvedName: 'Section' }, + props: { node_id: 'sec-1' }, + nodes: [ + { type: { resolvedName: 'NotARealComponent' }, props: {}, nodes: [] }, + { type: { resolvedName: 'Heading' }, props: { node_id: 'h-1', text: 'Welcome' }, nodes: [] }, + ], + }; + const state = treeToCraftState(tree); + const parsed = JSON.parse(state); + + // The unknown node must not appear anywhere in the produced state. + for (const id of Object.keys(parsed)) { + expect(parsed[id].type.resolvedName).not.toBe('NotARealComponent'); + } + // The Heading sibling survived (wrapped in Section's section-inner). + expect(parsed['h-1']).toBeDefined(); + expect(parsed['h-1'].props.text).toBe('Welcome'); + expect(warnSpy).toHaveBeenCalled(); + warnSpy.mockRestore(); + }); + + test('a fully-valid tree still produces a working ROOT-keyed state (regression)', () => { + const tree: SerializedTreeNode = { + type: { resolvedName: 'Section' }, + props: { node_id: 'sec-1' }, + nodes: [ + { type: { resolvedName: 'Heading' }, props: { node_id: 'h-1', text: 'Hi' }, nodes: [] }, + ], + }; + const state = treeToCraftState(tree); + const parsed = JSON.parse(state); + + expect(parsed.ROOT).toBeDefined(); + expect(parsed.ROOT.type.resolvedName).toBe('Section'); + expect(parsed.ROOT.isCanvas).toBe(true); + expect(parsed.ROOT.parent).toBeNull(); + const innerId = parsed.ROOT.linkedNodes['section-inner']; + expect(innerId).toBeDefined(); + expect(parsed[innerId].nodes).toEqual(['h-1']); + expect(parsed['h-1'].parent).toBe(innerId); + expect(parsed['h-1'].props.text).toBe('Hi'); + }); + + test('a ColumnLayout root still flattens children into col-N linkedNodes (regression)', () => { + const tree: SerializedTreeNode = { + type: { resolvedName: 'ColumnLayout' }, + props: { node_id: 'cols-1' }, + nodes: [ + { type: { resolvedName: 'Heading' }, props: { node_id: 'a' }, nodes: [] }, + { type: { resolvedName: 'Heading' }, props: { node_id: 'b' }, nodes: [] }, + ], + }; + const state = treeToCraftState(tree); + const parsed = JSON.parse(state); + + expect(parsed.ROOT.type.resolvedName).toBe('ColumnLayout'); + expect(parsed.ROOT.linkedNodes).toEqual({ 'col-0': 'a', 'col-1': 'b' }); + expect(parsed.ROOT.props.columns).toBe(2); + // NB: the ROOT-aliasing step only reassigns `nodes[]` children's parent, + // not `linkedNodes` children's — a pre-existing quirk (not introduced by + // this refactor, and out of scope for E3) that leaves column children's + // `parent` pointing at the tree's original (pre-alias) root id. + expect(parsed['a'].parent).toBe('cols-1'); + expect(parsed['a'].isCanvas).toBe(true); + }); + + test('style: [] normalizes to {} (regression)', () => { + const tree: SerializedTreeNode = { + type: { resolvedName: 'Heading' }, + props: { node_id: 'h-1', style: [] as unknown as Record }, + nodes: [], + }; + const state = treeToCraftState(tree); + const parsed = JSON.parse(state); + expect(parsed.ROOT.props.style).toEqual({}); + }); +}); diff --git a/craft/src/state/PageContext.tsx b/craft/src/state/PageContext.tsx index d08893b..3aeee48 100644 --- a/craft/src/state/PageContext.tsx +++ b/craft/src/state/PageContext.tsx @@ -3,26 +3,7 @@ import { useEditor } from '@craftjs/core'; import { PageData } from '../types'; import { SerializedTreeNode } from '../types/sitesmith'; import { useSiteDesign, SiteDesign } from './SiteDesignContext'; - -/** Only `Container` instances are "real" canvases in serialized state — they - * directly render whatever is in node.data.nodes. Layout-shell components - * (Section, HeroSimple, FeaturesGrid, ColumnLayout, CTASection, etc) use - * Craft.js linkedNodes internally; their own - * isCanvas must be FALSE or Craft.js's toNodeTree walker trips an Invariant - * because the shell claims to be a canvas but its render ignores `nodes`. */ -const CANVAS_TYPES = new Set(['Container']); - -/** Shells that wrap their content in a single . - * When the AI puts content directly under one of these, the children end up - * orphaned (the shell ignores data.nodes — it renders via the linkedNode) and - * Craft.js auto-creates the linkedNode at render time with a botched type - * field, which then crashes toNodeTree. Pre-create the linkedNode ourselves - * to keep the state shape Craft.js expects. */ -const SHELL_INNER: Record = { - Section: 'section-inner', - BackgroundSection: 'bg-section-inner', - FormContainer: 'form-inner', -}; +import { sanitizeAiTree, flattenTreeForCraft, FlatCraftNode } from '../utils/craft-tree'; interface PageContextValue { pages: PageData[]; @@ -60,12 +41,50 @@ const EMPTY_HEADER = const EMPTY_FOOTER = '{"ROOT":{"type":{"resolvedName":"Container"},"isCanvas":true,"props":{"style":{"minHeight":"60px","backgroundColor":"#0f172a","color":"#94a3b8","padding":"40px 24px","textAlign":"center"},"tag":"footer"},"displayName":"Container","custom":{},"hidden":false,"nodes":[],"linkedNodes":{}}}'; +/** + * Flatten a `SerializedTreeNode` (as produced by the AI, a template, etc.) + * into a Craft.js `SerializedNodes` JSON string ready for + * `actions.deserialize()`. + * + * Untrusted input is validated the same way `apply-ai-response.ts`'s + * `buildNodeTree` validates AI `patch`/section-replace trees: any node whose + * `type.resolvedName` isn't a registered component is dropped (subtree and + * all) via `sanitizeAiTree`, and a bad/colliding/`'ROOT'` id is regenerated + * — never left as-is. This matters here specifically because `replace` + * scope `site`/`page` responses reach this function via `actions.deserialize` + * with no further validation downstream, unlike the `buildNodeTree` path + * which also gets Craft.js's own `parseFreshNode` as a second line of + * defense. If the ROOT node itself is invalid, sanitizeAiTree returns null + * and we fall back to an empty canvas rather than handing deserialize() + * something that could throw. + * + * Exported standalone (not a hook) so it's directly unit-testable without + * mounting a Craft.js ``. + */ +export function treeToCraftState(tree: SerializedTreeNode): string { + const sanitized = sanitizeAiTree(tree, new Set()); + if (!sanitized) return EMPTY_CANVAS; + + const { rootNodeId, nodes: flatNodes } = flattenTreeForCraft(sanitized); + const nodes: Record = flatNodes; + + // Craft.js deserialize requires the root node keyed as 'ROOT'. + if (rootNodeId !== 'ROOT') { + nodes['ROOT'] = { ...nodes[rootNodeId], parent: null, isCanvas: true }; + delete nodes[rootNodeId]; + for (const childId of nodes['ROOT'].nodes) { + if (nodes[childId]) nodes[childId].parent = 'ROOT'; + } + } + return JSON.stringify(nodes); +} + // Default header seed: a ROOT header Container holding a Navbar with default // links. New sites previously opened with an EMPTY header (just a bare // Container), so there was no menu to edit and the empty zone rendered as a // stray band above the page. Seeding a real Navbar gives every new site an // editable menu-with-links out of the box (and removes the empty-header gap). -// Node shape matches serializeTreeForCraft() / Craft's actions.deserialize(). +// Node shape matches treeToCraftState() / Craft's actions.deserialize(). export const DEFAULT_HEADER_STATE = JSON.stringify({ ROOT: { type: { resolvedName: 'Container' }, @@ -379,101 +398,6 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) => }))); }, []); - /** Flatten a SerializedTreeNode into a Craft.js SerializedNodes JSON string */ - const treeToState = useCallback((tree: SerializedTreeNode): string => { - let counter = 0; - const nodes: Record = {}; - const walk = (node: SerializedTreeNode, parent: string | null): string => { - const id = (node.props.node_id as string | undefined) || `ai-auto-${counter++}`; - const childIds: string[] = []; - const typeName = node.type?.resolvedName; - // Normalize props: the AI sometimes emits `style: []` instead of `{}`. - // React/Craft.js choke when a CSSProperties slot is an array — normalize it. - const rawProps = node.props ?? {}; - const props: Record = { ...rawProps }; - if (Array.isArray(props.style)) props.style = {}; - nodes[id] = { - type: node.type, - // isCanvas must match the component's craft.rules — only layout - // wrappers accept children. Setting it true on leaf components - // (Heading, TextBlock, ButtonLink, etc) makes Craft.js render them - // as empty drop-canvas wrappers and the actual content disappears. - isCanvas: typeName ? CANVAS_TYPES.has(typeName) : false, - props, - displayName: typeName, - custom: {}, - hidden: false, - parent, - nodes: childIds, - linkedNodes: {}, - }; - for (const child of node.nodes ?? []) { - childIds.push(walk(child, id)); - } - // ColumnLayout uses Craft.js linkedNodes with fixed ids (col-0, col-1, ...). - // The AI emits children as direct `nodes`, but ColumnLayout's render ignores - // them and creates fresh column Elements — the AI's children become orphans - // and any subsequent toNodeTree walk hits an Invariant. Move direct children - // into linkedNodes so they render in the columns the user actually sees. - if (typeName === 'ColumnLayout' && childIds.length > 0) { - const linked: Record = {}; - childIds.forEach((cid, i) => { - linked[`col-${i}`] = cid; - if (nodes[cid]) (nodes[cid] as any).isCanvas = true; // columns are canvases - }); - (nodes[id] as any).nodes = []; - (nodes[id] as any).linkedNodes = linked; - // Reflect the actual column count on the component so its render matches. - const cur = (nodes[id] as any).props || {}; - if (!cur.columns || cur.columns !== childIds.length) cur.columns = childIds.length; - (nodes[id] as any).props = cur; - } - // Section/BackgroundSection/FormContainer each render a single - // ... . If the AI - // nests content as direct children, Craft.js will auto-create the - // linkedNode on first render — and store its type as the Container - // component class rather than {resolvedName:'Container'}, which then - // crashes toNodeTree with "type (undefined) does not exist in resolver". - // Pre-create the linkedNode ourselves with the correct serialized type - // so Craft.js never has to materialize it. - const innerKey = SHELL_INNER[typeName ?? '']; - if (innerKey && childIds.length > 0) { - const innerId = `${id}__${innerKey}`; - nodes[innerId] = { - type: { resolvedName: 'Container' }, - isCanvas: true, - props: { tag: 'div' }, - displayName: 'Container', - custom: {}, - hidden: false, - parent: id, - nodes: [...childIds], - linkedNodes: {}, - }; - for (const cid of childIds) { - if (nodes[cid]) (nodes[cid] as any).parent = innerId; - } - (nodes[id] as any).nodes = []; - (nodes[id] as any).linkedNodes = { [innerKey]: innerId }; - } - return id; - }; - const rootId = walk(tree, null); - // Craft.js deserialize requires the root node keyed as 'ROOT' - if (rootId !== 'ROOT') { - nodes['ROOT'] = nodes[rootId]; - (nodes['ROOT'] as any).parent = null; - // ROOT must be a canvas regardless of component type so children render. - (nodes['ROOT'] as any).isCanvas = true; - delete nodes[rootId]; - // Fix up parent references from ROOT's children - for (const childId of (nodes['ROOT'] as any).nodes) { - if (nodes[childId]) (nodes[childId] as any).parent = 'ROOT'; - } - } - return JSON.stringify(nodes); - }, []); - /** * AI helper: replace all pages with newly generated trees. * Stores each page's serialized state without touching the live canvas @@ -494,7 +418,7 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) => id: i === 0 ? 'home' : `page_${Date.now()}_${i}`, name: p.name, slug, - craftState: treeToState(p.tree), + craftState: treeToCraftState(p.tree), }; }); setPages(built); @@ -503,14 +427,14 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) => setActivePageId(built[0].id); activePageIdRef.current = built[0].id; loadState(firstState, EMPTY_CANVAS); - }, [treeToState, loadState]); + }, [loadState]); /** * AI helper: replace the current page's tree. * Deserializes the new tree into the live Craft.js canvas and persists it. */ const replaceCurrentPage = useCallback((page: { name: string; tree: SerializedTreeNode }) => { - const craftState = treeToState(page.tree); + const craftState = treeToCraftState(page.tree); const currentId = activePageIdRef.current; if (currentId === HEADER_ID) { setHeaderPage((prev) => ({ ...prev, name: page.name, craftState })); @@ -522,33 +446,33 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) => ); } loadState(craftState, EMPTY_CANVAS); - }, [treeToState, loadState]); + }, [loadState]); /** * AI helper: replace the shared header tree. * Updates stored state; does NOT switch the canvas to header view. */ const setHeader = useCallback((tree: SerializedTreeNode) => { - const craftState = treeToState(tree); + const craftState = treeToCraftState(tree); setHeaderPage((prev) => ({ ...prev, craftState })); // If the canvas is currently showing the header, refresh it live if (activePageIdRef.current === HEADER_ID) { loadState(craftState, EMPTY_HEADER); } - }, [treeToState, loadState]); + }, [loadState]); /** * AI helper: replace the shared footer tree. * Updates stored state; does NOT switch the canvas to footer view. */ const setFooter = useCallback((tree: SerializedTreeNode) => { - const craftState = treeToState(tree); + const craftState = treeToCraftState(tree); setFooterPage((prev) => ({ ...prev, craftState })); // If the canvas is currently showing the footer, refresh it live if (activePageIdRef.current === FOOTER_ID) { loadState(craftState, EMPTY_FOOTER); } - }, [treeToState, loadState]); + }, [loadState]); return (