refactor(builder): extract shared tree-flattener into craft-tree.ts
PageContext.treeToState, apply-ai-response.buildNodeTree, and the dead
serializeTreeForCraft each re-implemented the same ColumnLayout-linkedNodes +
SHELL_INNER walk and had drifted (buildNodeTree gained guards the others
lacked). Move sanitizeAiTree (resolvedName allowlist + id/ROOT repair) here
too and add flattenTreeForCraft, the shared structural walk (linkedNodes,
SHELL_INNER wrapping, style:[]->{} normalization) both real callers will
consolidate onto.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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>): 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<string, unknown> },
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 <Element canvas> 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 <Element id="<key>" is={Container}>.
|
||||
* 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<string, string> = {
|
||||
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<string> = 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<string, unknown>;
|
||||
displayName: string | undefined;
|
||||
custom: Record<string, unknown>;
|
||||
hidden: boolean;
|
||||
parent: string | null;
|
||||
nodes: string[];
|
||||
linkedNodes: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface FlattenTreeResult {
|
||||
rootNodeId: string;
|
||||
nodes: Record<string, FlatCraftNode>;
|
||||
/** 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<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string, FlatCraftNode> = {};
|
||||
const syntheticIds = new Set<string>();
|
||||
|
||||
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<string, unknown> = { ...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<string, string> = {};
|
||||
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
|
||||
// <Element id="<key>" is={Container} canvas> ... </Element>. 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
|
||||
|
||||
Reference in New Issue
Block a user