diff --git a/craft/src/utils/craft-tree.test.ts b/craft/src/utils/craft-tree.test.ts index fe6ce06..8d7df6f 100644 --- a/craft/src/utils/craft-tree.test.ts +++ b/craft/src/utils/craft-tree.test.ts @@ -1,6 +1,7 @@ import { describe, test, expect } from 'vitest'; import type { NodeTree, Node } from '@craftjs/core'; -import { regenerateTreeIds } from './craft-tree'; +import type { SerializedTreeNode } from '../types/sitesmith'; +import { regenerateTreeIds, flattenTreeForCraft, sanitizeAiTree } from './craft-tree'; function makeNode(overrides: Partial): Node { return { @@ -147,3 +148,115 @@ describe('regenerateTreeIds', () => { expect(input.nodes['root-1'].data.props).toEqual({ alignment: 'left' }); }); }); + +describe('flattenTreeForCraft', () => { + test('style: [] normalizes to {}', () => { + const tree: SerializedTreeNode = { + type: { resolvedName: 'Heading' }, + props: { node_id: 'h1', style: [] as unknown as Record }, + nodes: [], + }; + const { nodes } = flattenTreeForCraft(tree); + expect(nodes['h1'].props.style).toEqual({}); + }); + + test('a ColumnLayout with direct children flattens them into col-N linkedNodes', () => { + const tree: SerializedTreeNode = { + type: { resolvedName: 'ColumnLayout' }, + props: { node_id: 'cols' }, + nodes: [ + { type: { resolvedName: 'Heading' }, props: { node_id: 'a' }, nodes: [] }, + { type: { resolvedName: 'Heading' }, props: { node_id: 'b' }, nodes: [] }, + ], + }; + const { nodes } = flattenTreeForCraft(tree); + expect(nodes['cols'].nodes).toEqual([]); + expect(nodes['cols'].linkedNodes).toEqual({ 'col-0': 'a', 'col-1': 'b' }); + expect(nodes['cols'].props.columns).toBe(2); + expect(nodes['a'].parent).toBe('cols'); + expect(nodes['a'].isCanvas).toBe(true); + expect(nodes['b'].parent).toBe('cols'); + }); + + test('a Section with direct children wraps them in a synthetic section-inner Container', () => { + const tree: SerializedTreeNode = { + type: { resolvedName: 'Section' }, + props: { node_id: 'sec' }, + nodes: [ + { type: { resolvedName: 'Heading' }, props: { node_id: 'h1' }, nodes: [] }, + ], + }; + const { rootNodeId, nodes, syntheticIds } = flattenTreeForCraft(tree); + expect(rootNodeId).toBe('sec'); + const innerId = nodes['sec'].linkedNodes['section-inner']; + expect(innerId).toBe('sec__section-inner'); + expect(syntheticIds.has(innerId)).toBe(true); + expect(nodes[innerId].nodes).toEqual(['h1']); + expect(nodes[innerId].type.resolvedName).toBe('Container'); + expect(nodes['h1'].parent).toBe(innerId); + expect(nodes['sec'].nodes).toEqual([]); + }); + + test('a small tree with both a ColumnLayout and a SHELL_INNER wrapper flattens the same way regardless of caller intent', () => { + // Same shape both PageContext.treeToCraftState and apply-ai-response's + // buildNodeTree feed through this helper — the walk itself must not care + // which one is calling it. + const tree: SerializedTreeNode = { + type: { resolvedName: 'Section' }, + props: { node_id: 'sec' }, + nodes: [ + { + type: { resolvedName: 'ColumnLayout' }, + props: { node_id: 'cols' }, + nodes: [ + { type: { resolvedName: 'Heading' }, props: { node_id: 'a' }, nodes: [] }, + ], + }, + ], + }; + const run1 = flattenTreeForCraft(tree); + const run2 = flattenTreeForCraft(tree); + expect(run1.rootNodeId).toBe(run2.rootNodeId); + expect(Object.keys(run1.nodes).sort()).toEqual(Object.keys(run2.nodes).sort()); + const innerId = run1.nodes['sec'].linkedNodes['section-inner']; + expect(run1.nodes[innerId].linkedNodes).toEqual({}); + expect(run1.nodes[innerId].nodes).toEqual(['cols']); + expect(run1.nodes['cols'].linkedNodes).toEqual({ 'col-0': 'a' }); + expect(run1.nodes['a'].parent).toBe('cols'); + }); + + test('auto-generates an id when node_id is missing', () => { + const tree: SerializedTreeNode = { type: { resolvedName: 'Heading' }, props: {}, nodes: [] }; + const { rootNodeId, nodes } = flattenTreeForCraft(tree); + expect(typeof rootNodeId).toBe('string'); + expect(nodes[rootNodeId]).toBeDefined(); + }); + + test('isCanvas is true only for Container', () => { + const containerTree: SerializedTreeNode = { type: { resolvedName: 'Container' }, props: { node_id: 'c1' }, nodes: [] }; + const headingTree: SerializedTreeNode = { type: { resolvedName: 'Heading' }, props: { node_id: 'h1' }, nodes: [] }; + expect(flattenTreeForCraft(containerTree).nodes['c1'].isCanvas).toBe(true); + expect(flattenTreeForCraft(headingTree).nodes['h1'].isCanvas).toBe(false); + }); +}); + +describe('sanitizeAiTree (re-exported for treeToCraftState parity with buildNodeTree)', () => { + test('unknown resolvedName at the root is rejected (returns null)', () => { + const tree = { type: { resolvedName: 'NotARealComponent' }, props: {}, nodes: [] }; + expect(sanitizeAiTree(tree, new Set())).toBeNull(); + }); + + test('unknown resolvedName on a nested child drops only that subtree', () => { + const tree = { + type: { resolvedName: 'Section' }, + props: { node_id: 's1' }, + nodes: [ + { type: { resolvedName: 'NotARealComponent' }, props: {}, nodes: [] }, + { type: { resolvedName: 'Heading' }, props: { node_id: 'h1' }, nodes: [] }, + ], + }; + const out = sanitizeAiTree(tree, new Set()); + expect(out!.nodes!.length).toBe(1); + expect(out!.nodes![0].props.node_id).toBe('h1'); + }); +}); diff --git a/craft/src/utils/craft-tree.ts b/craft/src/utils/craft-tree.ts index aa08659..40a0cd8 100644 --- a/craft/src/utils/craft-tree.ts +++ b/craft/src/utils/craft-tree.ts @@ -1,5 +1,229 @@ import type { NodeTree, Node, NodeId } from '@craftjs/core'; import { getRandomId } from '@craftjs/utils'; +import type { SerializedTreeNode } from '../types/sitesmith'; +import { componentResolver } from '../components/resolver'; + +/** Only Container is a "real" Craft.js canvas in serialized state. Layout + * shells (Section/HeroSimple/ColumnLayout/etc) use linkedNodes + * internally — their own node must serialize with isCanvas:false or + * toNodeTree's walker hits an Invariant because the shell claims to be a + * canvas but its render ignores `data.nodes`. */ +export const CANVAS_TYPES = new Set(['Container']); + +/** Shells that wrap their content in a single . + * When a tree 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. */ +export const SHELL_INNER: Record = { + Section: 'section-inner', + BackgroundSection: 'bg-section-inner', + FormContainer: 'form-inner', +}; + +/** True if `name` is a registered component in the resolver (i.e. safe to + * hand to `actions.addNodeTree`/`actions.deserialize` without it throwing + * "does not exist in resolver" at render time). */ +function isKnownResolvedName(name: unknown): name is string { + return typeof name === 'string' && Object.prototype.hasOwnProperty.call(componentResolver, name); +} + +/** + * Validate + sanitize a tree (AI-supplied, or otherwise untrusted) before it + * is handed to `flattenTreeForCraft`/`query.parseFreshNode`/`actions.deserialize`. + * Fails SOFT — never throws: + * + * - **resolvedName allowlist**: any node whose `type.resolvedName` is not a + * key of `componentResolver` is dropped (it and its subtree), with a + * `console.warn`. If the tree's ROOT is itself invalid, this returns + * `null` and the caller must skip the whole op/tree (or substitute a safe + * fallback state). + * - **id policy — REGENERATE (not skip)**: a node id that is `'ROOT'`, + * empty/non-string, or collides with an id already in `usedIds` is + * replaced with a fresh `ai-auto-N` id (and a warning is logged). The + * node itself is kept — only structurally invalid *types* are dropped; + * a merely-bad *id* is repaired so we never lose otherwise-valid content + * over an id collision. + * + * `usedIds` should be seeded with any ids that must not collide (e.g. a live + * document's existing node ids); it is also used internally to keep ids + * unique within the tree being sanitized. + */ +export function sanitizeAiTree( + tree: SerializedTreeNode, + usedIds: Set = new Set(), + idCounter: { n: number } = { n: 0 }, +): SerializedTreeNode | null { + const resolvedName = tree?.type?.resolvedName; + if (!isKnownResolvedName(resolvedName)) { + console.warn(`sitesmith: dropping node with unknown component "${String(resolvedName)}"`); + return null; + } + + const rawId = tree.props?.node_id; + let id = typeof rawId === 'string' ? rawId.trim() : ''; + if (!id) { + id = `ai-auto-${idCounter.n++}`; + } else if (id === 'ROOT') { + console.warn('sitesmith: rejecting AI-supplied node id "ROOT" (reserved); regenerating'); + id = `ai-auto-${idCounter.n++}`; + } else if (usedIds.has(id)) { + console.warn(`sitesmith: AI-supplied node id "${id}" collides with an existing id; regenerating`); + id = `ai-auto-${idCounter.n++}`; + } + // Belt-and-suspenders: keep drawing fresh ids in the (pathological) case + // the freshly generated id itself collides. + while (usedIds.has(id)) id = `ai-auto-${idCounter.n++}`; + usedIds.add(id); + + const children: SerializedTreeNode[] = []; + for (const child of tree.nodes ?? []) { + const sanitizedChild = sanitizeAiTree(child, usedIds, idCounter); + if (sanitizedChild) children.push(sanitizedChild); + } + + return { + type: tree.type, + props: { ...tree.props, node_id: id }, + nodes: children, + }; +} + +/** A single flattened Craft.js serialized-node entry, as produced by + * `flattenTreeForCraft`. Shape matches `@craftjs/core`'s `SerializedNode`. */ +export interface FlatCraftNode { + type: { resolvedName: string }; + isCanvas: boolean; + props: Record; + displayName: string | undefined; + custom: Record; + hidden: boolean; + parent: string | null; + nodes: string[]; + linkedNodes: Record; +} + +export interface FlattenTreeResult { + rootNodeId: string; + nodes: Record; + /** ids of nodes synthesized by the flattener itself (currently: the + * auto-created SHELL_INNER wrapper for Section/BackgroundSection/ + * FormContainer) rather than present in the input tree. Callers that + * materialize real Craft.js nodes (e.g. via `query.parseFreshNode`) need + * to know which ids these are so they can be built directly instead of + * run through the resolver (which would merge in the component's default + * `craft.props`, changing the synthetic wrapper's props). */ + syntheticIds: Set; +} + +/** + * Flatten a `SerializedTreeNode` tree into a flat map of Craft.js + * serialized-node entries, applying the transforms every caller in this + * codebase needs: + * + * - `style: []` -> `{}` (the AI sometimes emits an empty array instead of + * an empty object for a CSSProperties slot; React/Craft.js choke on that). + * - `ColumnLayout` children are moved from `nodes` into fixed `col-0, + * col-1, …` `linkedNodes` (ColumnLayout renders via linkedNodes, not + * direct children — direct children would be silently orphaned). + * - `Section`/`BackgroundSection`/`FormContainer` children are wrapped in a + * single pre-created `Container` linkedNode (see `SHELL_INNER`) so + * Craft.js never has to auto-materialize one at render time. + * + * This function assumes the input tree is already valid (every node's + * `type.resolvedName` is a registered component, and `props.node_id` is a + * unique, non-'ROOT' string where present) — callers should run untrusted + * trees through `sanitizeAiTree` first. Ids are read from `props.node_id`, + * falling back to an auto-generated id if absent. + */ +export function flattenTreeForCraft(tree: SerializedTreeNode): FlattenTreeResult { + let counter = 0; + const nodes: Record = {}; + const syntheticIds = new Set(); + + const walk = (node: SerializedTreeNode, parent: string | null): string => { + const id = (node.props?.node_id as string | undefined) || `ai-auto-${counter++}`; + const typeName = node.type?.resolvedName; + const rawProps = node.props ?? {}; + const props: Record = { ...rawProps }; + if (Array.isArray(props.style)) props.style = {}; + + const childIds: string[] = []; + 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, ...). + // Direct children would be orphaned by ColumnLayout's render (it creates + // fresh column Elements and ignores data.nodes) and any subsequent + // toNodeTree walk would hit an Invariant. Move them into linkedNodes so + // they render in the columns actually shown to the user. + if (typeName === 'ColumnLayout' && childIds.length > 0) { + const linked: Record = {}; + childIds.forEach((cid, i) => { + linked[`col-${i}`] = cid; + if (nodes[cid]) nodes[cid].isCanvas = true; // columns are canvases + }); + nodes[id].nodes = []; + nodes[id].linkedNodes = linked; + const curProps = nodes[id].props; + if (!curProps.columns || curProps.columns !== childIds.length) { + curProps.columns = childIds.length; + } + } + + // Section/BackgroundSection/FormContainer each render a single + // ... . If content + // was nested as direct children, Craft.js would auto-create the + // linkedNode on first render — storing 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: {}, + }; + syntheticIds.add(innerId); + for (const cid of childIds) { + if (nodes[cid]) nodes[cid].parent = innerId; + } + nodes[id].nodes = []; + nodes[id].linkedNodes = { [innerKey]: innerId }; + } + + return id; + }; + + const rootNodeId = walk(tree, null); + return { rootNodeId, nodes, syntheticIds }; +} /** * Returns a deep-cloned NodeTree where every node id in the tree