From 806d21a281e16e11e5100409fe6dd9f1b18de485 Mon Sep 17 00:00:00 2001 From: KalPop Date: Tue, 7 Jul 2026 12:10:20 +0100 Subject: [PATCH 1/5] feat: lossless HTML-node roundtrip (hybrid DOM-native nodes) Implement lossless roundtrip between authored HTML and the DOM-native node model so imported markup is preserved exactly through edit and publish. - Phase 0/1: all base modules declare htmlContract; DOM-native node model and canvas renderers introduced. - Phase 2: publisher serializes from HTML fields via a unified render path. - Complete migration (phases 0-7): rules, walk/map importer, attribute sanitization, and publisher wired end-to-end. - Apply upstream attribute sanitization to the DOM-native-first importer. --- .gitignore | 4 + scripts/e2e-dev.ts | 10 +- server/ai/tools/site/writeTools.ts | 12 + server/db/client.ts | 2 + server/db/sqlite.ts | 4 + server/handlers/cms/pageDiff.ts | 12 + src/__tests__/agent/agentSlice.test.ts | 7 +- src/__tests__/agent/executor.test.ts | 44 +- .../architecture/agent-tool-surface.test.ts | 4 +- .../architecture/ai-tool-schema-ssot.test.ts | 2 + .../hybrid-dom-native-nodes.test.ts | 113 ++++ .../architecture/module-size-budgets.test.ts | 4 +- src/__tests__/db/createDbClient.test.ts | 17 +- src/__tests__/helpers/createTestDb.ts | 23 +- src/__tests__/helpers/index.ts | 13 +- src/__tests__/htmlImport/mapping.test.ts | 422 ++++++------- .../htmlImport/structurePreservation.test.ts | 25 +- src/__tests__/htmlImport/svgMapping.test.ts | 17 + .../htmlImport/textNormalize.test.ts | 15 + src/__tests__/persistence.test.ts | 4 +- src/admin/pages/site/agent/executor.ts | 44 +- .../pages/site/canvas/DomNodeRenderer.tsx | 129 ++++ src/admin/pages/site/canvas/NodeRenderer.tsx | 26 +- src/admin/pages/site/canvas/canvasDnd.ts | 4 +- .../pages/site/hooks/useInsertInserterItem.ts | 15 +- .../module-picker/ModuleInserterDialog.tsx | 10 + .../ModuleInserterItemButton.tsx | 5 + .../site/module-picker/moduleInserterModel.ts | 61 +- .../site/module-picker/moduleInserterPrefs.ts | 3 +- .../useModuleInserterPreference.ts | 10 +- .../DomNodeProperties.module.css | 73 +++ .../PropertiesPanel/DomNodeProperties.tsx | 157 +++++ .../renderModuleTabContent.tsx | 8 +- .../store/slices/site/lifecycleActions.ts | 48 +- .../site/store/slices/site/nodeActions.ts | 79 ++- .../pages/site/store/slices/site/types.ts | 30 + src/core/ai/index.ts | 2 + src/core/ai/toolSchemas.ts | 11 + src/core/htmlImport/rules.ts | 567 +----------------- src/core/htmlImport/walkAndMap.ts | 173 +++--- src/core/module-engine/index.ts | 1 + src/core/module-engine/registry.ts | 35 ++ src/core/module-engine/types.ts | 110 +++- src/core/page-tree/baseNode.ts | 136 ++++- src/core/page-tree/cloneNode.ts | 1 + src/core/page-tree/dnd.ts | 5 +- src/core/page-tree/index.ts | 3 + src/core/page-tree/mutations.ts | 91 ++- src/core/page-tree/nodeDisplayName.ts | 11 +- src/core/page-tree/selectors.ts | 11 +- src/core/plugin-sdk/builders/defineModule.ts | 19 +- .../plugin-sdk/cli/packCompileEnvironment.ts | 6 +- src/core/plugin-sdk/modules.ts | 17 +- src/core/plugins/moduleAdapter.ts | 45 +- src/core/publisher/index.ts | 2 +- src/core/publisher/render.ts | 7 + src/core/publisher/renderConfig.ts | 6 + src/core/publisher/renderLoop.ts | 2 +- src/core/publisher/renderNode.ts | 189 +++++- .../publisher/renderVisualComponentRef.ts | 2 +- src/core/templates/dynamicBindings.ts | 2 +- src/core/visualComponents/instantiate.ts | 44 +- src/modules/base/body/index.ts | 7 + src/modules/base/button/index.ts | 43 ++ src/modules/base/container/index.ts | 16 + src/modules/base/forms/htmlContracts.ts | 260 ++++++++ src/modules/base/forms/index.ts | 32 + src/modules/base/image/index.ts | 27 + src/modules/base/link/index.ts | 25 + src/modules/base/list/index.ts | 16 + src/modules/base/loop/index.ts | 19 + src/modules/base/outlet/index.ts | 13 + src/modules/base/slotInstance/index.ts | 8 + src/modules/base/slotOutlet/index.ts | 7 + src/modules/base/svg/index.ts | 24 + src/modules/base/text/index.ts | 24 + src/modules/base/video/index.ts | 40 ++ src/modules/base/visualComponentRef/index.ts | 9 + tests/e2e/visual-builder.e2e.ts | 57 ++ 79 files changed, 2584 insertions(+), 997 deletions(-) create mode 100644 src/__tests__/architecture/hybrid-dom-native-nodes.test.ts create mode 100644 src/admin/pages/site/canvas/DomNodeRenderer.tsx create mode 100644 src/admin/pages/site/panels/PropertiesPanel/DomNodeProperties.module.css create mode 100644 src/admin/pages/site/panels/PropertiesPanel/DomNodeProperties.tsx create mode 100644 src/modules/base/forms/htmlContracts.ts diff --git a/.gitignore b/.gitignore index c2c797642..b70599151 100644 --- a/.gitignore +++ b/.gitignore @@ -83,3 +83,7 @@ docs/superpowers/ .factory/ .trae/ .windsurf/ + +# Runtime PID file written by the MCP relay daemon +relay-daemon.pid + diff --git a/scripts/e2e-dev.ts b/scripts/e2e-dev.ts index 3d3cebb22..52db19a76 100644 --- a/scripts/e2e-dev.ts +++ b/scripts/e2e-dev.ts @@ -45,9 +45,15 @@ function stopChildren(signal: NodeJS.Signals = 'SIGTERM'): void { } } +// On Windows, `bun` and `vite` resolve to .cmd/.ps1 shims that uv_spawn +// can't find directly. Use process.execPath (the real bun.exe) for bun +// commands, and `bun x vite` for vite. On non-Windows the shims aren't an +// issue so this works identically. +const BUN_EXE = process.execPath + for (const command of [ - bunCommand('server/index.ts'), - bunRunCommand('dev:vite', '--host', '127.0.0.1', '--port', VITE_PORT, '--strictPort'), + [BUN_EXE, 'server/index.ts'], + [BUN_EXE, 'x', 'vite', '--host', '127.0.0.1', '--port', VITE_PORT, '--strictPort'], ]) { const child = Bun.spawn(command, { env: sharedEnv, diff --git a/server/ai/tools/site/writeTools.ts b/server/ai/tools/site/writeTools.ts index 2f7b04199..d19ede4a4 100644 --- a/server/ai/tools/site/writeTools.ts +++ b/server/ai/tools/site/writeTools.ts @@ -27,6 +27,7 @@ import { ReplaceNodeHtmlInputSchema, DeleteNodeInputSchema, UpdateNodePropsInputSchema, + UpdateDomNodeInputSchema, MoveNodeInputSchema, RenameNodeInputSchema, DuplicateNodeInputSchema, @@ -151,6 +152,16 @@ const updateNodePropsTool: AiTool = { inputSchema: UpdateNodePropsInputSchema, } +const updateDomNodeTool: AiTool = { + name: 'site_update_dom_node', + scope: 'site', + execution: 'browser', + requiredCapabilities: SITE_CONTENT_CAPS, + description: + 'Update a DOM-native node (a raw HTML element with no module — e.g.
,
,
  • , ). Pass `tag` to change the element type, `attributes` to replace all HTML attributes (pass null to clear), or `textContent` to set/clear leaf text (pass null to clear). Use site_update_node_props for module-based nodes instead.', + inputSchema: UpdateDomNodeInputSchema, +} + const moveNodeTool: AiTool = { name: 'site_move_node', scope: 'site', @@ -416,6 +427,7 @@ export const siteWriteTools: AiTool[] = [ replaceNodeHtmlTool, deleteNodeTool, updateNodePropsTool, + updateDomNodeTool, moveNodeTool, renameNodeTool, duplicateNodeTool, diff --git a/server/db/client.ts b/server/db/client.ts index 7837833d4..cad7e16ae 100644 --- a/server/db/client.ts +++ b/server/db/client.ts @@ -32,4 +32,6 @@ export interface DbClient { unsafe>(sql: string, params?: unknown[]): Promise> transaction(fn: (tx: DbClient) => Promise): Promise readonly dialect: Dialect + /** Close the underlying database connection. Only implemented by SQLite. */ + close?(): void } diff --git a/server/db/sqlite.ts b/server/db/sqlite.ts index eb9249dbf..d3a850721 100644 --- a/server/db/sqlite.ts +++ b/server/db/sqlite.ts @@ -158,5 +158,9 @@ export function createSqliteClient(filename: string): DbClient { return result } + fn.close = () => { + db.close() + } + return Object.assign(fn, { dialect: 'sqlite' as const }) } diff --git a/server/handlers/cms/pageDiff.ts b/server/handlers/cms/pageDiff.ts index d0063089e..ecde45728 100644 --- a/server/handlers/cms/pageDiff.ts +++ b/server/handlers/cms/pageDiff.ts @@ -162,6 +162,18 @@ function diffNode( requireChange(capabilities, 'style', `${nodePath}.breakpointOverrides`, 'breakpoint overrides changed') } + // DOM-native node fields (moduleId === ''). `tag` is structural (changing + // element type), `attributes` and `textContent` are content edits. + if (!deepEqual(previous.tag, next.tag)) { + requireChange(capabilities, 'structure', `${nodePath}.tag`, 'DOM tag changed') + } + if (!deepEqual(previous.attributes, next.attributes)) { + requireChange(capabilities, 'content', `${nodePath}.attributes`, 'DOM attributes changed') + } + if (!deepEqual(previous.textContent, next.textContent)) { + requireChange(capabilities, 'content', `${nodePath}.textContent`, 'DOM text content changed') + } + diffNodeProps(capabilities, nodePath, previous, next) } diff --git a/src/__tests__/agent/agentSlice.test.ts b/src/__tests__/agent/agentSlice.test.ts index cb91026e3..de83c03ad 100644 --- a/src/__tests__/agent/agentSlice.test.ts +++ b/src/__tests__/agent/agentSlice.test.ts @@ -15,6 +15,11 @@ import '@modules/base' // Test helpers // --------------------------------------------------------------------------- +function nodeModuleId(n: unknown): string { + const node = n as { moduleId: string; moduleOverlay?: { moduleId: string } | null } + return node.moduleOverlay?.moduleId ?? node.moduleId +} + function freshAgentState() { useEditorStore.setState({ site: null, @@ -196,7 +201,7 @@ describe('processStreamEvent — toolRequest dispatches to executor', () => { expect(result.ok).toBe(true) const page = useEditorStore.getState().site!.pages[0] - expect(Object.values(page.nodes).some((n) => n.moduleId === 'base.text')).toBe(true) + expect(Object.values(page.nodes).some((n) => nodeModuleId(n) === 'base.text')).toBe(true) }) it('reports an error result when the tool input is invalid', async () => { diff --git a/src/__tests__/agent/executor.test.ts b/src/__tests__/agent/executor.test.ts index 433a66a05..f4f28ca42 100644 --- a/src/__tests__/agent/executor.test.ts +++ b/src/__tests__/agent/executor.test.ts @@ -21,6 +21,18 @@ import '@modules/base' // Store reset helper // --------------------------------------------------------------------------- +/** Effective moduleId — moduleOverlay.moduleId takes precedence over node.moduleId. */ +function nodeModuleId(n: unknown): string { + const node = n as { moduleId: string; moduleOverlay?: { moduleId: string } | null } + return node.moduleOverlay?.moduleId ?? node.moduleId +} + +/** Effective props — moduleOverlay.props takes precedence over node.props. */ +function nodeProps(n: unknown): Record { + const node = n as { props: Record; moduleOverlay?: { props: Record } | null } + return node.moduleOverlay?.props ?? node.props +} + function freshStore() { useEditorStore.setState({ site: null, @@ -125,9 +137,9 @@ describe('executeAgentTool — insertHtml', () => { const nodes = Object.values(page.nodes) // The section element maps to base.container - expect(nodes.some((n) => n.moduleId === 'base.container')).toBe(true) + expect(nodes.some((n) => nodeModuleId(n) === 'base.container')).toBe(true) // The h1 and p elements map to base.text - expect(nodes.some((n) => n.moduleId === 'base.text')).toBe(true) + expect(nodes.some((n) => nodeModuleId(n) === 'base.text')).toBe(true) // The inserted root (section) is wired as a child of the page root const root = page.nodes[rootId] @@ -661,7 +673,7 @@ describe('executeAgentTool — replaceNodeHtml', () => { const pageAfter = useEditorStore.getState().site!.pages[0] // The container node is preserved as the parent expect(pageAfter.nodes[containerId]).toBeDefined() - expect(pageAfter.nodes[containerId].moduleId).toBe('base.container') + expect(nodeModuleId(pageAfter.nodes[containerId])).toBe('base.container') // Children are rebuilt from the new HTML (h1 + p = 2 nodes) expect(pageAfter.nodes[containerId].children).toHaveLength(2) @@ -670,7 +682,7 @@ describe('executeAgentTool — replaceNodeHtml', () => { const childNodes = pageAfter.nodes[containerId].children.map( (id) => pageAfter.nodes[id], ) - expect(childNodes.every((n) => n.moduleId === 'base.text')).toBe(true) + expect(childNodes.every((n) => nodeModuleId(n) === 'base.text')).toBe(true) }) it('returns failure when nodeId does not exist', async () => { @@ -901,7 +913,7 @@ describe('executeAgentTool — updateNodeProps', () => { const nodeId = expectNodeIds(insertResult)[0] await executeAgentTool('site_update_node_props', { nodeId, patch: { text: 'New' } }) const page = useEditorStore.getState().site!.pages[0] - expect(page.nodes[nodeId].props.text).toBe('New') + expect(nodeProps(page.nodes[nodeId]).text).toBe('New') }) it('rejects updateNodeProps with breakpointId for content props', async () => { @@ -927,7 +939,7 @@ describe('executeAgentTool — updateNodeProps', () => { expectToolError(result) expect(result.error ?? '').toContain('breakpointOverridable') const page = useEditorStore.getState().site!.pages[0] - expect(page.nodes[nodeId].props.text).toBe('Desktop copy') + expect(nodeProps(page.nodes[nodeId]).text).toBe('Desktop copy') expect(page.nodes[nodeId].breakpointOverrides.mobile).toBeUndefined() }) @@ -1385,7 +1397,7 @@ describe('executeAgentTool — insertHtml ', () => { const nodeIds = expectNodeIds(result) expect(nodeIds.length).toBe(3) const nodes = useEditorStore.getState().site!.pages[0].nodes - const moduleIds = nodeIds.map((id) => nodes[id].moduleId) + const moduleIds = nodeIds.map((id) => nodeModuleId(nodes[id])) expect(moduleIds).toContain('base.outlet') }) }) @@ -1412,8 +1424,8 @@ describe('executeAgentTool — duplicateNode', () => { expect(root.children).toEqual([sourceId, clonedNodeId]) // Cloned props match source. const cloned = useEditorStore.getState().site!.pages[0].nodes[clonedNodeId] - expect(cloned.props.text).toBe('Original') - expect(cloned.props.tag).toBe('h2') + expect(nodeProps(cloned).text).toBe('Original') + expect(nodeProps(cloned).tag).toBe('h2') }) it('produces N clones in arrival order when count is set', async () => { @@ -1482,7 +1494,7 @@ describe('executeAgentTool — updateNodeProps richtext sanitization (Constraint patch: { richtext: '

    Hello

    ' }, }) const page = useEditorStore.getState().site!.pages[0] - const stored = page.nodes[nodeId].props.richtext as string + const stored = nodeProps(page.nodes[nodeId]).richtext as string expect(stored).not.toContain('