create-visualbuild-app 0.1.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +919 -58
  2. package/docs/user-guide.md +759 -0
  3. package/package.json +92 -85
  4. package/scripts/create-builder-app.mjs +513 -501
  5. package/scripts/vb-dev.mjs +41 -32
  6. package/scripts/vb-generate.mjs +332 -224
  7. package/templates/default/app/.vercelignore +6 -0
  8. package/templates/default/app/README.md +56 -21
  9. package/templates/default/app/visualbuild/components.json +3 -0
  10. package/templates/default/app/visualbuild/config.d.ts +48 -0
  11. package/templates/default/app/visualbuild.config.ts +38 -0
  12. package/templates/default/builder/README.md +37 -21
  13. package/visual-app-builder/server/parseReactPage.ts +776 -571
  14. package/visual-app-builder/shared/createReactComponentSource.d.mts +2 -0
  15. package/visual-app-builder/shared/createReactComponentSource.mjs +45 -0
  16. package/visual-app-builder/shared/generateReactSource.d.mts +57 -37
  17. package/visual-app-builder/shared/generateReactSource.mjs +608 -443
  18. package/visual-app-builder/shared/npmBuildCommand.d.mts +17 -0
  19. package/visual-app-builder/shared/npmBuildCommand.mjs +26 -0
  20. package/visual-app-builder/shared/syncCustomComponentSource.d.mts +13 -0
  21. package/visual-app-builder/shared/syncCustomComponentSource.mjs +345 -0
  22. package/visual-app-builder/shared/vercelDeployment.d.mts +31 -0
  23. package/visual-app-builder/shared/vercelDeployment.mjs +266 -0
  24. package/visual-app-builder/shared/visualbuildConfig.d.mts +144 -0
  25. package/visual-app-builder/shared/visualbuildConfig.mjs +578 -0
  26. package/visual-app-builder/src/App.tsx +1090 -874
  27. package/visual-app-builder/src/components/Canvas.tsx +1116 -1059
  28. package/visual-app-builder/src/components/CodePanel.tsx +1147 -812
  29. package/visual-app-builder/src/components/ComponentSidebar.tsx +365 -302
  30. package/visual-app-builder/src/components/DeploymentModal.tsx +295 -0
  31. package/visual-app-builder/src/components/PageSwitcher.tsx +133 -133
  32. package/visual-app-builder/src/components/ProjectExplorer.tsx +1054 -1054
  33. package/visual-app-builder/src/components/PropertiesPanel.tsx +792 -692
  34. package/visual-app-builder/src/components/Topbar.tsx +257 -128
  35. package/visual-app-builder/src/data/componentRegistry.tsx +613 -292
  36. package/visual-app-builder/src/data/tailwindClassCatalog.ts +95 -0
  37. package/visual-app-builder/src/index.css +383 -111
  38. package/visual-app-builder/src/stores/useAppStore.ts +2385 -1265
  39. package/visual-app-builder/src/theme.ts +71 -0
  40. package/visual-app-builder/src/types/index.ts +350 -261
  41. package/visual-app-builder/src/utils/codegen.ts +90 -66
  42. package/visual-app-builder/src/utils/deployment.ts +54 -0
  43. package/visual-app-builder/src/utils/projectPersistence.ts +218 -177
  44. package/visual-app-builder/vite.config.ts +1946 -1479
