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,571 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import { parse } from '@babel/parser'
3
+
4
+ export interface ImportedVisualNode {
5
+ id: string
6
+ type: string
7
+ label?: string
8
+ props: Record<string, unknown>
9
+ children: ImportedVisualNode[]
10
+ }
11
+
12
+ export interface ImportedPageTree {
13
+ root: ImportedVisualNode
14
+ components: ImportedVisualNode[]
15
+ }
16
+
17
+ type AstNode = {
18
+ type: string
19
+ [key: string]: unknown
20
+ }
21
+
22
+ const supportedVisualTags = new Set([
23
+ 'header',
24
+ 'main',
25
+ 'div',
26
+ 'section',
27
+ 'nav',
28
+ 'article',
29
+ 'aside',
30
+ 'footer',
31
+ 'address',
32
+ 'hgroup',
33
+ 'h1',
34
+ 'h2',
35
+ 'h3',
36
+ 'h4',
37
+ 'h5',
38
+ 'h6',
39
+ 'p',
40
+ 'br',
41
+ 'hr',
42
+ 'pre',
43
+ 'blockquote',
44
+ 'span',
45
+ 'strong',
46
+ 'b',
47
+ 'em',
48
+ 'i',
49
+ 'u',
50
+ 's',
51
+ 'small',
52
+ 'mark',
53
+ 'sub',
54
+ 'sup',
55
+ 'code',
56
+ 'kbd',
57
+ 'samp',
58
+ 'var',
59
+ 'abbr',
60
+ 'cite',
61
+ 'q',
62
+ 'time',
63
+ 'a',
64
+ 'ul',
65
+ 'ol',
66
+ 'li',
67
+ 'dl',
68
+ 'dt',
69
+ 'dd',
70
+ 'menu',
71
+ 'button',
72
+ 'img',
73
+ 'picture',
74
+ 'source',
75
+ 'figure',
76
+ 'figcaption',
77
+ 'video',
78
+ 'audio',
79
+ 'iframe',
80
+ 'canvas',
81
+ 'svg',
82
+ 'form',
83
+ 'label',
84
+ 'input',
85
+ 'textarea',
86
+ 'select',
87
+ 'option',
88
+ 'optgroup',
89
+ 'fieldset',
90
+ 'legend',
91
+ 'datalist',
92
+ 'output',
93
+ 'progress',
94
+ 'meter',
95
+ 'table',
96
+ 'caption',
97
+ 'thead',
98
+ 'tbody',
99
+ 'tfoot',
100
+ 'tr',
101
+ 'th',
102
+ 'td',
103
+ 'colgroup',
104
+ 'col',
105
+ 'details',
106
+ 'summary',
107
+ 'dialog',
108
+ ])
109
+
110
+ export class StaticJsxImportError extends Error {
111
+ constructor(message: string) {
112
+ super(message)
113
+ this.name = 'StaticJsxImportError'
114
+ }
115
+ }
116
+
117
+ export function parseReactPageSource(
118
+ source: string,
119
+ filename = 'Page.tsx'
120
+ ): ImportedPageTree {
121
+ let program: AstNode
122
+
123
+ try {
124
+ program = parse(source, {
125
+ sourceType: 'module',
126
+ sourceFilename: filename,
127
+ plugins: ['jsx', 'typescript'],
128
+ }).program as unknown as AstNode
129
+ } catch (error) {
130
+ throw new StaticJsxImportError(formatParserError(error))
131
+ }
132
+
133
+ const returnedJsx = findReturnedJsx(program)
134
+
135
+ if (!returnedJsx) {
136
+ throw new StaticJsxImportError(
137
+ 'The managed page must return one static JSX element or fragment.'
138
+ )
139
+ }
140
+
141
+ return convertReturnedJsx(returnedJsx)
142
+ }
143
+
144
+ export function reconcileImportedTree(
145
+ imported: ImportedPageTree,
146
+ current: ImportedPageTree
147
+ ): ImportedPageTree {
148
+ return {
149
+ root: reconcileNode(imported.root, current.root, current.root.id),
150
+ components: reconcileNodeList(imported.components, current.components),
151
+ }
152
+ }
153
+
154
+ export function visualTreesEqual(
155
+ first: ImportedPageTree,
156
+ second: ImportedPageTree
157
+ ) {
158
+ return JSON.stringify(canonicalize(first)) === JSON.stringify(canonicalize(second))
159
+ }
160
+
161
+ function findReturnedJsx(program: AstNode) {
162
+ const body = asNodeArray(program.body)
163
+ const defaultExport = body.find(
164
+ (node) => node.type === 'ExportDefaultDeclaration'
165
+ )
166
+
167
+ if (defaultExport) {
168
+ const declaration = asNode(defaultExport.declaration)
169
+ const resolved = resolveExportedDeclaration(declaration, body)
170
+ const result = findJsxInFunction(resolved)
171
+
172
+ if (result) return result
173
+ }
174
+
175
+ for (const statement of body) {
176
+ const result = findJsxInFunction(statement)
177
+
178
+ if (result) return result
179
+ }
180
+
181
+ return null
182
+ }
183
+
184
+ function resolveExportedDeclaration(
185
+ declaration: AstNode | null,
186
+ body: AstNode[]
187
+ ) {
188
+ if (!declaration || declaration.type !== 'Identifier') {
189
+ return declaration
190
+ }
191
+
192
+ const name = String(declaration.name ?? '')
193
+
194
+ for (const statement of body) {
195
+ if (
196
+ statement.type === 'FunctionDeclaration' &&
197
+ asNode(statement.id)?.name === name
198
+ ) {
199
+ return statement
200
+ }
201
+
202
+ if (statement.type === 'VariableDeclaration') {
203
+ for (const item of asNodeArray(statement.declarations)) {
204
+ if (asNode(item.id)?.name === name) {
205
+ return asNode(item.init)
206
+ }
207
+ }
208
+ }
209
+ }
210
+
211
+ return declaration
212
+ }
213
+
214
+ function findJsxInFunction(node: AstNode | null): AstNode | null {
215
+ if (!node) return null
216
+
217
+ if (
218
+ node.type === 'ArrowFunctionExpression' ||
219
+ node.type === 'FunctionExpression' ||
220
+ node.type === 'FunctionDeclaration'
221
+ ) {
222
+ const body = unwrapExpression(asNode(node.body))
223
+
224
+ if (isJsx(body)) return body
225
+ return findReturnStatement(body)
226
+ }
227
+
228
+ if (node.type === 'ExportNamedDeclaration') {
229
+ return findJsxInFunction(asNode(node.declaration))
230
+ }
231
+
232
+ if (node.type === 'VariableDeclaration') {
233
+ for (const item of asNodeArray(node.declarations)) {
234
+ const result = findJsxInFunction(asNode(item.init))
235
+ if (result) return result
236
+ }
237
+ }
238
+
239
+ return null
240
+ }
241
+
242
+ function findReturnStatement(node: AstNode | null): AstNode | null {
243
+ if (!node) return null
244
+
245
+ if (node.type === 'ReturnStatement') {
246
+ const argument = unwrapExpression(asNode(node.argument))
247
+ return isJsx(argument) ? argument : null
248
+ }
249
+
250
+ for (const value of Object.values(node)) {
251
+ if (Array.isArray(value)) {
252
+ for (const child of value) {
253
+ const result = findReturnStatement(asNode(child))
254
+ if (result) return result
255
+ }
256
+ } else {
257
+ const child = asNode(value)
258
+ const result = findReturnStatement(child)
259
+ if (result) return result
260
+ }
261
+ }
262
+
263
+ return null
264
+ }
265
+
266
+ function unwrapExpression(node: AstNode | null): AstNode | null {
267
+ if (
268
+ node &&
269
+ [
270
+ 'ParenthesizedExpression',
271
+ 'TSAsExpression',
272
+ 'TSTypeAssertion',
273
+ 'TSSatisfiesExpression',
274
+ ].includes(node.type)
275
+ ) {
276
+ return unwrapExpression(asNode(node.expression))
277
+ }
278
+
279
+ return node
280
+ }
281
+
282
+ function isJsx(node: AstNode | null): node is AstNode {
283
+ return Boolean(
284
+ node &&
285
+ ['JSXElement', 'JSXFragment'].includes(node.type)
286
+ )
287
+ }
288
+
289
+ function convertReturnedJsx(jsx: AstNode): ImportedPageTree {
290
+ if (jsx.type === 'JSXFragment') {
291
+ return {
292
+ root: createImportedNode('main', { className: 'min-h-screen' }),
293
+ components: convertJsxChildren(asNodeArray(jsx.children)),
294
+ }
295
+ }
296
+
297
+ const root = convertJsxElement(jsx)
298
+ const components = [...root.children]
299
+ const text = String(root.props.text ?? '').trim()
300
+
301
+ if (text) {
302
+ components.unshift(createImportedNode('span', { text, className: '' }))
303
+ delete root.props.text
304
+ }
305
+
306
+ root.children = []
307
+ return { root, components }
308
+ }
309
+
310
+ function convertJsxElement(jsx: AstNode): ImportedVisualNode {
311
+ const opening = asNode(jsx.openingElement)
312
+ const nameNode = asNode(opening?.name)
313
+
314
+ if (!opening || !nameNode || nameNode.type !== 'JSXIdentifier') {
315
+ throw new StaticJsxImportError(
316
+ 'Namespaced tags and member-expression components are not supported.'
317
+ )
318
+ }
319
+
320
+ const tagName = String(nameNode.name ?? '')
321
+
322
+ if (!supportedVisualTags.has(tagName)) {
323
+ throw new StaticJsxImportError(
324
+ `Unsupported JSX element <${tagName}>. Reverse sync currently accepts standard HTML tags only.`
325
+ )
326
+ }
327
+
328
+ const props = convertJsxAttributes(asNodeArray(opening.attributes))
329
+ const rawChildren = asNodeArray(jsx.children)
330
+ const childItems = convertChildItems(rawChildren)
331
+ const hasElementChild = childItems.some(
332
+ (item) => typeof item !== 'string'
333
+ )
334
+ const children = childItems.flatMap((item) =>
335
+ typeof item === 'string'
336
+ ? hasElementChild
337
+ ? [createImportedNode('span', { text: item, className: '' })]
338
+ : []
339
+ : [item]
340
+ )
341
+ const directText = childItems
342
+ .filter((item): item is string => typeof item === 'string')
343
+ .join(' ')
344
+ .trim()
345
+
346
+ if (directText && !hasElementChild) {
347
+ props.text = directText
348
+ }
349
+
350
+ return createImportedNode(tagName, props, children)
351
+ }
352
+
353
+ function convertJsxAttributes(attributes: AstNode[]) {
354
+ const props: Record<string, unknown> = {}
355
+
356
+ for (const attribute of attributes) {
357
+ if (attribute.type === 'JSXSpreadAttribute') {
358
+ throw new StaticJsxImportError(
359
+ 'Spread attributes are not supported by reverse sync.'
360
+ )
361
+ }
362
+
363
+ if (attribute.type !== 'JSXAttribute') continue
364
+ const nameNode = asNode(attribute.name)
365
+
366
+ if (!nameNode || nameNode.type !== 'JSXIdentifier') {
367
+ throw new StaticJsxImportError('Only standard JSX attributes are supported.')
368
+ }
369
+
370
+ const rawName = String(nameNode.name ?? '')
371
+ const name = rawName === 'class' ? 'className' : rawName
372
+ const valueNode = asNode(attribute.value)
373
+
374
+ if (!valueNode) {
375
+ props[name] = true
376
+ continue
377
+ }
378
+
379
+ if (valueNode.type === 'StringLiteral') {
380
+ props[name] = valueNode.value
381
+ continue
382
+ }
383
+
384
+ if (valueNode.type === 'JSXExpressionContainer') {
385
+ const value = getStaticExpressionValue(asNode(valueNode.expression))
386
+
387
+ if (value === undefined) {
388
+ throw new StaticJsxImportError(
389
+ `Attribute "${name}" must use a static string, number, boolean, or null.`
390
+ )
391
+ }
392
+
393
+ props[name] = value
394
+ continue
395
+ }
396
+
397
+ throw new StaticJsxImportError(
398
+ `Attribute "${name}" uses unsupported JSX syntax.`
399
+ )
400
+ }
401
+
402
+ return props
403
+ }
404
+
405
+ function getStaticExpressionValue(node: AstNode | null): unknown {
406
+ const value = unwrapExpression(node)
407
+
408
+ if (!value) return undefined
409
+ if (value.type === 'StringLiteral' || value.type === 'NumericLiteral') {
410
+ return value.value
411
+ }
412
+ if (value.type === 'BooleanLiteral') return value.value
413
+ if (value.type === 'NullLiteral') return null
414
+ if (
415
+ value.type === 'TemplateLiteral' &&
416
+ asNodeArray(value.expressions).length === 0
417
+ ) {
418
+ return asNodeArray(value.quasis)
419
+ .map((quasi) => String(asNode(quasi.value)?.cooked ?? ''))
420
+ .join('')
421
+ }
422
+ if (value.type === 'UnaryExpression' && value.operator === '-') {
423
+ const argument = asNode(value.argument)
424
+ if (argument?.type === 'NumericLiteral') {
425
+ return -Number(argument.value)
426
+ }
427
+ }
428
+
429
+ return undefined
430
+ }
431
+
432
+ function convertJsxChildren(children: AstNode[]) {
433
+ return convertChildItems(children).flatMap((item) =>
434
+ typeof item === 'string'
435
+ ? [createImportedNode('span', { text: item, className: '' })]
436
+ : [item]
437
+ )
438
+ }
439
+
440
+ function convertChildItems(children: AstNode[]) {
441
+ const result: Array<string | ImportedVisualNode> = []
442
+
443
+ for (const child of children) {
444
+ if (child.type === 'JSXElement') {
445
+ result.push(convertJsxElement(child))
446
+ continue
447
+ }
448
+
449
+ if (child.type === 'JSXFragment') {
450
+ result.push(...convertChildItems(asNodeArray(child.children)))
451
+ continue
452
+ }
453
+
454
+ if (child.type === 'JSXText') {
455
+ const text = normalizeJsxText(String(child.value ?? ''))
456
+ if (text) result.push(text)
457
+ continue
458
+ }
459
+
460
+ if (child.type === 'JSXExpressionContainer') {
461
+ const expression = asNode(child.expression)
462
+
463
+ if (expression?.type === 'JSXEmptyExpression') continue
464
+ const value = getStaticExpressionValue(expression)
465
+
466
+ if (typeof value === 'string' || typeof value === 'number') {
467
+ result.push(String(value))
468
+ continue
469
+ }
470
+
471
+ throw new StaticJsxImportError(
472
+ 'Dynamic JSX expressions are not supported by reverse sync.'
473
+ )
474
+ }
475
+
476
+ if (child.type === 'JSXSpreadChild') {
477
+ throw new StaticJsxImportError(
478
+ 'Spread children are not supported by reverse sync.'
479
+ )
480
+ }
481
+ }
482
+
483
+ return result
484
+ }
485
+
486
+ function normalizeJsxText(value: string) {
487
+ return value.replace(/\s+/g, ' ').trim()
488
+ }
489
+
490
+ function canonicalize(value: unknown): unknown {
491
+ if (Array.isArray(value)) {
492
+ return value.map(canonicalize)
493
+ }
494
+
495
+ if (value && typeof value === 'object') {
496
+ return Object.fromEntries(
497
+ Object.entries(value)
498
+ .filter(([, entryValue]) => entryValue !== undefined)
499
+ .sort(([firstKey], [secondKey]) => firstKey.localeCompare(secondKey))
500
+ .map(([key, entryValue]) => [key, canonicalize(entryValue)])
501
+ )
502
+ }
503
+
504
+ return value
505
+ }
506
+
507
+ function reconcileNodeList(
508
+ imported: ImportedVisualNode[],
509
+ current: ImportedVisualNode[]
510
+ ) {
511
+ const used = new Set<number>()
512
+
513
+ return imported.map((node, index) => {
514
+ let matchIndex =
515
+ current[index]?.type === node.type
516
+ ? index
517
+ : current.findIndex(
518
+ (candidate, candidateIndex) =>
519
+ !used.has(candidateIndex) && candidate.type === node.type
520
+ )
521
+
522
+ if (matchIndex < 0 || used.has(matchIndex)) matchIndex = -1
523
+ if (matchIndex >= 0) used.add(matchIndex)
524
+
525
+ return reconcileNode(node, current[matchIndex] ?? null)
526
+ })
527
+ }
528
+
529
+ function reconcileNode(
530
+ imported: ImportedVisualNode,
531
+ current: ImportedVisualNode | null,
532
+ forcedId?: string
533
+ ): ImportedVisualNode {
534
+ return {
535
+ ...imported,
536
+ id: forcedId ?? current?.id ?? imported.id,
537
+ label: current?.label ?? imported.label,
538
+ children: reconcileNodeList(imported.children, current?.children ?? []),
539
+ }
540
+ }
541
+
542
+ function createImportedNode(
543
+ type: string,
544
+ props: Record<string, unknown>,
545
+ children: ImportedVisualNode[] = []
546
+ ): ImportedVisualNode {
547
+ return {
548
+ id: randomUUID(),
549
+ type,
550
+ props,
551
+ children,
552
+ }
553
+ }
554
+
555
+ function asNode(value: unknown): AstNode | null {
556
+ return value && typeof value === 'object' && 'type' in value
557
+ ? (value as AstNode)
558
+ : null
559
+ }
560
+
561
+ function asNodeArray(value: unknown): AstNode[] {
562
+ return Array.isArray(value)
563
+ ? value.map(asNode).filter((node): node is AstNode => Boolean(node))
564
+ : []
565
+ }
566
+
567
+ function formatParserError(error: unknown) {
568
+ if (!(error instanceof Error)) return 'The page contains invalid JSX.'
569
+ const location = error.message.match(/\(\d+:\d+\)/)?.[0]
570
+ return `Unable to parse the managed page${location ? ` at ${location}` : ''}: ${error.message}`
571
+ }
@@ -0,0 +1,37 @@
1
+ export interface SourceVisualNode {
2
+ id?: string
3
+ type: string
4
+ props?: Record<string, unknown>
5
+ children?: SourceVisualNode[]
6
+ }
7
+
8
+ export interface SourcePage {
9
+ id?: string
10
+ name: string
11
+ route: string
12
+ sourcePath?: string
13
+ root?: SourceVisualNode
14
+ components?: SourceVisualNode[]
15
+ }
16
+
17
+ export interface SourceAppShell {
18
+ header: SourceVisualNode | null
19
+ footer: SourceVisualNode | null
20
+ }
21
+
22
+ export function generatePageSource(page: SourcePage): string
23
+ export function generateAppSource(
24
+ pages: SourcePage[],
25
+ appShell?: SourceAppShell
26
+ ): string
27
+ export function generateLayoutSource(appShell: SourceAppShell): string
28
+ export function generateNodeSource(
29
+ node: SourceVisualNode,
30
+ indent?: string
31
+ ): string
32
+ export function toPageComponentName(name: string): string
33
+ export function getPageSourcePath(
34
+ page: Pick<SourcePage, 'name' | 'sourcePath'>
35
+ ): string
36
+ export function createPageSourcePath(name: string): string
37
+ export function normalizeProjectPath(value: string): string