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
@@ -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
+ }