@@ -1,1265 +1,2385 @@
1
- import { v4 as uuid } from 'uuid'
2
- import { create } from 'zustand'
3
- import { primitiveRegistry } from '../data/primitiveRegistry'
4
- import type {
5
- AppShell,
6
- AppState,
7
- ComponentType,
8
- Page,
9
- PrimitiveType,
10
- VisualNode,
11
- } from '../types'
12
- import { createPageSourcePath } from '../utils/codegen'
13
- import {
14
- createProjectSchema,
15
- loadProjectSchema,
16
- saveProjectSchema,
17
- syncTailwindClasses,
18
- } from '../utils/projectPersistence'
19
-
20
- const generateId = () => uuid()
21
- const projectCacheKey = 'visualbuild:project-cache'
22
-
23
- const defaultAppShell: AppShell = {
24
- header: null,
25
- footer: null,
26
- }
27
- const defaultProjectName = 'my-blog'
28
-
29
- const defaultPage: Page = {
30
- id: 'page-home',
31
- name: 'Home',
32
- route: '/',
33
- sourcePath: createPageSourcePath('Home'),
34
- root: createPageRoot('page-home'),
35
- components: [
36
- createComponentPreset('Navbar', {
37
- navId: 'shell-navbar',
38
- logo: 'My Blog',
39
- }),
40
- createComponentPreset('Hero', {
41
- sectionId: 'home-hero',
42
- title: 'Welcome to My Blog',
43
- subtitle: 'Built visually. Owned as React code.',
44
- cta: 'Start Building',
45
- }),
46
- ],
47
- }
48
-
49
- const cachedProject = readCachedProject()
50
- let tailwindSyncTimeout: number | null = null
51
- let activeProjectId = ''
52
-
53
- export const useAppStore = create<AppState>((set, get) => ({
54
- projectName: cachedProject?.projectName ?? defaultProjectName,
55
- appShell: cachedProject?.appShell ?? defaultAppShell,
56
- pages: cachedProject?.pages ?? [],
57
- activePageId: cachedProject?.activePageId ?? '',
58
- selectedComponentId: null,
59
- viewport: 'desktop',
60
- previewMode: false,
61
- isProjectLoaded: false,
62
-
63
- loadProject: async () => {
64
- const schema = await loadProjectSchema().catch((error: unknown) => {
65
- console.warn('Failed to load visualbuild/pages.json', error)
66
- return null
67
- })
68
-
69
- if (!schema || schema.pages.length === 0) {
70
- if (cachedProject) {
71
- activeProjectId = cachedProject.projectId
72
- scheduleTailwindSync(cachedProject.pages, cachedProject.appShell)
73
- set({
74
- projectName: cachedProject.projectName,
75
- appShell: cachedProject.appShell,
76
- pages: cachedProject.pages,
77
- activePageId: cachedProject.activePageId,
78
- selectedComponentId: null,
79
- isProjectLoaded: true,
80
- })
81
- return
82
- }
83
-
84
- activeProjectId = defaultProjectName
85
- if (get().isProjectLoaded) {
86
- return
87
- }
88
-
89
- cacheProject([defaultPage], defaultAppShell, false, defaultProjectName)
90
-
91
- set({
92
- projectName: defaultProjectName,
93
- appShell: defaultAppShell,
94
- pages: [defaultPage],
95
- activePageId: defaultPage.id,
96
- selectedComponentId: null,
97
- isProjectLoaded: true,
98
- })
99
- return
100
- }
101
-
102
- const projectName = schema.meta?.projectName ?? defaultProjectName
103
- activeProjectId = schema.runtimeProjectId ?? projectName
104
-
105
- if (
106
- cachedProject?.dirty &&
107
- cachedProject.projectId === activeProjectId
108
- ) {
109
- scheduleTailwindSync(cachedProject.pages, cachedProject.appShell)
110
- set({
111
- projectName: cachedProject.projectName,
112
- appShell: cachedProject.appShell,
113
- pages: cachedProject.pages,
114
- activePageId: cachedProject.activePageId,
115
- selectedComponentId: null,
116
- isProjectLoaded: true,
117
- })
118
- return
119
- }
120
-
121
- const { pages, appShell, changed } = normalizeLoadedProject(
122
- schema.pages,
123
- schema.appShell ?? defaultAppShell
124
- )
125
- cacheProject(pages, appShell, false, projectName)
126
-
127
- set({
128
- projectName,
129
- appShell,
130
- pages,
131
- activePageId: pages[0].id,
132
- selectedComponentId: null,
133
- isProjectLoaded: true,
134
- })
135
-
136
- if (changed) {
137
- persistProject(pages, appShell, projectName)
138
- }
139
- },
140
-
141
- refreshProjectFromDisk: async (force = false) => {
142
- const schema = await loadProjectSchema().catch((error: unknown) => {
143
- console.warn('Failed to refresh visualbuild/pages.json', error)
144
- return null
145
- })
146
-
147
- if (!schema || schema.pages.length === 0) {
148
- return 'failed'
149
- }
150
-
151
- const projectName = schema.meta?.projectName ?? defaultProjectName
152
- const nextProjectId = schema.runtimeProjectId ?? projectName
153
- const currentCache = readCachedProject()
154
-
155
- if (
156
- !force &&
157
- currentCache?.dirty &&
158
- currentCache.projectId === nextProjectId
159
- ) {
160
- return 'skipped-dirty'
161
- }
162
-
163
- const { pages, appShell } = normalizeLoadedProject(
164
- schema.pages,
165
- schema.appShell ?? defaultAppShell
166
- )
167
- const currentActivePageId = get().activePageId
168
- const activePageId = pages.some(
169
- (page) => page.id === currentActivePageId
170
- )
171
- ? currentActivePageId
172
- : pages[0].id
173
-
174
- activeProjectId = nextProjectId
175
- cacheProject(pages, appShell, false, projectName)
176
- set({
177
- projectName,
178
- appShell,
179
- pages,
180
- activePageId,
181
- selectedComponentId: null,
182
- isProjectLoaded: true,
183
- })
184
-
185
- return 'updated'
186
- },
187
-
188
- saveProject: async () => {
189
- const { pages, appShell, isProjectLoaded, projectName } = get()
190
-
191
- if (!isProjectLoaded) {
192
- return
193
- }
194
-
195
- cacheProject(pages, appShell, true, projectName)
196
- await saveProjectSchema(pages, appShell, projectName)
197
- cacheProject(pages, appShell, false, projectName)
198
- },
199
-
200
- addPage: (name, route) => {
201
- const pageId = generateId()
202
- const page: Page = {
203
- id: pageId,
204
- name,
205
- route,
206
- sourcePath: createPageSourcePath(name),
207
- root: createPageRoot(pageId),
208
- components: [],
209
- }
210
-
211
- set((state) => {
212
- const pages = [
213
- ...state.pages,
214
- page,
215
- ]
216
-
217
- persistProject(pages, state.appShell, state.projectName)
218
-
219
- return { pages }
220
- })
221
-
222
- return page
223
- },
224
-
225
- registerPage: (page) =>
226
- set((state) => {
227
- const pages = [...state.pages, page]
228
-
229
- persistProject(pages, state.appShell, state.projectName)
230
-
231
- return {
232
- pages,
233
- activePageId: page.id,
234
- selectedComponentId: null,
235
- }
236
- }),
237
-
238
- deletePage: (id) =>
239
- set((state) => {
240
- if (state.pages.length === 1) return state
241
- const pages = state.pages.filter((page) => page.id !== id)
242
- const activePageId =
243
- state.activePageId === id ? pages[0].id : state.activePageId
244
-
245
- persistProject(pages, state.appShell, state.projectName)
246
-
247
- return {
248
- pages,
249
- activePageId,
250
- selectedComponentId:
251
- state.activePageId === id ? null : state.selectedComponentId,
252
- }
253
- }),
254
-
255
- setActivePage: (id) => set({ activePageId: id, selectedComponentId: null }),
256
-
257
- setSelectedComponent: (id) => set({ selectedComponentId: id }),
258
-
259
- setViewport: (viewport) => set({ viewport }),
260
-
261
- togglePreviewMode: () =>
262
- set((state) => ({ previewMode: !state.previewMode })),
263
-
264
- addComponent: (pageId, type) => {
265
- get().addComponentAt(pageId, type, Number.POSITIVE_INFINITY)
266
- },
267
-
268
- addComponentAt: (pageId, type, index) =>
269
- set((state) => {
270
- const node = createComponentPreset(type)
271
- const pages = state.pages.map((page) =>
272
- page.id === pageId ? insertNodeAtRoot(page, node, index) : page
273
- )
274
-
275
- persistProject(pages, state.appShell, state.projectName)
276
-
277
- return { pages, selectedComponentId: node.id }
278
- }),
279
-
280
- addPrimitive: (pageId, type) => {
281
- get().addPrimitiveAt(pageId, type, Number.POSITIVE_INFINITY)
282
- },
283
-
284
- addPrimitiveAt: (pageId, type, index) =>
285
- set((state) => {
286
- const node = createPrimitive(type)
287
- const pages = state.pages.map((page) =>
288
- page.id === pageId ? insertNodeAtRoot(page, node, index) : page
289
- )
290
-
291
- persistProject(pages, state.appShell, state.projectName)
292
-
293
- return { pages, selectedComponentId: node.id }
294
- }),
295
-
296
- addPrimitiveToNode: (parentNodeId, type) => {
297
- get().addPrimitiveToNodeAt(parentNodeId, type, Number.POSITIVE_INFINITY)
298
- },
299
-
300
- addPrimitiveToNodeAt: (parentNodeId, type, index) =>
301
- set((state) => {
302
- const node = createPrimitive(type)
303
- const pages = state.pages.map((page) => ({
304
- ...page,
305
- components: addChildNodeAt(page.components, parentNodeId, node, index),
306
- }))
307
- const appShell = addChildToAppShell(
308
- state.appShell,
309
- parentNodeId,
310
- node,
311
- index
312
- )
313
-
314
- persistProject(pages, appShell, state.projectName)
315
-
316
- return { appShell, pages, selectedComponentId: node.id }
317
- }),
318
-
319
- addComponentToNode: (parentNodeId, type) => {
320
- get().addComponentToNodeAt(parentNodeId, type, Number.POSITIVE_INFINITY)
321
- },
322
-
323
- addComponentToNodeAt: (parentNodeId, type, index) =>
324
- set((state) => {
325
- const node = createComponentPreset(type)
326
- const pages = state.pages.map((page) => ({
327
- ...page,
328
- components: addChildNodeAt(page.components, parentNodeId, node, index),
329
- }))
330
- const appShell = addChildToAppShell(
331
- state.appShell,
332
- parentNodeId,
333
- node,
334
- index
335
- )
336
-
337
- persistProject(pages, appShell, state.projectName)
338
-
339
- return { appShell, pages, selectedComponentId: node.id }
340
- }),
341
-
342
- removeNode: (nodeId) =>
343
- set((state) => {
344
- const appShell = removeShellNodeById(state.appShell, nodeId)
345
- const pages = state.pages.map((page) => ({
346
- ...page,
347
- components: removeNodeById(page.components, nodeId),
348
- }))
349
-
350
- persistProject(pages, appShell, state.projectName)
351
-
352
- return {
353
- appShell,
354
- pages,
355
- selectedComponentId:
356
- state.selectedComponentId === nodeId ? null : state.selectedComponentId,
357
- }
358
- }),
359
-
360
- moveNode: (pageId, nodeId, targetParentNodeId, targetIndex) =>
361
- set((state) => {
362
- const movedProject = moveNodeInProject(
363
- state.pages,
364
- state.appShell,
365
- pageId,
366
- nodeId,
367
- targetParentNodeId,
368
- targetIndex
369
- )
370
-
371
- if (!movedProject) {
372
- return state
373
- }
374
-
375
- persistProject(
376
- movedProject.pages,
377
- movedProject.appShell,
378
- state.projectName
379
- )
380
-
381
- return {
382
- pages: movedProject.pages,
383
- appShell: movedProject.appShell,
384
- selectedComponentId: nodeId,
385
- }
386
- }),
387
-
388
- moveComponent: (pageId, nodeId, targetIndex) =>
389
- set((state) => {
390
- const pages = state.pages.map((page) =>
391
- page.id === pageId ? moveRootNode(page, nodeId, targetIndex) : page
392
- )
393
-
394
- persistProject(pages, state.appShell, state.projectName)
395
-
396
- return { pages }
397
- }),
398
-
399
- removeComponent: (pageId, nodeId) =>
400
- set((state) => {
401
- const pages = state.pages.map((page) =>
402
- page.id === pageId
403
- ? { ...page, components: removeNodeById(page.components, nodeId) }
404
- : page
405
- )
406
-
407
- persistProject(pages, state.appShell, state.projectName)
408
-
409
- return {
410
- pages,
411
- selectedComponentId:
412
- state.selectedComponentId === nodeId ? null : state.selectedComponentId,
413
- }
414
- }),
415
-
416
- setAppShellComponent: (_slot, type) => {
417
- const slot = type === 'Footer' ? 'footer' : 'header'
418
-
419
- set((state) => {
420
- const node = createComponentPreset(type)
421
- const appShell = {
422
- ...state.appShell,
423
- [slot]: node,
424
- }
425
-
426
- persistProject(state.pages, appShell, state.projectName)
427
-
428
- return {
429
- appShell,
430
- selectedComponentId: node.id,
431
- }
432
- })
433
- },
434
-
435
- removeAppShellComponent: (slot) =>
436
- set((state) => {
437
- const removedNodeId = state.appShell[slot]?.id
438
- const appShell = {
439
- ...state.appShell,
440
- [slot]: null,
441
- }
442
-
443
- persistProject(state.pages, appShell, state.projectName)
444
-
445
- return {
446
- appShell,
447
- selectedComponentId:
448
- state.selectedComponentId === removedNodeId
449
- ? null
450
- : state.selectedComponentId,
451
- }
452
- }),
453
-
454
- updateComponentProps: (_pageId, nodeId, props) => {
455
- get().updateNodeProps(nodeId, props)
456
- },
457
-
458
- updateNodeProps: (nodeId, props) =>
459
- set((state) => {
460
- const appShell = updateShellNodeProps(state.appShell, nodeId, props)
461
- const pages = state.pages.map((page) => ({
462
- ...page,
463
- components: updateNodeProps(page.components, nodeId, props),
464
- }))
465
-
466
- persistProject(pages, appShell, state.projectName)
467
-
468
- return { appShell, pages }
469
- }),
470
-
471
- updatePageRoot: (pageId, updates) =>
472
- set((state) => {
473
- const pages = state.pages.map((page) => {
474
- if (page.id !== pageId) {
475
- return page
476
- }
477
-
478
- const nextType = updates.type ?? page.root.type
479
- const defaultProps =
480
- updates.type && updates.type !== page.root.type
481
- ? primitiveRegistry[nextType].defaultProps
482
- : {}
483
-
484
- return {
485
- ...page,
486
- root: {
487
- ...page.root,
488
- type: nextType,
489
- props: {
490
- ...defaultProps,
491
- ...page.root.props,
492
- ...(updates.props ?? {}),
493
- },
494
- },
495
- }
496
- })
497
-
498
- persistProject(pages, state.appShell, state.projectName)
499
-
500
- return { pages }
501
- }),
502
-
503
- updateAppShellProps: (slot, props) =>
504
- set((state) => {
505
- const node = state.appShell[slot]
506
-
507
- if (!node) {
508
- return state
509
- }
510
-
511
- const appShell = {
512
- ...state.appShell,
513
- [slot]: {
514
- ...node,
515
- props: {
516
- ...node.props,
517
- ...props,
518
- },
519
- },
520
- }
521
-
522
- persistProject(state.pages, appShell, state.projectName)
523
-
524
- return { appShell }
525
- }),
526
- }))
527
-
528
- function createPageRoot(pageId: string, type: PrimitiveType = 'div'): VisualNode {
529
- return {
530
- id: `${pageId}:root`,
531
- type,
532
- label: 'Page root',
533
- props: {
534
- ...primitiveRegistry[type].defaultProps,
535
- className: 'min-h-screen',
536
- },
537
- children: [],
538
- }
539
- }
540
-
541
- function createPrimitive(type: PrimitiveType, props = {}): VisualNode {
542
- return {
543
- id: generateId(),
544
- type,
545
- props: { ...primitiveRegistry[type].defaultProps, ...props },
546
- children: [],
547
- }
548
- }
549
-
550
- function createComponentPreset(
551
- type: ComponentType,
552
- overrides: Record<string, unknown> = {}
553
- ): VisualNode {
554
- if (type === 'Navbar') {
555
- return {
556
- id: String(overrides.navId ?? generateId()),
557
- type: 'nav',
558
- label: 'Navbar',
559
- props: {
560
- ...primitiveRegistry.nav.defaultProps,
561
- className:
562
- 'flex items-center justify-between px-6 py-3 bg-gray-900 text-white',
563
- },
564
- children: [
565
- createPrimitive('span', {
566
- text: String(overrides.logo ?? 'My Blog'),
567
- className: 'font-bold text-sm',
568
- }),
569
- createPrimitive('div', {
570
- className: 'flex items-center gap-4',
571
- }),
572
- createPrimitive('div', {
573
- className: 'flex items-center gap-2',
574
- }),
575
- ],
576
- }
577
- }
578
-
579
- if (type === 'Hero') {
580
- return {
581
- id: String(overrides.sectionId ?? generateId()),
582
- type: 'section',
583
- label: 'Hero',
584
- props: {
585
- className:
586
- 'bg-gradient-to-br from-blue-600 to-indigo-700 text-white px-8 py-12 text-center',
587
- },
588
- children: [
589
- createPrimitive('h1', {
590
- text: String(overrides.title ?? 'Welcome to My Blog'),
591
- className: 'text-2xl font-bold mb-2',
592
- }),
593
- createPrimitive('p', {
594
- text: String(
595
- overrides.subtitle ?? 'Built visually. Owned as React code.'
596
- ),
597
- className: 'text-sm text-blue-100 mb-6',
598
- }),
599
- createPrimitive('button', {
600
- text: String(overrides.cta ?? 'Start Building'),
601
- className:
602
- 'px-5 py-2 bg-white text-blue-700 rounded-full text-sm font-semibold',
603
- }),
604
- ],
605
- }
606
- }
607
-
608
- if (type === 'Card') {
609
- return {
610
- id: generateId(),
611
- type: 'article',
612
- label: 'Card',
613
- props: { ...primitiveRegistry.article.defaultProps },
614
- children: [
615
- createPrimitive('h3', {
616
- text: 'Card Title',
617
- className: 'font-semibold text-gray-900 mb-1',
618
- }),
619
- createPrimitive('p', {
620
- text: 'Card description goes here.',
621
- className: 'text-sm text-gray-500',
622
- }),
623
- ],
624
- }
625
- }
626
-
627
- if (type === 'Button') {
628
- return createPrimitive('button', {
629
- text: 'Click me',
630
- className: 'px-4 py-1.5 rounded text-sm font-medium bg-blue-600 text-white',
631
- })
632
- }
633
-
634
- return {
635
- id: generateId(),
636
- type: 'footer',
637
- label: 'Footer',
638
- props: { ...primitiveRegistry.footer.defaultProps },
639
- children: [
640
- createPrimitive('span', {
641
- text: 'Copyright 2026 MySite',
642
- className: '',
643
- }),
644
- ],
645
- }
646
- }
647
-
648
- function insertNodeAtRoot(page: Page, node: VisualNode, index: number): Page {
649
- const components = [...page.components]
650
- const safeIndex = Math.max(0, Math.min(index, components.length))
651
-
652
- components.splice(safeIndex, 0, node)
653
-
654
- return { ...page, components }
655
- }
656
-
657
- function addChildNodeAt(
658
- nodes: VisualNode[],
659
- parentNodeId: string,
660
- child: VisualNode,
661
- index: number
662
- ): VisualNode[] {
663
- return nodes.map((node) =>
664
- node.id === parentNodeId
665
- ? { ...node, children: insertNodeAt(node.children, child, index) }
666
- : {
667
- ...node,
668
- children: addChildNodeAt(node.children, parentNodeId, child, index),
669
- }
670
- )
671
- }
672
-
673
- function addChildToAppShell(
674
- appShell: AppShell,
675
- parentNodeId: string,
676
- child: VisualNode,
677
- index: number
678
- ): AppShell {
679
- return {
680
- header: appShell.header
681
- ? insertChildIntoTree(appShell.header, parentNodeId, child, index)
682
- : null,
683
- footer: appShell.footer
684
- ? insertChildIntoTree(appShell.footer, parentNodeId, child, index)
685
- : null,
686
- }
687
- }
688
-
689
- function removeNodeById(nodes: VisualNode[], nodeId: string): VisualNode[] {
690
- return nodes
691
- .filter((node) => node.id !== nodeId)
692
- .map((node) => ({
693
- ...node,
694
- children: removeNodeById(node.children, nodeId),
695
- }))
696
- }
697
-
698
- function removeShellNodeById(appShell: AppShell, nodeId: string): AppShell {
699
- return {
700
- header:
701
- appShell.header?.id === nodeId
702
- ? null
703
- : appShell.header
704
- ? {
705
- ...appShell.header,
706
- children: removeNodeById(appShell.header.children, nodeId),
707
- }
708
- : null,
709
- footer:
710
- appShell.footer?.id === nodeId
711
- ? null
712
- : appShell.footer
713
- ? {
714
- ...appShell.footer,
715
- children: removeNodeById(appShell.footer.children, nodeId),
716
- }
717
- : null,
718
- }
719
- }
720
-
721
- function updateNodeProps(
722
- nodes: VisualNode[],
723
- nodeId: string,
724
- props: Record<string, unknown>
725
- ): VisualNode[] {
726
- return nodes.map((node) =>
727
- node.id === nodeId
728
- ? { ...node, props: { ...node.props, ...props } }
729
- : { ...node, children: updateNodeProps(node.children, nodeId, props) }
730
- )
731
- }
732
-
733
- function updateShellNodeProps(
734
- appShell: AppShell,
735
- nodeId: string,
736
- props: Record<string, unknown>
737
- ): AppShell {
738
- return {
739
- header: appShell.header
740
- ? updateNodeInTree(appShell.header, nodeId, props)
741
- : null,
742
- footer: appShell.footer
743
- ? updateNodeInTree(appShell.footer, nodeId, props)
744
- : null,
745
- }
746
- }
747
-
748
- function updateNodeInTree(
749
- node: VisualNode,
750
- nodeId: string,
751
- props: Record<string, unknown>
752
- ): VisualNode {
753
- if (node.id === nodeId) {
754
- return { ...node, props: { ...node.props, ...props } }
755
- }
756
-
757
- return {
758
- ...node,
759
- children: node.children.map((child) =>
760
- updateNodeInTree(child, nodeId, props)
761
- ),
762
- }
763
- }
764
-
765
- type NodeScope = `page:${string}` | 'shell:header' | 'shell:footer'
766
-
767
- interface NodeLocation {
768
- node: VisualNode
769
- parentNodeId: string | null
770
- index: number
771
- scope: NodeScope
772
- }
773
-
774
- function moveNodeInProject(
775
- pages: Page[],
776
- appShell: AppShell,
777
- pageId: string,
778
- nodeId: string,
779
- targetParentNodeId: string | null,
780
- targetIndex: number
781
- ) {
782
- const source = findMovableNode(pages, appShell, nodeId)
783
-
784
- if (!source) {
785
- return null
786
- }
787
-
788
- if (
789
- targetParentNodeId === nodeId ||
790
- (targetParentNodeId !== null &&
791
- containsNode(source.node, targetParentNodeId))
792
- ) {
793
- return null
794
- }
795
-
796
- const destination = findDestination(
797
- pages,
798
- appShell,
799
- pageId,
800
- targetParentNodeId
801
- )
802
-
803
- if (!destination) {
804
- return null
805
- }
806
-
807
- if (
808
- destination.parentNode &&
809
- !primitiveRegistry[destination.parentNode.type].acceptsChildren
810
- ) {
811
- return null
812
- }
813
-
814
- let insertionIndex = targetIndex
815
-
816
- if (
817
- source.scope === destination.scope &&
818
- source.parentNodeId === targetParentNodeId &&
819
- source.index < targetIndex
820
- ) {
821
- insertionIndex -= 1
822
- }
823
-
824
- let nextPages = pages.map((page) => ({
825
- ...page,
826
- components: removeNodeById(page.components, nodeId),
827
- }))
828
- let nextAppShell: AppShell = {
829
- header: appShell.header
830
- ? {
831
- ...appShell.header,
832
- children: removeNodeById(appShell.header.children, nodeId),
833
- }
834
- : null,
835
- footer: appShell.footer
836
- ? {
837
- ...appShell.footer,
838
- children: removeNodeById(appShell.footer.children, nodeId),
839
- }
840
- : null,
841
- }
842
-
843
- if (targetParentNodeId === null) {
844
- nextPages = nextPages.map((page) =>
845
- page.id === pageId
846
- ? insertNodeAtRoot(page, source.node, insertionIndex)
847
- : page
848
- )
849
- } else {
850
- nextPages = nextPages.map((page) => ({
851
- ...page,
852
- components: addChildNodeAt(
853
- page.components,
854
- targetParentNodeId,
855
- source.node,
856
- insertionIndex
857
- ),
858
- }))
859
- nextAppShell = addChildToAppShell(
860
- nextAppShell,
861
- targetParentNodeId,
862
- source.node,
863
- insertionIndex
864
- )
865
- }
866
-
867
- return { pages: nextPages, appShell: nextAppShell }
868
- }
869
-
870
- function findMovableNode(
871
- pages: Page[],
872
- appShell: AppShell,
873
- nodeId: string
874
- ): NodeLocation | null {
875
- for (const page of pages) {
876
- const location = findNodeLocation(
877
- page.components,
878
- nodeId,
879
- null,
880
- `page:${page.id}`
881
- )
882
-
883
- if (location) {
884
- return location
885
- }
886
- }
887
-
888
- if (appShell.header) {
889
- const location = findNodeLocation(
890
- appShell.header.children,
891
- nodeId,
892
- appShell.header.id,
893
- 'shell:header'
894
- )
895
-
896
- if (location) {
897
- return location
898
- }
899
- }
900
-
901
- if (appShell.footer) {
902
- return findNodeLocation(
903
- appShell.footer.children,
904
- nodeId,
905
- appShell.footer.id,
906
- 'shell:footer'
907
- )
908
- }
909
-
910
- return null
911
- }
912
-
913
- function findNodeLocation(
914
- nodes: VisualNode[],
915
- nodeId: string,
916
- parentNodeId: string | null,
917
- scope: NodeScope
918
- ): NodeLocation | null {
919
- for (const [index, node] of nodes.entries()) {
920
- if (node.id === nodeId) {
921
- return { node, parentNodeId, index, scope }
922
- }
923
-
924
- const childLocation = findNodeLocation(
925
- node.children,
926
- nodeId,
927
- node.id,
928
- scope
929
- )
930
-
931
- if (childLocation) {
932
- return childLocation
933
- }
934
- }
935
-
936
- return null
937
- }
938
-
939
- function findDestination(
940
- pages: Page[],
941
- appShell: AppShell,
942
- pageId: string,
943
- parentNodeId: string | null
944
- ): { scope: NodeScope; parentNode: VisualNode | null } | null {
945
- if (parentNodeId === null) {
946
- return pages.some((page) => page.id === pageId)
947
- ? { scope: `page:${pageId}`, parentNode: null }
948
- : null
949
- }
950
-
951
- for (const page of pages) {
952
- const parentNode = findNodeInTreeList(page.components, parentNodeId)
953
-
954
- if (parentNode) {
955
- return { scope: `page:${page.id}`, parentNode }
956
- }
957
- }
958
-
959
- if (appShell.header) {
960
- const parentNode = findNodeInTree(appShell.header, parentNodeId)
961
-
962
- if (parentNode) {
963
- return { scope: 'shell:header', parentNode }
964
- }
965
- }
966
-
967
- if (appShell.footer) {
968
- const parentNode = findNodeInTree(appShell.footer, parentNodeId)
969
-
970
- if (parentNode) {
971
- return { scope: 'shell:footer', parentNode }
972
- }
973
- }
974
-
975
- return null
976
- }
977
-
978
- function findNodeInTreeList(nodes: VisualNode[], nodeId: string) {
979
- for (const node of nodes) {
980
- const match = findNodeInTree(node, nodeId)
981
-
982
- if (match) {
983
- return match
984
- }
985
- }
986
-
987
- return null
988
- }
989
-
990
- function findNodeInTree(
991
- node: VisualNode,
992
- nodeId: string
993
- ): VisualNode | null {
994
- if (node.id === nodeId) {
995
- return node
996
- }
997
-
998
- return findNodeInTreeList(node.children, nodeId)
999
- }
1000
-
1001
- function containsNode(node: VisualNode, nodeId: string): boolean {
1002
- return (
1003
- node.id === nodeId ||
1004
- node.children.some((child) => containsNode(child, nodeId))
1005
- )
1006
- }
1007
-
1008
- function insertChildIntoTree(
1009
- node: VisualNode,
1010
- parentNodeId: string,
1011
- child: VisualNode,
1012
- index: number
1013
- ): VisualNode {
1014
- if (node.id === parentNodeId) {
1015
- return { ...node, children: insertNodeAt(node.children, child, index) }
1016
- }
1017
-
1018
- return {
1019
- ...node,
1020
- children: node.children.map((nestedChild) =>
1021
- insertChildIntoTree(nestedChild, parentNodeId, child, index)
1022
- ),
1023
- }
1024
- }
1025
-
1026
- function insertNodeAt(
1027
- nodes: VisualNode[],
1028
- node: VisualNode,
1029
- index: number
1030
- ) {
1031
- const nextNodes = [...nodes]
1032
- const safeIndex = Math.max(0, Math.min(index, nextNodes.length))
1033
-
1034
- nextNodes.splice(safeIndex, 0, node)
1035
-
1036
- return nextNodes
1037
- }
1038
-
1039
- function moveRootNode(page: Page, nodeId: string, targetIndex: number): Page {
1040
- const fromIndex = page.components.findIndex((node) => node.id === nodeId)
1041
-
1042
- if (fromIndex === -1) {
1043
- return page
1044
- }
1045
-
1046
- const components = [...page.components]
1047
- const [node] = components.splice(fromIndex, 1)
1048
- const safeIndex = Math.max(0, Math.min(targetIndex, components.length))
1049
-
1050
- components.splice(safeIndex, 0, node)
1051
-
1052
- return { ...page, components }
1053
- }
1054
-
1055
- function normalizeLoadedProject(pages: Page[], appShell: AppShell) {
1056
- let changed = false
1057
- const normalizedAppShell: AppShell = {
1058
- header: appShell.header ? normalizeUnknownNode(appShell.header) : null,
1059
- footer: appShell.footer ? normalizeUnknownNode(appShell.footer) : null,
1060
- }
1061
-
1062
- const normalizedPages = pages.map((page) => {
1063
- const rawPage = page as Page & { root?: unknown; sourcePath?: string }
1064
- const root = rawPage.root
1065
- ? normalizePageRoot(rawPage.root, page.id)
1066
- : createPageRoot(page.id)
1067
- const normalizedComponents: VisualNode[] = []
1068
-
1069
- for (const rawNode of page.components) {
1070
- const node = normalizeUnknownNode(rawNode)
1071
-
1072
- if (node.label === 'Navbar' && !normalizedAppShell.header) {
1073
- normalizedAppShell.header = node
1074
- changed = true
1075
- continue
1076
- }
1077
-
1078
- if (node.label === 'Footer' && !normalizedAppShell.footer) {
1079
- normalizedAppShell.footer = node
1080
- changed = true
1081
- continue
1082
- }
1083
-
1084
- normalizedComponents.push(node)
1085
- }
1086
-
1087
- if (!rawPage.root) {
1088
- changed = true
1089
- }
1090
-
1091
- const sourcePath =
1092
- rawPage.sourcePath?.replace(/\\/g, '/') ||
1093
- createPageSourcePath(page.name)
1094
-
1095
- if (!rawPage.sourcePath || rawPage.sourcePath !== sourcePath) {
1096
- changed = true
1097
- }
1098
-
1099
- return {
1100
- ...page,
1101
- sourcePath,
1102
- root,
1103
- components: normalizedComponents,
1104
- }
1105
- })
1106
-
1107
- return {
1108
- pages: normalizedPages,
1109
- appShell: normalizedAppShell,
1110
- changed,
1111
- }
1112
- }
1113
-
1114
- function normalizePageRoot(rawRoot: unknown, pageId: string): VisualNode {
1115
- const root = normalizeUnknownNode(rawRoot)
1116
-
1117
- if (!primitiveRegistry[root.type].acceptsChildren) {
1118
- return createPageRoot(pageId)
1119
- }
1120
-
1121
- return {
1122
- ...root,
1123
- id: root.id || `${pageId}:root`,
1124
- label: 'Page root',
1125
- children: [],
1126
- }
1127
- }
1128
-
1129
- function normalizeUnknownNode(rawNode: unknown): VisualNode {
1130
- const node = rawNode as {
1131
- id?: string
1132
- type?: string
1133
- label?: string
1134
- props?: Record<string, unknown>
1135
- children?: unknown[]
1136
- }
1137
-
1138
- if (isPrimitiveType(node.type)) {
1139
- return {
1140
- id: node.id ?? generateId(),
1141
- type: node.type,
1142
- label: node.label,
1143
- props: node.props ?? {},
1144
- children: (node.children ?? []).map((child) => normalizeUnknownNode(child)),
1145
- }
1146
- }
1147
-
1148
- if (node.type === 'Navbar') {
1149
- return createComponentPreset('Navbar', {
1150
- navId: node.id,
1151
- logo: node.props?.logo,
1152
- })
1153
- }
1154
-
1155
- if (node.type === 'Hero') {
1156
- return createComponentPreset('Hero', {
1157
- sectionId: node.id,
1158
- title: node.props?.title,
1159
- subtitle: node.props?.subtitle,
1160
- cta: node.props?.cta,
1161
- })
1162
- }
1163
-
1164
- if (node.type === 'Card' || node.type === 'Button' || node.type === 'Footer') {
1165
- return createComponentPreset(node.type)
1166
- }
1167
-
1168
- return createPrimitive('div')
1169
- }
1170
-
1171
- function isPrimitiveType(type: unknown): type is PrimitiveType {
1172
- return typeof type === 'string' && type in primitiveRegistry
1173
- }
1174
-
1175
- function persistProject(
1176
- pages: Page[],
1177
- appShell: AppShell,
1178
- projectName: string
1179
- ) {
1180
- cacheProject(pages, appShell, true, projectName)
1181
- }
1182
-
1183
- function cacheProject(
1184
- pages: Page[],
1185
- appShell: AppShell,
1186
- dirty: boolean,
1187
- projectName: string
1188
- ) {
1189
- if (typeof window === 'undefined') {
1190
- return
1191
- }
1192
-
1193
- try {
1194
- window.localStorage.setItem(
1195
- projectCacheKey,
1196
- JSON.stringify({
1197
- ...createProjectSchema(pages, appShell, projectName),
1198
- dirty,
1199
- cachedAt: Date.now(),
1200
- projectId: activeProjectId,
1201
- })
1202
- )
1203
- scheduleTailwindSync(pages, appShell)
1204
- } catch (error) {
1205
- console.warn('Failed to cache visualbuild project state', error)
1206
- }
1207
- }
1208
-
1209
- function scheduleTailwindSync(pages: Page[], appShell: AppShell) {
1210
- if (tailwindSyncTimeout !== null) {
1211
- window.clearTimeout(tailwindSyncTimeout)
1212
- }
1213
-
1214
- tailwindSyncTimeout = window.setTimeout(() => {
1215
- tailwindSyncTimeout = null
1216
- void syncTailwindClasses(pages, appShell).catch((error: unknown) => {
1217
- console.warn('Failed to update live Tailwind classes', error)
1218
- })
1219
- }, 100)
1220
- }
1221
-
1222
- function readCachedProject() {
1223
- if (typeof window === 'undefined') {
1224
- return null
1225
- }
1226
-
1227
- try {
1228
- const cached = window.localStorage.getItem(projectCacheKey)
1229
-
1230
- if (!cached) {
1231
- return null
1232
- }
1233
-
1234
- const schema = JSON.parse(cached) as {
1235
- meta?: {
1236
- projectName?: string
1237
- }
1238
- appShell?: AppShell
1239
- pages?: Page[]
1240
- dirty?: boolean
1241
- projectId?: string
1242
- }
1243
-
1244
- if (!schema.pages || schema.pages.length === 0) {
1245
- return null
1246
- }
1247
-
1248
- const { pages, appShell } = normalizeLoadedProject(
1249
- schema.pages,
1250
- schema.appShell ?? defaultAppShell
1251
- )
1252
-
1253
- return {
1254
- projectName: schema.meta?.projectName ?? defaultProjectName,
1255
- appShell,
1256
- pages,
1257
- activePageId: pages[0].id,
1258
- dirty: schema.dirty === true,
1259
- projectId: schema.projectId ?? '',
1260
- }
1261
- } catch (error) {
1262
- console.warn('Failed to restore cached visualbuild project state', error)
1263
- return null
1264
- }
1265
- }
1
+ import { v4 as uuid } from 'uuid'
2
+ import { create } from 'zustand'
3
+ import { primitiveRegistry } from '../data/primitiveRegistry'
4
+ import type {
5
+ AppShell,
6
+ AppState,
7
+ ComponentType,
8
+ CustomComponentDefinition,
9
+ Page,
10
+ PrimitiveType,
11
+ VisualBuildRuntimeConfig,
12
+ VisualNode,
13
+ } from '../types'
14
+ import { createPageSourcePath } from '../utils/codegen'
15
+ import {
16
+ createProjectSchema,
17
+ loadProjectConfig,
18
+ loadProjectSchema,
19
+ saveProjectSchema,
20
+ syncTailwindClasses,
21
+ } from '../utils/projectPersistence'
22
+
23
+ const generateId = () => uuid()
24
+ const projectCacheKey = 'visualbuild:project-cache'
25
+
26
+ const defaultAppShell: AppShell = {
27
+ header: null,
28
+ footer: null,
29
+ }
30
+ const defaultProjectName = 'my-app'
31
+ const defaultProjectConfig: VisualBuildRuntimeConfig = {
32
+ paths: {
33
+ sourceDir: 'src',
34
+ pagesDir: 'src/pages',
35
+ layoutsDir: 'src/layouts',
36
+ appFile: 'src/App.tsx',
37
+ schemaFile: 'visualbuild/pages.json',
38
+ componentRegistryFile: 'visualbuild/components.json',
39
+ generatedManifestFile: 'visualbuild/generated-files.json',
40
+ },
41
+ editor: {
42
+ theme: 'dark',
43
+ defaultViewport: 'desktop',
44
+ responsiveWidths: {
45
+ mobile: 390,
46
+ tablet: 768,
47
+ desktop: 'fluid',
48
+ },
49
+ panels: {
50
+ leftOpen: true,
51
+ leftTab: 'elements',
52
+ rightOpen: true,
53
+ rightTab: 'properties',
54
+ codeView: 'page',
55
+ },
56
+ },
57
+ generator: {
58
+ emitApp: true,
59
+ emitLayout: true,
60
+ cleanStaleFiles: true,
61
+ },
62
+ deployment: {
63
+ provider: 'vercel',
64
+ outputDirectory: 'dist',
65
+ },
66
+ }
67
+
68
+ const defaultPage: Page = {
69
+ id: 'page-home',
70
+ name: 'Home',
71
+ route: '/',
72
+ sourcePath: createPageSourcePath('Home'),
73
+ root: createPageRoot('page-home'),
74
+ components: [
75
+ createComponentPreset('Navbar', {
76
+ navId: 'shell-navbar',
77
+ logo: 'My App',
78
+ }),
79
+ createComponentPreset('Hero', {
80
+ sectionId: 'home-hero',
81
+ title: 'Welcome to My App',
82
+ subtitle: 'Built visually. Owned as React code.',
83
+ cta: 'Start Building',
84
+ }),
85
+ ],
86
+ }
87
+
88
+ const cachedProject = readCachedProject()
89
+ let tailwindSyncTimeout: number | null = null
90
+ let activeProjectId = ''
91
+
92
+ export const useAppStore = create<AppState>((set, get) => ({
93
+ projectName: cachedProject?.projectName ?? defaultProjectName,
94
+ appShell: cachedProject?.appShell ?? defaultAppShell,
95
+ pages: cachedProject?.pages ?? [],
96
+ activePageId: cachedProject?.activePageId ?? '',
97
+ selectedComponentId: null,
98
+ viewport: 'desktop',
99
+ previewMode: false,
100
+ isProjectLoaded: false,
101
+ customComponents: [],
102
+ projectConfig: defaultProjectConfig,
103
+
104
+ loadProject: async () => {
105
+ const config = await loadProjectConfig().catch((error: unknown) => {
106
+ console.warn('Failed to load visualbuild.config.ts', error)
107
+ return { ...defaultProjectConfig, customComponents: [] }
108
+ })
109
+ const schema = await loadProjectSchema().catch((error: unknown) => {
110
+ console.warn('Failed to load visualbuild/pages.json', error)
111
+ return null
112
+ })
113
+
114
+ if (!schema || schema.pages.length === 0) {
115
+ if (cachedProject) {
116
+ const normalized = normalizeLoadedProject(
117
+ cachedProject.pages,
118
+ cachedProject.appShell,
119
+ config.customComponents,
120
+ config.paths.pagesDir
121
+ )
122
+ activeProjectId = cachedProject.projectId
123
+ scheduleTailwindSync(normalized.pages, normalized.appShell)
124
+ set({
125
+ projectName: cachedProject.projectName,
126
+ appShell: normalized.appShell,
127
+ pages: normalized.pages,
128
+ activePageId: cachedProject.activePageId,
129
+ selectedComponentId: null,
130
+ viewport: config.editor.defaultViewport,
131
+ isProjectLoaded: true,
132
+ customComponents: config.customComponents,
133
+ projectConfig: config,
134
+ })
135
+ return
136
+ }
137
+
138
+ activeProjectId = defaultProjectName
139
+ if (get().isProjectLoaded) {
140
+ return
141
+ }
142
+
143
+ cacheProject([defaultPage], defaultAppShell, false, defaultProjectName)
144
+
145
+ set({
146
+ projectName: defaultProjectName,
147
+ appShell: defaultAppShell,
148
+ pages: [defaultPage],
149
+ activePageId: defaultPage.id,
150
+ selectedComponentId: null,
151
+ viewport: config.editor.defaultViewport,
152
+ isProjectLoaded: true,
153
+ customComponents: config.customComponents,
154
+ projectConfig: config,
155
+ })
156
+ return
157
+ }
158
+
159
+ const projectName = schema.meta?.projectName ?? defaultProjectName
160
+ activeProjectId = schema.runtimeProjectId ?? projectName
161
+
162
+ if (
163
+ cachedProject?.dirty &&
164
+ cachedProject.projectId === activeProjectId
165
+ ) {
166
+ const normalized = normalizeLoadedProject(
167
+ cachedProject.pages,
168
+ cachedProject.appShell,
169
+ config.customComponents,
170
+ config.paths.pagesDir
171
+ )
172
+ scheduleTailwindSync(normalized.pages, normalized.appShell)
173
+ set({
174
+ projectName: cachedProject.projectName,
175
+ appShell: normalized.appShell,
176
+ pages: normalized.pages,
177
+ activePageId: cachedProject.activePageId,
178
+ selectedComponentId: null,
179
+ viewport: config.editor.defaultViewport,
180
+ isProjectLoaded: true,
181
+ customComponents: config.customComponents,
182
+ projectConfig: config,
183
+ })
184
+ return
185
+ }
186
+
187
+ const { pages, appShell, changed } = normalizeLoadedProject(
188
+ schema.pages,
189
+ schema.appShell ?? defaultAppShell,
190
+ config.customComponents,
191
+ config.paths.pagesDir
192
+ )
193
+ cacheProject(pages, appShell, false, projectName)
194
+
195
+ set({
196
+ projectName,
197
+ appShell,
198
+ pages,
199
+ activePageId: pages[0].id,
200
+ selectedComponentId: null,
201
+ viewport: config.editor.defaultViewport,
202
+ isProjectLoaded: true,
203
+ customComponents: config.customComponents,
204
+ projectConfig: config,
205
+ })
206
+
207
+ if (changed) {
208
+ persistProject(pages, appShell, projectName)
209
+ }
210
+ },
211
+
212
+ refreshCustomComponents: async () => {
213
+ const config = await loadProjectConfig()
214
+ set((state) => {
215
+ const normalized = normalizeLoadedProject(
216
+ state.pages,
217
+ state.appShell,
218
+ config.customComponents,
219
+ config.paths.pagesDir
220
+ )
221
+ const refreshed = replaceCustomComponentTreesFromDefinitions(
222
+ normalized.pages,
223
+ normalized.appShell,
224
+ config.customComponents
225
+ )
226
+
227
+ if (normalized.changed || refreshed.changed) {
228
+ persistProject(
229
+ refreshed.pages,
230
+ refreshed.appShell,
231
+ state.projectName
232
+ )
233
+ }
234
+
235
+ return {
236
+ customComponents: config.customComponents,
237
+ projectConfig: config,
238
+ pages: refreshed.pages,
239
+ appShell: refreshed.appShell,
240
+ }
241
+ })
242
+ },
243
+
244
+ refreshProjectFromDisk: async (force = false) => {
245
+ const schema = await loadProjectSchema().catch((error: unknown) => {
246
+ console.warn('Failed to refresh visualbuild/pages.json', error)
247
+ return null
248
+ })
249
+
250
+ if (!schema || schema.pages.length === 0) {
251
+ return 'failed'
252
+ }
253
+
254
+ const projectName = schema.meta?.projectName ?? defaultProjectName
255
+ const nextProjectId = schema.runtimeProjectId ?? projectName
256
+ const currentCache = readCachedProject()
257
+
258
+ if (
259
+ !force &&
260
+ currentCache?.dirty &&
261
+ currentCache.projectId === nextProjectId
262
+ ) {
263
+ return 'skipped-dirty'
264
+ }
265
+
266
+ const { pages, appShell } = normalizeLoadedProject(
267
+ schema.pages,
268
+ schema.appShell ?? defaultAppShell,
269
+ get().customComponents,
270
+ get().projectConfig.paths.pagesDir
271
+ )
272
+ const currentActivePageId = get().activePageId
273
+ const activePageId = pages.some(
274
+ (page) => page.id === currentActivePageId
275
+ )
276
+ ? currentActivePageId
277
+ : pages[0].id
278
+
279
+ activeProjectId = nextProjectId
280
+ cacheProject(pages, appShell, false, projectName)
281
+ set({
282
+ projectName,
283
+ appShell,
284
+ pages,
285
+ activePageId,
286
+ selectedComponentId: null,
287
+ isProjectLoaded: true,
288
+ })
289
+
290
+ return 'updated'
291
+ },
292
+
293
+ saveProject: async () => {
294
+ const { pages, appShell, isProjectLoaded, projectName } = get()
295
+
296
+ if (!isProjectLoaded) {
297
+ return
298
+ }
299
+
300
+ cacheProject(pages, appShell, true, projectName)
301
+ await saveProjectSchema(pages, appShell, projectName)
302
+ cacheProject(pages, appShell, false, projectName)
303
+ },
304
+
305
+ addPage: (name, route) => {
306
+ const pageId = generateId()
307
+ const page: Page = {
308
+ id: pageId,
309
+ name,
310
+ route,
311
+ sourcePath: createPageSourcePath(
312
+ name,
313
+ get().projectConfig.paths.pagesDir
314
+ ),
315
+ root: createPageRoot(pageId),
316
+ components: [],
317
+ }
318
+
319
+ set((state) => {
320
+ const pages = [
321
+ ...state.pages,
322
+ page,
323
+ ]
324
+
325
+ persistProject(pages, state.appShell, state.projectName)
326
+
327
+ return { pages }
328
+ })
329
+
330
+ return page
331
+ },
332
+
333
+ registerPage: (page) =>
334
+ set((state) => {
335
+ const pages = [...state.pages, page]
336
+
337
+ persistProject(pages, state.appShell, state.projectName)
338
+
339
+ return {
340
+ pages,
341
+ activePageId: page.id,
342
+ selectedComponentId: null,
343
+ }
344
+ }),
345
+
346
+ deletePage: (id) =>
347
+ set((state) => {
348
+ if (state.pages.length === 1) return state
349
+ const pages = state.pages.filter((page) => page.id !== id)
350
+ const activePageId =
351
+ state.activePageId === id ? pages[0].id : state.activePageId
352
+
353
+ persistProject(pages, state.appShell, state.projectName)
354
+
355
+ return {
356
+ pages,
357
+ activePageId,
358
+ selectedComponentId:
359
+ state.activePageId === id ? null : state.selectedComponentId,
360
+ }
361
+ }),
362
+
363
+ setActivePage: (id) => set({ activePageId: id, selectedComponentId: null }),
364
+
365
+ setSelectedComponent: (id) => set({ selectedComponentId: id }),
366
+
367
+ setViewport: (viewport) => set({ viewport }),
368
+
369
+ togglePreviewMode: () =>
370
+ set((state) => ({ previewMode: !state.previewMode })),
371
+
372
+ addComponent: (pageId, type) => {
373
+ get().addComponentAt(pageId, type, Number.POSITIVE_INFINITY)
374
+ },
375
+
376
+ addComponentAt: (pageId, type, index) =>
377
+ set((state) => {
378
+ const node = createComponentPreset(type)
379
+ const pages = state.pages.map((page) =>
380
+ page.id === pageId ? insertNodeAtRoot(page, node, index) : page
381
+ )
382
+
383
+ persistProject(pages, state.appShell, state.projectName)
384
+
385
+ return { pages, selectedComponentId: node.id }
386
+ }),
387
+
388
+ addPrimitive: (pageId, type) => {
389
+ get().addPrimitiveAt(pageId, type, Number.POSITIVE_INFINITY)
390
+ },
391
+
392
+ addPrimitiveAt: (pageId, type, index) =>
393
+ set((state) => {
394
+ const node = createPrimitive(type)
395
+ const pages = state.pages.map((page) =>
396
+ page.id === pageId ? insertNodeAtRoot(page, node, index) : page
397
+ )
398
+
399
+ persistProject(pages, state.appShell, state.projectName)
400
+
401
+ return { pages, selectedComponentId: node.id }
402
+ }),
403
+
404
+ addPrimitiveToNode: (parentNodeId, type) => {
405
+ get().addPrimitiveToNodeAt(parentNodeId, type, Number.POSITIVE_INFINITY)
406
+ },
407
+
408
+ addPrimitiveToNodeAt: (parentNodeId, type, index) =>
409
+ set((state) => {
410
+ const node = createPrimitive(type)
411
+ const customOwner = findCustomComponentOwner(
412
+ getProjectNodes(state.pages, state.appShell),
413
+ parentNodeId,
414
+ state.customComponents
415
+ )
416
+ if (customOwner) {
417
+ const targetIsCustomRoot = customOwner.node.id === parentNodeId
418
+ const updated = updateCustomComponentDefinitionTree(
419
+ state.pages,
420
+ state.appShell,
421
+ customOwner.type,
422
+ (children) =>
423
+ targetIsCustomRoot
424
+ ? insertNodeAt(children, node, index)
425
+ : addChildNodeAt(children, parentNodeId, node, index)
426
+ )
427
+ persistProject(updated.pages, updated.appShell, state.projectName)
428
+ return {
429
+ ...updated,
430
+ selectedComponentId: node.id,
431
+ }
432
+ }
433
+ const pages = state.pages.map((page) => ({
434
+ ...page,
435
+ components: addChildNodeAt(page.components, parentNodeId, node, index),
436
+ }))
437
+ const appShell = addChildToAppShell(
438
+ state.appShell,
439
+ parentNodeId,
440
+ node,
441
+ index
442
+ )
443
+
444
+ persistProject(pages, appShell, state.projectName)
445
+
446
+ return { appShell, pages, selectedComponentId: node.id }
447
+ }),
448
+
449
+ addComponentToNode: (parentNodeId, type) => {
450
+ get().addComponentToNodeAt(parentNodeId, type, Number.POSITIVE_INFINITY)
451
+ },
452
+
453
+ addComponentToNodeAt: (parentNodeId, type, index) =>
454
+ set((state) => {
455
+ const node = createComponentPreset(type)
456
+ const customOwner = findCustomComponentOwner(
457
+ getProjectNodes(state.pages, state.appShell),
458
+ parentNodeId,
459
+ state.customComponents
460
+ )
461
+ if (customOwner) {
462
+ const targetIsCustomRoot = customOwner.node.id === parentNodeId
463
+ const updated = updateCustomComponentDefinitionTree(
464
+ state.pages,
465
+ state.appShell,
466
+ customOwner.type,
467
+ (children) =>
468
+ targetIsCustomRoot
469
+ ? insertNodeAt(children, node, index)
470
+ : addChildNodeAt(children, parentNodeId, node, index)
471
+ )
472
+ persistProject(updated.pages, updated.appShell, state.projectName)
473
+ return {
474
+ ...updated,
475
+ selectedComponentId: node.id,
476
+ }
477
+ }
478
+ const pages = state.pages.map((page) => ({
479
+ ...page,
480
+ components: addChildNodeAt(page.components, parentNodeId, node, index),
481
+ }))
482
+ const appShell = addChildToAppShell(
483
+ state.appShell,
484
+ parentNodeId,
485
+ node,
486
+ index
487
+ )
488
+
489
+ persistProject(pages, appShell, state.projectName)
490
+
491
+ return { appShell, pages, selectedComponentId: node.id }
492
+ }),
493
+
494
+ addCustomComponent: (pageId, name) => {
495
+ get().addCustomComponentAt(pageId, name, Number.POSITIVE_INFINITY)
496
+ },
497
+
498
+ addCustomComponentAt: (pageId, name, index) =>
499
+ set((state) => {
500
+ const definition = findCustomComponent(state.customComponents, name)
501
+ const node = createCustomComponentNode(definition)
502
+ const pages = state.pages.map((page) =>
503
+ page.id === pageId ? insertNodeAtRoot(page, node, index) : page
504
+ )
505
+
506
+ persistProject(pages, state.appShell, state.projectName)
507
+ return { pages, selectedComponentId: node.id }
508
+ }),
509
+
510
+ addCustomComponentToNode: (parentNodeId, name) => {
511
+ get().addCustomComponentToNodeAt(
512
+ parentNodeId,
513
+ name,
514
+ Number.POSITIVE_INFINITY
515
+ )
516
+ },
517
+
518
+ addCustomComponentToNodeAt: (parentNodeId, name, index) =>
519
+ set((state) => {
520
+ const definition = findCustomComponent(state.customComponents, name)
521
+ const node = createCustomComponentNode(definition)
522
+ const customOwner = findCustomComponentOwner(
523
+ getProjectNodes(state.pages, state.appShell),
524
+ parentNodeId,
525
+ state.customComponents
526
+ )
527
+ if (customOwner) {
528
+ const targetIsCustomRoot = customOwner.node.id === parentNodeId
529
+ const updated = updateCustomComponentDefinitionTree(
530
+ state.pages,
531
+ state.appShell,
532
+ customOwner.type,
533
+ (children) =>
534
+ targetIsCustomRoot
535
+ ? insertNodeAt(children, node, index)
536
+ : addChildNodeAt(children, parentNodeId, node, index)
537
+ )
538
+ persistProject(updated.pages, updated.appShell, state.projectName)
539
+ return {
540
+ ...updated,
541
+ selectedComponentId: node.id,
542
+ }
543
+ }
544
+ const pages = state.pages.map((page) => ({
545
+ ...page,
546
+ components: addChildNodeAt(page.components, parentNodeId, node, index),
547
+ }))
548
+ const appShell = addChildToAppShell(
549
+ state.appShell,
550
+ parentNodeId,
551
+ node,
552
+ index
553
+ )
554
+
555
+ persistProject(pages, appShell, state.projectName)
556
+ return { appShell, pages, selectedComponentId: node.id }
557
+ }),
558
+
559
+ registerCustomComponent: (definition) =>
560
+ set((state) => ({
561
+ customComponents: state.customComponents.some(
562
+ (component) => component.name === definition.name
563
+ )
564
+ ? state.customComponents
565
+ : [...state.customComponents, definition],
566
+ })),
567
+
568
+ removeNode: (nodeId) =>
569
+ set((state) => {
570
+ const customOwner = findCustomComponentOwner(
571
+ getProjectNodes(state.pages, state.appShell),
572
+ nodeId,
573
+ state.customComponents,
574
+ false
575
+ )
576
+ if (customOwner) {
577
+ const updated = updateCustomComponentDefinitionTree(
578
+ state.pages,
579
+ state.appShell,
580
+ customOwner.type,
581
+ (children) => removeNodeById(children, nodeId)
582
+ )
583
+ persistProject(updated.pages, updated.appShell, state.projectName)
584
+ return {
585
+ ...updated,
586
+ selectedComponentId: null,
587
+ }
588
+ }
589
+ const appShell = removeShellNodeById(state.appShell, nodeId)
590
+ const pages = state.pages.map((page) => ({
591
+ ...page,
592
+ components: removeNodeById(page.components, nodeId),
593
+ }))
594
+
595
+ persistProject(pages, appShell, state.projectName)
596
+
597
+ return {
598
+ appShell,
599
+ pages,
600
+ selectedComponentId:
601
+ state.selectedComponentId === nodeId ? null : state.selectedComponentId,
602
+ }
603
+ }),
604
+
605
+ moveNode: (pageId, nodeId, targetParentNodeId, targetIndex) =>
606
+ set((state) => {
607
+ const projectNodes = getProjectNodes(state.pages, state.appShell)
608
+ const customOwner = findCustomComponentOwner(
609
+ projectNodes,
610
+ nodeId,
611
+ state.customComponents,
612
+ false
613
+ )
614
+ const targetOwner = targetParentNodeId
615
+ ? findCustomComponentOwner(
616
+ projectNodes,
617
+ targetParentNodeId,
618
+ state.customComponents
619
+ )
620
+ : null
621
+ if (
622
+ customOwner &&
623
+ targetOwner?.type === customOwner.type
624
+ ) {
625
+ const targetIsRoot =
626
+ targetOwner.node.id === targetParentNodeId
627
+ const updated = updateCustomComponentDefinitionTree(
628
+ state.pages,
629
+ state.appShell,
630
+ customOwner.type,
631
+ (children) =>
632
+ moveNodeWithinDefinition(
633
+ children,
634
+ nodeId,
635
+ targetIsRoot ? null : targetParentNodeId,
636
+ targetIndex
637
+ )
638
+ )
639
+ persistProject(updated.pages, updated.appShell, state.projectName)
640
+ return {
641
+ ...updated,
642
+ selectedComponentId: nodeId,
643
+ }
644
+ }
645
+ const movedProject = moveNodeInProject(
646
+ state.pages,
647
+ state.appShell,
648
+ pageId,
649
+ nodeId,
650
+ targetParentNodeId,
651
+ targetIndex,
652
+ state.customComponents
653
+ )
654
+
655
+ if (!movedProject) {
656
+ return state
657
+ }
658
+
659
+ persistProject(
660
+ movedProject.pages,
661
+ movedProject.appShell,
662
+ state.projectName
663
+ )
664
+
665
+ return {
666
+ pages: movedProject.pages,
667
+ appShell: movedProject.appShell,
668
+ selectedComponentId: nodeId,
669
+ }
670
+ }),
671
+
672
+ moveComponent: (pageId, nodeId, targetIndex) =>
673
+ set((state) => {
674
+ const pages = state.pages.map((page) =>
675
+ page.id === pageId ? moveRootNode(page, nodeId, targetIndex) : page
676
+ )
677
+
678
+ persistProject(pages, state.appShell, state.projectName)
679
+
680
+ return { pages }
681
+ }),
682
+
683
+ removeComponent: (pageId, nodeId) =>
684
+ set((state) => {
685
+ const pages = state.pages.map((page) =>
686
+ page.id === pageId
687
+ ? { ...page, components: removeNodeById(page.components, nodeId) }
688
+ : page
689
+ )
690
+
691
+ persistProject(pages, state.appShell, state.projectName)
692
+
693
+ return {
694
+ pages,
695
+ selectedComponentId:
696
+ state.selectedComponentId === nodeId ? null : state.selectedComponentId,
697
+ }
698
+ }),
699
+
700
+ setAppShellComponent: (_slot, type) => {
701
+ const slot = type === 'Footer' ? 'footer' : 'header'
702
+
703
+ set((state) => {
704
+ const node = createComponentPreset(type)
705
+ const appShell = {
706
+ ...state.appShell,
707
+ [slot]: node,
708
+ }
709
+
710
+ persistProject(state.pages, appShell, state.projectName)
711
+
712
+ return {
713
+ appShell,
714
+ selectedComponentId: node.id,
715
+ }
716
+ })
717
+ },
718
+
719
+ removeAppShellComponent: (slot) =>
720
+ set((state) => {
721
+ const removedNodeId = state.appShell[slot]?.id
722
+ const appShell = {
723
+ ...state.appShell,
724
+ [slot]: null,
725
+ }
726
+
727
+ persistProject(state.pages, appShell, state.projectName)
728
+
729
+ return {
730
+ appShell,
731
+ selectedComponentId:
732
+ state.selectedComponentId === removedNodeId
733
+ ? null
734
+ : state.selectedComponentId,
735
+ }
736
+ }),
737
+
738
+ updateComponentProps: (_pageId, nodeId, props) => {
739
+ get().updateNodeProps(nodeId, props)
740
+ },
741
+
742
+ updateNodeProps: (nodeId, props) =>
743
+ set((state) => {
744
+ const projectNodes = getProjectNodes(state.pages, state.appShell)
745
+ const definitionOwner = findCustomComponentOwner(
746
+ projectNodes,
747
+ nodeId
748
+ ,
749
+ state.customComponents,
750
+ false
751
+ )
752
+ if (definitionOwner) {
753
+ const updated = updateCustomComponentDefinitionTree(
754
+ state.pages,
755
+ state.appShell,
756
+ definitionOwner.type,
757
+ (children) => updateNodeProps(children, nodeId, props)
758
+ )
759
+ persistProject(updated.pages, updated.appShell, state.projectName)
760
+ return updated
761
+ }
762
+ const selectedNode = findNodeInTreeList(projectNodes, nodeId)
763
+ const customType = state.customComponents.some(
764
+ (component) => component.name === selectedNode?.type
765
+ )
766
+ ? selectedNode?.type
767
+ : null
768
+ const appShell = customType
769
+ ? updateShellNodesByType(state.appShell, customType, props)
770
+ : updateShellNodeProps(state.appShell, nodeId, props)
771
+ const pages = state.pages.map((page) => ({
772
+ ...page,
773
+ components: customType
774
+ ? updateNodesByType(page.components, customType, props)
775
+ : updateNodeProps(page.components, nodeId, props),
776
+ }))
777
+
778
+ persistProject(pages, appShell, state.projectName)
779
+
780
+ return { appShell, pages }
781
+ }),
782
+
783
+ updatePageRoot: (pageId, updates) =>
784
+ set((state) => {
785
+ const pages = state.pages.map((page) => {
786
+ if (page.id !== pageId) {
787
+ return page
788
+ }
789
+
790
+ const nextType = (
791
+ updates.type ?? page.root.type
792
+ ) as PrimitiveType
793
+ const defaultProps =
794
+ updates.type && updates.type !== page.root.type
795
+ ? primitiveRegistry[nextType].defaultProps
796
+ : {}
797
+
798
+ return {
799
+ ...page,
800
+ root: {
801
+ ...page.root,
802
+ type: nextType,
803
+ props: {
804
+ ...defaultProps,
805
+ ...page.root.props,
806
+ ...(updates.props ?? {}),
807
+ },
808
+ },
809
+ }
810
+ })
811
+
812
+ persistProject(pages, state.appShell, state.projectName)
813
+
814
+ return { pages }
815
+ }),
816
+
817
+ updateAppShellProps: (slot, props) =>
818
+ set((state) => {
819
+ const node = state.appShell[slot]
820
+
821
+ if (!node) {
822
+ return state
823
+ }
824
+
825
+ const appShell = {
826
+ ...state.appShell,
827
+ [slot]: {
828
+ ...node,
829
+ props: {
830
+ ...node.props,
831
+ ...props,
832
+ },
833
+ },
834
+ }
835
+
836
+ persistProject(state.pages, appShell, state.projectName)
837
+
838
+ return { appShell }
839
+ }),
840
+ }))
841
+
842
+ function createPageRoot(pageId: string, type: PrimitiveType = 'div'): VisualNode {
843
+ return {
844
+ id: `${pageId}:root`,
845
+ type,
846
+ label: 'Page root',
847
+ props: {
848
+ ...primitiveRegistry[type].defaultProps,
849
+ className: 'min-h-screen',
850
+ },
851
+ children: [],
852
+ }
853
+ }
854
+
855
+ function createPrimitive(type: PrimitiveType, props = {}): VisualNode {
856
+ return {
857
+ id: generateId(),
858
+ type,
859
+ props: { ...primitiveRegistry[type].defaultProps, ...props },
860
+ children: [],
861
+ }
862
+ }
863
+
864
+ function createPresetNode(
865
+ type: PrimitiveType,
866
+ label: string,
867
+ props: Record<string, unknown>,
868
+ children: VisualNode[] = []
869
+ ): VisualNode {
870
+ return {
871
+ id: generateId(),
872
+ type,
873
+ label,
874
+ props: { ...primitiveRegistry[type].defaultProps, ...props },
875
+ children,
876
+ }
877
+ }
878
+
879
+ function findCustomComponent(
880
+ definitions: CustomComponentDefinition[],
881
+ name: string
882
+ ) {
883
+ const definition = definitions.find((candidate) => candidate.name === name)
884
+
885
+ if (!definition) {
886
+ throw new Error(`Custom component "${name}" is not registered`)
887
+ }
888
+
889
+ return definition
890
+ }
891
+
892
+ function createCustomComponentNode(
893
+ definition: CustomComponentDefinition
894
+ ): VisualNode {
895
+ return {
896
+ id: generateId(),
897
+ type: definition.name,
898
+ label: definition.label,
899
+ props: {
900
+ ...definition.defaultProps,
901
+ componentTree: {
902
+ children: cloneVisualNodes(
903
+ definition.preview?.children.filter(
904
+ (node) => node.type !== customComponentChildrenSlotType
905
+ ) ?? []
906
+ ),
907
+ },
908
+ },
909
+ children: [],
910
+ }
911
+ }
912
+
913
+ export function createComponentPreset(
914
+ type: ComponentType,
915
+ overrides: Record<string, unknown> = {}
916
+ ): VisualNode {
917
+ if (type === 'Navbar') {
918
+ return {
919
+ id: String(overrides.navId ?? generateId()),
920
+ type: 'nav',
921
+ label: 'Navbar',
922
+ props: {
923
+ ...primitiveRegistry.nav.defaultProps,
924
+ className:
925
+ 'flex items-center justify-between px-6 py-3 bg-gray-900 text-white',
926
+ },
927
+ children: [
928
+ createPrimitive('span', {
929
+ text: String(overrides.logo ?? 'My App'),
930
+ className: 'font-bold text-sm',
931
+ }),
932
+ createPrimitive('div', {
933
+ className: 'flex items-center gap-4',
934
+ }),
935
+ createPrimitive('div', {
936
+ className: 'flex items-center gap-2',
937
+ }),
938
+ ],
939
+ }
940
+ }
941
+
942
+ if (type === 'Hero') {
943
+ return {
944
+ id: String(overrides.sectionId ?? generateId()),
945
+ type: 'section',
946
+ label: 'Hero',
947
+ props: {
948
+ className:
949
+ 'bg-gradient-to-br from-blue-600 to-indigo-700 text-white px-8 py-12 text-center',
950
+ },
951
+ children: [
952
+ createPrimitive('h1', {
953
+ text: String(overrides.title ?? 'Welcome to My App'),
954
+ className: 'text-2xl font-bold mb-2',
955
+ }),
956
+ createPrimitive('p', {
957
+ text: String(
958
+ overrides.subtitle ?? 'Built visually. Owned as React code.'
959
+ ),
960
+ className: 'text-sm text-blue-100 mb-6',
961
+ }),
962
+ createPrimitive('button', {
963
+ text: String(overrides.cta ?? 'Start Building'),
964
+ className:
965
+ 'px-5 py-2 bg-white text-blue-700 rounded-full text-sm font-semibold',
966
+ }),
967
+ ],
968
+ }
969
+ }
970
+
971
+ if (type === 'Card') {
972
+ return {
973
+ id: generateId(),
974
+ type: 'article',
975
+ label: 'Card',
976
+ props: { ...primitiveRegistry.article.defaultProps },
977
+ children: [
978
+ createPrimitive('h3', {
979
+ text: 'Card Title',
980
+ className: 'font-semibold text-gray-900 mb-1',
981
+ }),
982
+ createPrimitive('p', {
983
+ text: 'Card description goes here.',
984
+ className: 'text-sm text-gray-500',
985
+ }),
986
+ ],
987
+ }
988
+ }
989
+
990
+ if (type === 'Button') {
991
+ return createPrimitive('button', {
992
+ text: 'Click me',
993
+ className: 'px-4 py-1.5 rounded text-sm font-medium bg-blue-600 text-white',
994
+ })
995
+ }
996
+
997
+ if (type === 'Footer') {
998
+ return createPresetNode(
999
+ 'footer',
1000
+ 'Footer',
1001
+ {},
1002
+ [
1003
+ createPrimitive('span', {
1004
+ text: 'Copyright 2026 MySite',
1005
+ className: '',
1006
+ }),
1007
+ ]
1008
+ )
1009
+ }
1010
+
1011
+ if (type === 'Grid') {
1012
+ return createPresetNode(
1013
+ 'section',
1014
+ 'Grid',
1015
+ { className: 'w-full px-6 py-10' },
1016
+ [
1017
+ createPrimitive('h2', {
1018
+ text: 'Explore the collection',
1019
+ className: 'mb-6 text-2xl font-semibold text-gray-900',
1020
+ }),
1021
+ createPresetNode(
1022
+ 'div',
1023
+ 'Grid items',
1024
+ {
1025
+ className:
1026
+ 'grid w-full grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3',
1027
+ },
1028
+ ['One', 'Two', 'Three'].map((title) =>
1029
+ createPresetNode(
1030
+ 'article',
1031
+ 'Grid item',
1032
+ {
1033
+ className:
1034
+ 'rounded-lg border border-gray-200 bg-white p-5 shadow-sm',
1035
+ },
1036
+ [
1037
+ createPrimitive('h3', {
1038
+ text: `Item ${title}`,
1039
+ className: 'text-lg font-semibold text-gray-900',
1040
+ }),
1041
+ createPrimitive('p', {
1042
+ text: 'Add your content here.',
1043
+ className: 'mt-2 text-sm text-gray-600',
1044
+ }),
1045
+ ]
1046
+ )
1047
+ )
1048
+ ),
1049
+ ]
1050
+ )
1051
+ }
1052
+
1053
+ if (type === 'Testimonials') {
1054
+ return createPresetNode(
1055
+ 'section',
1056
+ 'Testimonials',
1057
+ { className: 'w-full bg-gray-50 px-6 py-12' },
1058
+ [
1059
+ createPrimitive('h2', {
1060
+ text: 'What customers say',
1061
+ className: 'mb-8 text-center text-2xl font-semibold text-gray-900',
1062
+ }),
1063
+ createPresetNode(
1064
+ 'div',
1065
+ 'Testimonial list',
1066
+ { className: 'grid grid-cols-1 gap-5 md:grid-cols-3' },
1067
+ ['Alex Morgan', 'Sam Rivera', 'Jamie Lee'].map((name) =>
1068
+ createPresetNode(
1069
+ 'blockquote',
1070
+ 'Testimonial',
1071
+ {
1072
+ className:
1073
+ 'rounded-lg border border-gray-200 bg-white p-6 shadow-sm',
1074
+ },
1075
+ [
1076
+ createPrimitive('p', {
1077
+ text: 'This product made our workflow faster and clearer.',
1078
+ className: 'text-sm leading-6 text-gray-700',
1079
+ }),
1080
+ createPrimitive('cite', {
1081
+ text: name,
1082
+ className: 'mt-4 block text-sm font-semibold text-gray-900',
1083
+ }),
1084
+ ]
1085
+ )
1086
+ )
1087
+ ),
1088
+ ]
1089
+ )
1090
+ }
1091
+
1092
+ if (type === 'Pricing') {
1093
+ return createPresetNode(
1094
+ 'section',
1095
+ 'Pricing',
1096
+ { className: 'w-full px-6 py-12' },
1097
+ [
1098
+ createPrimitive('h2', {
1099
+ text: 'Simple pricing',
1100
+ className: 'mb-8 text-center text-2xl font-semibold text-gray-900',
1101
+ }),
1102
+ createPresetNode(
1103
+ 'div',
1104
+ 'Pricing plans',
1105
+ { className: 'grid grid-cols-1 gap-5 md:grid-cols-3' },
1106
+ [
1107
+ ['Starter', '$9'],
1108
+ ['Professional', '$29'],
1109
+ ['Team', '$59'],
1110
+ ].map(([name, price]) =>
1111
+ createPresetNode(
1112
+ 'article',
1113
+ 'Pricing plan',
1114
+ {
1115
+ className:
1116
+ 'rounded-lg border border-gray-200 bg-white p-6 text-center shadow-sm',
1117
+ },
1118
+ [
1119
+ createPrimitive('h3', {
1120
+ text: name,
1121
+ className: 'text-lg font-semibold text-gray-900',
1122
+ }),
1123
+ createPrimitive('p', {
1124
+ text: `${price} / month`,
1125
+ className: 'my-4 text-2xl font-bold text-blue-600',
1126
+ }),
1127
+ createPrimitive('button', {
1128
+ text: 'Choose plan',
1129
+ className:
1130
+ 'rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white',
1131
+ }),
1132
+ ]
1133
+ )
1134
+ )
1135
+ ),
1136
+ ]
1137
+ )
1138
+ }
1139
+
1140
+ if (type === 'Form') {
1141
+ return createPresetNode(
1142
+ 'form',
1143
+ 'Form',
1144
+ {
1145
+ className:
1146
+ 'mx-auto w-full max-w-xl space-y-4 rounded-lg border border-gray-200 bg-white p-6 shadow-sm',
1147
+ },
1148
+ [
1149
+ createPrimitive('h2', {
1150
+ text: 'Tell us about yourself',
1151
+ className: 'text-xl font-semibold text-gray-900',
1152
+ }),
1153
+ createPrimitive('label', {
1154
+ text: 'Name',
1155
+ className: 'block text-sm font-medium text-gray-700',
1156
+ }),
1157
+ createPrimitive('input', {
1158
+ type: 'text',
1159
+ placeholder: 'Your name',
1160
+ className:
1161
+ 'w-full rounded-md border border-gray-300 px-3 py-2 text-sm',
1162
+ }),
1163
+ createPrimitive('button', {
1164
+ text: 'Submit',
1165
+ className:
1166
+ 'rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white',
1167
+ }),
1168
+ ]
1169
+ )
1170
+ }
1171
+
1172
+ if (type === 'Image') {
1173
+ return createPresetNode(
1174
+ 'figure',
1175
+ 'Image',
1176
+ { className: 'w-full overflow-hidden rounded-lg bg-gray-100' },
1177
+ [
1178
+ createPrimitive('img', {
1179
+ src: 'https://images.unsplash.com/photo-1498050108023-c5249f4df085?auto=format&fit=crop&w=1200&q=80',
1180
+ alt: 'Workspace with a laptop',
1181
+ className: 'h-72 w-full object-cover',
1182
+ }),
1183
+ createPrimitive('figcaption', {
1184
+ text: 'Replace this image and caption with your own.',
1185
+ className: 'px-4 py-3 text-sm text-gray-600',
1186
+ }),
1187
+ ]
1188
+ )
1189
+ }
1190
+
1191
+ if (type === 'Features') {
1192
+ return createPresetNode(
1193
+ 'section',
1194
+ 'Features',
1195
+ { className: 'w-full px-6 py-12' },
1196
+ [
1197
+ createPrimitive('h2', {
1198
+ text: 'Everything you need',
1199
+ className: 'mb-8 text-center text-2xl font-semibold text-gray-900',
1200
+ }),
1201
+ createPresetNode(
1202
+ 'div',
1203
+ 'Feature list',
1204
+ { className: 'grid grid-cols-1 gap-6 md:grid-cols-3' },
1205
+ ['Fast setup', 'Flexible design', 'Owned source code'].map(
1206
+ (title, index) =>
1207
+ createPresetNode(
1208
+ 'article',
1209
+ 'Feature',
1210
+ { className: 'rounded-lg bg-gray-50 p-5' },
1211
+ [
1212
+ createPrimitive('span', {
1213
+ text: String(index + 1).padStart(2, '0'),
1214
+ className: 'text-xs font-bold text-blue-600',
1215
+ }),
1216
+ createPrimitive('h3', {
1217
+ text: title,
1218
+ className: 'mt-3 text-lg font-semibold text-gray-900',
1219
+ }),
1220
+ createPrimitive('p', {
1221
+ text: 'Describe this product benefit in one clear sentence.',
1222
+ className: 'mt-2 text-sm text-gray-600',
1223
+ }),
1224
+ ]
1225
+ )
1226
+ )
1227
+ ),
1228
+ ]
1229
+ )
1230
+ }
1231
+
1232
+ if (type === 'Gallery') {
1233
+ return createPresetNode(
1234
+ 'section',
1235
+ 'Gallery',
1236
+ { className: 'w-full px-6 py-12' },
1237
+ [
1238
+ createPrimitive('h2', {
1239
+ text: 'Gallery',
1240
+ className: 'mb-6 text-2xl font-semibold text-gray-900',
1241
+ }),
1242
+ createPresetNode(
1243
+ 'div',
1244
+ 'Gallery grid',
1245
+ { className: 'grid grid-cols-2 gap-3 md:grid-cols-3' },
1246
+ [1015, 1016, 1025].map((imageId, index) =>
1247
+ createPresetNode(
1248
+ 'figure',
1249
+ 'Gallery image',
1250
+ { className: 'overflow-hidden rounded-lg bg-gray-100' },
1251
+ [
1252
+ createPrimitive('img', {
1253
+ src: `https://picsum.photos/id/${imageId}/800/600`,
1254
+ alt: `Gallery item ${index + 1}`,
1255
+ className: 'aspect-[4/3] w-full object-cover',
1256
+ }),
1257
+ ]
1258
+ )
1259
+ )
1260
+ ),
1261
+ ]
1262
+ )
1263
+ }
1264
+
1265
+ if (type === 'FAQ') {
1266
+ return createPresetNode(
1267
+ 'section',
1268
+ 'FAQ',
1269
+ { className: 'mx-auto w-full max-w-3xl px-6 py-12' },
1270
+ [
1271
+ createPrimitive('h2', {
1272
+ text: 'Frequently asked questions',
1273
+ className: 'mb-6 text-2xl font-semibold text-gray-900',
1274
+ }),
1275
+ ...[
1276
+ 'Can I edit every element?',
1277
+ 'Does the builder generate React code?',
1278
+ 'Can I use Tailwind classes?',
1279
+ ].map((question) =>
1280
+ createPresetNode(
1281
+ 'details',
1282
+ 'FAQ item',
1283
+ { className: 'border-b border-gray-200 py-4' },
1284
+ [
1285
+ createPrimitive('summary', {
1286
+ text: question,
1287
+ className: 'cursor-pointer font-medium text-gray-900',
1288
+ }),
1289
+ createPrimitive('p', {
1290
+ text: 'Add a concise answer for this question.',
1291
+ className: 'mt-3 text-sm text-gray-600',
1292
+ }),
1293
+ ]
1294
+ )
1295
+ ),
1296
+ ]
1297
+ )
1298
+ }
1299
+
1300
+ if (type === 'ContactForm') {
1301
+ return createPresetNode(
1302
+ 'section',
1303
+ 'Contact Form',
1304
+ { className: 'w-full bg-gray-50 px-6 py-12' },
1305
+ [
1306
+ createPresetNode(
1307
+ 'form',
1308
+ 'Contact form fields',
1309
+ {
1310
+ className:
1311
+ 'mx-auto w-full max-w-xl space-y-4 rounded-lg bg-white p-6 shadow-sm',
1312
+ },
1313
+ [
1314
+ createPrimitive('h2', {
1315
+ text: 'Contact us',
1316
+ className: 'text-2xl font-semibold text-gray-900',
1317
+ }),
1318
+ createPrimitive('input', {
1319
+ type: 'text',
1320
+ placeholder: 'Name',
1321
+ className:
1322
+ 'w-full rounded-md border border-gray-300 px-3 py-2 text-sm',
1323
+ }),
1324
+ createPrimitive('input', {
1325
+ type: 'email',
1326
+ placeholder: 'Email',
1327
+ className:
1328
+ 'w-full rounded-md border border-gray-300 px-3 py-2 text-sm',
1329
+ }),
1330
+ createPrimitive('textarea', {
1331
+ placeholder: 'Message',
1332
+ className:
1333
+ 'min-h-32 w-full rounded-md border border-gray-300 px-3 py-2 text-sm',
1334
+ }),
1335
+ createPrimitive('button', {
1336
+ text: 'Send message',
1337
+ className:
1338
+ 'rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white',
1339
+ }),
1340
+ ]
1341
+ ),
1342
+ ]
1343
+ )
1344
+ }
1345
+
1346
+ return createPresetNode(
1347
+ 'section',
1348
+ 'Login Form',
1349
+ { className: 'flex w-full justify-center bg-gray-50 px-6 py-12' },
1350
+ [
1351
+ createPresetNode(
1352
+ 'form',
1353
+ 'Login form fields',
1354
+ {
1355
+ className:
1356
+ 'w-full max-w-sm space-y-4 rounded-lg bg-white p-6 shadow-sm',
1357
+ },
1358
+ [
1359
+ createPrimitive('h2', {
1360
+ text: 'Welcome back',
1361
+ className: 'text-2xl font-semibold text-gray-900',
1362
+ }),
1363
+ createPrimitive('input', {
1364
+ type: 'email',
1365
+ placeholder: 'Email',
1366
+ className:
1367
+ 'w-full rounded-md border border-gray-300 px-3 py-2 text-sm',
1368
+ }),
1369
+ createPrimitive('input', {
1370
+ type: 'password',
1371
+ placeholder: 'Password',
1372
+ className:
1373
+ 'w-full rounded-md border border-gray-300 px-3 py-2 text-sm',
1374
+ }),
1375
+ createPrimitive('button', {
1376
+ text: 'Sign in',
1377
+ className:
1378
+ 'w-full rounded-md bg-gray-900 px-4 py-2 text-sm font-semibold text-white',
1379
+ }),
1380
+ ]
1381
+ ),
1382
+ ]
1383
+ )
1384
+ }
1385
+
1386
+ function insertNodeAtRoot(page: Page, node: VisualNode, index: number): Page {
1387
+ const components = [...page.components]
1388
+ const safeIndex = Math.max(0, Math.min(index, components.length))
1389
+
1390
+ components.splice(safeIndex, 0, node)
1391
+
1392
+ return { ...page, components }
1393
+ }
1394
+
1395
+ function addChildNodeAt(
1396
+ nodes: VisualNode[],
1397
+ parentNodeId: string,
1398
+ child: VisualNode,
1399
+ index: number
1400
+ ): VisualNode[] {
1401
+ return nodes.map((node) =>
1402
+ node.id === parentNodeId
1403
+ ? { ...node, children: insertNodeAt(node.children, child, index) }
1404
+ : {
1405
+ ...node,
1406
+ children: addChildNodeAt(node.children, parentNodeId, child, index),
1407
+ }
1408
+ )
1409
+ }
1410
+
1411
+ function addChildToAppShell(
1412
+ appShell: AppShell,
1413
+ parentNodeId: string,
1414
+ child: VisualNode,
1415
+ index: number
1416
+ ): AppShell {
1417
+ return {
1418
+ header: appShell.header
1419
+ ? insertChildIntoTree(appShell.header, parentNodeId, child, index)
1420
+ : null,
1421
+ footer: appShell.footer
1422
+ ? insertChildIntoTree(appShell.footer, parentNodeId, child, index)
1423
+ : null,
1424
+ }
1425
+ }
1426
+
1427
+ function removeNodeById(nodes: VisualNode[], nodeId: string): VisualNode[] {
1428
+ return nodes
1429
+ .filter((node) => node.id !== nodeId)
1430
+ .map((node) => ({
1431
+ ...node,
1432
+ children: removeNodeById(node.children, nodeId),
1433
+ }))
1434
+ }
1435
+
1436
+ function removeShellNodeById(appShell: AppShell, nodeId: string): AppShell {
1437
+ return {
1438
+ header:
1439
+ appShell.header?.id === nodeId
1440
+ ? null
1441
+ : appShell.header
1442
+ ? {
1443
+ ...appShell.header,
1444
+ children: removeNodeById(appShell.header.children, nodeId),
1445
+ }
1446
+ : null,
1447
+ footer:
1448
+ appShell.footer?.id === nodeId
1449
+ ? null
1450
+ : appShell.footer
1451
+ ? {
1452
+ ...appShell.footer,
1453
+ children: removeNodeById(appShell.footer.children, nodeId),
1454
+ }
1455
+ : null,
1456
+ }
1457
+ }
1458
+
1459
+ function updateNodeProps(
1460
+ nodes: VisualNode[],
1461
+ nodeId: string,
1462
+ props: Record<string, unknown>
1463
+ ): VisualNode[] {
1464
+ return nodes.map((node) =>
1465
+ node.id === nodeId
1466
+ ? { ...node, props: { ...node.props, ...props } }
1467
+ : { ...node, children: updateNodeProps(node.children, nodeId, props) }
1468
+ )
1469
+ }
1470
+
1471
+ function updateShellNodeProps(
1472
+ appShell: AppShell,
1473
+ nodeId: string,
1474
+ props: Record<string, unknown>
1475
+ ): AppShell {
1476
+ return {
1477
+ header: appShell.header
1478
+ ? updateNodeInTree(appShell.header, nodeId, props)
1479
+ : null,
1480
+ footer: appShell.footer
1481
+ ? updateNodeInTree(appShell.footer, nodeId, props)
1482
+ : null,
1483
+ }
1484
+ }
1485
+
1486
+ function updateNodesByType(
1487
+ nodes: VisualNode[],
1488
+ type: string,
1489
+ props: Record<string, unknown>
1490
+ ): VisualNode[] {
1491
+ return nodes.map((node) => ({
1492
+ ...node,
1493
+ props:
1494
+ node.type === type ? { ...node.props, ...props } : node.props,
1495
+ children: updateNodesByType(node.children, type, props),
1496
+ }))
1497
+ }
1498
+
1499
+ function updateShellNodesByType(
1500
+ appShell: AppShell,
1501
+ type: string,
1502
+ props: Record<string, unknown>
1503
+ ): AppShell {
1504
+ const updateRoot = (node: VisualNode | null) =>
1505
+ node
1506
+ ? {
1507
+ ...node,
1508
+ props:
1509
+ node.type === type ? { ...node.props, ...props } : node.props,
1510
+ children: updateNodesByType(node.children, type, props),
1511
+ }
1512
+ : null
1513
+
1514
+ return {
1515
+ header: updateRoot(appShell.header),
1516
+ footer: updateRoot(appShell.footer),
1517
+ }
1518
+ }
1519
+
1520
+ function updateNodeInTree(
1521
+ node: VisualNode,
1522
+ nodeId: string,
1523
+ props: Record<string, unknown>
1524
+ ): VisualNode {
1525
+ if (node.id === nodeId) {
1526
+ return { ...node, props: { ...node.props, ...props } }
1527
+ }
1528
+
1529
+ return {
1530
+ ...node,
1531
+ children: node.children.map((child) =>
1532
+ updateNodeInTree(child, nodeId, props)
1533
+ ),
1534
+ }
1535
+ }
1536
+
1537
+ type NodeScope = `page:${string}` | 'shell:header' | 'shell:footer'
1538
+
1539
+ interface NodeLocation {
1540
+ node: VisualNode
1541
+ parentNodeId: string | null
1542
+ index: number
1543
+ scope: NodeScope
1544
+ }
1545
+
1546
+ function moveNodeInProject(
1547
+ pages: Page[],
1548
+ appShell: AppShell,
1549
+ pageId: string,
1550
+ nodeId: string,
1551
+ targetParentNodeId: string | null,
1552
+ targetIndex: number,
1553
+ customComponents: CustomComponentDefinition[] = []
1554
+ ) {
1555
+ const source = findMovableNode(pages, appShell, nodeId)
1556
+
1557
+ if (!source) {
1558
+ return null
1559
+ }
1560
+
1561
+ if (
1562
+ targetParentNodeId === nodeId ||
1563
+ (targetParentNodeId !== null &&
1564
+ containsNode(source.node, targetParentNodeId))
1565
+ ) {
1566
+ return null
1567
+ }
1568
+
1569
+ const destination = findDestination(
1570
+ pages,
1571
+ appShell,
1572
+ pageId,
1573
+ targetParentNodeId
1574
+ )
1575
+
1576
+ if (!destination) {
1577
+ return null
1578
+ }
1579
+
1580
+ if (
1581
+ destination.parentNode &&
1582
+ !(
1583
+ (isPrimitiveType(destination.parentNode.type) &&
1584
+ primitiveRegistry[destination.parentNode.type].acceptsChildren) ||
1585
+ customComponents.some(
1586
+ (component) =>
1587
+ component.name === destination.parentNode?.type &&
1588
+ component.acceptsChildren
1589
+ )
1590
+ )
1591
+ ) {
1592
+ return null
1593
+ }
1594
+
1595
+ let insertionIndex = targetIndex
1596
+
1597
+ if (
1598
+ source.scope === destination.scope &&
1599
+ source.parentNodeId === targetParentNodeId &&
1600
+ source.index < targetIndex
1601
+ ) {
1602
+ insertionIndex -= 1
1603
+ }
1604
+
1605
+ let nextPages = pages.map((page) => ({
1606
+ ...page,
1607
+ components: removeNodeById(page.components, nodeId),
1608
+ }))
1609
+ let nextAppShell: AppShell = {
1610
+ header: appShell.header
1611
+ ? {
1612
+ ...appShell.header,
1613
+ children: removeNodeById(appShell.header.children, nodeId),
1614
+ }
1615
+ : null,
1616
+ footer: appShell.footer
1617
+ ? {
1618
+ ...appShell.footer,
1619
+ children: removeNodeById(appShell.footer.children, nodeId),
1620
+ }
1621
+ : null,
1622
+ }
1623
+
1624
+ if (targetParentNodeId === null) {
1625
+ nextPages = nextPages.map((page) =>
1626
+ page.id === pageId
1627
+ ? insertNodeAtRoot(page, source.node, insertionIndex)
1628
+ : page
1629
+ )
1630
+ } else {
1631
+ nextPages = nextPages.map((page) => ({
1632
+ ...page,
1633
+ components: addChildNodeAt(
1634
+ page.components,
1635
+ targetParentNodeId,
1636
+ source.node,
1637
+ insertionIndex
1638
+ ),
1639
+ }))
1640
+ nextAppShell = addChildToAppShell(
1641
+ nextAppShell,
1642
+ targetParentNodeId,
1643
+ source.node,
1644
+ insertionIndex
1645
+ )
1646
+ }
1647
+
1648
+ return { pages: nextPages, appShell: nextAppShell }
1649
+ }
1650
+
1651
+ function findMovableNode(
1652
+ pages: Page[],
1653
+ appShell: AppShell,
1654
+ nodeId: string
1655
+ ): NodeLocation | null {
1656
+ for (const page of pages) {
1657
+ const location = findNodeLocation(
1658
+ page.components,
1659
+ nodeId,
1660
+ null,
1661
+ `page:${page.id}`
1662
+ )
1663
+
1664
+ if (location) {
1665
+ return location
1666
+ }
1667
+ }
1668
+
1669
+ if (appShell.header) {
1670
+ const location = findNodeLocation(
1671
+ appShell.header.children,
1672
+ nodeId,
1673
+ appShell.header.id,
1674
+ 'shell:header'
1675
+ )
1676
+
1677
+ if (location) {
1678
+ return location
1679
+ }
1680
+ }
1681
+
1682
+ if (appShell.footer) {
1683
+ return findNodeLocation(
1684
+ appShell.footer.children,
1685
+ nodeId,
1686
+ appShell.footer.id,
1687
+ 'shell:footer'
1688
+ )
1689
+ }
1690
+
1691
+ return null
1692
+ }
1693
+
1694
+ function findNodeLocation(
1695
+ nodes: VisualNode[],
1696
+ nodeId: string,
1697
+ parentNodeId: string | null,
1698
+ scope: NodeScope
1699
+ ): NodeLocation | null {
1700
+ for (const [index, node] of nodes.entries()) {
1701
+ if (node.id === nodeId) {
1702
+ return { node, parentNodeId, index, scope }
1703
+ }
1704
+
1705
+ const childLocation = findNodeLocation(
1706
+ node.children,
1707
+ nodeId,
1708
+ node.id,
1709
+ scope
1710
+ )
1711
+
1712
+ if (childLocation) {
1713
+ return childLocation
1714
+ }
1715
+ }
1716
+
1717
+ return null
1718
+ }
1719
+
1720
+ function findDestination(
1721
+ pages: Page[],
1722
+ appShell: AppShell,
1723
+ pageId: string,
1724
+ parentNodeId: string | null
1725
+ ): { scope: NodeScope; parentNode: VisualNode | null } | null {
1726
+ if (parentNodeId === null) {
1727
+ return pages.some((page) => page.id === pageId)
1728
+ ? { scope: `page:${pageId}`, parentNode: null }
1729
+ : null
1730
+ }
1731
+
1732
+ for (const page of pages) {
1733
+ const parentNode = findNodeInTreeList(page.components, parentNodeId)
1734
+
1735
+ if (parentNode) {
1736
+ return { scope: `page:${page.id}`, parentNode }
1737
+ }
1738
+ }
1739
+
1740
+ if (appShell.header) {
1741
+ const parentNode = findNodeInTree(appShell.header, parentNodeId)
1742
+
1743
+ if (parentNode) {
1744
+ return { scope: 'shell:header', parentNode }
1745
+ }
1746
+ }
1747
+
1748
+ if (appShell.footer) {
1749
+ const parentNode = findNodeInTree(appShell.footer, parentNodeId)
1750
+
1751
+ if (parentNode) {
1752
+ return { scope: 'shell:footer', parentNode }
1753
+ }
1754
+ }
1755
+
1756
+ return null
1757
+ }
1758
+
1759
+ function findNodeInTreeList(nodes: VisualNode[], nodeId: string) {
1760
+ for (const node of nodes) {
1761
+ const match = findNodeInTree(node, nodeId)
1762
+
1763
+ if (match) {
1764
+ return match
1765
+ }
1766
+ }
1767
+
1768
+ return null
1769
+ }
1770
+
1771
+ function findNodeInTree(
1772
+ node: VisualNode,
1773
+ nodeId: string
1774
+ ): VisualNode | null {
1775
+ if (node.id === nodeId) {
1776
+ return node
1777
+ }
1778
+
1779
+ return findNodeInTreeList(node.children, nodeId)
1780
+ }
1781
+
1782
+ function getProjectNodes(pages: Page[], appShell: AppShell) {
1783
+ return [
1784
+ ...(appShell.header ? [appShell.header] : []),
1785
+ ...pages.flatMap((page) => page.components),
1786
+ ...(appShell.footer ? [appShell.footer] : []),
1787
+ ]
1788
+ }
1789
+
1790
+ function findCustomComponentOwner(
1791
+ nodes: VisualNode[],
1792
+ nodeId: string,
1793
+ definitions: CustomComponentDefinition[],
1794
+ includeRoot = true
1795
+ ): { type: string; node: VisualNode } | null {
1796
+ const names = new Set(definitions.map((definition) => definition.name))
1797
+
1798
+ const visit = (node: VisualNode): { type: string; node: VisualNode } | null => {
1799
+ if (names.has(node.type)) {
1800
+ if (
1801
+ (includeRoot && node.id === nodeId) ||
1802
+ findNodeInTreeList(getCustomComponentTreeChildren(node), nodeId)
1803
+ ) {
1804
+ return { type: node.type, node }
1805
+ }
1806
+ }
1807
+
1808
+ for (const child of node.children) {
1809
+ const owner = visit(child)
1810
+ if (owner) return owner
1811
+ }
1812
+
1813
+ return null
1814
+ }
1815
+
1816
+ for (const node of nodes) {
1817
+ const owner = visit(node)
1818
+ if (owner) return owner
1819
+ }
1820
+
1821
+ return null
1822
+ }
1823
+
1824
+ function updateCustomComponentDefinitionTree(
1825
+ pages: Page[],
1826
+ appShell: AppShell,
1827
+ type: string,
1828
+ update: (children: VisualNode[], instance: VisualNode) => VisualNode[]
1829
+ ) {
1830
+ const updateNode = (node: VisualNode): VisualNode => {
1831
+ const nextNode =
1832
+ node.type === type
1833
+ ? {
1834
+ ...node,
1835
+ props: {
1836
+ ...node.props,
1837
+ componentTree: {
1838
+ children: update(
1839
+ getCustomComponentTreeChildren(node),
1840
+ node
1841
+ ),
1842
+ },
1843
+ },
1844
+ }
1845
+ : node
1846
+
1847
+ return {
1848
+ ...nextNode,
1849
+ children: nextNode.children.map(updateNode),
1850
+ }
1851
+ }
1852
+
1853
+ return {
1854
+ pages: pages.map((page) => ({
1855
+ ...page,
1856
+ components: page.components.map(updateNode),
1857
+ })),
1858
+ appShell: {
1859
+ header: appShell.header ? updateNode(appShell.header) : null,
1860
+ footer: appShell.footer ? updateNode(appShell.footer) : null,
1861
+ },
1862
+ }
1863
+ }
1864
+
1865
+ function moveNodeWithinDefinition(
1866
+ children: VisualNode[],
1867
+ nodeId: string,
1868
+ targetParentNodeId: string | null,
1869
+ targetIndex: number
1870
+ ) {
1871
+ const source = findNodeLocation(
1872
+ children,
1873
+ nodeId,
1874
+ null,
1875
+ 'page:definition'
1876
+ )
1877
+
1878
+ if (
1879
+ !source ||
1880
+ targetParentNodeId === nodeId ||
1881
+ (targetParentNodeId &&
1882
+ containsNode(source.node, targetParentNodeId))
1883
+ ) {
1884
+ return children
1885
+ }
1886
+
1887
+ let insertionIndex = targetIndex
1888
+ if (
1889
+ source.parentNodeId === targetParentNodeId &&
1890
+ source.index < targetIndex
1891
+ ) {
1892
+ insertionIndex -= 1
1893
+ }
1894
+
1895
+ const withoutSource = removeNodeById(children, nodeId)
1896
+ return targetParentNodeId
1897
+ ? addChildNodeAt(
1898
+ withoutSource,
1899
+ targetParentNodeId,
1900
+ source.node,
1901
+ insertionIndex
1902
+ )
1903
+ : insertNodeAt(withoutSource, source.node, insertionIndex)
1904
+ }
1905
+
1906
+ function containsNode(node: VisualNode, nodeId: string): boolean {
1907
+ return (
1908
+ node.id === nodeId ||
1909
+ node.children.some((child) => containsNode(child, nodeId))
1910
+ )
1911
+ }
1912
+
1913
+ function insertChildIntoTree(
1914
+ node: VisualNode,
1915
+ parentNodeId: string,
1916
+ child: VisualNode,
1917
+ index: number
1918
+ ): VisualNode {
1919
+ if (node.id === parentNodeId) {
1920
+ return { ...node, children: insertNodeAt(node.children, child, index) }
1921
+ }
1922
+
1923
+ return {
1924
+ ...node,
1925
+ children: node.children.map((nestedChild) =>
1926
+ insertChildIntoTree(nestedChild, parentNodeId, child, index)
1927
+ ),
1928
+ }
1929
+ }
1930
+
1931
+ function insertNodeAt(
1932
+ nodes: VisualNode[],
1933
+ node: VisualNode,
1934
+ index: number
1935
+ ) {
1936
+ const nextNodes = [...nodes]
1937
+ const safeIndex = Math.max(0, Math.min(index, nextNodes.length))
1938
+
1939
+ nextNodes.splice(safeIndex, 0, node)
1940
+
1941
+ return nextNodes
1942
+ }
1943
+
1944
+ function moveRootNode(page: Page, nodeId: string, targetIndex: number): Page {
1945
+ const fromIndex = page.components.findIndex((node) => node.id === nodeId)
1946
+
1947
+ if (fromIndex === -1) {
1948
+ return page
1949
+ }
1950
+
1951
+ const components = [...page.components]
1952
+ const [node] = components.splice(fromIndex, 1)
1953
+ const safeIndex = Math.max(0, Math.min(targetIndex, components.length))
1954
+
1955
+ components.splice(safeIndex, 0, node)
1956
+
1957
+ return { ...page, components }
1958
+ }
1959
+
1960
+ const customComponentChildrenSlotType =
1961
+ '__visualbuild_children_slot__'
1962
+
1963
+ function normalizeLoadedProject(
1964
+ pages: Page[],
1965
+ appShell: AppShell,
1966
+ customComponents: CustomComponentDefinition[] = [],
1967
+ pagesDir = 'src/pages'
1968
+ ) {
1969
+ let changed = false
1970
+ const normalizedAppShell: AppShell = {
1971
+ header: appShell.header ? normalizeUnknownNode(appShell.header) : null,
1972
+ footer: appShell.footer ? normalizeUnknownNode(appShell.footer) : null,
1973
+ }
1974
+
1975
+ const normalizedPages = pages.map((page) => {
1976
+ const rawPage = page as Page & { root?: unknown; sourcePath?: string }
1977
+ const root = rawPage.root
1978
+ ? normalizePageRoot(rawPage.root, page.id)
1979
+ : createPageRoot(page.id)
1980
+ const normalizedComponents: VisualNode[] = []
1981
+
1982
+ for (const rawNode of page.components) {
1983
+ const node = normalizeUnknownNode(rawNode)
1984
+
1985
+ if (node.label === 'Navbar' && !normalizedAppShell.header) {
1986
+ normalizedAppShell.header = node
1987
+ changed = true
1988
+ continue
1989
+ }
1990
+
1991
+ if (node.label === 'Footer' && !normalizedAppShell.footer) {
1992
+ normalizedAppShell.footer = node
1993
+ changed = true
1994
+ continue
1995
+ }
1996
+
1997
+ normalizedComponents.push(node)
1998
+ }
1999
+
2000
+ if (!rawPage.root) {
2001
+ changed = true
2002
+ }
2003
+
2004
+ const sourcePath =
2005
+ rawPage.sourcePath?.replace(/\\/g, '/') ||
2006
+ createPageSourcePath(page.name, pagesDir)
2007
+
2008
+ if (!rawPage.sourcePath || rawPage.sourcePath !== sourcePath) {
2009
+ changed = true
2010
+ }
2011
+
2012
+ return {
2013
+ ...page,
2014
+ sourcePath,
2015
+ root,
2016
+ components: normalizedComponents,
2017
+ }
2018
+ })
2019
+
2020
+ const hydrated = hydrateCustomComponentTrees(
2021
+ normalizedPages,
2022
+ normalizedAppShell,
2023
+ customComponents
2024
+ )
2025
+
2026
+ return {
2027
+ pages: hydrated.pages,
2028
+ appShell: hydrated.appShell,
2029
+ changed: changed || hydrated.changed,
2030
+ }
2031
+ }
2032
+
2033
+ function hydrateCustomComponentTrees(
2034
+ pages: Page[],
2035
+ appShell: AppShell,
2036
+ definitions: CustomComponentDefinition[]
2037
+ ) {
2038
+ const definitionByName = new Map(
2039
+ definitions.map((definition) => [definition.name, definition])
2040
+ )
2041
+ const treesByName = new Map<string, VisualNode[]>()
2042
+ let changed = false
2043
+
2044
+ const hydrateNode = (node: VisualNode): VisualNode => {
2045
+ const definition = definitionByName.get(node.type)
2046
+
2047
+ if (!definition) {
2048
+ return {
2049
+ ...node,
2050
+ children: node.children.map(hydrateNode),
2051
+ }
2052
+ }
2053
+
2054
+ let definitionChildren = treesByName.get(node.type)
2055
+
2056
+ if (!definitionChildren) {
2057
+ const storedChildren = getCustomComponentTreeChildren(node)
2058
+ const sourceChildren = cloneVisualNodes(
2059
+ definition.preview?.children.filter(
2060
+ (child) => child.type !== customComponentChildrenSlotType
2061
+ ) ?? []
2062
+ )
2063
+ const instanceChildren = node.children.map(hydrateNode)
2064
+ definitionChildren =
2065
+ storedChildren.length > 0
2066
+ ? cloneVisualNodes(storedChildren)
2067
+ : [...sourceChildren, ...instanceChildren]
2068
+ treesByName.set(node.type, definitionChildren)
2069
+ }
2070
+
2071
+ if (
2072
+ node.children.length > 0 ||
2073
+ !hasCustomComponentTree(node)
2074
+ ) {
2075
+ changed = true
2076
+ }
2077
+
2078
+ return {
2079
+ ...node,
2080
+ props: {
2081
+ ...node.props,
2082
+ componentTree: {
2083
+ children: cloneVisualNodes(definitionChildren),
2084
+ },
2085
+ },
2086
+ children: [],
2087
+ }
2088
+ }
2089
+
2090
+ const nextPages = pages.map((page) => ({
2091
+ ...page,
2092
+ components: page.components.map(hydrateNode),
2093
+ }))
2094
+ const nextAppShell = {
2095
+ header: appShell.header ? hydrateNode(appShell.header) : null,
2096
+ footer: appShell.footer ? hydrateNode(appShell.footer) : null,
2097
+ }
2098
+
2099
+ return { pages: nextPages, appShell: nextAppShell, changed }
2100
+ }
2101
+
2102
+ function replaceCustomComponentTreesFromDefinitions(
2103
+ pages: Page[],
2104
+ appShell: AppShell,
2105
+ definitions: CustomComponentDefinition[]
2106
+ ) {
2107
+ const definitionByName = new Map(
2108
+ definitions.map((definition) => [definition.name, definition])
2109
+ )
2110
+ const treesByName = new Map<string, VisualNode[]>()
2111
+ let changed = false
2112
+
2113
+ const refreshNode = (node: VisualNode): VisualNode => {
2114
+ const definition = definitionByName.get(node.type)
2115
+
2116
+ if (!definition?.preview) {
2117
+ return {
2118
+ ...node,
2119
+ children: node.children.map(refreshNode),
2120
+ }
2121
+ }
2122
+
2123
+ let nextChildren = treesByName.get(node.type)
2124
+ if (!nextChildren) {
2125
+ nextChildren = reconcileVisualNodeIds(
2126
+ definition.preview.children.filter(
2127
+ (child) => child.type !== customComponentChildrenSlotType
2128
+ ),
2129
+ getCustomComponentTreeChildren(node)
2130
+ )
2131
+ treesByName.set(node.type, nextChildren)
2132
+ }
2133
+
2134
+ if (
2135
+ JSON.stringify(getCustomComponentTreeChildren(node)) !==
2136
+ JSON.stringify(nextChildren)
2137
+ ) {
2138
+ changed = true
2139
+ }
2140
+
2141
+ return {
2142
+ ...node,
2143
+ props: {
2144
+ ...node.props,
2145
+ componentTree: {
2146
+ children: cloneVisualNodes(nextChildren),
2147
+ },
2148
+ },
2149
+ children: [],
2150
+ }
2151
+ }
2152
+
2153
+ return {
2154
+ pages: pages.map((page) => ({
2155
+ ...page,
2156
+ components: page.components.map(refreshNode),
2157
+ })),
2158
+ appShell: {
2159
+ header: appShell.header ? refreshNode(appShell.header) : null,
2160
+ footer: appShell.footer ? refreshNode(appShell.footer) : null,
2161
+ },
2162
+ changed,
2163
+ }
2164
+ }
2165
+
2166
+ function reconcileVisualNodeIds(
2167
+ incoming: VisualNode[],
2168
+ current: VisualNode[]
2169
+ ): VisualNode[] {
2170
+ return incoming.map((node, index) => {
2171
+ const existing =
2172
+ current[index]?.type === node.type
2173
+ ? current[index]
2174
+ : current.find((candidate) => candidate.type === node.type)
2175
+
2176
+ return {
2177
+ ...node,
2178
+ id: existing?.id ?? node.id,
2179
+ props: { ...node.props },
2180
+ children: reconcileVisualNodeIds(
2181
+ node.children,
2182
+ existing?.children ?? []
2183
+ ),
2184
+ }
2185
+ })
2186
+ }
2187
+
2188
+ function hasCustomComponentTree(node: VisualNode) {
2189
+ const tree = node.props.componentTree
2190
+ return (
2191
+ typeof tree === 'object' &&
2192
+ tree !== null &&
2193
+ Array.isArray((tree as { children?: unknown }).children)
2194
+ )
2195
+ }
2196
+
2197
+ function getCustomComponentTreeChildren(node: VisualNode): VisualNode[] {
2198
+ if (!hasCustomComponentTree(node)) return []
2199
+
2200
+ return (node.props.componentTree as { children: VisualNode[] }).children
2201
+ }
2202
+
2203
+ function cloneVisualNodes(nodes: VisualNode[]): VisualNode[] {
2204
+ return nodes.map((node) => ({
2205
+ ...node,
2206
+ props: { ...node.props },
2207
+ children: cloneVisualNodes(node.children),
2208
+ }))
2209
+ }
2210
+
2211
+ function normalizePageRoot(rawRoot: unknown, pageId: string): VisualNode {
2212
+ const root = normalizeUnknownNode(rawRoot)
2213
+
2214
+ if (
2215
+ !isPrimitiveType(root.type) ||
2216
+ !primitiveRegistry[root.type].acceptsChildren
2217
+ ) {
2218
+ return createPageRoot(pageId)
2219
+ }
2220
+
2221
+ return {
2222
+ ...root,
2223
+ id: root.id || `${pageId}:root`,
2224
+ label: 'Page root',
2225
+ children: [],
2226
+ }
2227
+ }
2228
+
2229
+ function normalizeUnknownNode(rawNode: unknown): VisualNode {
2230
+ const node = rawNode as {
2231
+ id?: string
2232
+ type?: string
2233
+ label?: string
2234
+ props?: Record<string, unknown>
2235
+ children?: unknown[]
2236
+ }
2237
+
2238
+ if (isPrimitiveType(node.type)) {
2239
+ return {
2240
+ id: node.id ?? generateId(),
2241
+ type: node.type,
2242
+ label: node.label,
2243
+ props: node.props ?? {},
2244
+ children: (node.children ?? []).map((child) => normalizeUnknownNode(child)),
2245
+ }
2246
+ }
2247
+
2248
+ if (node.type === 'Navbar') {
2249
+ return createComponentPreset('Navbar', {
2250
+ navId: node.id,
2251
+ logo: node.props?.logo,
2252
+ })
2253
+ }
2254
+
2255
+ if (node.type === 'Hero') {
2256
+ return createComponentPreset('Hero', {
2257
+ sectionId: node.id,
2258
+ title: node.props?.title,
2259
+ subtitle: node.props?.subtitle,
2260
+ cta: node.props?.cta,
2261
+ })
2262
+ }
2263
+
2264
+ if (
2265
+ node.type === 'Card' ||
2266
+ node.type === 'Button' ||
2267
+ node.type === 'Footer' ||
2268
+ node.type === 'Grid' ||
2269
+ node.type === 'Testimonials' ||
2270
+ node.type === 'Pricing' ||
2271
+ node.type === 'Form' ||
2272
+ node.type === 'Image' ||
2273
+ node.type === 'Features' ||
2274
+ node.type === 'Gallery' ||
2275
+ node.type === 'FAQ' ||
2276
+ node.type === 'ContactForm' ||
2277
+ node.type === 'LoginForm'
2278
+ ) {
2279
+ return createComponentPreset(node.type)
2280
+ }
2281
+
2282
+ return {
2283
+ id: node.id ?? generateId(),
2284
+ type: String(node.type ?? 'div'),
2285
+ label: node.label,
2286
+ props: node.props ?? {},
2287
+ children: (node.children ?? []).map((child) => normalizeUnknownNode(child)),
2288
+ }
2289
+ }
2290
+
2291
+ function isPrimitiveType(type: unknown): type is PrimitiveType {
2292
+ return typeof type === 'string' && type in primitiveRegistry
2293
+ }
2294
+
2295
+ function persistProject(
2296
+ pages: Page[],
2297
+ appShell: AppShell,
2298
+ projectName: string
2299
+ ) {
2300
+ cacheProject(pages, appShell, true, projectName)
2301
+ }
2302
+
2303
+ function cacheProject(
2304
+ pages: Page[],
2305
+ appShell: AppShell,
2306
+ dirty: boolean,
2307
+ projectName: string
2308
+ ) {
2309
+ if (typeof window === 'undefined') {
2310
+ return
2311
+ }
2312
+
2313
+ try {
2314
+ window.localStorage.setItem(
2315
+ projectCacheKey,
2316
+ JSON.stringify({
2317
+ ...createProjectSchema(pages, appShell, projectName),
2318
+ dirty,
2319
+ cachedAt: Date.now(),
2320
+ projectId: activeProjectId,
2321
+ })
2322
+ )
2323
+ scheduleTailwindSync(pages, appShell)
2324
+ } catch (error) {
2325
+ console.warn('Failed to cache visualbuild project state', error)
2326
+ }
2327
+ }
2328
+
2329
+ function scheduleTailwindSync(pages: Page[], appShell: AppShell) {
2330
+ if (tailwindSyncTimeout !== null) {
2331
+ window.clearTimeout(tailwindSyncTimeout)
2332
+ }
2333
+
2334
+ tailwindSyncTimeout = window.setTimeout(() => {
2335
+ tailwindSyncTimeout = null
2336
+ void syncTailwindClasses(pages, appShell).catch((error: unknown) => {
2337
+ console.warn('Failed to update live Tailwind classes', error)
2338
+ })
2339
+ }, 100)
2340
+ }
2341
+
2342
+ function readCachedProject() {
2343
+ if (typeof window === 'undefined') {
2344
+ return null
2345
+ }
2346
+
2347
+ try {
2348
+ const cached = window.localStorage.getItem(projectCacheKey)
2349
+
2350
+ if (!cached) {
2351
+ return null
2352
+ }
2353
+
2354
+ const schema = JSON.parse(cached) as {
2355
+ meta?: {
2356
+ projectName?: string
2357
+ }
2358
+ appShell?: AppShell
2359
+ pages?: Page[]
2360
+ dirty?: boolean
2361
+ projectId?: string
2362
+ }
2363
+
2364
+ if (!schema.pages || schema.pages.length === 0) {
2365
+ return null
2366
+ }
2367
+
2368
+ const { pages, appShell } = normalizeLoadedProject(
2369
+ schema.pages,
2370
+ schema.appShell ?? defaultAppShell
2371
+ )
2372
+
2373
+ return {
2374
+ projectName: schema.meta?.projectName ?? defaultProjectName,
2375
+ appShell,
2376
+ pages,
2377
+ activePageId: pages[0].id,
2378
+ dirty: schema.dirty === true,
2379
+ projectId: schema.projectId ?? '',
2380
+ }
2381
+ } catch (error) {
2382
+ console.warn('Failed to restore cached visualbuild project state', error)
2383
+ return null
2384
+ }
2385
+ }