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,443 +1,608 @@
1
- import { generate } from '@babel/generator'
2
- import * as t from '@babel/types'
3
-
4
- const voidElements = new Set(['br', 'hr', 'img', 'input', 'source', 'col'])
5
-
6
- export function generatePageSource(page) {
7
- const root = {
8
- ...(page.root ?? {
9
- id: `${page.id}:root`,
10
- type: 'div',
11
- props: { className: 'min-h-screen' },
12
- children: [],
13
- }),
14
- children: page.components ?? [],
15
- }
16
- const declaration = t.functionDeclaration(
17
- t.identifier(toPageComponentName(page.name)),
18
- [],
19
- t.blockStatement([
20
- t.returnStatement(createJsxElement(root, 1)),
21
- ])
22
- )
23
-
24
- return printProgram([t.exportDefaultDeclaration(declaration)])
25
- }
26
-
27
- export function generateAppSource(
28
- pages,
29
- appShell = { header: null, footer: null }
30
- ) {
31
- const hasLayout = Boolean(appShell.header || appShell.footer)
32
- const statements = pages.map((page) =>
33
- t.importDeclaration(
34
- [
35
- t.importDefaultSpecifier(
36
- t.identifier(toPageComponentName(page.name))
37
- ),
38
- ],
39
- t.stringLiteral(toPageImportPath(page))
40
- )
41
- )
42
-
43
- if (hasLayout) {
44
- statements.push(
45
- t.importDeclaration(
46
- [t.importDefaultSpecifier(t.identifier('AppLayout'))],
47
- t.stringLiteral('./layouts/AppLayout')
48
- )
49
- )
50
- }
51
-
52
- statements.push(
53
- t.variableDeclaration('const', [
54
- t.variableDeclarator(
55
- t.identifier('routes'),
56
- t.arrayExpression(
57
- pages.map((page) =>
58
- t.objectExpression([
59
- t.objectProperty(
60
- t.identifier('path'),
61
- t.stringLiteral(page.route)
62
- ),
63
- t.objectProperty(
64
- t.identifier('component'),
65
- t.identifier(toPageComponentName(page.name))
66
- ),
67
- ])
68
- )
69
- )
70
- ),
71
- ])
72
- )
73
-
74
- const fallbackPage = t.identifier(
75
- toPageComponentName(pages[0]?.name ?? 'Home')
76
- )
77
- const appBody = [
78
- t.variableDeclaration('const', [
79
- t.variableDeclarator(
80
- t.identifier('path'),
81
- t.memberExpression(
82
- t.memberExpression(
83
- t.identifier('window'),
84
- t.identifier('location')
85
- ),
86
- t.identifier('pathname')
87
- )
88
- ),
89
- ]),
90
- t.variableDeclaration('const', [
91
- t.variableDeclarator(
92
- t.identifier('match'),
93
- t.callExpression(
94
- t.memberExpression(
95
- t.identifier('routes'),
96
- t.identifier('find')
97
- ),
98
- [
99
- t.arrowFunctionExpression(
100
- [t.identifier('route')],
101
- t.binaryExpression(
102
- '===',
103
- t.memberExpression(
104
- t.identifier('route'),
105
- t.identifier('path')
106
- ),
107
- t.identifier('path')
108
- )
109
- ),
110
- ]
111
- )
112
- ),
113
- ]),
114
- t.variableDeclaration('const', [
115
- t.variableDeclarator(
116
- t.identifier('Page'),
117
- t.logicalExpression(
118
- '??',
119
- t.optionalMemberExpression(
120
- t.identifier('match'),
121
- t.identifier('component'),
122
- false,
123
- true
124
- ),
125
- fallbackPage
126
- )
127
- ),
128
- ]),
129
- t.returnStatement(
130
- hasLayout
131
- ? t.jsxElement(
132
- t.jsxOpeningElement(t.jsxIdentifier('AppLayout'), [], false),
133
- t.jsxClosingElement(t.jsxIdentifier('AppLayout')),
134
- withLineBreaks([
135
- t.jsxElement(
136
- t.jsxOpeningElement(t.jsxIdentifier('Page'), [], true),
137
- null,
138
- [],
139
- true
140
- ),
141
- ], 1),
142
- false
143
- )
144
- : t.jsxElement(
145
- t.jsxOpeningElement(t.jsxIdentifier('Page'), [], true),
146
- null,
147
- [],
148
- true
149
- )
150
- ),
151
- ]
152
- const appDeclaration = t.functionDeclaration(
153
- t.identifier('App'),
154
- [],
155
- t.blockStatement(appBody)
156
- )
157
-
158
- statements.push(t.exportDefaultDeclaration(appDeclaration))
159
- return printProgram(statements)
160
- }
161
-
162
- export function generateLayoutSource(appShell) {
163
- const reactImport = t.importDeclaration(
164
- [
165
- t.importSpecifier(
166
- t.identifier('ReactNode'),
167
- t.identifier('ReactNode')
168
- ),
169
- ],
170
- t.stringLiteral('react')
171
- )
172
- reactImport.importKind = 'type'
173
-
174
- const childrenProperty = t.objectProperty(
175
- t.identifier('children'),
176
- t.identifier('children'),
177
- false,
178
- true
179
- )
180
- const childrenPattern = t.objectPattern([childrenProperty])
181
- const childrenType = t.tsPropertySignature(
182
- t.identifier('children'),
183
- t.tsTypeAnnotation(
184
- t.tsTypeReference(t.identifier('ReactNode'))
185
- )
186
- )
187
- childrenPattern.typeAnnotation = t.tsTypeAnnotation(
188
- t.tsTypeLiteral([childrenType])
189
- )
190
-
191
- const wrapperChildren = []
192
-
193
- if (appShell.header) {
194
- wrapperChildren.push(createJsxElement(appShell.header, 2))
195
- }
196
-
197
- wrapperChildren.push(
198
- t.jsxExpressionContainer(t.identifier('children'))
199
- )
200
-
201
- if (appShell.footer) {
202
- wrapperChildren.push(createJsxElement(appShell.footer, 2))
203
- }
204
-
205
- const declaration = t.functionDeclaration(
206
- t.identifier('AppLayout'),
207
- [childrenPattern],
208
- t.blockStatement([
209
- t.returnStatement(
210
- t.jsxElement(
211
- t.jsxOpeningElement(t.jsxIdentifier('div'), [], false),
212
- t.jsxClosingElement(t.jsxIdentifier('div')),
213
- withLineBreaks(wrapperChildren, 1),
214
- false
215
- )
216
- ),
217
- ])
218
- )
219
-
220
- return printProgram([
221
- reactImport,
222
- t.exportDefaultDeclaration(declaration),
223
- ])
224
- }
225
-
226
- export function generateNodeSource(node, indent = '') {
227
- const source = generate(createJsxElement(node), generatorOptions).code
228
- return source
229
- .split('\n')
230
- .map((line) => `${indent}${line}`)
231
- .join('\n')
232
- }
233
-
234
- export function toPageComponentName(name) {
235
- const pascalName = String(name)
236
- .split(/[^a-zA-Z0-9]+/)
237
- .filter(Boolean)
238
- .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
239
- .join('')
240
-
241
- return `${pascalName || 'Untitled'}Page`
242
- }
243
-
244
- export function getPageSourcePath(page) {
245
- return normalizeProjectPath(
246
- page.sourcePath || `src/pages/${toPageComponentName(page.name)}.tsx`
247
- )
248
- }
249
-
250
- export function createPageSourcePath(name) {
251
- return `src/pages/${toPageComponentName(name)}.tsx`
252
- }
253
-
254
- export function normalizeProjectPath(value) {
255
- return String(value).replace(/\\/g, '/').replace(/^\.\/+/, '')
256
- }
257
-
258
- function createJsxElement(node, depth = 0) {
259
- const tagName = String(node.type)
260
- const selfClosing = voidElements.has(tagName) || tagName === 'textarea'
261
- const opening = t.jsxOpeningElement(
262
- t.jsxIdentifier(tagName),
263
- createJsxAttributes(node),
264
- selfClosing
265
- )
266
-
267
- if (selfClosing) {
268
- return t.jsxElement(opening, null, [], true)
269
- }
270
-
271
- const children = Array.isArray(node.children)
272
- ? node.children.map((child) => createJsxElement(child, depth + 1))
273
- : []
274
- const text = String(node.props?.text ?? '')
275
-
276
- if (children.length === 0 && text) {
277
- children.push(t.jsxText(escapeJsxText(text)))
278
- }
279
-
280
- return t.jsxElement(
281
- opening,
282
- t.jsxClosingElement(t.jsxIdentifier(tagName)),
283
- children.length > 0 ? withLineBreaks(children, depth) : children,
284
- false
285
- )
286
- }
287
-
288
- function withLineBreaks(children, depth) {
289
- const childIndent = ' '.repeat(depth + 1)
290
- const closingIndent = ' '.repeat(depth)
291
-
292
- return [
293
- t.jsxText(`\n${childIndent}`),
294
- ...children.flatMap((child, index) => [
295
- child,
296
- t.jsxText(
297
- index === children.length - 1
298
- ? `\n${closingIndent}`
299
- : `\n${childIndent}`
300
- ),
301
- ]),
302
- ]
303
- }
304
-
305
- function createJsxAttributes(node) {
306
- const props = node.props ?? {}
307
- const attributes = [
308
- props.id ? createValueAttribute('id', String(props.id)) : null,
309
- props.className
310
- ? createValueAttribute('className', String(props.className))
311
- : null,
312
- node.type === 'a'
313
- ? createValueAttribute('href', String(props.href ?? '#'))
314
- : null,
315
- node.type === 'a'
316
- ? createValueAttribute('target', String(props.target ?? '_self'))
317
- : null,
318
- node.type === 'button'
319
- ? createValueAttribute('type', String(props.type ?? 'button'))
320
- : null,
321
- node.type === 'img'
322
- ? createValueAttribute('src', String(props.src ?? ''))
323
- : null,
324
- node.type === 'img'
325
- ? createValueAttribute('alt', String(props.alt ?? ''))
326
- : null,
327
- node.type === 'input'
328
- ? createValueAttribute('type', String(props.type ?? 'text'))
329
- : null,
330
- node.type === 'input' || node.type === 'textarea'
331
- ? props.name
332
- ? createValueAttribute('name', String(props.name))
333
- : null
334
- : null,
335
- node.type === 'input' || node.type === 'textarea'
336
- ? props.placeholder
337
- ? createValueAttribute('placeholder', String(props.placeholder))
338
- : null
339
- : null,
340
- node.type === 'textarea'
341
- ? createValueAttribute('defaultValue', String(props.text ?? ''))
342
- : null,
343
- node.type === 'label' && props.htmlFor
344
- ? createValueAttribute('htmlFor', String(props.htmlFor))
345
- : null,
346
- node.type === 'iframe'
347
- ? createValueAttribute('src', String(props.src ?? ''))
348
- : null,
349
- node.type === 'iframe'
350
- ? createValueAttribute(
351
- 'title',
352
- String(props.title ?? 'Embedded content')
353
- )
354
- : null,
355
- node.type === 'source'
356
- ? createValueAttribute('src', String(props.src ?? ''))
357
- : null,
358
- node.type === 'source' && props.type
359
- ? createValueAttribute('type', String(props.type))
360
- : null,
361
- node.type === 'video' || node.type === 'audio'
362
- ? props.src
363
- ? createValueAttribute('src', String(props.src))
364
- : null
365
- : null,
366
- node.type === 'video' || node.type === 'audio'
367
- ? props.controls !== false
368
- ? createBooleanAttribute('controls')
369
- : null
370
- : null,
371
- node.type === 'option' && props.value
372
- ? createValueAttribute('value', String(props.value))
373
- : null,
374
- node.type === 'optgroup'
375
- ? createValueAttribute('label', String(props.label ?? 'Group'))
376
- : null,
377
- node.type === 'progress' || node.type === 'meter'
378
- ? props.value !== undefined
379
- ? createValueAttribute('value', Number(props.value))
380
- : null
381
- : null,
382
- node.type === 'meter'
383
- ? props.min !== undefined
384
- ? createValueAttribute('min', Number(props.min))
385
- : null
386
- : null,
387
- node.type === 'progress' || node.type === 'meter'
388
- ? props.max !== undefined
389
- ? createValueAttribute('max', Number(props.max))
390
- : null
391
- : null,
392
- ]
393
-
394
- return attributes.filter(Boolean)
395
- }
396
-
397
- function createValueAttribute(name, value) {
398
- return t.jsxAttribute(
399
- t.jsxIdentifier(name),
400
- t.jsxExpressionContainer(
401
- typeof value === 'number'
402
- ? Number.isFinite(value)
403
- ? t.numericLiteral(value)
404
- : t.nullLiteral()
405
- : t.stringLiteral(value)
406
- )
407
- )
408
- }
409
-
410
- function createBooleanAttribute(name) {
411
- return t.jsxAttribute(t.jsxIdentifier(name), null)
412
- }
413
-
414
- function toPageImportPath(page) {
415
- const sourcePath = getPageSourcePath(page)
416
- const sourceRelativePath = sourcePath.startsWith('src/')
417
- ? sourcePath.slice(4)
418
- : sourcePath
419
- const withoutExtension = sourceRelativePath.replace(/\.[^./]+$/, '')
420
-
421
- return withoutExtension.startsWith('.')
422
- ? withoutExtension
423
- : `./${withoutExtension}`
424
- }
425
-
426
- function escapeJsxText(value) {
427
- return value
428
- .replace(/&/g, '&')
429
- .replace(/</g, '&lt;')
430
- .replace(/>/g, '&gt;')
431
- }
432
-
433
- function printProgram(statements) {
434
- const file = t.file(t.program(statements))
435
- return `${generate(file, generatorOptions).code}\n`
436
- }
437
-
438
- const generatorOptions = {
439
- comments: false,
440
- compact: false,
441
- concise: false,
442
- jsescOption: { minimal: true },
443
- }
1
+ import { generate } from '@babel/generator'
2
+ import * as t from '@babel/types'
3
+
4
+ const voidElements = new Set(['br', 'hr', 'img', 'input', 'source', 'col'])
5
+
6
+ export function generatePageSource(page, customComponents = []) {
7
+ customComponents = Array.isArray(customComponents) ? customComponents : []
8
+ const root = {
9
+ ...(page.root ?? {
10
+ id: `${page.id}:root`,
11
+ type: 'div',
12
+ props: { className: 'min-h-screen' },
13
+ children: [],
14
+ }),
15
+ children: page.components ?? [],
16
+ }
17
+ const declaration = t.functionDeclaration(
18
+ t.identifier(toPageComponentName(page.name)),
19
+ [],
20
+ t.blockStatement([
21
+ t.returnStatement(createJsxElement(root, 1, customComponents)),
22
+ ])
23
+ )
24
+
25
+ return printProgram([
26
+ ...createCustomComponentImports(
27
+ [root],
28
+ getPageSourcePath(page),
29
+ customComponents
30
+ ),
31
+ t.exportDefaultDeclaration(declaration),
32
+ ])
33
+ }
34
+
35
+ export function generateAppSource(
36
+ pages,
37
+ appShell = { header: null, footer: null },
38
+ options = {}
39
+ ) {
40
+ const appFile = options.appFile ?? 'src/App.tsx'
41
+ const layoutFile =
42
+ options.layoutFile ?? 'src/layouts/AppLayout.tsx'
43
+ const hasLayout =
44
+ options.emitLayout !== false &&
45
+ Boolean(appShell.header || appShell.footer)
46
+ const statements = pages.map((page) =>
47
+ t.importDeclaration(
48
+ [
49
+ t.importDefaultSpecifier(
50
+ t.identifier(toPageComponentName(page.name))
51
+ ),
52
+ ],
53
+ t.stringLiteral(toPageImportPath(page, appFile))
54
+ )
55
+ )
56
+
57
+ if (hasLayout) {
58
+ statements.push(
59
+ t.importDeclaration(
60
+ [t.importDefaultSpecifier(t.identifier('AppLayout'))],
61
+ t.stringLiteral(toRelativeImportPath(appFile, layoutFile))
62
+ )
63
+ )
64
+ }
65
+
66
+ statements.push(
67
+ t.variableDeclaration('const', [
68
+ t.variableDeclarator(
69
+ t.identifier('routes'),
70
+ t.arrayExpression(
71
+ pages.map((page) =>
72
+ t.objectExpression([
73
+ t.objectProperty(
74
+ t.identifier('path'),
75
+ t.stringLiteral(page.route)
76
+ ),
77
+ t.objectProperty(
78
+ t.identifier('component'),
79
+ t.identifier(toPageComponentName(page.name))
80
+ ),
81
+ ])
82
+ )
83
+ )
84
+ ),
85
+ ])
86
+ )
87
+
88
+ const fallbackPage = t.identifier(
89
+ toPageComponentName(pages[0]?.name ?? 'Home')
90
+ )
91
+ const appBody = [
92
+ t.variableDeclaration('const', [
93
+ t.variableDeclarator(
94
+ t.identifier('path'),
95
+ t.memberExpression(
96
+ t.memberExpression(
97
+ t.identifier('window'),
98
+ t.identifier('location')
99
+ ),
100
+ t.identifier('pathname')
101
+ )
102
+ ),
103
+ ]),
104
+ t.variableDeclaration('const', [
105
+ t.variableDeclarator(
106
+ t.identifier('match'),
107
+ t.callExpression(
108
+ t.memberExpression(
109
+ t.identifier('routes'),
110
+ t.identifier('find')
111
+ ),
112
+ [
113
+ t.arrowFunctionExpression(
114
+ [t.identifier('route')],
115
+ t.binaryExpression(
116
+ '===',
117
+ t.memberExpression(
118
+ t.identifier('route'),
119
+ t.identifier('path')
120
+ ),
121
+ t.identifier('path')
122
+ )
123
+ ),
124
+ ]
125
+ )
126
+ ),
127
+ ]),
128
+ t.variableDeclaration('const', [
129
+ t.variableDeclarator(
130
+ t.identifier('Page'),
131
+ t.logicalExpression(
132
+ '??',
133
+ t.optionalMemberExpression(
134
+ t.identifier('match'),
135
+ t.identifier('component'),
136
+ false,
137
+ true
138
+ ),
139
+ fallbackPage
140
+ )
141
+ ),
142
+ ]),
143
+ t.returnStatement(
144
+ hasLayout
145
+ ? t.jsxElement(
146
+ t.jsxOpeningElement(t.jsxIdentifier('AppLayout'), [], false),
147
+ t.jsxClosingElement(t.jsxIdentifier('AppLayout')),
148
+ withLineBreaks([
149
+ t.jsxElement(
150
+ t.jsxOpeningElement(t.jsxIdentifier('Page'), [], true),
151
+ null,
152
+ [],
153
+ true
154
+ ),
155
+ ], 1),
156
+ false
157
+ )
158
+ : t.jsxElement(
159
+ t.jsxOpeningElement(t.jsxIdentifier('Page'), [], true),
160
+ null,
161
+ [],
162
+ true
163
+ )
164
+ ),
165
+ ]
166
+ const appDeclaration = t.functionDeclaration(
167
+ t.identifier('App'),
168
+ [],
169
+ t.blockStatement(appBody)
170
+ )
171
+
172
+ statements.push(t.exportDefaultDeclaration(appDeclaration))
173
+ return printProgram(statements)
174
+ }
175
+
176
+ export function generateLayoutSource(
177
+ appShell,
178
+ customComponents = [],
179
+ options = {}
180
+ ) {
181
+ customComponents = Array.isArray(customComponents) ? customComponents : []
182
+ const reactImport = t.importDeclaration(
183
+ [
184
+ t.importSpecifier(
185
+ t.identifier('ReactNode'),
186
+ t.identifier('ReactNode')
187
+ ),
188
+ ],
189
+ t.stringLiteral('react')
190
+ )
191
+ reactImport.importKind = 'type'
192
+
193
+ const childrenProperty = t.objectProperty(
194
+ t.identifier('children'),
195
+ t.identifier('children'),
196
+ false,
197
+ true
198
+ )
199
+ const childrenPattern = t.objectPattern([childrenProperty])
200
+ const childrenType = t.tsPropertySignature(
201
+ t.identifier('children'),
202
+ t.tsTypeAnnotation(
203
+ t.tsTypeReference(t.identifier('ReactNode'))
204
+ )
205
+ )
206
+ childrenPattern.typeAnnotation = t.tsTypeAnnotation(
207
+ t.tsTypeLiteral([childrenType])
208
+ )
209
+
210
+ const wrapperChildren = []
211
+
212
+ if (appShell.header) {
213
+ wrapperChildren.push(createJsxElement(appShell.header, 2, customComponents))
214
+ }
215
+
216
+ wrapperChildren.push(
217
+ t.jsxExpressionContainer(t.identifier('children'))
218
+ )
219
+
220
+ if (appShell.footer) {
221
+ wrapperChildren.push(createJsxElement(appShell.footer, 2, customComponents))
222
+ }
223
+
224
+ const declaration = t.functionDeclaration(
225
+ t.identifier('AppLayout'),
226
+ [childrenPattern],
227
+ t.blockStatement([
228
+ t.returnStatement(
229
+ t.jsxElement(
230
+ t.jsxOpeningElement(t.jsxIdentifier('div'), [], false),
231
+ t.jsxClosingElement(t.jsxIdentifier('div')),
232
+ withLineBreaks(wrapperChildren, 1),
233
+ false
234
+ )
235
+ ),
236
+ ])
237
+ )
238
+
239
+ return printProgram([
240
+ reactImport,
241
+ ...createCustomComponentImports(
242
+ [appShell.header, appShell.footer].filter(Boolean),
243
+ options.layoutFile ?? 'src/layouts/AppLayout.tsx',
244
+ customComponents
245
+ ),
246
+ t.exportDefaultDeclaration(declaration),
247
+ ])
248
+ }
249
+
250
+ export function generateNodeSource(node, indent = '') {
251
+ const source = generate(createJsxElement(node), generatorOptions).code
252
+ return source
253
+ .split('\n')
254
+ .map((line) => `${indent}${line}`)
255
+ .join('\n')
256
+ }
257
+
258
+ export function toPageComponentName(name) {
259
+ const pascalName = String(name)
260
+ .split(/[^a-zA-Z0-9]+/)
261
+ .filter(Boolean)
262
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
263
+ .join('')
264
+
265
+ return `${pascalName || 'Untitled'}Page`
266
+ }
267
+
268
+ export function getPageSourcePath(page) {
269
+ return normalizeProjectPath(
270
+ page.sourcePath || `src/pages/${toPageComponentName(page.name)}.tsx`
271
+ )
272
+ }
273
+
274
+ export function createPageSourcePath(name, pagesDir = 'src/pages') {
275
+ return `${normalizeProjectPath(pagesDir)}/${toPageComponentName(name)}.tsx`
276
+ }
277
+
278
+ export function normalizeProjectPath(value) {
279
+ return String(value).replace(/\\/g, '/').replace(/^\.\/+/, '')
280
+ }
281
+
282
+ function createJsxElement(
283
+ node,
284
+ depth = 0,
285
+ customComponents = [],
286
+ skipCustomWrapper = false
287
+ ) {
288
+ const tagName = String(node.type)
289
+ const customDefinition = customComponents.find(
290
+ (component) => component.name === tagName
291
+ )
292
+
293
+ if (customDefinition && !skipCustomWrapper) {
294
+ return createWrappedCustomElement(node, depth, customComponents)
295
+ }
296
+ const selfClosing = voidElements.has(tagName) || tagName === 'textarea'
297
+ const opening = t.jsxOpeningElement(
298
+ t.jsxIdentifier(tagName),
299
+ createJsxAttributes(node, customComponents),
300
+ selfClosing
301
+ )
302
+
303
+ if (selfClosing) {
304
+ return t.jsxElement(opening, null, [], true)
305
+ }
306
+
307
+ const children = Array.isArray(node.children)
308
+ ? node.children.map((child) =>
309
+ createJsxElement(child, depth + 1, customComponents)
310
+ )
311
+ : []
312
+ const text = String(node.props?.text ?? '')
313
+
314
+ if (children.length === 0 && text) {
315
+ children.push(t.jsxText(escapeJsxText(text)))
316
+ }
317
+
318
+ return t.jsxElement(
319
+ opening,
320
+ t.jsxClosingElement(t.jsxIdentifier(tagName)),
321
+ children.length > 0 ? withLineBreaks(children, depth) : children,
322
+ false
323
+ )
324
+ }
325
+
326
+ function createWrappedCustomElement(node, depth, customComponents) {
327
+ const props = node.props ?? {}
328
+ const componentProps = Object.fromEntries(
329
+ Object.entries(props).filter(
330
+ ([name]) =>
331
+ ![
332
+ 'wrapperTag',
333
+ 'id',
334
+ 'className',
335
+ 'attributes',
336
+ 'componentTree',
337
+ ].includes(name)
338
+ )
339
+ )
340
+ const opening = t.jsxOpeningElement(
341
+ t.jsxIdentifier(String(node.type)),
342
+ createJsxAttributes(
343
+ { ...node, props: componentProps },
344
+ customComponents
345
+ ),
346
+ true
347
+ )
348
+
349
+ return t.jsxElement(opening, null, [], true)
350
+ }
351
+
352
+ function withLineBreaks(children, depth) {
353
+ const childIndent = ' '.repeat(depth + 1)
354
+ const closingIndent = ' '.repeat(depth)
355
+
356
+ return [
357
+ t.jsxText(`\n${childIndent}`),
358
+ ...children.flatMap((child, index) => [
359
+ child,
360
+ t.jsxText(
361
+ index === children.length - 1
362
+ ? `\n${closingIndent}`
363
+ : `\n${childIndent}`
364
+ ),
365
+ ]),
366
+ ]
367
+ }
368
+
369
+ function createJsxAttributes(node, customComponents = []) {
370
+ const props = node.props ?? {}
371
+ const isCustom = customComponents.some(
372
+ (component) => component.name === node.type
373
+ )
374
+
375
+ if (isCustom) {
376
+ const additionalAttributes =
377
+ props.attributes &&
378
+ typeof props.attributes === 'object' &&
379
+ !Array.isArray(props.attributes)
380
+ ? Object.entries(props.attributes)
381
+ : []
382
+
383
+ return [
384
+ ...Object.entries(props).filter(
385
+ ([name, value]) =>
386
+ name !== 'attributes' && value !== undefined && value !== ''
387
+ ),
388
+ ...additionalAttributes.filter(
389
+ ([name, value]) =>
390
+ isSafeJsxAttributeName(name) &&
391
+ name !== 'children' &&
392
+ name !== 'key' &&
393
+ name !== 'ref' &&
394
+ !Object.prototype.hasOwnProperty.call(props, name) &&
395
+ isSerializableAttributeValue(value)
396
+ ),
397
+ ]
398
+ .map(([name, value]) => createValueAttribute(name, value))
399
+ }
400
+
401
+ const attributes = [
402
+ props.id ? createValueAttribute('id', String(props.id)) : null,
403
+ props.className
404
+ ? createValueAttribute('className', String(props.className))
405
+ : null,
406
+ node.type === 'a'
407
+ ? createValueAttribute('href', String(props.href ?? '#'))
408
+ : null,
409
+ node.type === 'a'
410
+ ? createValueAttribute('target', String(props.target ?? '_self'))
411
+ : null,
412
+ node.type === 'button'
413
+ ? createValueAttribute('type', String(props.type ?? 'button'))
414
+ : null,
415
+ node.type === 'img'
416
+ ? createValueAttribute('src', String(props.src ?? ''))
417
+ : null,
418
+ node.type === 'img'
419
+ ? createValueAttribute('alt', String(props.alt ?? ''))
420
+ : null,
421
+ node.type === 'input'
422
+ ? createValueAttribute('type', String(props.type ?? 'text'))
423
+ : null,
424
+ node.type === 'input' || node.type === 'textarea'
425
+ ? props.name
426
+ ? createValueAttribute('name', String(props.name))
427
+ : null
428
+ : null,
429
+ node.type === 'input' || node.type === 'textarea'
430
+ ? props.placeholder
431
+ ? createValueAttribute('placeholder', String(props.placeholder))
432
+ : null
433
+ : null,
434
+ node.type === 'textarea'
435
+ ? createValueAttribute('defaultValue', String(props.text ?? ''))
436
+ : null,
437
+ node.type === 'label' && props.htmlFor
438
+ ? createValueAttribute('htmlFor', String(props.htmlFor))
439
+ : null,
440
+ node.type === 'iframe'
441
+ ? createValueAttribute('src', String(props.src ?? ''))
442
+ : null,
443
+ node.type === 'iframe'
444
+ ? createValueAttribute(
445
+ 'title',
446
+ String(props.title ?? 'Embedded content')
447
+ )
448
+ : null,
449
+ node.type === 'source'
450
+ ? createValueAttribute('src', String(props.src ?? ''))
451
+ : null,
452
+ node.type === 'source' && props.type
453
+ ? createValueAttribute('type', String(props.type))
454
+ : null,
455
+ node.type === 'video' || node.type === 'audio'
456
+ ? props.src
457
+ ? createValueAttribute('src', String(props.src))
458
+ : null
459
+ : null,
460
+ node.type === 'video' || node.type === 'audio'
461
+ ? props.controls !== false
462
+ ? createBooleanAttribute('controls')
463
+ : null
464
+ : null,
465
+ node.type === 'option' && props.value
466
+ ? createValueAttribute('value', String(props.value))
467
+ : null,
468
+ node.type === 'optgroup'
469
+ ? createValueAttribute('label', String(props.label ?? 'Group'))
470
+ : null,
471
+ node.type === 'progress' || node.type === 'meter'
472
+ ? props.value !== undefined
473
+ ? createValueAttribute('value', Number(props.value))
474
+ : null
475
+ : null,
476
+ node.type === 'meter'
477
+ ? props.min !== undefined
478
+ ? createValueAttribute('min', Number(props.min))
479
+ : null
480
+ : null,
481
+ node.type === 'progress' || node.type === 'meter'
482
+ ? props.max !== undefined
483
+ ? createValueAttribute('max', Number(props.max))
484
+ : null
485
+ : null,
486
+ ]
487
+
488
+ return attributes.filter(Boolean)
489
+ }
490
+
491
+ function isSafeJsxAttributeName(name) {
492
+ return /^[A-Za-z_:][A-Za-z0-9_.:-]*$/.test(name)
493
+ }
494
+
495
+ function isSerializableAttributeValue(value) {
496
+ return (
497
+ typeof value === 'string' ||
498
+ typeof value === 'number' ||
499
+ typeof value === 'boolean' ||
500
+ Array.isArray(value)
501
+ )
502
+ }
503
+
504
+ function createValueAttribute(name, value) {
505
+ return t.jsxAttribute(
506
+ t.jsxIdentifier(name),
507
+ t.jsxExpressionContainer(
508
+ typeof value === 'number'
509
+ ? Number.isFinite(value)
510
+ ? t.numericLiteral(value)
511
+ : t.nullLiteral()
512
+ : typeof value === 'boolean'
513
+ ? t.booleanLiteral(value)
514
+ : Array.isArray(value)
515
+ ? t.arrayExpression(
516
+ value.map((item) =>
517
+ typeof item === 'number'
518
+ ? t.numericLiteral(item)
519
+ : typeof item === 'boolean'
520
+ ? t.booleanLiteral(item)
521
+ : t.stringLiteral(String(item))
522
+ )
523
+ )
524
+ : t.stringLiteral(String(value))
525
+ )
526
+ )
527
+ }
528
+
529
+ function createCustomComponentImports(nodes, ownerPath, definitions) {
530
+ const usedNames = new Set()
531
+ const visit = (node) => {
532
+ if (!node) return
533
+ if (definitions.some((definition) => definition.name === node.type)) {
534
+ usedNames.add(node.type)
535
+ }
536
+ for (const child of node.children ?? []) visit(child)
537
+ }
538
+ nodes.forEach(visit)
539
+
540
+ return definitions
541
+ .filter((definition) => usedNames.has(definition.name))
542
+ .map((definition) => {
543
+ const specifier =
544
+ definition.exportName === 'default'
545
+ ? t.importDefaultSpecifier(t.identifier(definition.name))
546
+ : t.importSpecifier(
547
+ t.identifier(definition.name),
548
+ t.identifier(definition.exportName)
549
+ )
550
+
551
+ return t.importDeclaration(
552
+ [specifier],
553
+ t.stringLiteral(
554
+ toRelativeImportPath(ownerPath, definition.importPath)
555
+ )
556
+ )
557
+ })
558
+ }
559
+
560
+ function toRelativeImportPath(ownerPath, targetPath) {
561
+ const ownerSegments = normalizeProjectPath(ownerPath).split('/')
562
+ ownerSegments.pop()
563
+ const targetSegments = normalizeProjectPath(targetPath)
564
+ .replace(/\.[cm]?[jt]sx?$/, '')
565
+ .split('/')
566
+
567
+ while (
568
+ ownerSegments.length > 0 &&
569
+ targetSegments.length > 0 &&
570
+ ownerSegments[0] === targetSegments[0]
571
+ ) {
572
+ ownerSegments.shift()
573
+ targetSegments.shift()
574
+ }
575
+
576
+ const relative = [
577
+ ...ownerSegments.map(() => '..'),
578
+ ...targetSegments,
579
+ ].join('/')
580
+ return relative.startsWith('.') ? relative : `./${relative}`
581
+ }
582
+
583
+ function createBooleanAttribute(name) {
584
+ return t.jsxAttribute(t.jsxIdentifier(name), null)
585
+ }
586
+
587
+ function toPageImportPath(page, appFile = 'src/App.tsx') {
588
+ return toRelativeImportPath(appFile, getPageSourcePath(page))
589
+ }
590
+
591
+ function escapeJsxText(value) {
592
+ return value
593
+ .replace(/&/g, '&amp;')
594
+ .replace(/</g, '&lt;')
595
+ .replace(/>/g, '&gt;')
596
+ }
597
+
598
+ function printProgram(statements) {
599
+ const file = t.file(t.program(statements))
600
+ return `${generate(file, generatorOptions).code}\n`
601
+ }
602
+
603
+ const generatorOptions = {
604
+ comments: false,
605
+ compact: false,
606
+ concise: false,
607
+ jsescOption: { minimal: true },
608
+ }