fix(builder): guard PageContext.treeToState against unknown resolvedNames
treeToState had no resolvedName allowlist, so an AI `replace` response (scope site/page/header/footer, reaches this function via actions.deserialize with no further validation downstream) containing an unknown component could produce a state that throws at deserialize/render. Extract the core transform into standalone treeToCraftState (directly unit-testable without mounting a Craft.js Editor), route it through sanitizeAiTree for the same resolvedName allowlist + id/ROOT repair buildNodeTree already had, and fall back to the empty canvas if the tree's own root is invalid. Delegate the structural walk to the shared flattenTreeForCraft extracted in the previous commit. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+48
-124
@@ -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 <Element canvas id="…"> 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<string>(['Container']);
|
||||
|
||||
/** Shells that wrap their content in a single <Element id="<key>" is={Container}>.
|
||||
* 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<string, string> = {
|
||||
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 `<Editor>`.
|
||||
*/
|
||||
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<string, FlatCraftNode> = 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<string, unknown> = {};
|
||||
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<string, unknown> = { ...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<string, string> = {};
|
||||
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
|
||||
// <Element id="<key>" is={Container} canvas> ... </Element>. 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 (
|
||||
<PageContext.Provider
|
||||
|
||||
Reference in New Issue
Block a user