Merge PR #8: fix templates dropping nested content

This commit was merged in pull request #8.
This commit is contained in:
2026-07-13 13:28:39 +00:00
6 changed files with 386 additions and 67 deletions
+18 -14
View File
@@ -8,9 +8,10 @@ import {
TemplateComponent,
TemplateCategory,
} from '../../templates';
import { componentResolver } from '../../components/resolver';
import { clickableProps } from '../../utils/a11y';
import { Modal } from '../../ui/Modal';
import { buildNodeTree } from '../../utils/craft-tree';
import { templateComponentToTreeNode } from '../../templates/apply-template';
// ---------------------------------------------------------------------------
// Types
@@ -77,28 +78,31 @@ export const TemplateModal: React.FC<TemplateModalProps> = ({ open, onClose }) =
return allTemplates.filter((t) => t.category === 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.
* 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(
(components: TemplateComponent[]) => {
for (const comp of components) {
const Component = resolverMap[comp.type];
if (!Component) {
console.warn(`Template references unknown component type: ${comp.type}`);
continue;
try {
const tree = buildNodeTree(query, templateComponentToTreeNode(comp));
actions.addNodeTree(tree, 'ROOT');
} catch (e) {
console.warn(`Failed to build template component tree for type "${comp.type}":`, e);
}
const element = React.createElement(Component, comp.props);
const tree = query.parseReactElement(element).toNodeTree();
actions.addNodeTree(tree, 'ROOT');
}
},
[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);
});
});
+49
View File
@@ -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),
};
}
+7 -53
View File
@@ -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.
+99
View File
@@ -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