create-visualbuild-app 0.1.0

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 +123 -0
  2. package/package.json +85 -0
  3. package/scripts/create-builder-app.mjs +501 -0
  4. package/scripts/vb-dev.mjs +32 -0
  5. package/scripts/vb-generate.mjs +224 -0
  6. package/templates/default/app/README.md +21 -0
  7. package/templates/default/app/eslint.config.js +25 -0
  8. package/templates/default/app/index.html +15 -0
  9. package/templates/default/app/src/index.css +23 -0
  10. package/templates/default/app/src/main.tsx +10 -0
  11. package/templates/default/app/tsconfig.app.json +21 -0
  12. package/templates/default/app/tsconfig.json +7 -0
  13. package/templates/default/app/tsconfig.node.json +19 -0
  14. package/templates/default/app/visualbuild/generated-files.json +3 -0
  15. package/templates/default/app/visualbuild/pages.json +12 -0
  16. package/templates/default/app/vite.config.ts +7 -0
  17. package/templates/default/builder/README.md +21 -0
  18. package/templates/default/builder/eslint.config.js +25 -0
  19. package/templates/default/builder/tsconfig.app.json +21 -0
  20. package/templates/default/builder/tsconfig.json +7 -0
  21. package/templates/default/builder/tsconfig.node.json +22 -0
  22. package/visual-app-builder/index.html +15 -0
  23. package/visual-app-builder/server/parseReactPage.ts +571 -0
  24. package/visual-app-builder/shared/generateReactSource.d.mts +37 -0
  25. package/visual-app-builder/shared/generateReactSource.mjs +443 -0
  26. package/visual-app-builder/src/App.tsx +874 -0
  27. package/visual-app-builder/src/components/Canvas.tsx +1059 -0
  28. package/visual-app-builder/src/components/CodePanel.tsx +812 -0
  29. package/visual-app-builder/src/components/ComponentSidebar.tsx +302 -0
  30. package/visual-app-builder/src/components/PageSwitcher.tsx +161 -0
  31. package/visual-app-builder/src/components/ProjectExplorer.tsx +1054 -0
  32. package/visual-app-builder/src/components/PropertiesPanel.tsx +692 -0
  33. package/visual-app-builder/src/components/Topbar.tsx +128 -0
  34. package/visual-app-builder/src/data/componentRegistry.tsx +292 -0
  35. package/visual-app-builder/src/data/primitiveRegistry.ts +368 -0
  36. package/visual-app-builder/src/index.css +111 -0
  37. package/visual-app-builder/src/main.tsx +10 -0
  38. package/visual-app-builder/src/stores/useAppStore.ts +1265 -0
  39. package/visual-app-builder/src/tailwindGeneratedSafelist.ts +36 -0
  40. package/visual-app-builder/src/types/index.ts +261 -0
  41. package/visual-app-builder/src/utils/codegen.ts +66 -0
  42. package/visual-app-builder/src/utils/projectFiles.ts +146 -0
  43. package/visual-app-builder/src/utils/projectPersistence.ts +177 -0
  44. package/visual-app-builder/vite.config.ts +1479 -0
