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:
@@ -8,9 +8,10 @@ import {
|
|||||||
TemplateComponent,
|
TemplateComponent,
|
||||||
TemplateCategory,
|
TemplateCategory,
|
||||||
} from '../../templates';
|
} from '../../templates';
|
||||||
import { componentResolver } from '../../components/resolver';
|
|
||||||
import { clickableProps } from '../../utils/a11y';
|
import { clickableProps } from '../../utils/a11y';
|
||||||
import { Modal } from '../../ui/Modal';
|
import { Modal } from '../../ui/Modal';
|
||||||
|
import { buildNodeTree } from '../../utils/craft-tree';
|
||||||
|
import { templateComponentToTreeNode } from '../../templates/apply-template';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Types
|
// Types
|
||||||
@@ -77,28 +78,31 @@ export const TemplateModal: React.FC<TemplateModalProps> = ({ open, onClose }) =
|
|||||||
return allTemplates.filter((t) => t.category === activeTab);
|
return allTemplates.filter((t) => t.category === activeTab);
|
||||||
}, [activeTab]);
|
}, [activeTab]);
|
||||||
|
|
||||||
// Resolve a TemplateComponent type name to its React component
|
|
||||||
const resolverMap = componentResolver as Record<string, React.ComponentType<any>>;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add all components from a template definition onto the current (empty) canvas ROOT.
|
* Add all components from a template definition onto the current (empty) canvas ROOT.
|
||||||
* Uses Craft.js parseReactElement + addNodeTree which correctly builds valid node structures.
|
* Converts each `TemplateComponent` (the plain `{type, props, children?}`
|
||||||
|
* shape templates author) to a `SerializedTreeNode` and runs it through
|
||||||
|
* `buildNodeTree` (sanitize -> flatten -> materialize), the same tree pipeline
|
||||||
|
* already used for AI-generated content. This recurses into nested
|
||||||
|
* `children` (e.g. a header/footer `Container` wrapping `Logo`/`Menu`, or a
|
||||||
|
* page `Section` wrapping a `Heading`) AND correctly routes them through a
|
||||||
|
* component's SHELL_INNER linked canvas / linked columns where needed --
|
||||||
|
* see `templates/apply-template.ts` for the two bugs this fixes (dropped
|
||||||
|
* children, and a naive `parseReactElement` tree crashing `Section`/
|
||||||
|
* `BackgroundSection`/`FormContainer`/`ColumnLayout`).
|
||||||
*/
|
*/
|
||||||
const addTemplateComponents = useCallback(
|
const addTemplateComponents = useCallback(
|
||||||
(components: TemplateComponent[]) => {
|
(components: TemplateComponent[]) => {
|
||||||
for (const comp of components) {
|
for (const comp of components) {
|
||||||
const Component = resolverMap[comp.type];
|
try {
|
||||||
if (!Component) {
|
const tree = buildNodeTree(query, templateComponentToTreeNode(comp));
|
||||||
console.warn(`Template references unknown component type: ${comp.type}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const element = React.createElement(Component, comp.props);
|
|
||||||
const tree = query.parseReactElement(element).toNodeTree();
|
|
||||||
actions.addNodeTree(tree, 'ROOT');
|
actions.addNodeTree(tree, 'ROOT');
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`Failed to build template component tree for type "${comp.type}":`, e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[query, actions, resolverMap],
|
[query, actions],
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import { describe, test, expect } from 'vitest';
|
||||||
|
import React from 'react';
|
||||||
|
import { createRoot, Root } from 'react-dom/client';
|
||||||
|
import { act } from 'react-dom/test-utils';
|
||||||
|
import { Editor, useEditor } from '@craftjs/core';
|
||||||
|
import { componentResolver } from '../../components/resolver';
|
||||||
|
import { allTemplates } from '../../templates';
|
||||||
|
import { buildNodeTree } from '../../utils/craft-tree';
|
||||||
|
import { templateComponentToTreeNode } from '../../templates/apply-template';
|
||||||
|
import { exportBodyHtml } from '../../utils/html-export';
|
||||||
|
import { DEFAULT_HEADER_STATE } from '../../state/PageContext';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regression test for: loading a template makes the header/footer ZONE
|
||||||
|
* PREVIEW (`ZonePreview` in `editor/Canvas.tsx`, which calls
|
||||||
|
* `exportBodyHtml(craftState)`) render as an empty strip -- no nav links, no
|
||||||
|
* logo, no footer text -- and (a second bug found while fixing the first)
|
||||||
|
* makes a template page's `Section`-wrapped content crash the live editor
|
||||||
|
* entirely.
|
||||||
|
*
|
||||||
|
* This drives the EXACT same path `TemplateModal.tsx`'s `applyZone` /
|
||||||
|
* `addTemplateComponents` uses to load a template's header/footer/page
|
||||||
|
* content onto the live Craft.js canvas (`buildNodeTree` +
|
||||||
|
* `actions.addNodeTree(tree, 'ROOT')`), then serializes it
|
||||||
|
* (`query.serialize()`, exactly what `PageContext`'s `switchPage` /
|
||||||
|
* `saveCurrentState` do right after `TemplateModal` finishes applying a
|
||||||
|
* zone/page) and feeds the result through `exportBodyHtml` -- the same
|
||||||
|
* function `ZonePreview` calls, and the same function `handlePublish` uses
|
||||||
|
* to compose the published header/footer HTML. So this also covers whether
|
||||||
|
* published output is affected (it was).
|
||||||
|
*/
|
||||||
|
let api: any;
|
||||||
|
function Harness() {
|
||||||
|
const { actions, query } = useEditor();
|
||||||
|
api = { actions, query };
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mount() {
|
||||||
|
const container = document.createElement('div');
|
||||||
|
document.body.appendChild(container);
|
||||||
|
let root!: Root;
|
||||||
|
act(() => {
|
||||||
|
root = createRoot(container);
|
||||||
|
root.render(
|
||||||
|
<Editor resolver={componentResolver}>
|
||||||
|
<Harness />
|
||||||
|
</Editor>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
container,
|
||||||
|
unmount: () => {
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
container.remove();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mirrors TemplateModal.tsx's addTemplateComponents(). */
|
||||||
|
function addTemplateComponents(components: any[]) {
|
||||||
|
for (const comp of components) {
|
||||||
|
const tree = buildNodeTree(api.query, templateComponentToTreeNode(comp));
|
||||||
|
api.actions.addNodeTree(tree, 'ROOT');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_HEADER_ROOT =
|
||||||
|
'{"ROOT":{"type":{"resolvedName":"Container"},"isCanvas":true,"props":{"style":{},"tag":"header"},"displayName":"Container","custom":{},"hidden":false,"nodes":[],"linkedNodes":{}}}';
|
||||||
|
|
||||||
|
const EMPTY_CANVAS_ROOT =
|
||||||
|
'{"ROOT":{"type":{"resolvedName":"Container"},"isCanvas":true,"props":{"style":{},"tag":"div"},"displayName":"Container","custom":{},"hidden":false,"nodes":[],"linkedNodes":{}}}';
|
||||||
|
|
||||||
|
describe('template header/footer zone export (loading a template)', () => {
|
||||||
|
test('sanity check: the DEFAULT header (fresh project, no template) exports its content', () => {
|
||||||
|
const { html } = exportBodyHtml(DEFAULT_HEADER_STATE);
|
||||||
|
expect(html).toContain('MySite');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a template header (SaaS Landing) exports its real nav/logo content -- not an empty strip', () => {
|
||||||
|
const tpl = allTemplates.find((t) => t.id === 'saas-landing');
|
||||||
|
expect(tpl).toBeDefined();
|
||||||
|
expect(tpl!.header.components.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const { unmount } = mount();
|
||||||
|
act(() => {
|
||||||
|
api.actions.deserialize(EMPTY_HEADER_ROOT);
|
||||||
|
});
|
||||||
|
act(() => {
|
||||||
|
addTemplateComponents(tpl!.header.components);
|
||||||
|
});
|
||||||
|
|
||||||
|
const serialized = api.query.serialize();
|
||||||
|
const { html } = exportBodyHtml(serialized);
|
||||||
|
|
||||||
|
// The template's logo text and a real nav link -- if these are missing,
|
||||||
|
// the header rendered as an empty strip (the reported bug).
|
||||||
|
expect(html).toContain('FlowStack');
|
||||||
|
expect(html).toContain('Features');
|
||||||
|
expect(html).toContain('Start Free');
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a template footer (SaaS Landing) exports its real copyright/tagline content -- not an empty strip', () => {
|
||||||
|
const tpl = allTemplates.find((t) => t.id === 'saas-landing');
|
||||||
|
expect(tpl).toBeDefined();
|
||||||
|
expect(tpl!.footer.components.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const { unmount } = mount();
|
||||||
|
act(() => {
|
||||||
|
api.actions.deserialize(
|
||||||
|
'{"ROOT":{"type":{"resolvedName":"Container"},"isCanvas":true,"props":{"style":{},"tag":"footer"},"displayName":"Container","custom":{},"hidden":false,"nodes":[],"linkedNodes":{}}}',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
act(() => {
|
||||||
|
addTemplateComponents(tpl!.footer.components);
|
||||||
|
});
|
||||||
|
|
||||||
|
const serialized = api.query.serialize();
|
||||||
|
const { html } = exportBodyHtml(serialized);
|
||||||
|
|
||||||
|
expect(html).toContain('FlowStack, Inc.');
|
||||||
|
expect(html).toContain('Ship products faster');
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a template page Section with nested children (SaaS Landing Home) builds + exports without crashing', () => {
|
||||||
|
// Regression for the SECOND bug surfaced while fixing the first: naively
|
||||||
|
// rebuilding nested children via React.createElement + parseReactElement
|
||||||
|
// crashes any component with an internal SHELL_INNER linked canvas
|
||||||
|
// (Section/BackgroundSection/FormContainer) or linked columns
|
||||||
|
// (ColumnLayout). The SaaS Landing home page's second component is a
|
||||||
|
// `Section` wrapping a `Heading` ("Trusted by 10,000+ development
|
||||||
|
// teams...") -- exactly this shape.
|
||||||
|
const tpl = allTemplates.find((t) => t.id === 'saas-landing');
|
||||||
|
const homePage = tpl!.pages[0];
|
||||||
|
const sectionComp = homePage.content.components.find((c) => c.type === 'Section');
|
||||||
|
expect(sectionComp).toBeDefined();
|
||||||
|
expect(sectionComp!.children?.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const { unmount } = mount();
|
||||||
|
act(() => {
|
||||||
|
api.actions.deserialize(EMPTY_CANVAS_ROOT);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
act(() => {
|
||||||
|
addTemplateComponents([sectionComp!]);
|
||||||
|
});
|
||||||
|
}).not.toThrow();
|
||||||
|
|
||||||
|
const serialized = api.query.serialize();
|
||||||
|
const { html } = exportBodyHtml(serialized);
|
||||||
|
expect(html).toContain('Trusted by 10,000+ development teams');
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { describe, test, expect } from 'vitest';
|
||||||
|
import { templateComponentToTreeNode } from './apply-template';
|
||||||
|
|
||||||
|
describe('templateComponentToTreeNode', () => {
|
||||||
|
test('converts a childless TemplateComponent to a SerializedTreeNode with an empty `nodes` array', () => {
|
||||||
|
const node = templateComponentToTreeNode({ type: 'Heading', props: { text: 'Hi' } });
|
||||||
|
expect(node).toEqual({
|
||||||
|
type: { resolvedName: 'Heading' },
|
||||||
|
props: { text: 'Hi' },
|
||||||
|
nodes: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('regression: recursively converts nested `children` into `nodes` (previously dropped entirely)', () => {
|
||||||
|
const node = templateComponentToTreeNode({
|
||||||
|
type: 'Container',
|
||||||
|
props: { tag: 'header' },
|
||||||
|
children: [
|
||||||
|
{ type: 'Logo', props: { text: 'FlowStack' } },
|
||||||
|
{ type: 'Menu', props: { links: [] } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(node.nodes).toHaveLength(2);
|
||||||
|
expect(node.nodes![0]).toEqual({ type: { resolvedName: 'Logo' }, props: { text: 'FlowStack' }, nodes: [] });
|
||||||
|
expect(node.nodes![1]).toEqual({ type: { resolvedName: 'Menu' }, props: { links: [] }, nodes: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('recurses more than one level deep', () => {
|
||||||
|
const node = templateComponentToTreeNode({
|
||||||
|
type: 'Section',
|
||||||
|
props: {},
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: 'ColumnLayout',
|
||||||
|
props: { columns: 1 },
|
||||||
|
children: [{ type: 'TextBlock', props: { text: 'deep' } }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const col = node.nodes![0];
|
||||||
|
expect(col.type.resolvedName).toBe('ColumnLayout');
|
||||||
|
expect(col.nodes![0]).toEqual({ type: { resolvedName: 'TextBlock' }, props: { text: 'deep' }, nodes: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not mutate the input TemplateComponent', () => {
|
||||||
|
const comp = { type: 'Heading', props: { text: 'Hi' } };
|
||||||
|
const frozen = JSON.parse(JSON.stringify(comp));
|
||||||
|
templateComponentToTreeNode(comp);
|
||||||
|
expect(comp).toEqual(frozen);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import type { SerializedTreeNode } from '../types/sitesmith';
|
||||||
|
import { TemplateComponent } from './definitions';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a template's `TemplateComponent` tree (the plain
|
||||||
|
* `{ type, props, children? }` shape authored in `templates/definitions.ts`)
|
||||||
|
* into a `SerializedTreeNode` (the shape `craft-tree.ts`'s `buildNodeTree` /
|
||||||
|
* `sanitizeAiTree` / `flattenTreeForCraft` pipeline already knows how to
|
||||||
|
* materialize into a real Craft.js `NodeTree`).
|
||||||
|
*
|
||||||
|
* Bug this fixes: `TemplateModal`'s `addTemplateComponents` used to call
|
||||||
|
* `React.createElement(Component, comp.props)` with no children argument,
|
||||||
|
* which silently dropped every nested `children` array authored in
|
||||||
|
* definitions.ts -- e.g. `makeHeader()`'s `Container > Logo + Menu`,
|
||||||
|
* `makeFooterContent()`'s `Container > TextBlock...`, or a page `Section`
|
||||||
|
* wrapping a `Heading`/`TextBlock`/`ButtonLink`. The resulting Craft.js node
|
||||||
|
* ended up with `nodes: []`, so the header/footer zone preview
|
||||||
|
* (`ZonePreview` in `editor/Canvas.tsx`, via `exportBodyHtml`) -- and the
|
||||||
|
* same-path published HTML (`handlePublish` composes header/footer HTML
|
||||||
|
* from the same serialized state) -- rendered as an empty strip instead of
|
||||||
|
* the real nav/footer/section content.
|
||||||
|
*
|
||||||
|
* A follow-up attempt fixed that by building a real nested React element
|
||||||
|
* tree (`React.createElement(Component, props, ...children)`) and handing
|
||||||
|
* it to `query.parseReactElement(...).toNodeTree()`. That approach breaks
|
||||||
|
* for any component with an internal SHELL_INNER-style linked canvas
|
||||||
|
* (`Section`, `BackgroundSection`, `FormContainer` -- see `SHELL_INNER` in
|
||||||
|
* `craft-tree.ts`) or linked columns (`ColumnLayout`): `parseReactElement`
|
||||||
|
* statically walks the JSX tree and has no way to know that e.g. `Section`
|
||||||
|
* routes its `children` into a nested `<Element id="section-inner" canvas>`
|
||||||
|
* at render time, not directly under itself. Feeding it raw nested children
|
||||||
|
* produces a tree where Craft.js has to auto-materialize that linked node on
|
||||||
|
* first render -- which stores the wrong `type` and crashes with "component
|
||||||
|
* type (undefined) does not exist in the resolver" the moment the section
|
||||||
|
* renders (reproduced live: loading the SaaS Landing template crashed the
|
||||||
|
* whole editor on its "Trusted by 10,000+ development teams" `Section`).
|
||||||
|
*
|
||||||
|
* Converting to `SerializedTreeNode` and reusing `buildNodeTree` sidesteps
|
||||||
|
* both bugs at once: it's the same tree-building pipeline already used (and
|
||||||
|
* tested) for AI-generated content in `apply-ai-response.ts`, which already
|
||||||
|
* knows how to route content through SHELL_INNER / linked columns correctly.
|
||||||
|
*/
|
||||||
|
export function templateComponentToTreeNode(comp: TemplateComponent): SerializedTreeNode {
|
||||||
|
return {
|
||||||
|
type: { resolvedName: comp.type },
|
||||||
|
props: { ...comp.props },
|
||||||
|
nodes: (comp.children || []).map(templateComponentToTreeNode),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
import { useEditor } from '@craftjs/core';
|
import { useEditor } from '@craftjs/core';
|
||||||
import type { NodeTree } from '@craftjs/core';
|
|
||||||
import { usePages } from '../state/PageContext';
|
import { usePages } from '../state/PageContext';
|
||||||
import { SitesmithResponse, SerializedTreeNode, SitesmithPatchOp } from '../types/sitesmith';
|
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
|
// Re-exported for existing callers/tests that import sanitizeAiTree/
|
||||||
// this module — the implementation now lives in craft-tree.ts alongside the
|
// buildNodeTree from this module — the implementation lives in craft-tree.ts
|
||||||
// shared flattener, since treeToState (PageContext.tsx) needs the same
|
// alongside the shared flattener: `templates/apply-template.ts` needs the
|
||||||
// validation this module pioneered.
|
// exact same tree-materialization logic to load template header/footer/page
|
||||||
export { sanitizeAiTree };
|
// 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.
|
* 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']);
|
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.
|
* 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.
|
* 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 };
|
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
|
* Returns a deep-cloned NodeTree where every node id in the tree
|
||||||
* (rootNodeId, the `nodes` map keys, each `node.id`, each internal
|
* (rootNodeId, the `nodes` map keys, each `node.id`, each internal
|
||||||
|
|||||||
Reference in New Issue
Block a user