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.
- package/README.md +919 -58
- package/docs/user-guide.md +759 -0
- package/package.json +92 -85
- package/scripts/create-builder-app.mjs +513 -501
- package/scripts/vb-dev.mjs +41 -32
- package/scripts/vb-generate.mjs +332 -224
- package/templates/default/app/.vercelignore +6 -0
- package/templates/default/app/README.md +56 -21
- package/templates/default/app/visualbuild/components.json +3 -0
- package/templates/default/app/visualbuild/config.d.ts +48 -0
- package/templates/default/app/visualbuild.config.ts +38 -0
- package/templates/default/builder/README.md +37 -21
- package/visual-app-builder/server/parseReactPage.ts +776 -571
- package/visual-app-builder/shared/createReactComponentSource.d.mts +2 -0
- package/visual-app-builder/shared/createReactComponentSource.mjs +45 -0
- package/visual-app-builder/shared/generateReactSource.d.mts +57 -37
- package/visual-app-builder/shared/generateReactSource.mjs +608 -443
- package/visual-app-builder/shared/npmBuildCommand.d.mts +17 -0
- package/visual-app-builder/shared/npmBuildCommand.mjs +26 -0
- package/visual-app-builder/shared/syncCustomComponentSource.d.mts +13 -0
- package/visual-app-builder/shared/syncCustomComponentSource.mjs +345 -0
- package/visual-app-builder/shared/vercelDeployment.d.mts +31 -0
- package/visual-app-builder/shared/vercelDeployment.mjs +266 -0
- package/visual-app-builder/shared/visualbuildConfig.d.mts +144 -0
- package/visual-app-builder/shared/visualbuildConfig.mjs +578 -0
- package/visual-app-builder/src/App.tsx +1090 -874
- package/visual-app-builder/src/components/Canvas.tsx +1116 -1059
- package/visual-app-builder/src/components/CodePanel.tsx +1147 -812
- package/visual-app-builder/src/components/ComponentSidebar.tsx +365 -302
- package/visual-app-builder/src/components/DeploymentModal.tsx +295 -0
- package/visual-app-builder/src/components/PageSwitcher.tsx +133 -133
- package/visual-app-builder/src/components/ProjectExplorer.tsx +1054 -1054
- package/visual-app-builder/src/components/PropertiesPanel.tsx +792 -692
- package/visual-app-builder/src/components/Topbar.tsx +257 -128
- package/visual-app-builder/src/data/componentRegistry.tsx +613 -292
- package/visual-app-builder/src/data/tailwindClassCatalog.ts +95 -0
- package/visual-app-builder/src/index.css +383 -111
- package/visual-app-builder/src/stores/useAppStore.ts +2385 -1265
- package/visual-app-builder/src/theme.ts +71 -0
- package/visual-app-builder/src/types/index.ts +350 -261
- package/visual-app-builder/src/utils/codegen.ts +90 -66
- package/visual-app-builder/src/utils/deployment.ts +54 -0
- package/visual-app-builder/src/utils/projectPersistence.ts +218 -177
- package/visual-app-builder/vite.config.ts +1946 -1479
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
export type VisualBuildTheme =
|
|
2
|
+
| 'default'
|
|
3
|
+
| 'codex-dark'
|
|
4
|
+
| 'vs-light'
|
|
5
|
+
| 'vs-dark'
|
|
6
|
+
| 'system'
|
|
7
|
+
| 'dark'
|
|
8
|
+
| 'light'
|
|
9
|
+
export type VisualBuildViewport = 'mobile' | 'tablet' | 'desktop'
|
|
10
|
+
export type VisualBuildCodeView = 'page' | 'app' | 'layout' | 'schema' | 'file'
|
|
11
|
+
|
|
12
|
+
export interface VisualBuildPaths {
|
|
13
|
+
sourceDir?: string
|
|
14
|
+
pagesDir?: string
|
|
15
|
+
layoutsDir?: string
|
|
16
|
+
appFile?: string
|
|
17
|
+
schemaFile?: string
|
|
18
|
+
componentRegistryFile?: string
|
|
19
|
+
generatedManifestFile?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface VisualBuildEditorOptions {
|
|
23
|
+
theme?: VisualBuildTheme
|
|
24
|
+
defaultViewport?: VisualBuildViewport
|
|
25
|
+
responsiveWidths?: {
|
|
26
|
+
mobile?: number
|
|
27
|
+
tablet?: number
|
|
28
|
+
desktop?: number | 'fluid'
|
|
29
|
+
}
|
|
30
|
+
panels?: {
|
|
31
|
+
leftOpen?: boolean
|
|
32
|
+
leftTab?: 'files' | 'elements'
|
|
33
|
+
rightOpen?: boolean
|
|
34
|
+
rightTab?: 'code' | 'properties'
|
|
35
|
+
codeView?: VisualBuildCodeView
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface VisualBuildGeneratorOptions {
|
|
40
|
+
emitApp?: boolean
|
|
41
|
+
emitLayout?: boolean
|
|
42
|
+
cleanStaleFiles?: boolean
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface VisualBuildDeploymentOptions {
|
|
46
|
+
provider?: 'vercel'
|
|
47
|
+
outputDirectory?: string
|
|
48
|
+
projectName?: string
|
|
49
|
+
teamId?: string
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type VisualBuildPropType =
|
|
53
|
+
| 'string'
|
|
54
|
+
| 'text'
|
|
55
|
+
| 'number'
|
|
56
|
+
| 'boolean'
|
|
57
|
+
| 'color'
|
|
58
|
+
| 'select'
|
|
59
|
+
| 'array'
|
|
60
|
+
| 'attributes'
|
|
61
|
+
|
|
62
|
+
export interface VisualBuildPropDefinition {
|
|
63
|
+
type: VisualBuildPropType
|
|
64
|
+
label?: string
|
|
65
|
+
default?: unknown
|
|
66
|
+
options?: string[]
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface VisualBuildCustomComponent {
|
|
70
|
+
name: string
|
|
71
|
+
importPath: string
|
|
72
|
+
exportName?: 'default' | string
|
|
73
|
+
label?: string
|
|
74
|
+
category?: string
|
|
75
|
+
icon?: string
|
|
76
|
+
description?: string
|
|
77
|
+
acceptsChildren?: boolean
|
|
78
|
+
props?: Record<string, VisualBuildPropDefinition>
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface VisualBuildConfig {
|
|
82
|
+
paths?: VisualBuildPaths
|
|
83
|
+
editor?: VisualBuildEditorOptions
|
|
84
|
+
generator?: VisualBuildGeneratorOptions
|
|
85
|
+
deployment?: VisualBuildDeploymentOptions
|
|
86
|
+
components?: VisualBuildCustomComponent[]
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface ResolvedVisualBuildCustomComponent {
|
|
90
|
+
name: string
|
|
91
|
+
importPath: string
|
|
92
|
+
exportName: string
|
|
93
|
+
label: string
|
|
94
|
+
category: string
|
|
95
|
+
icon: string
|
|
96
|
+
description: string
|
|
97
|
+
acceptsChildren: boolean
|
|
98
|
+
defaultProps: Record<string, unknown>
|
|
99
|
+
propSchema: Record<
|
|
100
|
+
string,
|
|
101
|
+
{
|
|
102
|
+
type: VisualBuildPropType
|
|
103
|
+
label: string
|
|
104
|
+
default: unknown
|
|
105
|
+
options?: string[]
|
|
106
|
+
}
|
|
107
|
+
>
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface ResolvedVisualBuildConfig {
|
|
111
|
+
paths: Required<VisualBuildPaths>
|
|
112
|
+
editor: {
|
|
113
|
+
theme: VisualBuildTheme
|
|
114
|
+
defaultViewport: VisualBuildViewport
|
|
115
|
+
responsiveWidths: {
|
|
116
|
+
mobile: number
|
|
117
|
+
tablet: number
|
|
118
|
+
desktop: number | 'fluid'
|
|
119
|
+
}
|
|
120
|
+
panels: {
|
|
121
|
+
leftOpen: boolean
|
|
122
|
+
leftTab: 'files' | 'elements'
|
|
123
|
+
rightOpen: boolean
|
|
124
|
+
rightTab: 'code' | 'properties'
|
|
125
|
+
codeView: VisualBuildCodeView
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
generator: Required<VisualBuildGeneratorOptions>
|
|
129
|
+
deployment: {
|
|
130
|
+
provider: 'vercel'
|
|
131
|
+
outputDirectory: string
|
|
132
|
+
projectName?: string
|
|
133
|
+
teamId?: string
|
|
134
|
+
}
|
|
135
|
+
components: ResolvedVisualBuildCustomComponent[]
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export const defaultVisualBuildConfig: ResolvedVisualBuildConfig
|
|
139
|
+
export function loadVisualBuildConfig(
|
|
140
|
+
projectRoot: string
|
|
141
|
+
): Promise<ResolvedVisualBuildConfig>
|
|
142
|
+
export function validateVisualBuildConfig(
|
|
143
|
+
value: unknown
|
|
144
|
+
): ResolvedVisualBuildConfig
|
|
@@ -0,0 +1,578 @@
|
|
|
1
|
+
import fs from 'node:fs/promises'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { pathToFileURL } from 'node:url'
|
|
4
|
+
|
|
5
|
+
const propTypes = new Set([
|
|
6
|
+
'string',
|
|
7
|
+
'text',
|
|
8
|
+
'number',
|
|
9
|
+
'boolean',
|
|
10
|
+
'color',
|
|
11
|
+
'select',
|
|
12
|
+
'array',
|
|
13
|
+
'attributes',
|
|
14
|
+
])
|
|
15
|
+
const themes = new Set([
|
|
16
|
+
'default',
|
|
17
|
+
'codex-dark',
|
|
18
|
+
'vs-light',
|
|
19
|
+
'vs-dark',
|
|
20
|
+
'system',
|
|
21
|
+
'dark',
|
|
22
|
+
'light',
|
|
23
|
+
])
|
|
24
|
+
const viewports = new Set(['mobile', 'tablet', 'desktop'])
|
|
25
|
+
const leftPanelTabs = new Set(['files', 'elements'])
|
|
26
|
+
const rightPanelTabs = new Set(['code', 'properties'])
|
|
27
|
+
const codeViews = new Set(['page', 'app', 'layout', 'schema', 'file'])
|
|
28
|
+
|
|
29
|
+
export const defaultVisualBuildConfig = Object.freeze({
|
|
30
|
+
paths: Object.freeze({
|
|
31
|
+
sourceDir: 'src',
|
|
32
|
+
pagesDir: 'src/pages',
|
|
33
|
+
layoutsDir: 'src/layouts',
|
|
34
|
+
appFile: 'src/App.tsx',
|
|
35
|
+
schemaFile: 'visualbuild/pages.json',
|
|
36
|
+
componentRegistryFile: 'visualbuild/components.json',
|
|
37
|
+
generatedManifestFile: 'visualbuild/generated-files.json',
|
|
38
|
+
}),
|
|
39
|
+
editor: Object.freeze({
|
|
40
|
+
theme: 'default',
|
|
41
|
+
defaultViewport: 'desktop',
|
|
42
|
+
responsiveWidths: Object.freeze({
|
|
43
|
+
mobile: 390,
|
|
44
|
+
tablet: 768,
|
|
45
|
+
desktop: 'fluid',
|
|
46
|
+
}),
|
|
47
|
+
panels: Object.freeze({
|
|
48
|
+
leftOpen: true,
|
|
49
|
+
leftTab: 'elements',
|
|
50
|
+
rightOpen: true,
|
|
51
|
+
rightTab: 'properties',
|
|
52
|
+
codeView: 'page',
|
|
53
|
+
}),
|
|
54
|
+
}),
|
|
55
|
+
generator: Object.freeze({
|
|
56
|
+
emitApp: true,
|
|
57
|
+
emitLayout: true,
|
|
58
|
+
cleanStaleFiles: true,
|
|
59
|
+
}),
|
|
60
|
+
deployment: Object.freeze({
|
|
61
|
+
provider: 'vercel',
|
|
62
|
+
outputDirectory: 'dist',
|
|
63
|
+
projectName: undefined,
|
|
64
|
+
teamId: undefined,
|
|
65
|
+
}),
|
|
66
|
+
components: Object.freeze([]),
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
export async function loadVisualBuildConfig(projectRoot) {
|
|
70
|
+
const configPath = path.join(projectRoot, 'visualbuild.config.ts')
|
|
71
|
+
const exists = await fs
|
|
72
|
+
.access(configPath)
|
|
73
|
+
.then(() => true)
|
|
74
|
+
.catch(() => false)
|
|
75
|
+
let sourceConfig = {}
|
|
76
|
+
|
|
77
|
+
if (exists) {
|
|
78
|
+
const sourceUrl = pathToFileURL(configPath)
|
|
79
|
+
sourceUrl.searchParams.set('updated', String(Date.now()))
|
|
80
|
+
const loaded = await import(sourceUrl.href)
|
|
81
|
+
sourceConfig = loaded.default ?? loaded
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const baseConfig = validateVisualBuildConfig({
|
|
85
|
+
...sourceConfig,
|
|
86
|
+
components: [],
|
|
87
|
+
})
|
|
88
|
+
const registrationsPath = path.resolve(
|
|
89
|
+
projectRoot,
|
|
90
|
+
...baseConfig.paths.componentRegistryFile.split('/')
|
|
91
|
+
)
|
|
92
|
+
const fileRegistrations = await fs
|
|
93
|
+
.readFile(registrationsPath, 'utf8')
|
|
94
|
+
.then((source) => JSON.parse(source))
|
|
95
|
+
.catch((error) => {
|
|
96
|
+
if (error?.code === 'ENOENT') return { components: [] }
|
|
97
|
+
throw error
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
return validateVisualBuildConfig({
|
|
101
|
+
...sourceConfig,
|
|
102
|
+
components: (fileRegistrations.components ?? []).map((component) => ({
|
|
103
|
+
...component,
|
|
104
|
+
acceptsChildren: true,
|
|
105
|
+
props: {
|
|
106
|
+
wrapperTag: {
|
|
107
|
+
type: 'select',
|
|
108
|
+
label: 'Component root tag',
|
|
109
|
+
default: 'div',
|
|
110
|
+
options: [
|
|
111
|
+
'div',
|
|
112
|
+
'main',
|
|
113
|
+
'section',
|
|
114
|
+
'article',
|
|
115
|
+
'header',
|
|
116
|
+
'footer',
|
|
117
|
+
'nav',
|
|
118
|
+
'aside',
|
|
119
|
+
'form',
|
|
120
|
+
],
|
|
121
|
+
},
|
|
122
|
+
id: {
|
|
123
|
+
type: 'string',
|
|
124
|
+
label: 'ID',
|
|
125
|
+
default: '',
|
|
126
|
+
},
|
|
127
|
+
className: {
|
|
128
|
+
type: 'text',
|
|
129
|
+
label: 'Tailwind classes',
|
|
130
|
+
default: '',
|
|
131
|
+
},
|
|
132
|
+
attributes: {
|
|
133
|
+
type: 'attributes',
|
|
134
|
+
label: 'Additional attributes',
|
|
135
|
+
default: {},
|
|
136
|
+
},
|
|
137
|
+
...(component.props ?? {}),
|
|
138
|
+
},
|
|
139
|
+
})),
|
|
140
|
+
})
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function validateVisualBuildConfig(value) {
|
|
144
|
+
const config = value && typeof value === 'object' ? value : {}
|
|
145
|
+
const paths = validatePaths(config.paths)
|
|
146
|
+
const editor = validateEditor(config.editor)
|
|
147
|
+
const generator = validateGenerator(config.generator)
|
|
148
|
+
const deployment = validateDeployment(config.deployment)
|
|
149
|
+
const components = Array.isArray(config.components) ? config.components : []
|
|
150
|
+
const seenNames = new Set()
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
paths,
|
|
154
|
+
editor,
|
|
155
|
+
generator,
|
|
156
|
+
deployment,
|
|
157
|
+
components: components.map((component, index) => {
|
|
158
|
+
const location = `components[${index}]`
|
|
159
|
+
const name = requiredString(component?.name, `${location}.name`)
|
|
160
|
+
|
|
161
|
+
if (!/^[A-Z][A-Za-z0-9]*$/.test(name)) {
|
|
162
|
+
throw new Error(
|
|
163
|
+
`${location}.name must be a PascalCase React component name`
|
|
164
|
+
)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (seenNames.has(name)) {
|
|
168
|
+
throw new Error(`Custom component "${name}" is registered more than once`)
|
|
169
|
+
}
|
|
170
|
+
seenNames.add(name)
|
|
171
|
+
|
|
172
|
+
const importPath = normalizeImportPath(
|
|
173
|
+
requiredString(component?.importPath, `${location}.importPath`),
|
|
174
|
+
paths.sourceDir
|
|
175
|
+
)
|
|
176
|
+
const propSchema = validatePropSchema(component?.props ?? {}, location)
|
|
177
|
+
const defaultProps = Object.fromEntries(
|
|
178
|
+
Object.entries(propSchema).map(([key, schema]) => [key, schema.default])
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
name,
|
|
183
|
+
label: optionalString(component?.label, splitPascalCase(name)),
|
|
184
|
+
category: optionalString(component?.category, 'Custom'),
|
|
185
|
+
icon: optionalString(component?.icon, 'C'),
|
|
186
|
+
description: optionalString(
|
|
187
|
+
component?.description,
|
|
188
|
+
`Local ${name} component`
|
|
189
|
+
),
|
|
190
|
+
importPath,
|
|
191
|
+
exportName: optionalString(component?.exportName, 'default'),
|
|
192
|
+
defaultProps,
|
|
193
|
+
propSchema,
|
|
194
|
+
acceptsChildren: component?.acceptsChildren === true,
|
|
195
|
+
}
|
|
196
|
+
}),
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function validateDeployment(value) {
|
|
201
|
+
const deployment = objectValue(value, 'deployment')
|
|
202
|
+
const provider = enumValue(
|
|
203
|
+
deployment.provider,
|
|
204
|
+
new Set(['vercel']),
|
|
205
|
+
defaultVisualBuildConfig.deployment.provider,
|
|
206
|
+
'deployment.provider'
|
|
207
|
+
)
|
|
208
|
+
const outputDirectory = projectDirectory(
|
|
209
|
+
deployment.outputDirectory,
|
|
210
|
+
defaultVisualBuildConfig.deployment.outputDirectory,
|
|
211
|
+
'deployment.outputDirectory'
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
if (outputDirectory === '.') {
|
|
215
|
+
throw new Error('deployment.outputDirectory cannot be the project root')
|
|
216
|
+
}
|
|
217
|
+
const projectName = optionalConfiguredString(
|
|
218
|
+
deployment.projectName,
|
|
219
|
+
'deployment.projectName'
|
|
220
|
+
)
|
|
221
|
+
const teamId = optionalConfiguredString(
|
|
222
|
+
deployment.teamId,
|
|
223
|
+
'deployment.teamId'
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
if (
|
|
227
|
+
projectName &&
|
|
228
|
+
(projectName.length > 100 ||
|
|
229
|
+
!/^[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?$/.test(projectName) ||
|
|
230
|
+
projectName.includes('---'))
|
|
231
|
+
) {
|
|
232
|
+
throw new Error(
|
|
233
|
+
'deployment.projectName must be a valid lowercase Vercel project name'
|
|
234
|
+
)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (teamId && !/^team_[A-Za-z0-9]+$/.test(teamId)) {
|
|
238
|
+
throw new Error('deployment.teamId must start with "team_"')
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return {
|
|
242
|
+
provider,
|
|
243
|
+
outputDirectory,
|
|
244
|
+
...(projectName ? { projectName } : {}),
|
|
245
|
+
...(teamId ? { teamId } : {}),
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function validatePaths(value) {
|
|
250
|
+
const paths = objectValue(value, 'paths')
|
|
251
|
+
|
|
252
|
+
const result = {
|
|
253
|
+
sourceDir: projectDirectory(
|
|
254
|
+
paths.sourceDir,
|
|
255
|
+
defaultVisualBuildConfig.paths.sourceDir,
|
|
256
|
+
'paths.sourceDir'
|
|
257
|
+
),
|
|
258
|
+
pagesDir: projectDirectory(
|
|
259
|
+
paths.pagesDir,
|
|
260
|
+
defaultVisualBuildConfig.paths.pagesDir,
|
|
261
|
+
'paths.pagesDir'
|
|
262
|
+
),
|
|
263
|
+
layoutsDir: projectDirectory(
|
|
264
|
+
paths.layoutsDir,
|
|
265
|
+
defaultVisualBuildConfig.paths.layoutsDir,
|
|
266
|
+
'paths.layoutsDir'
|
|
267
|
+
),
|
|
268
|
+
appFile: projectFile(
|
|
269
|
+
paths.appFile,
|
|
270
|
+
defaultVisualBuildConfig.paths.appFile,
|
|
271
|
+
'paths.appFile',
|
|
272
|
+
/\.(jsx|tsx)$/i
|
|
273
|
+
),
|
|
274
|
+
schemaFile: projectFile(
|
|
275
|
+
paths.schemaFile,
|
|
276
|
+
defaultVisualBuildConfig.paths.schemaFile,
|
|
277
|
+
'paths.schemaFile',
|
|
278
|
+
/\.json$/i
|
|
279
|
+
),
|
|
280
|
+
componentRegistryFile: projectFile(
|
|
281
|
+
paths.componentRegistryFile,
|
|
282
|
+
defaultVisualBuildConfig.paths.componentRegistryFile,
|
|
283
|
+
'paths.componentRegistryFile',
|
|
284
|
+
/\.json$/i
|
|
285
|
+
),
|
|
286
|
+
generatedManifestFile: projectFile(
|
|
287
|
+
paths.generatedManifestFile,
|
|
288
|
+
defaultVisualBuildConfig.paths.generatedManifestFile,
|
|
289
|
+
'paths.generatedManifestFile',
|
|
290
|
+
/\.json$/i
|
|
291
|
+
),
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
for (const [location, configuredPath] of [
|
|
295
|
+
['paths.pagesDir', result.pagesDir],
|
|
296
|
+
['paths.layoutsDir', result.layoutsDir],
|
|
297
|
+
['paths.appFile', result.appFile],
|
|
298
|
+
]) {
|
|
299
|
+
if (!isPathInside(configuredPath, result.sourceDir)) {
|
|
300
|
+
throw new Error(
|
|
301
|
+
`${location} must stay inside paths.sourceDir (${result.sourceDir})`
|
|
302
|
+
)
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return result
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function validateEditor(value) {
|
|
310
|
+
const editor = objectValue(value, 'editor')
|
|
311
|
+
const responsiveWidths = objectValue(
|
|
312
|
+
editor.responsiveWidths,
|
|
313
|
+
'editor.responsiveWidths'
|
|
314
|
+
)
|
|
315
|
+
const panels = objectValue(editor.panels, 'editor.panels')
|
|
316
|
+
|
|
317
|
+
return {
|
|
318
|
+
theme: enumValue(
|
|
319
|
+
editor.theme,
|
|
320
|
+
themes,
|
|
321
|
+
defaultVisualBuildConfig.editor.theme,
|
|
322
|
+
'editor.theme'
|
|
323
|
+
),
|
|
324
|
+
defaultViewport: enumValue(
|
|
325
|
+
editor.defaultViewport,
|
|
326
|
+
viewports,
|
|
327
|
+
defaultVisualBuildConfig.editor.defaultViewport,
|
|
328
|
+
'editor.defaultViewport'
|
|
329
|
+
),
|
|
330
|
+
responsiveWidths: {
|
|
331
|
+
mobile: positiveWidth(
|
|
332
|
+
responsiveWidths.mobile,
|
|
333
|
+
defaultVisualBuildConfig.editor.responsiveWidths.mobile,
|
|
334
|
+
'editor.responsiveWidths.mobile'
|
|
335
|
+
),
|
|
336
|
+
tablet: positiveWidth(
|
|
337
|
+
responsiveWidths.tablet,
|
|
338
|
+
defaultVisualBuildConfig.editor.responsiveWidths.tablet,
|
|
339
|
+
'editor.responsiveWidths.tablet'
|
|
340
|
+
),
|
|
341
|
+
desktop: positiveWidth(
|
|
342
|
+
responsiveWidths.desktop,
|
|
343
|
+
defaultVisualBuildConfig.editor.responsiveWidths.desktop,
|
|
344
|
+
'editor.responsiveWidths.desktop',
|
|
345
|
+
true
|
|
346
|
+
),
|
|
347
|
+
},
|
|
348
|
+
panels: {
|
|
349
|
+
leftOpen: booleanValue(
|
|
350
|
+
panels.leftOpen,
|
|
351
|
+
defaultVisualBuildConfig.editor.panels.leftOpen,
|
|
352
|
+
'editor.panels.leftOpen'
|
|
353
|
+
),
|
|
354
|
+
leftTab: enumValue(
|
|
355
|
+
panels.leftTab,
|
|
356
|
+
leftPanelTabs,
|
|
357
|
+
defaultVisualBuildConfig.editor.panels.leftTab,
|
|
358
|
+
'editor.panels.leftTab'
|
|
359
|
+
),
|
|
360
|
+
rightOpen: booleanValue(
|
|
361
|
+
panels.rightOpen,
|
|
362
|
+
defaultVisualBuildConfig.editor.panels.rightOpen,
|
|
363
|
+
'editor.panels.rightOpen'
|
|
364
|
+
),
|
|
365
|
+
rightTab: enumValue(
|
|
366
|
+
panels.rightTab,
|
|
367
|
+
rightPanelTabs,
|
|
368
|
+
defaultVisualBuildConfig.editor.panels.rightTab,
|
|
369
|
+
'editor.panels.rightTab'
|
|
370
|
+
),
|
|
371
|
+
codeView: enumValue(
|
|
372
|
+
panels.codeView,
|
|
373
|
+
codeViews,
|
|
374
|
+
defaultVisualBuildConfig.editor.panels.codeView,
|
|
375
|
+
'editor.panels.codeView'
|
|
376
|
+
),
|
|
377
|
+
},
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function validateGenerator(value) {
|
|
382
|
+
const generator = objectValue(value, 'generator')
|
|
383
|
+
|
|
384
|
+
return {
|
|
385
|
+
emitApp: booleanValue(
|
|
386
|
+
generator.emitApp,
|
|
387
|
+
defaultVisualBuildConfig.generator.emitApp,
|
|
388
|
+
'generator.emitApp'
|
|
389
|
+
),
|
|
390
|
+
emitLayout: booleanValue(
|
|
391
|
+
generator.emitLayout,
|
|
392
|
+
defaultVisualBuildConfig.generator.emitLayout,
|
|
393
|
+
'generator.emitLayout'
|
|
394
|
+
),
|
|
395
|
+
cleanStaleFiles: booleanValue(
|
|
396
|
+
generator.cleanStaleFiles,
|
|
397
|
+
defaultVisualBuildConfig.generator.cleanStaleFiles,
|
|
398
|
+
'generator.cleanStaleFiles'
|
|
399
|
+
),
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function validatePropSchema(value, location) {
|
|
404
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
405
|
+
throw new Error(`${location}.props must be an object`)
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
return Object.fromEntries(
|
|
409
|
+
Object.entries(value).map(([name, schema]) => {
|
|
410
|
+
if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name)) {
|
|
411
|
+
throw new Error(`${location}.props contains invalid prop name "${name}"`)
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
if (
|
|
415
|
+
!schema ||
|
|
416
|
+
typeof schema !== 'object' ||
|
|
417
|
+
!propTypes.has(schema.type)
|
|
418
|
+
) {
|
|
419
|
+
throw new Error(`${location}.props.${name}.type is not supported`)
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
if (
|
|
423
|
+
schema.type === 'select' &&
|
|
424
|
+
(!Array.isArray(schema.options) ||
|
|
425
|
+
schema.options.some((option) => typeof option !== 'string'))
|
|
426
|
+
) {
|
|
427
|
+
throw new Error(
|
|
428
|
+
`${location}.props.${name}.options must be a string array`
|
|
429
|
+
)
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
return [
|
|
433
|
+
name,
|
|
434
|
+
{
|
|
435
|
+
type: schema.type,
|
|
436
|
+
label: optionalString(schema.label, splitPascalCase(name)),
|
|
437
|
+
default: schema.default ?? defaultValueForType(schema.type),
|
|
438
|
+
...(schema.options ? { options: schema.options } : {}),
|
|
439
|
+
},
|
|
440
|
+
]
|
|
441
|
+
})
|
|
442
|
+
)
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function normalizeImportPath(value, sourceDir) {
|
|
446
|
+
const normalized = normalizeProjectRelativePath(value, 'component importPath')
|
|
447
|
+
|
|
448
|
+
if (
|
|
449
|
+
!isPathInside(normalized, sourceDir) ||
|
|
450
|
+
!/\.[cm]?[jt]sx?$/.test(normalized)
|
|
451
|
+
) {
|
|
452
|
+
throw new Error(
|
|
453
|
+
`Custom component importPath must reference a JavaScript or TypeScript file inside ${sourceDir}`
|
|
454
|
+
)
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
return normalized
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function projectDirectory(value, fallback, location) {
|
|
461
|
+
const normalized = normalizeProjectRelativePath(
|
|
462
|
+
value === undefined ? fallback : requiredString(value, location),
|
|
463
|
+
location
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
if (/\.[A-Za-z0-9]+$/.test(normalized)) {
|
|
467
|
+
throw new Error(`${location} must reference a directory`)
|
|
468
|
+
}
|
|
469
|
+
return normalized
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function projectFile(value, fallback, location, extensionPattern) {
|
|
473
|
+
const normalized = normalizeProjectRelativePath(
|
|
474
|
+
value === undefined ? fallback : requiredString(value, location),
|
|
475
|
+
location
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
if (!extensionPattern.test(normalized)) {
|
|
479
|
+
throw new Error(`${location} has an unsupported file extension`)
|
|
480
|
+
}
|
|
481
|
+
return normalized
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function normalizeProjectRelativePath(value, location) {
|
|
485
|
+
const normalized = String(value)
|
|
486
|
+
.replace(/\\/g, '/')
|
|
487
|
+
.replace(/^\.\/+/, '')
|
|
488
|
+
.replace(/\/+/g, '/')
|
|
489
|
+
|
|
490
|
+
if (
|
|
491
|
+
!normalized ||
|
|
492
|
+
normalized.startsWith('/') ||
|
|
493
|
+
/^[A-Za-z]:/.test(normalized) ||
|
|
494
|
+
normalized.split('/').some((segment) => segment === '..')
|
|
495
|
+
) {
|
|
496
|
+
throw new Error(`${location} must stay inside the project root`)
|
|
497
|
+
}
|
|
498
|
+
return normalized.replace(/\/$/, '')
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function isPathInside(candidate, directory) {
|
|
502
|
+
return candidate === directory || candidate.startsWith(`${directory}/`)
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function objectValue(value, location) {
|
|
506
|
+
if (value === undefined) return {}
|
|
507
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
508
|
+
throw new Error(`${location} must be an object`)
|
|
509
|
+
}
|
|
510
|
+
return value
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function enumValue(value, allowed, fallback, location) {
|
|
514
|
+
if (value === undefined) return fallback
|
|
515
|
+
if (!allowed.has(value)) {
|
|
516
|
+
throw new Error(
|
|
517
|
+
`${location} must be one of: ${[...allowed].join(', ')}`
|
|
518
|
+
)
|
|
519
|
+
}
|
|
520
|
+
return value
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function booleanValue(value, fallback, location) {
|
|
524
|
+
if (value === undefined) return fallback
|
|
525
|
+
if (typeof value !== 'boolean') {
|
|
526
|
+
throw new Error(`${location} must be a boolean`)
|
|
527
|
+
}
|
|
528
|
+
return value
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function positiveWidth(value, fallback, location, allowFluid = false) {
|
|
532
|
+
if (value === undefined) return fallback
|
|
533
|
+
if (allowFluid && value === 'fluid') return value
|
|
534
|
+
if (
|
|
535
|
+
typeof value !== 'number' ||
|
|
536
|
+
!Number.isFinite(value) ||
|
|
537
|
+
value < 240 ||
|
|
538
|
+
value > 4096
|
|
539
|
+
) {
|
|
540
|
+
throw new Error(
|
|
541
|
+
`${location} must be a number from 240 to 4096${
|
|
542
|
+
allowFluid ? ' or "fluid"' : ''
|
|
543
|
+
}`
|
|
544
|
+
)
|
|
545
|
+
}
|
|
546
|
+
return Math.round(value)
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function requiredString(value, location) {
|
|
550
|
+
if (typeof value !== 'string' || !value.trim()) {
|
|
551
|
+
throw new Error(`${location} must be a non-empty string`)
|
|
552
|
+
}
|
|
553
|
+
return value.trim()
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function optionalString(value, fallback) {
|
|
557
|
+
return typeof value === 'string' && value.trim() ? value.trim() : fallback
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function optionalConfiguredString(value, location) {
|
|
561
|
+
if (value === undefined) return undefined
|
|
562
|
+
if (typeof value !== 'string' || !value.trim()) {
|
|
563
|
+
throw new Error(`${location} must be a non-empty string when provided`)
|
|
564
|
+
}
|
|
565
|
+
return value.trim()
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function splitPascalCase(value) {
|
|
569
|
+
return value.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function defaultValueForType(type) {
|
|
573
|
+
if (type === 'boolean') return false
|
|
574
|
+
if (type === 'number') return 0
|
|
575
|
+
if (type === 'array') return []
|
|
576
|
+
if (type === 'attributes') return {}
|
|
577
|
+
return ''
|
|
578
|
+
}
|