@@ -0,0 +1,36 @@
1
+ export const generatedStarterClassNames = `
2
+ min-h-screen bg-slate-950 text-white flex items-center justify-center px-6
3
+ w-full max-w-5xl rounded-2xl border border-white/10 bg-white/10 px-8 py-12 text-center shadow-2xl backdrop-blur
4
+ mb-4 text-xs font-semibold uppercase tracking-[0.28em] text-blue-300
5
+ text-4xl font-bold tracking-tight sm:text-5xl
6
+ mx-auto mt-5 max-w-2xl text-base text-slate-300
7
+ mt-8 flex-wrap gap-3
8
+ rounded-lg bg-blue-500 px-5 py-3 text-sm shadow-lg shadow-blue-500/25 hover:bg-blue-400
9
+ text-slate-400
10
+ `
11
+
12
+ export const editableCanvasClassNames = `
13
+ block inline inline-block inline-flex flex grid hidden contents
14
+ flex-row flex-col flex-wrap items-start items-center items-end items-stretch justify-start justify-center justify-end justify-between justify-around content-center self-start self-center self-end
15
+ gap-0 gap-1 gap-2 gap-3 gap-4 gap-5 gap-6 gap-8 gap-10 gap-12
16
+ grid-cols-1 grid-cols-2 grid-cols-3 grid-cols-4 grid-cols-5 grid-cols-6 col-span-1 col-span-2 col-span-3 col-span-full
17
+ w-full w-screen w-auto w-fit w-1/2 w-1/3 w-2/3 w-1/4 w-3/4 min-w-0 max-w-sm max-w-md max-w-lg max-w-xl max-w-2xl max-w-3xl max-w-4xl max-w-5xl max-w-6xl max-w-full
18
+ h-full h-screen h-auto min-h-0 min-h-6 min-h-8 min-h-10 min-h-12 min-h-16 min-h-24 min-h-32 min-h-48 min-h-screen
19
+ p-0 p-1 p-2 p-3 p-4 p-5 p-6 p-8 p-10 p-12
20
+ px-0 px-1 px-2 px-3 px-4 px-5 px-6 px-8 px-10 px-12
21
+ py-0 py-1 py-2 py-3 py-4 py-5 py-6 py-8 py-10 py-12
22
+ pt-0 pt-1 pt-2 pt-3 pt-4 pt-5 pt-6 pt-8 pb-0 pb-1 pb-2 pb-3 pb-4 pb-5 pb-6 pb-8
23
+ pl-0 pl-1 pl-2 pl-3 pl-4 pl-5 pl-6 pl-8 pr-0 pr-1 pr-2 pr-3 pr-4 pr-5 pr-6 pr-8
24
+ m-0 m-1 m-2 m-3 m-4 m-5 m-6 m-8 mx-auto mx-0 mx-1 mx-2 mx-3 mx-4 mx-5 mx-6 mx-8 my-0 my-1 my-2 my-3 my-4 my-5 my-6 my-8
25
+ mt-0 mt-1 mt-2 mt-3 mt-4 mt-5 mt-6 mt-8 mb-0 mb-1 mb-2 mb-3 mb-4 mb-5 mb-6 mb-8
26
+ space-y-1 space-y-2 space-y-3 space-y-4 space-y-5 space-y-6 space-x-1 space-x-2 space-x-3 space-x-4 space-x-5 space-x-6
27
+ text-left text-center text-right text-xs text-sm text-base text-lg text-xl text-2xl text-3xl text-4xl text-5xl font-thin font-light font-normal font-medium font-semibold font-bold font-extrabold italic not-italic underline line-through leading-none leading-tight leading-normal leading-relaxed tracking-tight tracking-normal tracking-wide tracking-widest uppercase lowercase capitalize
28
+ text-white text-black text-gray-50 text-gray-100 text-gray-200 text-gray-300 text-gray-400 text-gray-500 text-gray-600 text-gray-700 text-gray-800 text-gray-900 text-slate-300 text-slate-400 text-slate-950 text-red-500 text-orange-500 text-amber-500 text-yellow-500 text-green-500 text-emerald-500 text-teal-500 text-cyan-500 text-sky-500 text-blue-100 text-blue-300 text-blue-400 text-blue-500 text-blue-600 text-blue-700 text-indigo-500 text-indigo-600 text-indigo-700 text-violet-500 text-purple-500 text-pink-500
29
+ bg-white bg-black bg-transparent bg-slate-950 bg-gray-50 bg-gray-100 bg-gray-200 bg-gray-300 bg-gray-800 bg-gray-900 bg-gray-950 bg-red-500 bg-orange-500 bg-yellow-500 bg-green-500 bg-blue-500 bg-blue-600 bg-blue-700 bg-indigo-600 bg-indigo-700 bg-purple-600
30
+ bg-white/10 bg-white/20 bg-gray-950/25 bg-blue-100/20 bg-gradient-to-br from-blue-600 to-indigo-700
31
+ border border-0 border-2 border-t border-b border-l border-r border-transparent border-white border-black border-gray-200 border-gray-300 border-gray-700 border-blue-500 border-white/10 border-white/20
32
+ rounded rounded-sm rounded-md rounded-lg rounded-xl rounded-2xl rounded-full
33
+ shadow shadow-sm shadow-md shadow-lg shadow-xl shadow-2xl shadow-blue-500/25
34
+ overflow-hidden overflow-auto overflow-x-auto overflow-y-auto relative absolute fixed sticky top-0 right-0 bottom-0 left-0 z-10 z-20 z-50
35
+ opacity-0 opacity-50 opacity-75 opacity-100 cursor-pointer select-none transition transition-colors duration-150 duration-200 hover:bg-blue-400 hover:bg-gray-100 hover:bg-gray-900 hover:text-white hover:text-black focus:outline-none focus:ring-2 focus:ring-blue-500 backdrop-blur backdrop-blur-sm
36
+ `
@@ -0,0 +1,261 @@
1
+ import type { ReactNode } from 'react'
2
+
3
+ export type ComponentType =
4
+ | 'Navbar'
5
+ | 'Hero'
6
+ | 'Card'
7
+ | 'Button'
8
+ | 'Footer'
9
+
10
+ export type PrimitiveType =
11
+ | 'header'
12
+ | 'main'
13
+ | 'div'
14
+ | 'section'
15
+ | 'nav'
16
+ | 'article'
17
+ | 'aside'
18
+ | 'footer'
19
+ | 'address'
20
+ | 'hgroup'
21
+ | 'h1'
22
+ | 'h2'
23
+ | 'h3'
24
+ | 'h4'
25
+ | 'h5'
26
+ | 'h6'
27
+ | 'p'
28
+ | 'br'
29
+ | 'hr'
30
+ | 'pre'
31
+ | 'blockquote'
32
+ | 'span'
33
+ | 'strong'
34
+ | 'b'
35
+ | 'em'
36
+ | 'i'
37
+ | 'u'
38
+ | 's'
39
+ | 'small'
40
+ | 'mark'
41
+ | 'sub'
42
+ | 'sup'
43
+ | 'code'
44
+ | 'kbd'
45
+ | 'samp'
46
+ | 'var'
47
+ | 'abbr'
48
+ | 'cite'
49
+ | 'q'
50
+ | 'time'
51
+ | 'a'
52
+ | 'ul'
53
+ | 'ol'
54
+ | 'li'
55
+ | 'dl'
56
+ | 'dt'
57
+ | 'dd'
58
+ | 'menu'
59
+ | 'button'
60
+ | 'img'
61
+ | 'picture'
62
+ | 'source'
63
+ | 'figure'
64
+ | 'figcaption'
65
+ | 'video'
66
+ | 'audio'
67
+ | 'iframe'
68
+ | 'canvas'
69
+ | 'svg'
70
+ | 'form'
71
+ | 'label'
72
+ | 'input'
73
+ | 'textarea'
74
+ | 'select'
75
+ | 'option'
76
+ | 'optgroup'
77
+ | 'fieldset'
78
+ | 'legend'
79
+ | 'datalist'
80
+ | 'output'
81
+ | 'progress'
82
+ | 'meter'
83
+ | 'table'
84
+ | 'caption'
85
+ | 'thead'
86
+ | 'tbody'
87
+ | 'tfoot'
88
+ | 'tr'
89
+ | 'th'
90
+ | 'td'
91
+ | 'colgroup'
92
+ | 'col'
93
+ | 'details'
94
+ | 'summary'
95
+ | 'dialog'
96
+
97
+ export type AppShellSlot = 'header' | 'footer'
98
+
99
+ export type Viewport = 'mobile' | 'tablet' | 'desktop'
100
+
101
+ export type ProjectRefreshResult =
102
+ | 'updated'
103
+ | 'skipped-dirty'
104
+ | 'failed'
105
+
106
+ export type PropType =
107
+ | 'string'
108
+ | 'text'
109
+ | 'number'
110
+ | 'boolean'
111
+ | 'color'
112
+ | 'select'
113
+ | 'array'
114
+
115
+ export interface PropSchema {
116
+ type: PropType
117
+ label: string
118
+ default: unknown
119
+ options?: string[]
120
+ }
121
+
122
+ export interface ComponentDefinition {
123
+ type: ComponentType
124
+ label: string
125
+ category: string
126
+ icon: string
127
+ description: string
128
+ defaultProps: Record<string, unknown>
129
+ propSchema: Record<string, PropSchema>
130
+ renderer: (
131
+ props: Record<string, unknown>,
132
+ context: ComponentRenderContext
133
+ ) => ReactNode
134
+ generator: (props: Record<string, unknown>) => string
135
+ source: () => string
136
+ }
137
+
138
+ export interface PrimitiveDefinition {
139
+ type: PrimitiveType
140
+ label: string
141
+ category: string
142
+ icon: string
143
+ description: string
144
+ defaultProps: Record<string, unknown>
145
+ propSchema: Record<string, PropSchema>
146
+ acceptsChildren: boolean
147
+ }
148
+
149
+ export interface ComponentRenderContext {
150
+ pages: Page[]
151
+ activePageId: string
152
+ setActivePage: (id: string) => void
153
+ }
154
+
155
+ export interface VisualNode {
156
+ id: string
157
+ type: PrimitiveType
158
+ label?: string
159
+ props: Record<string, unknown>
160
+ children: VisualNode[]
161
+ }
162
+
163
+ export interface Page {
164
+ id: string
165
+ name: string
166
+ route: string
167
+ sourcePath: string
168
+ root: VisualNode
169
+ components: VisualNode[]
170
+ }
171
+
172
+ export interface AppShell {
173
+ header: VisualNode | null
174
+ footer: VisualNode | null
175
+ }
176
+
177
+ export interface AppState {
178
+ projectName: string
179
+ appShell: AppShell
180
+ pages: Page[]
181
+ activePageId: string
182
+ selectedComponentId: string | null
183
+ viewport: Viewport
184
+ previewMode: boolean
185
+ isProjectLoaded: boolean
186
+ // Actions
187
+ loadProject: () => Promise<void>
188
+ refreshProjectFromDisk: (
189
+ force?: boolean
190
+ ) => Promise<ProjectRefreshResult>
191
+ saveProject: () => Promise<void>
192
+ addPage: (name: string, route: string) => Page
193
+ registerPage: (page: Page) => void
194
+ deletePage: (id: string) => void
195
+ setActivePage: (id: string) => void
196
+ setSelectedComponent: (id: string | null) => void
197
+ setViewport: (viewport: Viewport) => void
198
+ togglePreviewMode: () => void
199
+ addComponent: (pageId: string, type: ComponentType) => void
200
+ addComponentAt: (
201
+ pageId: string,
202
+ type: ComponentType,
203
+ index: number
204
+ ) => void
205
+ addPrimitive: (pageId: string, type: PrimitiveType) => void
206
+ addPrimitiveAt: (
207
+ pageId: string,
208
+ type: PrimitiveType,
209
+ index: number
210
+ ) => void
211
+ addPrimitiveToNode: (
212
+ parentNodeId: string,
213
+ type: PrimitiveType
214
+ ) => void
215
+ addPrimitiveToNodeAt: (
216
+ parentNodeId: string,
217
+ type: PrimitiveType,
218
+ index: number
219
+ ) => void
220
+ addComponentToNode: (
221
+ parentNodeId: string,
222
+ type: ComponentType
223
+ ) => void
224
+ addComponentToNodeAt: (
225
+ parentNodeId: string,
226
+ type: ComponentType,
227
+ index: number
228
+ ) => void
229
+ removeNode: (nodeId: string) => void
230
+ moveNode: (
231
+ pageId: string,
232
+ nodeId: string,
233
+ targetParentNodeId: string | null,
234
+ targetIndex: number
235
+ ) => void
236
+ moveComponent: (
237
+ pageId: string,
238
+ nodeId: string,
239
+ targetIndex: number
240
+ ) => void
241
+ removeComponent: (pageId: string, nodeId: string) => void
242
+ setAppShellComponent: (slot: AppShellSlot, type: ComponentType) => void
243
+ removeAppShellComponent: (slot: AppShellSlot) => void
244
+ updateComponentProps: (
245
+ pageId: string,
246
+ nodeId: string,
247
+ props: Record<string, unknown>
248
+ ) => void
249
+ updateNodeProps: (nodeId: string, props: Record<string, unknown>) => void
250
+ updatePageRoot: (
251
+ pageId: string,
252
+ updates: {
253
+ type?: PrimitiveType
254
+ props?: Record<string, unknown>
255
+ }
256
+ ) => void
257
+ updateAppShellProps: (
258
+ slot: AppShellSlot,
259
+ props: Record<string, unknown>
260
+ ) => void
261
+ }
@@ -0,0 +1,66 @@
1
+ import {
2
+ createPageSourcePath as createAstPageSourcePath,
3
+ generateAppSource,
4
+ generateLayoutSource,
5
+ generateNodeSource,
6
+ generatePageSource,
7
+ getPageSourcePath as getAstPageSourcePath,
8
+ toPageComponentName as toAstPageComponentName,
9
+ } from '../../shared/generateReactSource.mjs'
10
+ import type { AppShell, ComponentType, Page, VisualNode } from '../types'
11
+
12
+ export function generatePageCode(page: Page): string {
13
+ return generatePageSource(page).trimEnd()
14
+ }
15
+
16
+ export function generateAppCode(pages: Page[], appShell: AppShell): string {
17
+ return generateAppSource(pages, appShell).trimEnd()
18
+ }
19
+
20
+ export function generateLayoutCode(appShell: AppShell): string {
21
+ return generateLayoutSource(appShell).trimEnd()
22
+ }
23
+
24
+ export function generateProjectSchema(
25
+ pages: Page[],
26
+ appShell: AppShell,
27
+ projectName = 'my-blog'
28
+ ): string {
29
+ return JSON.stringify(
30
+ {
31
+ meta: {
32
+ projectName,
33
+ framework: 'react',
34
+ styling: 'tailwind',
35
+ },
36
+ appShell,
37
+ pages,
38
+ },
39
+ null,
40
+ 2
41
+ )
42
+ }
43
+
44
+ export function generateComponentSource(type: ComponentType): string {
45
+ return `export function ${type}() {
46
+ return null
47
+ }`
48
+ }
49
+
50
+ export function toPageComponentName(name: string): string {
51
+ return toAstPageComponentName(name)
52
+ }
53
+
54
+ export function getPageSourcePath(
55
+ page: Pick<Page, 'name' | 'sourcePath'>
56
+ ): string {
57
+ return getAstPageSourcePath(page)
58
+ }
59
+
60
+ export function createPageSourcePath(name: string): string {
61
+ return createAstPageSourcePath(name)
62
+ }
63
+
64
+ export function generateNodeCode(node: VisualNode, indent: string): string {
65
+ return generateNodeSource(node, indent)
66
+ }
@@ -0,0 +1,146 @@
1
+ export type ProjectEntryKind = 'file' | 'directory' | 'symlink'
2
+
3
+ export interface ProjectEntry {
4
+ name: string
5
+ path: string
6
+ kind: ProjectEntryKind
7
+ }
8
+
9
+ export interface ProjectDirectory {
10
+ rootName: string
11
+ path: string
12
+ entries: ProjectEntry[]
13
+ }
14
+
15
+ export interface ProjectFile {
16
+ path: string
17
+ content: string
18
+ size: number
19
+ isBinary: boolean
20
+ isTooLarge: boolean
21
+ }
22
+
23
+ export async function listProjectDirectory(path = '') {
24
+ return requestJson<ProjectDirectory>(
25
+ `/__visualbuild/files?path=${encodeURIComponent(path)}`
26
+ )
27
+ }
28
+
29
+ const excludedMoveDirectoryNames = new Set(['node_modules'])
30
+
31
+ export async function listProjectDirectories() {
32
+ const root = await listProjectDirectory()
33
+ const directories: ProjectEntry[] = [
34
+ { name: root.rootName, path: '', kind: 'directory' },
35
+ ]
36
+ let pendingPaths = ['']
37
+
38
+ while (pendingPaths.length > 0) {
39
+ const results = await Promise.all(
40
+ pendingPaths.map((directoryPath) =>
41
+ listProjectDirectory(directoryPath)
42
+ )
43
+ )
44
+ const nextPaths: string[] = []
45
+
46
+ for (const result of results) {
47
+ for (const entry of result.entries) {
48
+ if (
49
+ entry.kind !== 'directory' ||
50
+ excludedMoveDirectoryNames.has(entry.name)
51
+ ) {
52
+ continue
53
+ }
54
+
55
+ directories.push(entry)
56
+ nextPaths.push(entry.path)
57
+ }
58
+ }
59
+
60
+ pendingPaths = nextPaths
61
+ }
62
+
63
+ return directories.sort((left, right) =>
64
+ left.path.localeCompare(right.path)
65
+ )
66
+ }
67
+
68
+ export async function createProjectEntry(
69
+ parentPath: string,
70
+ name: string,
71
+ kind: 'file' | 'directory'
72
+ ) {
73
+ return requestJson<ProjectEntry>('/__visualbuild/files', {
74
+ method: 'POST',
75
+ headers: { 'Content-Type': 'application/json' },
76
+ body: JSON.stringify({ parentPath, name, kind }),
77
+ })
78
+ }
79
+
80
+ export async function renameProjectEntry(path: string, name: string) {
81
+ return requestJson<ProjectEntry>('/__visualbuild/files', {
82
+ method: 'PATCH',
83
+ headers: { 'Content-Type': 'application/json' },
84
+ body: JSON.stringify({ path, name }),
85
+ })
86
+ }
87
+
88
+ export async function moveProjectEntry(
89
+ path: string,
90
+ parentPath: string
91
+ ) {
92
+ const name = path.split('/').pop()
93
+
94
+ if (!name) {
95
+ throw new Error('The project root cannot be moved')
96
+ }
97
+
98
+ return requestJson<ProjectEntry>('/__visualbuild/files', {
99
+ method: 'PATCH',
100
+ headers: { 'Content-Type': 'application/json' },
101
+ body: JSON.stringify({ path, name, parentPath }),
102
+ })
103
+ }
104
+
105
+ export async function deleteProjectEntry(path: string) {
106
+ await request(`/__visualbuild/files?path=${encodeURIComponent(path)}`, {
107
+ method: 'DELETE',
108
+ })
109
+ }
110
+
111
+ export async function loadProjectFile(path: string) {
112
+ return requestJson<ProjectFile>(
113
+ `/__visualbuild/file?path=${encodeURIComponent(path)}`
114
+ )
115
+ }
116
+
117
+ export async function saveProjectFile(path: string, content: string) {
118
+ await request('/__visualbuild/file', {
119
+ method: 'PUT',
120
+ headers: { 'Content-Type': 'application/json' },
121
+ body: JSON.stringify({ path, content }),
122
+ })
123
+ notifyProjectFilesChanged()
124
+ }
125
+
126
+ export function notifyProjectFilesChanged() {
127
+ window.dispatchEvent(new Event('visualbuild:filesystem-changed'))
128
+ }
129
+
130
+ async function requestJson<T>(url: string, init?: RequestInit) {
131
+ const response = await request(url, init)
132
+ return (await response.json()) as T
133
+ }
134
+
135
+ async function request(url: string, init?: RequestInit) {
136
+ const response = await fetch(url, init)
137
+
138
+ if (!response.ok) {
139
+ const payload = (await response.json().catch(() => null)) as {
140
+ error?: string
141
+ } | null
142
+ throw new Error(payload?.error ?? `Request failed with ${response.status}`)
143
+ }
144
+
145
+ return response
146
+ }
@@ -0,0 +1,177 @@
1
+ import type { AppShell, Page } from '../types'
2
+
3
+ export interface ProjectSchema {
4
+ meta: {
5
+ projectName: string
6
+ framework: string
7
+ styling: string
8
+ }
9
+ appShell: AppShell
10
+ pages: Page[]
11
+ runtimeProjectId?: string
12
+ }
13
+
14
+ export type ImportedProjectPage = Page
15
+
16
+ export interface GeneratorDiagnostic {
17
+ severity: 'error'
18
+ phase: 'generate'
19
+ code: string
20
+ title: string
21
+ message: string
22
+ path: string
23
+ suggestion?: string
24
+ }
25
+
26
+ class ProjectSaveError extends Error {
27
+ diagnostic?: GeneratorDiagnostic
28
+
29
+ constructor(message: string, diagnostic?: GeneratorDiagnostic) {
30
+ super(message)
31
+ this.name = 'ProjectSaveError'
32
+ this.diagnostic = diagnostic
33
+ }
34
+ }
35
+
36
+ export function getGeneratorDiagnostic(error: unknown) {
37
+ return error instanceof ProjectSaveError ? error.diagnostic : undefined
38
+ }
39
+
40
+ export function createProjectSchema(
41
+ pages: Page[],
42
+ appShell: AppShell,
43
+ projectName = 'my-blog'
44
+ ): ProjectSchema {
45
+ return {
46
+ meta: {
47
+ projectName,
48
+ framework: 'react',
49
+ styling: 'tailwind',
50
+ },
51
+ appShell,
52
+ pages,
53
+ }
54
+ }
55
+
56
+ export async function loadProjectSchema(): Promise<ProjectSchema | null> {
57
+ const response = await fetch('/__visualbuild/pages')
58
+
59
+ if (!response.ok) {
60
+ return null
61
+ }
62
+
63
+ const schema = (await response.json()) as ProjectSchema
64
+
65
+ return {
66
+ ...schema,
67
+ runtimeProjectId:
68
+ response.headers.get('X-VisualBuild-Project-Id') ?? undefined,
69
+ }
70
+ }
71
+
72
+ export async function saveProjectSchema(
73
+ pages: Page[],
74
+ appShell: AppShell,
75
+ projectName: string
76
+ ) {
77
+ const response = await fetch('/__visualbuild/pages', {
78
+ method: 'POST',
79
+ headers: {
80
+ 'Content-Type': 'application/json',
81
+ },
82
+ body: JSON.stringify(createProjectSchema(pages, appShell, projectName), null, 2),
83
+ })
84
+
85
+ if (!response.ok) {
86
+ const responseBody = await response.text()
87
+ let payload: {
88
+ error?: string
89
+ diagnostic?: GeneratorDiagnostic
90
+ } | null = null
91
+
92
+ try {
93
+ payload = JSON.parse(responseBody) as {
94
+ error?: string
95
+ diagnostic?: GeneratorDiagnostic
96
+ }
97
+ } catch {
98
+ // Older servers may still return a plain error string.
99
+ }
100
+
101
+ throw new ProjectSaveError(
102
+ payload?.error ||
103
+ responseBody ||
104
+ 'Failed to save VisualBuild project',
105
+ payload?.diagnostic
106
+ )
107
+ }
108
+
109
+ notifyProjectFilesChanged()
110
+ }
111
+
112
+ export async function importProjectPage(
113
+ path: string,
114
+ name: string,
115
+ route: string
116
+ ) {
117
+ const response = await fetch('/__visualbuild/import-page', {
118
+ method: 'POST',
119
+ headers: {
120
+ 'Content-Type': 'application/json',
121
+ },
122
+ body: JSON.stringify({ path, name, route }),
123
+ })
124
+
125
+ if (!response.ok) {
126
+ const payload = (await response.json().catch(() => null)) as {
127
+ error?: string
128
+ } | null
129
+ throw new Error(payload?.error ?? 'Failed to import page source')
130
+ }
131
+
132
+ return (await response.json()) as ImportedProjectPage
133
+ }
134
+
135
+ export async function syncTailwindClasses(pages: Page[], appShell: AppShell) {
136
+ const classNames = collectProjectClassNames(pages, appShell)
137
+ const response = await fetch('/__visualbuild/tailwind', {
138
+ method: 'POST',
139
+ headers: {
140
+ 'Content-Type': 'text/plain',
141
+ },
142
+ body: classNames.join('\n'),
143
+ })
144
+
145
+ if (!response.ok) {
146
+ throw new Error(
147
+ (await response.text()) || 'Failed to compile Tailwind classes'
148
+ )
149
+ }
150
+ }
151
+
152
+ function collectProjectClassNames(pages: Page[], appShell: AppShell) {
153
+ const classNames = new Set<string>()
154
+ const visitNode = (node: Page['root']) => {
155
+ const value = String(node.props.className ?? '').trim()
156
+
157
+ if (value) {
158
+ classNames.add(value)
159
+ }
160
+
161
+ node.children.forEach(visitNode)
162
+ }
163
+
164
+ if (appShell.header) visitNode(appShell.header)
165
+ if (appShell.footer) visitNode(appShell.footer)
166
+
167
+ pages.forEach((page) => {
168
+ visitNode(page.root)
169
+ page.components.forEach(visitNode)
170
+ })
171
+
172
+ return [...classNames]
173
+ }
174
+
175
+ function notifyProjectFilesChanged() {
176
+ window.dispatchEvent(new Event('visualbuild:filesystem-changed'))
177
+ }