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,571 +1,776 @@
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
- }
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
+ export interface ImportedComponentPreview {
18
+ rootTag: string
19
+ rootProps: Record<string, unknown>
20
+ children: ImportedVisualNode[]
21
+ rendersChildren: boolean
22
+ }
23
+
24
+ export const componentChildrenSlotType =
25
+ '__visualbuild_children_slot__'
26
+
27
+ type AstNode = {
28
+ type: string
29
+ [key: string]: unknown
30
+ }
31
+
32
+ const supportedVisualTags = new Set([
33
+ 'header',
34
+ 'main',
35
+ 'div',
36
+ 'section',
37
+ 'nav',
38
+ 'article',
39
+ 'aside',
40
+ 'footer',
41
+ 'address',
42
+ 'hgroup',
43
+ 'h1',
44
+ 'h2',
45
+ 'h3',
46
+ 'h4',
47
+ 'h5',
48
+ 'h6',
49
+ 'p',
50
+ 'br',
51
+ 'hr',
52
+ 'pre',
53
+ 'blockquote',
54
+ 'span',
55
+ 'strong',
56
+ 'b',
57
+ 'em',
58
+ 'i',
59
+ 'u',
60
+ 's',
61
+ 'small',
62
+ 'mark',
63
+ 'sub',
64
+ 'sup',
65
+ 'code',
66
+ 'kbd',
67
+ 'samp',
68
+ 'var',
69
+ 'abbr',
70
+ 'cite',
71
+ 'q',
72
+ 'time',
73
+ 'a',
74
+ 'ul',
75
+ 'ol',
76
+ 'li',
77
+ 'dl',
78
+ 'dt',
79
+ 'dd',
80
+ 'menu',
81
+ 'button',
82
+ 'img',
83
+ 'picture',
84
+ 'source',
85
+ 'figure',
86
+ 'figcaption',
87
+ 'video',
88
+ 'audio',
89
+ 'iframe',
90
+ 'canvas',
91
+ 'svg',
92
+ 'form',
93
+ 'label',
94
+ 'input',
95
+ 'textarea',
96
+ 'select',
97
+ 'option',
98
+ 'optgroup',
99
+ 'fieldset',
100
+ 'legend',
101
+ 'datalist',
102
+ 'output',
103
+ 'progress',
104
+ 'meter',
105
+ 'table',
106
+ 'caption',
107
+ 'thead',
108
+ 'tbody',
109
+ 'tfoot',
110
+ 'tr',
111
+ 'th',
112
+ 'td',
113
+ 'colgroup',
114
+ 'col',
115
+ 'details',
116
+ 'summary',
117
+ 'dialog',
118
+ ])
119
+
120
+ export class StaticJsxImportError extends Error {
121
+ constructor(message: string) {
122
+ super(message)
123
+ this.name = 'StaticJsxImportError'
124
+ }
125
+ }
126
+
127
+ export function parseReactPageSource(
128
+ source: string,
129
+ filename = 'Page.tsx',
130
+ customComponentNames: Iterable<string> = []
131
+ ): ImportedPageTree {
132
+ let program: AstNode
133
+
134
+ try {
135
+ program = parse(source, {
136
+ sourceType: 'module',
137
+ sourceFilename: filename,
138
+ plugins: ['jsx', 'typescript'],
139
+ }).program as unknown as AstNode
140
+ } catch (error) {
141
+ throw new StaticJsxImportError(formatParserError(error))
142
+ }
143
+
144
+ const returnedJsx = findReturnedJsx(program)
145
+
146
+ if (!returnedJsx) {
147
+ throw new StaticJsxImportError(
148
+ 'The managed page must return one static JSX element or fragment.'
149
+ )
150
+ }
151
+
152
+ return convertReturnedJsx(returnedJsx, new Set(customComponentNames))
153
+ }
154
+
155
+ export function parseReactComponentPreview(
156
+ source: string,
157
+ filename = 'Component.tsx',
158
+ customComponentNames: Iterable<string> = []
159
+ ): ImportedComponentPreview {
160
+ let program: AstNode
161
+
162
+ try {
163
+ program = parse(source, {
164
+ sourceType: 'module',
165
+ sourceFilename: filename,
166
+ plugins: ['jsx', 'typescript'],
167
+ }).program as unknown as AstNode
168
+ } catch (error) {
169
+ throw new StaticJsxImportError(formatParserError(error))
170
+ }
171
+
172
+ const returnedJsx = findReturnedJsx(program)
173
+
174
+ if (!returnedJsx) {
175
+ throw new StaticJsxImportError(
176
+ 'The component must return one static JSX element or fragment.'
177
+ )
178
+ }
179
+
180
+ const tree = convertReturnedJsx(
181
+ returnedJsx,
182
+ new Set(customComponentNames),
183
+ true
184
+ )
185
+
186
+ return {
187
+ rootTag: tree.root.type,
188
+ rootProps: tree.root.props,
189
+ children: tree.components,
190
+ rendersChildren: containsNodeType(
191
+ tree.components,
192
+ componentChildrenSlotType
193
+ ),
194
+ }
195
+ }
196
+
197
+ export function reconcileImportedTree(
198
+ imported: ImportedPageTree,
199
+ current: ImportedPageTree,
200
+ customComponentNames: Iterable<string> = []
201
+ ): ImportedPageTree {
202
+ const customTypes = new Set(customComponentNames)
203
+
204
+ return {
205
+ root: reconcileNode(
206
+ imported.root,
207
+ current.root,
208
+ current.root.id,
209
+ customTypes
210
+ ),
211
+ components: reconcileNodeList(
212
+ imported.components,
213
+ current.components,
214
+ customTypes
215
+ ),
216
+ }
217
+ }
218
+
219
+ export function visualTreesEqual(
220
+ first: ImportedPageTree,
221
+ second: ImportedPageTree
222
+ ) {
223
+ return JSON.stringify(canonicalize(first)) === JSON.stringify(canonicalize(second))
224
+ }
225
+
226
+ function findReturnedJsx(program: AstNode) {
227
+ const body = asNodeArray(program.body)
228
+ const defaultExport = body.find(
229
+ (node) => node.type === 'ExportDefaultDeclaration'
230
+ )
231
+
232
+ if (defaultExport) {
233
+ const declaration = asNode(defaultExport.declaration)
234
+ const resolved = resolveExportedDeclaration(declaration, body)
235
+ const result = findJsxInFunction(resolved)
236
+
237
+ if (result) return result
238
+ }
239
+
240
+ for (const statement of body) {
241
+ const result = findJsxInFunction(statement)
242
+
243
+ if (result) return result
244
+ }
245
+
246
+ return null
247
+ }
248
+
249
+ function resolveExportedDeclaration(
250
+ declaration: AstNode | null,
251
+ body: AstNode[]
252
+ ) {
253
+ if (!declaration || declaration.type !== 'Identifier') {
254
+ return declaration
255
+ }
256
+
257
+ const name = String(declaration.name ?? '')
258
+
259
+ for (const statement of body) {
260
+ if (
261
+ statement.type === 'FunctionDeclaration' &&
262
+ asNode(statement.id)?.name === name
263
+ ) {
264
+ return statement
265
+ }
266
+
267
+ if (statement.type === 'VariableDeclaration') {
268
+ for (const item of asNodeArray(statement.declarations)) {
269
+ if (asNode(item.id)?.name === name) {
270
+ return asNode(item.init)
271
+ }
272
+ }
273
+ }
274
+ }
275
+
276
+ return declaration
277
+ }
278
+
279
+ function findJsxInFunction(node: AstNode | null): AstNode | null {
280
+ if (!node) return null
281
+
282
+ if (
283
+ node.type === 'ArrowFunctionExpression' ||
284
+ node.type === 'FunctionExpression' ||
285
+ node.type === 'FunctionDeclaration'
286
+ ) {
287
+ const body = unwrapExpression(asNode(node.body))
288
+
289
+ if (isJsx(body)) return body
290
+ return findReturnStatement(body)
291
+ }
292
+
293
+ if (node.type === 'ExportNamedDeclaration') {
294
+ return findJsxInFunction(asNode(node.declaration))
295
+ }
296
+
297
+ if (node.type === 'VariableDeclaration') {
298
+ for (const item of asNodeArray(node.declarations)) {
299
+ const result = findJsxInFunction(asNode(item.init))
300
+ if (result) return result
301
+ }
302
+ }
303
+
304
+ return null
305
+ }
306
+
307
+ function findReturnStatement(node: AstNode | null): AstNode | null {
308
+ if (!node) return null
309
+
310
+ if (node.type === 'ReturnStatement') {
311
+ const argument = unwrapExpression(asNode(node.argument))
312
+ return isJsx(argument) ? argument : null
313
+ }
314
+
315
+ for (const value of Object.values(node)) {
316
+ if (Array.isArray(value)) {
317
+ for (const child of value) {
318
+ const result = findReturnStatement(asNode(child))
319
+ if (result) return result
320
+ }
321
+ } else {
322
+ const child = asNode(value)
323
+ const result = findReturnStatement(child)
324
+ if (result) return result
325
+ }
326
+ }
327
+
328
+ return null
329
+ }
330
+
331
+ function unwrapExpression(node: AstNode | null): AstNode | null {
332
+ if (
333
+ node &&
334
+ [
335
+ 'ParenthesizedExpression',
336
+ 'TSAsExpression',
337
+ 'TSTypeAssertion',
338
+ 'TSSatisfiesExpression',
339
+ ].includes(node.type)
340
+ ) {
341
+ return unwrapExpression(asNode(node.expression))
342
+ }
343
+
344
+ return node
345
+ }
346
+
347
+ function isJsx(node: AstNode | null): node is AstNode {
348
+ return Boolean(
349
+ node &&
350
+ ['JSXElement', 'JSXFragment'].includes(node.type)
351
+ )
352
+ }
353
+
354
+ function convertReturnedJsx(
355
+ jsx: AstNode,
356
+ customComponentNames: Set<string>,
357
+ allowChildrenSlot = false
358
+ ): ImportedPageTree {
359
+ if (jsx.type === 'JSXFragment') {
360
+ return {
361
+ root: createImportedNode('main', { className: 'min-h-screen' }),
362
+ components: convertJsxChildren(
363
+ asNodeArray(jsx.children),
364
+ customComponentNames,
365
+ allowChildrenSlot
366
+ ),
367
+ }
368
+ }
369
+
370
+ const root = convertJsxElement(
371
+ jsx,
372
+ customComponentNames,
373
+ allowChildrenSlot
374
+ )
375
+ const components = [...root.children]
376
+ const text = String(root.props.text ?? '').trim()
377
+
378
+ if (text) {
379
+ components.unshift(createImportedNode('span', { text, className: '' }))
380
+ delete root.props.text
381
+ }
382
+
383
+ root.children = []
384
+ return { root, components }
385
+ }
386
+
387
+ function convertJsxElement(
388
+ jsx: AstNode,
389
+ customComponentNames: Set<string>,
390
+ allowChildrenSlot = false
391
+ ): ImportedVisualNode {
392
+ const opening = asNode(jsx.openingElement)
393
+ const nameNode = asNode(opening?.name)
394
+
395
+ if (!opening || !nameNode || nameNode.type !== 'JSXIdentifier') {
396
+ throw new StaticJsxImportError(
397
+ 'Namespaced tags and member-expression components are not supported.'
398
+ )
399
+ }
400
+
401
+ const tagName = String(nameNode.name ?? '')
402
+
403
+ if (
404
+ !supportedVisualTags.has(tagName) &&
405
+ !customComponentNames.has(tagName)
406
+ ) {
407
+ throw new StaticJsxImportError(
408
+ `Unsupported JSX element <${tagName}>. Register the component before using it in a managed page.`
409
+ )
410
+ }
411
+
412
+ const props = convertJsxAttributes(asNodeArray(opening.attributes))
413
+ const rawChildren = asNodeArray(jsx.children)
414
+ const childItems = convertChildItems(
415
+ rawChildren,
416
+ customComponentNames,
417
+ allowChildrenSlot
418
+ )
419
+ const hasElementChild = childItems.some(
420
+ (item) => typeof item !== 'string'
421
+ )
422
+ const children = childItems.flatMap((item) =>
423
+ typeof item === 'string'
424
+ ? hasElementChild
425
+ ? [createImportedNode('span', { text: item, className: '' })]
426
+ : []
427
+ : [item]
428
+ )
429
+ const directText = childItems
430
+ .filter((item): item is string => typeof item === 'string')
431
+ .join(' ')
432
+ .trim()
433
+
434
+ if (directText && !hasElementChild) {
435
+ props.text = directText
436
+ }
437
+
438
+ const wrappedComponentName = String(
439
+ props['data-visualbuild-component'] ?? ''
440
+ )
441
+ const wrappedComponent = children.find(
442
+ (child) =>
443
+ child.type === wrappedComponentName &&
444
+ customComponentNames.has(child.type)
445
+ )
446
+
447
+ if (wrappedComponent) {
448
+ const additionalAttributes = Object.fromEntries(
449
+ Object.entries(props).filter(
450
+ ([name]) =>
451
+ ![
452
+ 'data-visualbuild-component',
453
+ 'id',
454
+ 'className',
455
+ 'text',
456
+ ].includes(name)
457
+ )
458
+ )
459
+
460
+ return createImportedNode(
461
+ wrappedComponent.type,
462
+ {
463
+ ...wrappedComponent.props,
464
+ wrapperTag: tagName,
465
+ id: props.id ?? '',
466
+ className: props.className ?? '',
467
+ attributes: additionalAttributes,
468
+ },
469
+ wrappedComponent.children
470
+ )
471
+ }
472
+
473
+ return createImportedNode(tagName, props, children)
474
+ }
475
+
476
+ function convertJsxAttributes(attributes: AstNode[]) {
477
+ const props: Record<string, unknown> = {}
478
+
479
+ for (const attribute of attributes) {
480
+ if (attribute.type === 'JSXSpreadAttribute') {
481
+ throw new StaticJsxImportError(
482
+ 'Spread attributes are not supported by reverse sync.'
483
+ )
484
+ }
485
+
486
+ if (attribute.type !== 'JSXAttribute') continue
487
+ const nameNode = asNode(attribute.name)
488
+
489
+ if (!nameNode || nameNode.type !== 'JSXIdentifier') {
490
+ throw new StaticJsxImportError('Only standard JSX attributes are supported.')
491
+ }
492
+
493
+ const rawName = String(nameNode.name ?? '')
494
+ const name = rawName === 'class' ? 'className' : rawName
495
+ const valueNode = asNode(attribute.value)
496
+
497
+ if (!valueNode) {
498
+ props[name] = true
499
+ continue
500
+ }
501
+
502
+ if (valueNode.type === 'StringLiteral') {
503
+ props[name] = valueNode.value
504
+ continue
505
+ }
506
+
507
+ if (valueNode.type === 'JSXExpressionContainer') {
508
+ const value = getStaticExpressionValue(asNode(valueNode.expression))
509
+
510
+ if (value === undefined) {
511
+ throw new StaticJsxImportError(
512
+ `Attribute "${name}" must use a static string, number, boolean, or null.`
513
+ )
514
+ }
515
+
516
+ props[name] = value
517
+ continue
518
+ }
519
+
520
+ throw new StaticJsxImportError(
521
+ `Attribute "${name}" uses unsupported JSX syntax.`
522
+ )
523
+ }
524
+
525
+ return props
526
+ }
527
+
528
+ function getStaticExpressionValue(node: AstNode | null): unknown {
529
+ const value = unwrapExpression(node)
530
+
531
+ if (!value) return undefined
532
+ if (value.type === 'StringLiteral' || value.type === 'NumericLiteral') {
533
+ return value.value
534
+ }
535
+ if (value.type === 'BooleanLiteral') return value.value
536
+ if (value.type === 'NullLiteral') return null
537
+ if (
538
+ value.type === 'TemplateLiteral' &&
539
+ asNodeArray(value.expressions).length === 0
540
+ ) {
541
+ return asNodeArray(value.quasis)
542
+ .map((quasi) => String(asNode(quasi.value)?.cooked ?? ''))
543
+ .join('')
544
+ }
545
+ if (value.type === 'UnaryExpression' && value.operator === '-') {
546
+ const argument = asNode(value.argument)
547
+ if (argument?.type === 'NumericLiteral') {
548
+ return -Number(argument.value)
549
+ }
550
+ }
551
+
552
+ return undefined
553
+ }
554
+
555
+ function convertJsxChildren(
556
+ children: AstNode[],
557
+ customComponentNames: Set<string>,
558
+ allowChildrenSlot = false
559
+ ) {
560
+ return convertChildItems(
561
+ children,
562
+ customComponentNames,
563
+ allowChildrenSlot
564
+ ).flatMap((item) =>
565
+ typeof item === 'string'
566
+ ? [createImportedNode('span', { text: item, className: '' })]
567
+ : [item]
568
+ )
569
+ }
570
+
571
+ function convertChildItems(
572
+ children: AstNode[],
573
+ customComponentNames: Set<string>,
574
+ allowChildrenSlot = false
575
+ ) {
576
+ const result: Array<string | ImportedVisualNode> = []
577
+
578
+ for (const child of children) {
579
+ if (child.type === 'JSXElement') {
580
+ result.push(
581
+ convertJsxElement(
582
+ child,
583
+ customComponentNames,
584
+ allowChildrenSlot
585
+ )
586
+ )
587
+ continue
588
+ }
589
+
590
+ if (child.type === 'JSXFragment') {
591
+ result.push(
592
+ ...convertChildItems(
593
+ asNodeArray(child.children),
594
+ customComponentNames,
595
+ allowChildrenSlot
596
+ )
597
+ )
598
+ continue
599
+ }
600
+
601
+ if (child.type === 'JSXText') {
602
+ const text = normalizeJsxText(String(child.value ?? ''))
603
+ if (text) result.push(text)
604
+ continue
605
+ }
606
+
607
+ if (child.type === 'JSXExpressionContainer') {
608
+ const expression = asNode(child.expression)
609
+
610
+ if (expression?.type === 'JSXEmptyExpression') continue
611
+ if (
612
+ allowChildrenSlot &&
613
+ expression?.type === 'Identifier' &&
614
+ expression.name === 'children'
615
+ ) {
616
+ result.push(createImportedNode(componentChildrenSlotType, {}))
617
+ continue
618
+ }
619
+ const value = getStaticExpressionValue(expression)
620
+
621
+ if (typeof value === 'string' || typeof value === 'number') {
622
+ result.push(String(value))
623
+ continue
624
+ }
625
+
626
+ throw new StaticJsxImportError(
627
+ 'Dynamic JSX expressions are not supported by reverse sync.'
628
+ )
629
+ }
630
+
631
+ if (child.type === 'JSXSpreadChild') {
632
+ throw new StaticJsxImportError(
633
+ 'Spread children are not supported by reverse sync.'
634
+ )
635
+ }
636
+ }
637
+
638
+ return result
639
+ }
640
+
641
+ function normalizeJsxText(value: string) {
642
+ return value.replace(/\s+/g, ' ').trim()
643
+ }
644
+
645
+ function canonicalize(value: unknown): unknown {
646
+ if (Array.isArray(value)) {
647
+ return value.map(canonicalize)
648
+ }
649
+
650
+ if (value && typeof value === 'object') {
651
+ return Object.fromEntries(
652
+ Object.entries(value)
653
+ .filter(([, entryValue]) => entryValue !== undefined)
654
+ .sort(([firstKey], [secondKey]) => firstKey.localeCompare(secondKey))
655
+ .map(([key, entryValue]) => [key, canonicalize(entryValue)])
656
+ )
657
+ }
658
+
659
+ return value
660
+ }
661
+
662
+ function reconcileNodeList(
663
+ imported: ImportedVisualNode[],
664
+ current: ImportedVisualNode[],
665
+ customComponentNames: Set<string>
666
+ ) {
667
+ const used = new Set<number>()
668
+
669
+ return imported.map((node, index) => {
670
+ let matchIndex =
671
+ current[index]?.type === node.type
672
+ ? index
673
+ : current.findIndex(
674
+ (candidate, candidateIndex) =>
675
+ !used.has(candidateIndex) && candidate.type === node.type
676
+ )
677
+
678
+ if (matchIndex < 0 || used.has(matchIndex)) matchIndex = -1
679
+ if (matchIndex >= 0) used.add(matchIndex)
680
+
681
+ return reconcileNode(
682
+ node,
683
+ current[matchIndex] ?? null,
684
+ undefined,
685
+ customComponentNames
686
+ )
687
+ })
688
+ }
689
+
690
+ function reconcileNode(
691
+ imported: ImportedVisualNode,
692
+ current: ImportedVisualNode | null,
693
+ forcedId: string | undefined,
694
+ customComponentNames: Set<string>
695
+ ): ImportedVisualNode {
696
+ const props =
697
+ current &&
698
+ customComponentNames.has(imported.type) &&
699
+ current.type === imported.type
700
+ ? preserveCustomRootProps(imported.props, current.props)
701
+ : imported.props
702
+
703
+ return {
704
+ ...imported,
705
+ id: forcedId ?? current?.id ?? imported.id,
706
+ label: current?.label ?? imported.label,
707
+ props,
708
+ children: reconcileNodeList(
709
+ imported.children,
710
+ current?.children ?? [],
711
+ customComponentNames
712
+ ),
713
+ }
714
+ }
715
+
716
+ const customRootPropNames = [
717
+ 'wrapperTag',
718
+ 'id',
719
+ 'className',
720
+ 'attributes',
721
+ 'componentTree',
722
+ ] as const
723
+
724
+ function preserveCustomRootProps(
725
+ imported: Record<string, unknown>,
726
+ current: Record<string, unknown>
727
+ ) {
728
+ const rootProps = Object.fromEntries(
729
+ customRootPropNames
730
+ .filter((name) => Object.hasOwn(current, name))
731
+ .map((name) => [name, current[name]])
732
+ )
733
+
734
+ return {
735
+ ...imported,
736
+ ...rootProps,
737
+ }
738
+ }
739
+
740
+ function createImportedNode(
741
+ type: string,
742
+ props: Record<string, unknown>,
743
+ children: ImportedVisualNode[] = []
744
+ ): ImportedVisualNode {
745
+ return {
746
+ id: randomUUID(),
747
+ type,
748
+ props,
749
+ children,
750
+ }
751
+ }
752
+
753
+ function containsNodeType(nodes: ImportedVisualNode[], type: string): boolean {
754
+ return nodes.some(
755
+ (node) =>
756
+ node.type === type || containsNodeType(node.children, type)
757
+ )
758
+ }
759
+
760
+ function asNode(value: unknown): AstNode | null {
761
+ return value && typeof value === 'object' && 'type' in value
762
+ ? (value as AstNode)
763
+ : null
764
+ }
765
+
766
+ function asNodeArray(value: unknown): AstNode[] {
767
+ return Array.isArray(value)
768
+ ? value.map(asNode).filter((node): node is AstNode => Boolean(node))
769
+ : []
770
+ }
771
+
772
+ function formatParserError(error: unknown) {
773
+ if (!(error instanceof Error)) return 'The page contains invalid JSX.'
774
+ const location = error.message.match(/\(\d+:\d+\)/)?.[0]
775
+ return `Unable to parse the managed page${location ? ` at ${location}` : ''}: ${error.message}`
776
+ }