diff --git a/craft/src/utils/canvas-summary.test.ts b/craft/src/utils/canvas-summary.test.ts new file mode 100644 index 0000000..586e3c9 --- /dev/null +++ b/craft/src/utils/canvas-summary.test.ts @@ -0,0 +1,24 @@ +import { describe, test, expect } from 'vitest'; +import { summarizeCanvas } from './canvas-summary'; + +const fixture = { + ROOT: { type: { resolvedName: 'Container' }, props: { aiName: 'Page Root', node_id: 'ai-root-1' }, nodes: ['n1','n2'], parent: null }, + n1: { type: { resolvedName: 'Heading' }, props: { aiName: 'Hero Title', node_id: 'ai-hero-1', text: 'Welcome', level: 1, style: { color: '#fff' } }, nodes: [], parent: 'ROOT' }, + n2: { type: { resolvedName: 'HtmlBlock' }, props: { aiName: 'Custom Embed', node_id: 'ai-html-1', code: '
opaque
' }, nodes: [], parent: 'ROOT' }, +}; + +describe('summarizeCanvas', () => { + test('one line per node with id and aiName', () => { + const out = summarizeCanvas(fixture as any); + expect(out).toContain('Container id=ai-root-1'); + expect(out).toContain('Heading id=ai-hero-1 name="Hero Title"'); + }); + test('excludes style props', () => { + expect(summarizeCanvas(fixture as any)).not.toContain('color='); + }); + test('truncates to maxChars', () => { + const out = summarizeCanvas(fixture as any, 60); + expect(out.length).toBeLessThanOrEqual(60); + expect(out).toContain('truncated'); + }); +}); diff --git a/craft/src/utils/canvas-summary.ts b/craft/src/utils/canvas-summary.ts new file mode 100644 index 0000000..d241b96 --- /dev/null +++ b/craft/src/utils/canvas-summary.ts @@ -0,0 +1,32 @@ +import { SerializedNodes } from '@craftjs/core'; + +export function summarizeCanvas(state: SerializedNodes, maxChars = 6000): string { + const root = state['ROOT']; + if (!root) return '(empty canvas)'; + const lines: string[] = []; + const visit = (id: string, depth: number) => { + const node = state[id]; + if (!node) return; + const indent = ' '.repeat(depth); + const type = typeof node.type === 'object' ? (node.type as any).resolvedName : String(node.type); + const props = node.props || {}; + const aiName = (props as any).aiName ?? ''; + const nodeId = (props as any).node_id ?? id; + const interesting: string[] = []; + for (const [k, v] of Object.entries(props)) { + if (k === 'aiName' || k === 'node_id' || k === 'style') continue; + if (v == null) continue; + const repr = typeof v === 'string' ? v : JSON.stringify(v); + const truncated = repr.length > 60 ? repr.slice(0, 57) + '…' : repr; + interesting.push(`${k}=${truncated}`); + if (interesting.length >= 3) break; + } + lines.push(`${indent}- ${type} id=${nodeId} name="${aiName}" {${interesting.join(', ')}}`); + if (type === 'HtmlBlock') return; + for (const childId of node.nodes || []) visit(childId, depth + 1); + }; + visit('ROOT', 0); + let out = lines.join('\n'); + if (out.length > maxChars) out = out.slice(0, maxChars - 30) + '\n… (truncated)'; + return out; +}