site-builder: dynamic CTAs, section anchors, edit-with-Sitesmith

Three related features:

1. Dynamic CTA buttons on HeroSimple, CTASection, CallToAction.
   New shared ctas[] array (text + href + variant + target) replaces the
   primary/secondary pair. Settings panel gets add/remove/reorder controls.
   Legacy fields stay readable for backwards compat — first user edit
   migrates the section onto the new array.

2. Anchor IDs on all layout/section components (Container, Section,
   BackgroundSection, ColumnLayout, plus 6 section blocks done by parallel
   subagent, plus Hero/CTA/CallToAction). Anchor input lives in the
   settings panel with an "auto from heading" button that walks the
   subtree for the first Heading.text. Renders as id="..." on the
   outermost element so #anchor URLs resolve.

3. Edit-with-Sitesmith targeted invocation. Right-click → "Ask Sitesmith"
   and a button at the top of the right-side settings panel both open the
   modal pre-targeted at the selected node. The node's serialized subtree
   is sent to the server; system prompt is augmented to require a patch
   with replace_node. Editor lifts modal state into a new SitesmithContext.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-25 12:43:28 -07:00
parent 7b747f775f
commit d0925d9e2d
24 changed files with 723 additions and 237 deletions

View File

@@ -167,7 +167,16 @@ export function useApplyAiResponse() {
const { actions, query } = useEditor();
const pages = usePages();
return async function apply(resp: SitesmithResponse): Promise<{ ok: boolean; message?: string }> {
/**
* @param targetNodeId If set and the AI returned a section-scoped replace
* instead of a patch, treat the first returned tree as a replacement for
* this node (the user said "edit this block" — they don't want a new
* section appended at the bottom).
*/
return async function apply(
resp: SitesmithResponse,
targetNodeId?: string,
): Promise<{ ok: boolean; message?: string }> {
// 'ask' type = AI wants clarification, nothing to apply
if (resp.type === 'ask') return { ok: true };
@@ -185,6 +194,21 @@ export function useApplyAiResponse() {
}
if (resp.scope === 'section') {
// When targeted at a specific node, the AI's tree replaces that node
// in place (vs appending a fresh section at the end of ROOT).
if (targetNodeId && resp.pages.length > 0) {
try {
const nodeTree = buildNodeTree(query, resp.pages[0].tree);
const parent: string = query.node(targetNodeId).get().data.parent ?? 'ROOT';
const siblings: string[] = query.node(parent).childNodes();
const index = siblings.indexOf(targetNodeId);
actions.delete(targetNodeId);
actions.addNodeTree(nodeTree, parent, index);
return { ok: true };
} catch (e) {
console.warn('sitesmith: targeted section replace failed, falling back to append', e);
}
}
// Insert each provided tree as a new node tree appended to ROOT
for (const p of resp.pages) {
try {

View File

@@ -0,0 +1,40 @@
import { SitesmithTarget } from '../state/SitesmithContext';
/**
* Build a Sitesmith target descriptor from a Craft.js node id. The returned
* `treeJson` is a flat node-map (compatible with the editor's serialized
* format) for just the selected subtree; the server includes it in the AI
* prompt so the model has the exact current shape of the block to modify.
*/
export function buildSitesmithTarget(query: any, nodeId: string): SitesmithTarget | null {
if (!nodeId || nodeId === 'ROOT') return null;
try {
const node = query.node(nodeId).get();
const displayName = node?.data?.displayName || node?.data?.type?.resolvedName || 'Block';
// Use Craft.js' own subtree serializer — toNodeTree gives a flat map keyed
// by node id, identical to what `actions.deserialize()` consumes.
const subtree = query.node(nodeId).toNodeTree();
const serializedMap: Record<string, unknown> = {};
for (const [id, n] of Object.entries(subtree.nodes ?? {}) as [string, any][]) {
serializedMap[id] = {
type: n.data.type,
props: n.data.props,
displayName: n.data.displayName,
isCanvas: n.data.isCanvas ?? false,
parent: n.data.parent,
nodes: n.data.nodes ?? [],
linkedNodes: n.data.linkedNodes ?? {},
hidden: n.data.hidden ?? false,
custom: n.data.custom ?? {},
};
}
return {
nodeId,
displayName,
treeJson: JSON.stringify({ root: subtree.rootNodeId, nodes: serializedMap }),
};
} catch (e) {
console.warn('buildSitesmithTarget failed:', e);
return null;
}
}