fix(builder): render template header/footer nested content in zone preview + export
TemplateModal's addTemplateComponents() built each template component via
React.createElement(Component, comp.props) without ever passing
comp.children, silently dropping every nested children array authored in
templates/definitions.ts (header/footer Container > Logo/Menu/TextBlock,
page Section > Heading/TextBlock/ButtonLink). The resulting Craft.js node had
nodes: [], so the header/footer zone preview (ZonePreview -> exportBodyHtml)
rendered as an empty strip, and published output was affected the same way.
Fix converts each TemplateComponent to a SerializedTreeNode and reuses
craft-tree.ts's buildNodeTree (sanitize -> flatten -> materialize) -- the
same tested tree pipeline already used for AI-generated content -- instead
of hand-rolling a React-element tree, since a naive nested-children fix via
parseReactElement crashes any component with an internal SHELL_INNER linked
canvas (Section/BackgroundSection/FormContainer) or linked columns
(ColumnLayout). Also fixes two latent bugs in buildNodeTree itself, only
surfaced by exercising it against a real Craft.js editor for the first time:
data.type must be the actual resolved component reference (not a string or
{resolvedName} object) for correct rendering, and the synthesized SHELL_INNER
node needs data.name set for actions.addNodeTree's own validation.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,14 +1,14 @@
|
||||
import { useEditor } from '@craftjs/core';
|
||||
import type { NodeTree } from '@craftjs/core';
|
||||
import { usePages } from '../state/PageContext';
|
||||
import { SitesmithResponse, SerializedTreeNode, SitesmithPatchOp } from '../types/sitesmith';
|
||||
import { sanitizeAiTree, flattenTreeForCraft } from './craft-tree';
|
||||
import { sanitizeAiTree, buildNodeTree } from './craft-tree';
|
||||
|
||||
// Re-exported for existing callers/tests that import sanitizeAiTree from
|
||||
// this module — the implementation now lives in craft-tree.ts alongside the
|
||||
// shared flattener, since treeToState (PageContext.tsx) needs the same
|
||||
// validation this module pioneered.
|
||||
export { sanitizeAiTree };
|
||||
// Re-exported for existing callers/tests that import sanitizeAiTree/
|
||||
// buildNodeTree from this module — the implementation lives in craft-tree.ts
|
||||
// alongside the shared flattener: `templates/apply-template.ts` needs the
|
||||
// exact same tree-materialization logic to load template header/footer/page
|
||||
// content (see the doc comment on `buildNodeTree` in craft-tree.ts).
|
||||
export { sanitizeAiTree, buildNodeTree };
|
||||
|
||||
/**
|
||||
* update_props may never touch these keys, regardless of what the AI sends.
|
||||
@@ -18,52 +18,6 @@ export { sanitizeAiTree };
|
||||
*/
|
||||
const PROTECTED_UPDATE_PROPS_KEYS = new Set<string>(['node_id']);
|
||||
|
||||
/**
|
||||
* Build a Craft.js `NodeTree` from a `SerializedTreeNode` using `query.parseFreshNode`.
|
||||
* This is the correct way to construct a tree for `actions.addNodeTree()` when
|
||||
* inserting/replacing sections or individual nodes.
|
||||
*/
|
||||
function buildNodeTree(query: any, tree: SerializedTreeNode): NodeTree {
|
||||
// Validate + sanitize at the AI boundary before touching Craft.js at all:
|
||||
// unknown resolvedNames are dropped (never reach parseFreshNode/addNodeTree,
|
||||
// which would throw), and ids are repaired ('ROOT'/empty/colliding →
|
||||
// regenerated) against the live document's existing node ids so a new
|
||||
// AI-supplied id can never clobber or duplicate one already on the canvas.
|
||||
const existingIds = new Set<string>(
|
||||
typeof query?.getNodes === 'function' ? Object.keys(query.getNodes()) : [],
|
||||
);
|
||||
const sanitized = sanitizeAiTree(tree, existingIds);
|
||||
if (!sanitized) {
|
||||
throw new Error('sitesmith: AI tree root has an unknown/invalid component type; nothing to build');
|
||||
}
|
||||
|
||||
// sanitizeAiTree() has already guaranteed every node has a valid, unique,
|
||||
// non-'ROOT' node_id and a registered resolvedName — the shared flattener
|
||||
// just needs to do the structural walk (linkedNodes/SHELL_INNER/style
|
||||
// normalization) common to every tree-flattening caller in this codebase.
|
||||
const flat = flattenTreeForCraft(sanitized);
|
||||
|
||||
const craftNodes: Record<string, any> = {};
|
||||
for (const [id, flatNode] of Object.entries(flat.nodes)) {
|
||||
if (flat.syntheticIds.has(id)) {
|
||||
// The SHELL_INNER wrapper Container is synthesized by the flattener,
|
||||
// not present in the AI's tree — build it directly rather than
|
||||
// through query.parseFreshNode(), which would merge in Container's
|
||||
// default `craft.props` (e.g. default padding) and change its look.
|
||||
craftNodes[id] = {
|
||||
id,
|
||||
data: { ...flatNode },
|
||||
events: { selected: false, hovered: false, dragged: false },
|
||||
rules: { canDrag: () => true, canMoveIn: () => true, canMoveOut: () => true, canDrop: () => true },
|
||||
};
|
||||
} else {
|
||||
craftNodes[id] = (query.parseFreshNode({ id, data: { ...flatNode } }) as any).toNode();
|
||||
}
|
||||
}
|
||||
|
||||
return { rootNodeId: flat.rootNodeId, nodes: craftNodes };
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the Craft.js node id that corresponds to an AI node_id value.
|
||||
* Checks `data.props.node_id` first, then falls back to raw id equality.
|
||||
|
||||
@@ -225,6 +225,105 @@ export function flattenTreeForCraft(tree: SerializedTreeNode): FlattenTreeResult
|
||||
return { rootNodeId, nodes, syntheticIds };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a Craft.js `NodeTree` from a `SerializedTreeNode` using
|
||||
* `query.parseFreshNode`. This is the correct way to construct a tree for
|
||||
* `actions.addNodeTree()` when inserting/replacing sections, individual
|
||||
* nodes, or (via `templates/apply-template.ts`) a template's header/footer/
|
||||
* page content -- anything whose input isn't already a live Craft.js state.
|
||||
*
|
||||
* Originally lived in `apply-ai-response.ts` (the AI-response path pioneered
|
||||
* it); moved here alongside `sanitizeAiTree`/`flattenTreeForCraft` once
|
||||
* `templates/apply-template.ts` needed the exact same tree-materialization
|
||||
* logic — building a node tree by hand via `React.createElement` +
|
||||
* `query.parseReactElement` does NOT correctly route content through a
|
||||
* `Section`/`BackgroundSection`/`FormContainer`'s internal SHELL_INNER
|
||||
* linked canvas or `ColumnLayout`'s linked columns (see the `SHELL_INNER`
|
||||
* comment above): Craft.js would auto-materialize that linked node at
|
||||
* render time with a botched `type` field and crash `toNodeTree` with
|
||||
* "component type (undefined) does not exist in the resolver". Going through
|
||||
* `sanitizeAiTree` + `flattenTreeForCraft` (as this function does) avoids
|
||||
* that entirely.
|
||||
*/
|
||||
export function buildNodeTree(query: any, tree: SerializedTreeNode): NodeTree {
|
||||
// Validate + sanitize at the boundary before touching Craft.js at all:
|
||||
// unknown resolvedNames are dropped (never reach parseFreshNode/addNodeTree,
|
||||
// which would throw), and ids are repaired ('ROOT'/empty/colliding →
|
||||
// regenerated) against the live document's existing node ids so a new
|
||||
// caller-supplied id can never clobber or duplicate one already on the canvas.
|
||||
const existingIds = new Set<string>(
|
||||
typeof query?.getNodes === 'function' ? Object.keys(query.getNodes()) : [],
|
||||
);
|
||||
const sanitized = sanitizeAiTree(tree, existingIds);
|
||||
if (!sanitized) {
|
||||
throw new Error('craft-tree: tree root has an unknown/invalid component type; nothing to build');
|
||||
}
|
||||
|
||||
// sanitizeAiTree() has already guaranteed every node has a valid, unique,
|
||||
// non-'ROOT' node_id and a registered resolvedName — flattenTreeForCraft
|
||||
// just needs to do the structural walk (linkedNodes/SHELL_INNER/style
|
||||
// normalization) common to every tree-flattening caller in this codebase.
|
||||
const flat = flattenTreeForCraft(sanitized);
|
||||
|
||||
const craftNodes: Record<string, any> = {};
|
||||
for (const [id, flatNode] of Object.entries(flat.nodes)) {
|
||||
// Live Craft.js nodes need `data.type` to be the ACTUAL component
|
||||
// reference (the function/class from `componentResolver`) -- not the
|
||||
// `{ resolvedName }` wrapper object `flattenTreeForCraft` produces (that
|
||||
// wrapper is the *serialized* form used by `actions.deserialize()`/
|
||||
// `query.serialize()`, a different Craft.js entry point), and not a
|
||||
// plain resolvedName string either.
|
||||
//
|
||||
// A plain string passes `parseFreshNode`'s own validation (it special-
|
||||
// cases strings) AND `actions.addNodeTree`'s, so that alone looks fine --
|
||||
// but it's wrong for RENDERING: Craft.js's node renderer does
|
||||
// `React.createElement(data.type, props)` directly, so a string type
|
||||
// makes React treat e.g. "Section" as a literal (invalid, lowercase-
|
||||
// expected) HTML tag name instead of resolving it to the real `Section`
|
||||
// component. Symptom: "is using incorrect casing" console warnings and
|
||||
// the component's own render logic (and thus its content) never runs --
|
||||
// reproduced live by loading a template: the header/footer rendered fine
|
||||
// but the page canvas went blank. The `{ resolvedName }` wrapper object
|
||||
// fails even earlier, at `parseFreshNode`/`addNodeTree`'s own validation
|
||||
// ("component type (undefined) does not exist in the resolver"), because
|
||||
// their resolution only accepts a string or a component reference.
|
||||
//
|
||||
// None of this was caught by the pre-existing mocked test for this
|
||||
// function (in apply-ai-response.test.ts) because its fake
|
||||
// `parseFreshNode` just echoes the input back unchanged rather than
|
||||
// exercising Craft.js's real validation/rendering.
|
||||
const resolvedComponent = (componentResolver as Record<string, unknown>)[flatNode.type.resolvedName];
|
||||
|
||||
if (flat.syntheticIds.has(id)) {
|
||||
// The SHELL_INNER wrapper Container is synthesized by the flattener,
|
||||
// not present in the input tree — build it directly rather than
|
||||
// through query.parseFreshNode(), which would merge in Container's
|
||||
// default `craft.props` (e.g. default padding) and change its look.
|
||||
//
|
||||
// `actions.addNodeTree`'s own validation for a non-string `data.type`
|
||||
// checks `resolver[data.name]` (NOT a reverse lookup from `data.type`
|
||||
// like `parseFreshNode` does) -- so `data.name` must be set to the
|
||||
// resolvedName string here too, or this synthetic node fails that
|
||||
// check with "component type (Container) does not exist in the
|
||||
// resolver" the moment `addNodeTree` runs, even though `resolvedComponent`
|
||||
// itself is perfectly valid.
|
||||
craftNodes[id] = {
|
||||
id,
|
||||
data: { ...flatNode, type: resolvedComponent, name: flatNode.type.resolvedName },
|
||||
events: { selected: false, hovered: false, dragged: false },
|
||||
rules: { canDrag: () => true, canMoveIn: () => true, canMoveOut: () => true, canDrop: () => true },
|
||||
};
|
||||
} else {
|
||||
craftNodes[id] = (query.parseFreshNode({
|
||||
id,
|
||||
data: { ...flatNode, type: resolvedComponent },
|
||||
}) as any).toNode();
|
||||
}
|
||||
}
|
||||
|
||||
return { rootNodeId: flat.rootNodeId, nodes: craftNodes };
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a deep-cloned NodeTree where every node id in the tree
|
||||
* (rootNodeId, the `nodes` map keys, each `node.id`, each internal
|
||||
|
||||
Reference in New Issue
Block a user