create-visualbuild-app 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +123 -0
- package/package.json +85 -0
- package/scripts/create-builder-app.mjs +501 -0
- package/scripts/vb-dev.mjs +32 -0
- package/scripts/vb-generate.mjs +224 -0
- package/templates/default/app/README.md +21 -0
- package/templates/default/app/eslint.config.js +25 -0
- package/templates/default/app/index.html +15 -0
- package/templates/default/app/src/index.css +23 -0
- package/templates/default/app/src/main.tsx +10 -0
- package/templates/default/app/tsconfig.app.json +21 -0
- package/templates/default/app/tsconfig.json +7 -0
- package/templates/default/app/tsconfig.node.json +19 -0
- package/templates/default/app/visualbuild/generated-files.json +3 -0
- package/templates/default/app/visualbuild/pages.json +12 -0
- package/templates/default/app/vite.config.ts +7 -0
- package/templates/default/builder/README.md +21 -0
- package/templates/default/builder/eslint.config.js +25 -0
- package/templates/default/builder/tsconfig.app.json +21 -0
- package/templates/default/builder/tsconfig.json +7 -0
- package/templates/default/builder/tsconfig.node.json +22 -0
- package/visual-app-builder/index.html +15 -0
- package/visual-app-builder/server/parseReactPage.ts +571 -0
- package/visual-app-builder/shared/generateReactSource.d.mts +37 -0
- package/visual-app-builder/shared/generateReactSource.mjs +443 -0
- package/visual-app-builder/src/App.tsx +874 -0
- package/visual-app-builder/src/components/Canvas.tsx +1059 -0
- package/visual-app-builder/src/components/CodePanel.tsx +812 -0
- package/visual-app-builder/src/components/ComponentSidebar.tsx +302 -0
- package/visual-app-builder/src/components/PageSwitcher.tsx +161 -0
- package/visual-app-builder/src/components/ProjectExplorer.tsx +1054 -0
- package/visual-app-builder/src/components/PropertiesPanel.tsx +692 -0
- package/visual-app-builder/src/components/Topbar.tsx +128 -0
- package/visual-app-builder/src/data/componentRegistry.tsx +292 -0
- package/visual-app-builder/src/data/primitiveRegistry.ts +368 -0
- package/visual-app-builder/src/index.css +111 -0
- package/visual-app-builder/src/main.tsx +10 -0
- package/visual-app-builder/src/stores/useAppStore.ts +1265 -0
- package/visual-app-builder/src/tailwindGeneratedSafelist.ts +36 -0
- package/visual-app-builder/src/types/index.ts +261 -0
- package/visual-app-builder/src/utils/codegen.ts +66 -0
- package/visual-app-builder/src/utils/projectFiles.ts +146 -0
- package/visual-app-builder/src/utils/projectPersistence.ts +177 -0
- package/visual-app-builder/vite.config.ts +1479 -0
|
@@ -0,0 +1,1479 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process'
|
|
2
|
+
import { randomUUID } from 'node:crypto'
|
|
3
|
+
import fs from 'node:fs/promises'
|
|
4
|
+
import type { ServerResponse } from 'node:http'
|
|
5
|
+
import path from 'node:path'
|
|
6
|
+
import { promisify } from 'node:util'
|
|
7
|
+
import { watch as watchFiles } from 'chokidar'
|
|
8
|
+
import { defineConfig } from 'vite'
|
|
9
|
+
import type { Plugin } from 'vite'
|
|
10
|
+
import react from '@vitejs/plugin-react'
|
|
11
|
+
import tailwindcss from '@tailwindcss/vite'
|
|
12
|
+
import {
|
|
13
|
+
parseReactPageSource,
|
|
14
|
+
reconcileImportedTree,
|
|
15
|
+
visualTreesEqual,
|
|
16
|
+
type ImportedPageTree,
|
|
17
|
+
type ImportedVisualNode,
|
|
18
|
+
} from './server/parseReactPage'
|
|
19
|
+
|
|
20
|
+
const execFileAsync = promisify(execFile)
|
|
21
|
+
const builderPackageRoot = process.cwd()
|
|
22
|
+
const projectRoot = path.resolve(
|
|
23
|
+
process.env.VISUALBUILD_APP_DIR || builderPackageRoot
|
|
24
|
+
)
|
|
25
|
+
const builderRoot = path.resolve(builderPackageRoot, 'visual-app-builder')
|
|
26
|
+
const generatedSourceRoot = path.resolve(projectRoot, 'src')
|
|
27
|
+
const visualbuildRoot = path.resolve(projectRoot, 'visualbuild')
|
|
28
|
+
const maxEditableFileBytes = 2 * 1024 * 1024
|
|
29
|
+
const hiddenProjectEntries = new Set(['.git', 'node_modules'])
|
|
30
|
+
|
|
31
|
+
type ProjectEntryKind = 'file' | 'directory' | 'symlink'
|
|
32
|
+
|
|
33
|
+
interface ProjectEntry {
|
|
34
|
+
name: string
|
|
35
|
+
path: string
|
|
36
|
+
kind: ProjectEntryKind
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface ManagedPage {
|
|
40
|
+
id: string
|
|
41
|
+
name: string
|
|
42
|
+
route: string
|
|
43
|
+
sourcePath?: string
|
|
44
|
+
root: ImportedVisualNode
|
|
45
|
+
components: ImportedVisualNode[]
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface ManagedProjectSchema {
|
|
49
|
+
pages: ManagedPage[]
|
|
50
|
+
[key: string]: unknown
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface ReverseSyncEvent {
|
|
54
|
+
type: 'reverse-sync-success' | 'reverse-sync-error'
|
|
55
|
+
path: string
|
|
56
|
+
pageId?: string
|
|
57
|
+
message: string
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface GeneratorDiagnostic {
|
|
61
|
+
severity: 'error'
|
|
62
|
+
phase: 'generate'
|
|
63
|
+
code: string
|
|
64
|
+
title: string
|
|
65
|
+
message: string
|
|
66
|
+
path: string
|
|
67
|
+
suggestion?: string
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface GeneratorDiagnosticEvent extends GeneratorDiagnostic {
|
|
71
|
+
type: 'generator-diagnostic'
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
interface FileSystemEvent {
|
|
75
|
+
type: 'filesystem-change'
|
|
76
|
+
path: string
|
|
77
|
+
action: 'add' | 'change' | 'unlink' | 'addDir' | 'unlinkDir'
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
interface ManagedPagesDeletedEvent {
|
|
81
|
+
type: 'managed-pages-deleted'
|
|
82
|
+
path: string
|
|
83
|
+
pageIds: string[]
|
|
84
|
+
message: string
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
interface ManagedPagePathChange {
|
|
88
|
+
pageId: string
|
|
89
|
+
previousPath: string
|
|
90
|
+
sourcePath: string
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
interface ManagedPagesMovedEvent {
|
|
94
|
+
type: 'managed-pages-moved'
|
|
95
|
+
path: string
|
|
96
|
+
targetPath: string
|
|
97
|
+
pages: ManagedPagePathChange[]
|
|
98
|
+
message: string
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
type VisualBuildEvent =
|
|
102
|
+
| ReverseSyncEvent
|
|
103
|
+
| GeneratorDiagnosticEvent
|
|
104
|
+
| FileSystemEvent
|
|
105
|
+
| ManagedPagesDeletedEvent
|
|
106
|
+
| ManagedPagesMovedEvent
|
|
107
|
+
|
|
108
|
+
function isGeneratedProjectPath(watchedPath: string) {
|
|
109
|
+
const absolutePath = path.resolve(watchedPath)
|
|
110
|
+
|
|
111
|
+
return [generatedSourceRoot, visualbuildRoot].some(
|
|
112
|
+
(rootPath) =>
|
|
113
|
+
absolutePath === rootPath || absolutePath.startsWith(`${rootPath}${path.sep}`)
|
|
114
|
+
)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function sendJson(
|
|
118
|
+
res: { statusCode: number; setHeader: (name: string, value: string) => void; end: (body?: string) => void },
|
|
119
|
+
statusCode: number,
|
|
120
|
+
payload: unknown
|
|
121
|
+
) {
|
|
122
|
+
res.statusCode = statusCode
|
|
123
|
+
res.setHeader('Content-Type', 'application/json')
|
|
124
|
+
res.end(JSON.stringify(payload))
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function getProjectPath(relativePath = '') {
|
|
128
|
+
const normalizedInput = relativePath.replace(/\\/g, '/').trim()
|
|
129
|
+
|
|
130
|
+
if (
|
|
131
|
+
normalizedInput.startsWith('/') ||
|
|
132
|
+
/^[a-zA-Z]:/.test(normalizedInput) ||
|
|
133
|
+
normalizedInput.includes('\0')
|
|
134
|
+
) {
|
|
135
|
+
throw new Error('Invalid project path')
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const segments = normalizedInput
|
|
139
|
+
.split('/')
|
|
140
|
+
.filter((segment) => segment && segment !== '.')
|
|
141
|
+
|
|
142
|
+
if (segments.some((segment) => segment === '..')) {
|
|
143
|
+
throw new Error('Project path cannot leave the app root')
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const absolutePath = path.resolve(projectRoot, ...segments)
|
|
147
|
+
const relativeToRoot = path.relative(projectRoot, absolutePath)
|
|
148
|
+
|
|
149
|
+
if (relativeToRoot.startsWith('..') || path.isAbsolute(relativeToRoot)) {
|
|
150
|
+
throw new Error('Project path cannot leave the app root')
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
absolutePath,
|
|
155
|
+
relativePath: segments.join('/'),
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function assertRealPathInsideProject(absolutePath: string) {
|
|
160
|
+
const [realProjectRoot, realTarget] = await Promise.all([
|
|
161
|
+
fs.realpath(projectRoot),
|
|
162
|
+
fs.realpath(absolutePath),
|
|
163
|
+
])
|
|
164
|
+
const relativeToRoot = path.relative(realProjectRoot, realTarget)
|
|
165
|
+
|
|
166
|
+
if (relativeToRoot.startsWith('..') || path.isAbsolute(relativeToRoot)) {
|
|
167
|
+
throw new Error('Project path cannot leave the app root')
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function validateEntryName(name: unknown) {
|
|
172
|
+
if (
|
|
173
|
+
typeof name !== 'string' ||
|
|
174
|
+
!name.trim() ||
|
|
175
|
+
name === '.' ||
|
|
176
|
+
name === '..' ||
|
|
177
|
+
/[\\/:*?"<>|]/.test(name)
|
|
178
|
+
) {
|
|
179
|
+
throw new Error('Enter a valid file or folder name')
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return name.trim()
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function readJsonBody(req: NodeJS.ReadableStream) {
|
|
186
|
+
let body = ''
|
|
187
|
+
|
|
188
|
+
for await (const chunk of req) {
|
|
189
|
+
body += String(chunk)
|
|
190
|
+
|
|
191
|
+
if (body.length > maxEditableFileBytes * 2) {
|
|
192
|
+
throw new Error('Request is too large')
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return body ? (JSON.parse(body) as Record<string, unknown>) : {}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function listProjectDirectory(relativePath: string) {
|
|
200
|
+
const projectPath = getProjectPath(relativePath)
|
|
201
|
+
const stats = await fs.lstat(projectPath.absolutePath)
|
|
202
|
+
|
|
203
|
+
if (!stats.isDirectory() || stats.isSymbolicLink()) {
|
|
204
|
+
throw new Error('The selected path is not a directory')
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
await assertRealPathInsideProject(projectPath.absolutePath)
|
|
208
|
+
const directoryEntries = await fs.readdir(projectPath.absolutePath, {
|
|
209
|
+
withFileTypes: true,
|
|
210
|
+
})
|
|
211
|
+
const entries: ProjectEntry[] = directoryEntries
|
|
212
|
+
.filter((entry) => !hiddenProjectEntries.has(entry.name))
|
|
213
|
+
.map((entry) => {
|
|
214
|
+
const entryPath = projectPath.relativePath
|
|
215
|
+
? `${projectPath.relativePath}/${entry.name}`
|
|
216
|
+
: entry.name
|
|
217
|
+
const kind: ProjectEntryKind = entry.isSymbolicLink()
|
|
218
|
+
? 'symlink'
|
|
219
|
+
: entry.isDirectory()
|
|
220
|
+
? 'directory'
|
|
221
|
+
: 'file'
|
|
222
|
+
|
|
223
|
+
return { name: entry.name, path: entryPath, kind }
|
|
224
|
+
})
|
|
225
|
+
.sort((first, second) => {
|
|
226
|
+
if (first.kind === 'directory' && second.kind !== 'directory') return -1
|
|
227
|
+
if (first.kind !== 'directory' && second.kind === 'directory') return 1
|
|
228
|
+
return first.name.localeCompare(second.name, undefined, {
|
|
229
|
+
numeric: true,
|
|
230
|
+
sensitivity: 'base',
|
|
231
|
+
})
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
rootName: path.basename(projectRoot),
|
|
236
|
+
path: projectPath.relativePath,
|
|
237
|
+
entries,
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function readProjectFile(relativePath: string) {
|
|
242
|
+
const projectPath = getProjectPath(relativePath)
|
|
243
|
+
const stats = await fs.lstat(projectPath.absolutePath)
|
|
244
|
+
|
|
245
|
+
if (!stats.isFile() || stats.isSymbolicLink()) {
|
|
246
|
+
throw new Error('The selected path is not an editable file')
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
await assertRealPathInsideProject(projectPath.absolutePath)
|
|
250
|
+
|
|
251
|
+
if (stats.size > maxEditableFileBytes) {
|
|
252
|
+
return {
|
|
253
|
+
path: projectPath.relativePath,
|
|
254
|
+
content: '',
|
|
255
|
+
size: stats.size,
|
|
256
|
+
isBinary: false,
|
|
257
|
+
isTooLarge: true,
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const buffer = await fs.readFile(projectPath.absolutePath)
|
|
262
|
+
const isBinary = buffer.subarray(0, 8000).includes(0)
|
|
263
|
+
|
|
264
|
+
return {
|
|
265
|
+
path: projectPath.relativePath,
|
|
266
|
+
content: isBinary ? '' : buffer.toString('utf8'),
|
|
267
|
+
size: stats.size,
|
|
268
|
+
isBinary,
|
|
269
|
+
isTooLarge: false,
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
async function importPageFromSource(
|
|
274
|
+
relativePath: string,
|
|
275
|
+
name: string,
|
|
276
|
+
route: string
|
|
277
|
+
) {
|
|
278
|
+
const projectPath = getProjectPath(relativePath)
|
|
279
|
+
|
|
280
|
+
if (
|
|
281
|
+
!projectPath.relativePath.startsWith('src/') ||
|
|
282
|
+
!/\.(jsx|tsx)$/i.test(projectPath.relativePath)
|
|
283
|
+
) {
|
|
284
|
+
throw new Error('Pages must use a .jsx or .tsx file inside src')
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const stats = await fs.lstat(projectPath.absolutePath)
|
|
288
|
+
|
|
289
|
+
if (!stats.isFile() || stats.isSymbolicLink()) {
|
|
290
|
+
throw new Error('Select a valid React source file')
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
await assertRealPathInsideProject(projectPath.absolutePath)
|
|
294
|
+
const source = await fs.readFile(projectPath.absolutePath, 'utf8')
|
|
295
|
+
const pageId = randomUUID()
|
|
296
|
+
const converted = parseReactPageSource(source, projectPath.relativePath)
|
|
297
|
+
|
|
298
|
+
return {
|
|
299
|
+
id: pageId,
|
|
300
|
+
name,
|
|
301
|
+
route,
|
|
302
|
+
sourcePath: projectPath.relativePath,
|
|
303
|
+
root: {
|
|
304
|
+
...converted.root,
|
|
305
|
+
id: `${pageId}:root`,
|
|
306
|
+
label: 'Page root',
|
|
307
|
+
children: [],
|
|
308
|
+
},
|
|
309
|
+
components: converted.components,
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function getErrorStatus(error: unknown) {
|
|
314
|
+
const code =
|
|
315
|
+
typeof error === 'object' && error !== null && 'code' in error
|
|
316
|
+
? String(error.code)
|
|
317
|
+
: ''
|
|
318
|
+
|
|
319
|
+
if (code === 'ENOENT') return 404
|
|
320
|
+
if (code === 'EEXIST' || code === 'ENOTEMPTY') return 409
|
|
321
|
+
return 400
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const generatorDiagnosticMarker = 'VISUALBUILD_DIAGNOSTIC:'
|
|
325
|
+
|
|
326
|
+
function getProcessOutput(error: unknown, key: 'stdout' | 'stderr') {
|
|
327
|
+
if (
|
|
328
|
+
typeof error !== 'object' ||
|
|
329
|
+
error === null ||
|
|
330
|
+
!(key in error)
|
|
331
|
+
) {
|
|
332
|
+
return ''
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
return String(error[key] ?? '')
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function parseGeneratorDiagnostic(
|
|
339
|
+
error: unknown,
|
|
340
|
+
reason: string
|
|
341
|
+
): GeneratorDiagnostic {
|
|
342
|
+
const stderr = getProcessOutput(error, 'stderr')
|
|
343
|
+
const diagnosticLine = stderr
|
|
344
|
+
.split(/\r?\n/)
|
|
345
|
+
.find((line) => line.startsWith(generatorDiagnosticMarker))
|
|
346
|
+
|
|
347
|
+
if (diagnosticLine) {
|
|
348
|
+
try {
|
|
349
|
+
const diagnostic = JSON.parse(
|
|
350
|
+
diagnosticLine.slice(generatorDiagnosticMarker.length)
|
|
351
|
+
) as Partial<GeneratorDiagnostic>
|
|
352
|
+
|
|
353
|
+
if (
|
|
354
|
+
diagnostic.severity === 'error' &&
|
|
355
|
+
diagnostic.phase === 'generate' &&
|
|
356
|
+
typeof diagnostic.code === 'string' &&
|
|
357
|
+
typeof diagnostic.title === 'string' &&
|
|
358
|
+
typeof diagnostic.message === 'string' &&
|
|
359
|
+
typeof diagnostic.path === 'string'
|
|
360
|
+
) {
|
|
361
|
+
return {
|
|
362
|
+
severity: diagnostic.severity,
|
|
363
|
+
phase: diagnostic.phase,
|
|
364
|
+
code: diagnostic.code,
|
|
365
|
+
title: diagnostic.title,
|
|
366
|
+
message: diagnostic.message,
|
|
367
|
+
path: diagnostic.path,
|
|
368
|
+
suggestion:
|
|
369
|
+
typeof diagnostic.suggestion === 'string'
|
|
370
|
+
? diagnostic.suggestion
|
|
371
|
+
: undefined,
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
} catch {
|
|
375
|
+
// Fall through to a stable diagnostic if the child process output is malformed.
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
return {
|
|
380
|
+
severity: 'error',
|
|
381
|
+
phase: 'generate',
|
|
382
|
+
code: 'GENERATION_FAILED',
|
|
383
|
+
title: 'React source generation failed',
|
|
384
|
+
message:
|
|
385
|
+
error instanceof Error
|
|
386
|
+
? error.message
|
|
387
|
+
: `Failed to generate project files after ${reason}.`,
|
|
388
|
+
path: 'visualbuild/pages.json',
|
|
389
|
+
suggestion:
|
|
390
|
+
'Review the project schema and try again. The last valid files were kept.',
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function getGeneratorDiagnostic(error: unknown) {
|
|
395
|
+
if (
|
|
396
|
+
typeof error !== 'object' ||
|
|
397
|
+
error === null ||
|
|
398
|
+
!('diagnostic' in error)
|
|
399
|
+
) {
|
|
400
|
+
return null
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
return error.diagnostic as GeneratorDiagnostic
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function visualbuildDevApi(): Plugin {
|
|
407
|
+
const pagesPath = path.resolve(projectRoot, 'visualbuild/pages.json')
|
|
408
|
+
const runtimeClassesPath = path.resolve(
|
|
409
|
+
builderRoot,
|
|
410
|
+
'.visualbuild-runtime-classes.html'
|
|
411
|
+
)
|
|
412
|
+
const generatorPath = path.resolve(
|
|
413
|
+
builderPackageRoot,
|
|
414
|
+
'scripts/vb-generate.mjs'
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
return {
|
|
418
|
+
name: 'visualbuild-dev-api',
|
|
419
|
+
async configResolved() {
|
|
420
|
+
await fs.writeFile(
|
|
421
|
+
runtimeClassesPath,
|
|
422
|
+
await fs.readFile(runtimeClassesPath, 'utf8').catch(() => '')
|
|
423
|
+
)
|
|
424
|
+
},
|
|
425
|
+
configureServer(server) {
|
|
426
|
+
let suppressWatchUntil = 0
|
|
427
|
+
let suppressReloadUntil = 0
|
|
428
|
+
let generateTimer: ReturnType<typeof setTimeout> | null = null
|
|
429
|
+
let managedDeletionQueue = Promise.resolve()
|
|
430
|
+
const reverseSyncTimers = new Map<
|
|
431
|
+
string,
|
|
432
|
+
ReturnType<typeof setTimeout>
|
|
433
|
+
>()
|
|
434
|
+
const suppressedUnlinkPaths = new Map<string, number>()
|
|
435
|
+
const eventClients = new Set<ServerResponse>()
|
|
436
|
+
const hotChannel = server.ws as unknown as {
|
|
437
|
+
send: (payload: unknown, data?: unknown) => void
|
|
438
|
+
}
|
|
439
|
+
const sendHotPayload = hotChannel.send.bind(hotChannel)
|
|
440
|
+
|
|
441
|
+
hotChannel.send = (payload, data) => {
|
|
442
|
+
if (
|
|
443
|
+
Date.now() < suppressReloadUntil &&
|
|
444
|
+
typeof payload === 'object' &&
|
|
445
|
+
payload !== null &&
|
|
446
|
+
'type' in payload &&
|
|
447
|
+
payload.type === 'full-reload'
|
|
448
|
+
) {
|
|
449
|
+
return
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
sendHotPayload(payload, data)
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
async function generateProjectFiles(reason: string) {
|
|
456
|
+
try {
|
|
457
|
+
const { stdout, stderr } = await execFileAsync(
|
|
458
|
+
process.execPath,
|
|
459
|
+
[generatorPath],
|
|
460
|
+
{ cwd: projectRoot }
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
if (stdout.trim()) {
|
|
464
|
+
server.config.logger.info(stdout.trim())
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
if (stderr.trim()) {
|
|
468
|
+
server.config.logger.warn(stderr.trim())
|
|
469
|
+
}
|
|
470
|
+
} catch (error) {
|
|
471
|
+
const diagnostic = parseGeneratorDiagnostic(error, reason)
|
|
472
|
+
server.config.logger.error(
|
|
473
|
+
`[VisualBuild] ${diagnostic.title}: ${diagnostic.message}`
|
|
474
|
+
)
|
|
475
|
+
broadcastVisualBuildEvent({
|
|
476
|
+
type: 'generator-diagnostic',
|
|
477
|
+
...diagnostic,
|
|
478
|
+
})
|
|
479
|
+
|
|
480
|
+
const generationError = new Error(diagnostic.message)
|
|
481
|
+
Object.assign(generationError, { diagnostic })
|
|
482
|
+
throw generationError
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const pagesWatcher = watchFiles(pagesPath, {
|
|
487
|
+
ignoreInitial: true,
|
|
488
|
+
usePolling: process.platform === 'win32',
|
|
489
|
+
interval: 100,
|
|
490
|
+
awaitWriteFinish: {
|
|
491
|
+
stabilityThreshold: 120,
|
|
492
|
+
pollInterval: 25,
|
|
493
|
+
},
|
|
494
|
+
}).on('change', () => {
|
|
495
|
+
if (Date.now() < suppressWatchUntil) {
|
|
496
|
+
return
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
if (generateTimer) {
|
|
500
|
+
clearTimeout(generateTimer)
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
generateTimer = setTimeout(() => {
|
|
504
|
+
generateTimer = null
|
|
505
|
+
void generateProjectFiles('pages.json change').catch(() => {})
|
|
506
|
+
}, 80)
|
|
507
|
+
})
|
|
508
|
+
|
|
509
|
+
function broadcastVisualBuildEvent(event: VisualBuildEvent) {
|
|
510
|
+
const payload = `event: visualbuild\ndata: ${JSON.stringify(event)}\n\n`
|
|
511
|
+
|
|
512
|
+
for (const client of eventClients) {
|
|
513
|
+
client.write(payload)
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function normalizeManagedPath(value: unknown) {
|
|
518
|
+
return typeof value === 'string'
|
|
519
|
+
? value.replace(/\\/g, '/').replace(/^\.\/+/, '')
|
|
520
|
+
: ''
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function findManagedPagesForPath(
|
|
524
|
+
schema: ManagedProjectSchema,
|
|
525
|
+
relativePath: string,
|
|
526
|
+
isDirectory: boolean
|
|
527
|
+
) {
|
|
528
|
+
const normalizedTarget = normalizeManagedPath(relativePath)
|
|
529
|
+
|
|
530
|
+
return schema.pages.filter((page) => {
|
|
531
|
+
const sourcePath = normalizeManagedPath(page.sourcePath)
|
|
532
|
+
|
|
533
|
+
return (
|
|
534
|
+
sourcePath === normalizedTarget ||
|
|
535
|
+
(isDirectory && sourcePath.startsWith(`${normalizedTarget}/`))
|
|
536
|
+
)
|
|
537
|
+
})
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function replaceManagedPathPrefix(
|
|
541
|
+
sourcePath: string,
|
|
542
|
+
previousPath: string,
|
|
543
|
+
targetPath: string
|
|
544
|
+
) {
|
|
545
|
+
return `${targetPath}${sourcePath.slice(previousPath.length)}`
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function validateManagedPagePaths(schema: ManagedProjectSchema) {
|
|
549
|
+
const seenSourcePaths = new Set<string>()
|
|
550
|
+
|
|
551
|
+
for (const page of schema.pages) {
|
|
552
|
+
const sourcePath = normalizeManagedPath(page.sourcePath)
|
|
553
|
+
|
|
554
|
+
if (
|
|
555
|
+
!sourcePath.startsWith('src/') ||
|
|
556
|
+
!/\.(jsx|tsx)$/i.test(sourcePath) ||
|
|
557
|
+
sourcePath.includes('/../') ||
|
|
558
|
+
sourcePath.endsWith('/..')
|
|
559
|
+
) {
|
|
560
|
+
throw new Error(
|
|
561
|
+
`Managed page "${page.name}" must remain a .jsx or .tsx file inside src`
|
|
562
|
+
)
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
if (
|
|
566
|
+
sourcePath === 'src/App.tsx' ||
|
|
567
|
+
sourcePath === 'src/layouts/AppLayout.tsx'
|
|
568
|
+
) {
|
|
569
|
+
throw new Error(`Managed page path is reserved: ${sourcePath}`)
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
if (seenSourcePaths.has(sourcePath)) {
|
|
573
|
+
throw new Error(`Duplicate managed page path: ${sourcePath}`)
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
seenSourcePaths.add(sourcePath)
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function suppressManagedUnlink(relativePath: string) {
|
|
581
|
+
suppressedUnlinkPaths.set(
|
|
582
|
+
normalizeManagedPath(relativePath),
|
|
583
|
+
Date.now() + 2500
|
|
584
|
+
)
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function consumeSuppressedUnlink(relativePath: string) {
|
|
588
|
+
const normalizedPath = normalizeManagedPath(relativePath)
|
|
589
|
+
const expiresAt = suppressedUnlinkPaths.get(normalizedPath)
|
|
590
|
+
|
|
591
|
+
if (!expiresAt) return false
|
|
592
|
+
|
|
593
|
+
suppressedUnlinkPaths.delete(normalizedPath)
|
|
594
|
+
return expiresAt >= Date.now()
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
async function persistManagedPageDeletion(
|
|
598
|
+
relativePath: string,
|
|
599
|
+
isDirectory: boolean,
|
|
600
|
+
deleteTarget?: () => Promise<void>
|
|
601
|
+
) {
|
|
602
|
+
const schema = JSON.parse(
|
|
603
|
+
await fs.readFile(pagesPath, 'utf8')
|
|
604
|
+
) as ManagedProjectSchema
|
|
605
|
+
const removedPages = findManagedPagesForPath(
|
|
606
|
+
schema,
|
|
607
|
+
relativePath,
|
|
608
|
+
isDirectory
|
|
609
|
+
)
|
|
610
|
+
|
|
611
|
+
if (removedPages.length === 0) {
|
|
612
|
+
if (deleteTarget) await deleteTarget()
|
|
613
|
+
return []
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
if (removedPages.length === schema.pages.length) {
|
|
617
|
+
throw new Error(
|
|
618
|
+
'A VisualBuild project must keep at least one registered page'
|
|
619
|
+
)
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
const removedPageIds = new Set(removedPages.map((page) => page.id))
|
|
623
|
+
const nextSchema: ManagedProjectSchema = {
|
|
624
|
+
...schema,
|
|
625
|
+
pages: schema.pages.filter((page) => !removedPageIds.has(page.id)),
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
for (const page of removedPages) {
|
|
629
|
+
const sourcePath = normalizeManagedPath(page.sourcePath)
|
|
630
|
+
|
|
631
|
+
if (sourcePath) {
|
|
632
|
+
suppressManagedUnlink(sourcePath)
|
|
633
|
+
const pendingTimer = reverseSyncTimers.get(
|
|
634
|
+
path.resolve(projectRoot, sourcePath)
|
|
635
|
+
)
|
|
636
|
+
|
|
637
|
+
if (pendingTimer) {
|
|
638
|
+
clearTimeout(pendingTimer)
|
|
639
|
+
reverseSyncTimers.delete(path.resolve(projectRoot, sourcePath))
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
suppressWatchUntil = Date.now() + 1500
|
|
645
|
+
await fs.writeFile(
|
|
646
|
+
pagesPath,
|
|
647
|
+
`${JSON.stringify(nextSchema, null, 2)}\n`,
|
|
648
|
+
'utf8'
|
|
649
|
+
)
|
|
650
|
+
|
|
651
|
+
try {
|
|
652
|
+
if (deleteTarget) await deleteTarget()
|
|
653
|
+
} catch (error) {
|
|
654
|
+
suppressWatchUntil = Date.now() + 1500
|
|
655
|
+
await fs.writeFile(
|
|
656
|
+
pagesPath,
|
|
657
|
+
`${JSON.stringify(schema, null, 2)}\n`,
|
|
658
|
+
'utf8'
|
|
659
|
+
)
|
|
660
|
+
throw error
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
await generateProjectFiles('managed page deletion')
|
|
664
|
+
broadcastVisualBuildEvent({
|
|
665
|
+
type: 'managed-pages-deleted',
|
|
666
|
+
path: normalizeManagedPath(relativePath),
|
|
667
|
+
pageIds: removedPages.map((page) => page.id),
|
|
668
|
+
message:
|
|
669
|
+
removedPages.length === 1
|
|
670
|
+
? `Deleted ${removedPages[0].name}`
|
|
671
|
+
: `Deleted ${removedPages.length} pages`,
|
|
672
|
+
})
|
|
673
|
+
|
|
674
|
+
return removedPages
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
async function persistManagedPageMove(
|
|
678
|
+
relativePath: string,
|
|
679
|
+
targetRelativePath: string,
|
|
680
|
+
isDirectory: boolean,
|
|
681
|
+
moveTarget: () => Promise<void>,
|
|
682
|
+
rollbackTarget: () => Promise<void>
|
|
683
|
+
) {
|
|
684
|
+
const normalizedSource = normalizeManagedPath(relativePath)
|
|
685
|
+
const normalizedTarget = normalizeManagedPath(targetRelativePath)
|
|
686
|
+
const schema = JSON.parse(
|
|
687
|
+
await fs.readFile(pagesPath, 'utf8')
|
|
688
|
+
) as ManagedProjectSchema
|
|
689
|
+
const affectedPages = findManagedPagesForPath(
|
|
690
|
+
schema,
|
|
691
|
+
normalizedSource,
|
|
692
|
+
isDirectory
|
|
693
|
+
)
|
|
694
|
+
|
|
695
|
+
if (affectedPages.length === 0) {
|
|
696
|
+
await moveTarget()
|
|
697
|
+
return []
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
const affectedPageIds = new Set(affectedPages.map((page) => page.id))
|
|
701
|
+
const pathChanges: ManagedPagePathChange[] = affectedPages.map(
|
|
702
|
+
(page) => {
|
|
703
|
+
const previousPath = normalizeManagedPath(page.sourcePath)
|
|
704
|
+
|
|
705
|
+
return {
|
|
706
|
+
pageId: page.id,
|
|
707
|
+
previousPath,
|
|
708
|
+
sourcePath: isDirectory
|
|
709
|
+
? replaceManagedPathPrefix(
|
|
710
|
+
previousPath,
|
|
711
|
+
normalizedSource,
|
|
712
|
+
normalizedTarget
|
|
713
|
+
)
|
|
714
|
+
: normalizedTarget,
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
)
|
|
718
|
+
const nextPaths = new Map(
|
|
719
|
+
pathChanges.map((change) => [change.pageId, change.sourcePath])
|
|
720
|
+
)
|
|
721
|
+
const nextSchema: ManagedProjectSchema = {
|
|
722
|
+
...schema,
|
|
723
|
+
pages: schema.pages.map((page) =>
|
|
724
|
+
affectedPageIds.has(page.id)
|
|
725
|
+
? { ...page, sourcePath: nextPaths.get(page.id) }
|
|
726
|
+
: page
|
|
727
|
+
),
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
validateManagedPagePaths(nextSchema)
|
|
731
|
+
|
|
732
|
+
for (const change of pathChanges) {
|
|
733
|
+
suppressManagedUnlink(change.previousPath)
|
|
734
|
+
const previousAbsolutePath = path.resolve(
|
|
735
|
+
projectRoot,
|
|
736
|
+
change.previousPath
|
|
737
|
+
)
|
|
738
|
+
const pendingTimer = reverseSyncTimers.get(previousAbsolutePath)
|
|
739
|
+
|
|
740
|
+
if (pendingTimer) {
|
|
741
|
+
clearTimeout(pendingTimer)
|
|
742
|
+
reverseSyncTimers.delete(previousAbsolutePath)
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
let targetMoved = false
|
|
747
|
+
let schemaUpdated = false
|
|
748
|
+
|
|
749
|
+
try {
|
|
750
|
+
await moveTarget()
|
|
751
|
+
targetMoved = true
|
|
752
|
+
suppressWatchUntil = Date.now() + 1500
|
|
753
|
+
await fs.writeFile(
|
|
754
|
+
pagesPath,
|
|
755
|
+
`${JSON.stringify(nextSchema, null, 2)}\n`,
|
|
756
|
+
'utf8'
|
|
757
|
+
)
|
|
758
|
+
schemaUpdated = true
|
|
759
|
+
await generateProjectFiles('managed page move')
|
|
760
|
+
} catch (error) {
|
|
761
|
+
suppressWatchUntil = Date.now() + 1500
|
|
762
|
+
|
|
763
|
+
if (schemaUpdated) {
|
|
764
|
+
await fs.writeFile(
|
|
765
|
+
pagesPath,
|
|
766
|
+
`${JSON.stringify(schema, null, 2)}\n`,
|
|
767
|
+
'utf8'
|
|
768
|
+
)
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
if (targetMoved) {
|
|
772
|
+
await rollbackTarget()
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
if (schemaUpdated) {
|
|
776
|
+
await generateProjectFiles('managed page move rollback').catch(
|
|
777
|
+
() => {}
|
|
778
|
+
)
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
throw error
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
broadcastVisualBuildEvent({
|
|
785
|
+
type: 'managed-pages-moved',
|
|
786
|
+
path: normalizedSource,
|
|
787
|
+
targetPath: normalizedTarget,
|
|
788
|
+
pages: pathChanges,
|
|
789
|
+
message:
|
|
790
|
+
pathChanges.length === 1
|
|
791
|
+
? `Moved ${affectedPages[0].name}`
|
|
792
|
+
: `Moved ${pathChanges.length} managed pages`,
|
|
793
|
+
})
|
|
794
|
+
|
|
795
|
+
return pathChanges
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
function unregisterExternallyDeletedPage(relativePath: string) {
|
|
799
|
+
managedDeletionQueue = managedDeletionQueue
|
|
800
|
+
.then(async () => {
|
|
801
|
+
const schema = JSON.parse(
|
|
802
|
+
await fs.readFile(pagesPath, 'utf8')
|
|
803
|
+
) as ManagedProjectSchema
|
|
804
|
+
const removedPages = findManagedPagesForPath(
|
|
805
|
+
schema,
|
|
806
|
+
relativePath,
|
|
807
|
+
false
|
|
808
|
+
)
|
|
809
|
+
|
|
810
|
+
if (removedPages.length === 0) return
|
|
811
|
+
|
|
812
|
+
if (removedPages.length === schema.pages.length) {
|
|
813
|
+
await generateProjectFiles('restore required page')
|
|
814
|
+
broadcastVisualBuildEvent({
|
|
815
|
+
type: 'reverse-sync-error',
|
|
816
|
+
path: relativePath,
|
|
817
|
+
pageId: removedPages[0].id,
|
|
818
|
+
message:
|
|
819
|
+
'The only registered page cannot be deleted. VisualBuild restored its source file.',
|
|
820
|
+
})
|
|
821
|
+
return
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
await persistManagedPageDeletion(relativePath, false)
|
|
825
|
+
})
|
|
826
|
+
.catch((error) => {
|
|
827
|
+
const message =
|
|
828
|
+
error instanceof Error
|
|
829
|
+
? error.message
|
|
830
|
+
: 'Could not unregister the deleted page.'
|
|
831
|
+
broadcastVisualBuildEvent({
|
|
832
|
+
type: 'reverse-sync-error',
|
|
833
|
+
path: relativePath,
|
|
834
|
+
message,
|
|
835
|
+
})
|
|
836
|
+
server.config.logger.warn(
|
|
837
|
+
`[VisualBuild] Page deletion sync skipped ${relativePath}: ${message}`
|
|
838
|
+
)
|
|
839
|
+
})
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
async function reverseSyncSourceFile(absolutePath: string) {
|
|
843
|
+
const relativePath = path
|
|
844
|
+
.relative(projectRoot, absolutePath)
|
|
845
|
+
.split(path.sep)
|
|
846
|
+
.join('/')
|
|
847
|
+
|
|
848
|
+
try {
|
|
849
|
+
const schema = JSON.parse(
|
|
850
|
+
await fs.readFile(pagesPath, 'utf8')
|
|
851
|
+
) as ManagedProjectSchema
|
|
852
|
+
const pageIndex = schema.pages.findIndex(
|
|
853
|
+
(page) =>
|
|
854
|
+
normalizeManagedPath(page.sourcePath) === relativePath
|
|
855
|
+
)
|
|
856
|
+
|
|
857
|
+
if (pageIndex < 0) return
|
|
858
|
+
|
|
859
|
+
const page = schema.pages[pageIndex]
|
|
860
|
+
const source = await fs.readFile(absolutePath, 'utf8')
|
|
861
|
+
const imported = parseReactPageSource(source, relativePath)
|
|
862
|
+
const current: ImportedPageTree = {
|
|
863
|
+
root: page.root,
|
|
864
|
+
components: page.components,
|
|
865
|
+
}
|
|
866
|
+
const reconciled = reconcileImportedTree(imported, current)
|
|
867
|
+
|
|
868
|
+
if (visualTreesEqual(reconciled, current)) return
|
|
869
|
+
|
|
870
|
+
schema.pages[pageIndex] = {
|
|
871
|
+
...page,
|
|
872
|
+
root: reconciled.root,
|
|
873
|
+
components: reconciled.components,
|
|
874
|
+
}
|
|
875
|
+
suppressWatchUntil = Date.now() + 1200
|
|
876
|
+
await fs.writeFile(
|
|
877
|
+
pagesPath,
|
|
878
|
+
`${JSON.stringify(schema, null, 2)}\n`,
|
|
879
|
+
'utf8'
|
|
880
|
+
)
|
|
881
|
+
broadcastVisualBuildEvent({
|
|
882
|
+
type: 'reverse-sync-success',
|
|
883
|
+
path: relativePath,
|
|
884
|
+
pageId: page.id,
|
|
885
|
+
message: `Updated ${page.name} from ${relativePath}`,
|
|
886
|
+
})
|
|
887
|
+
server.config.logger.info(
|
|
888
|
+
`[VisualBuild] Reverse synced ${relativePath}`
|
|
889
|
+
)
|
|
890
|
+
} catch (error) {
|
|
891
|
+
const message =
|
|
892
|
+
error instanceof Error
|
|
893
|
+
? error.message
|
|
894
|
+
: 'Reverse sync could not parse the managed page.'
|
|
895
|
+
broadcastVisualBuildEvent({
|
|
896
|
+
type: 'reverse-sync-error',
|
|
897
|
+
path: relativePath,
|
|
898
|
+
message,
|
|
899
|
+
})
|
|
900
|
+
server.config.logger.warn(
|
|
901
|
+
`[VisualBuild] Reverse sync skipped ${relativePath}: ${message}`
|
|
902
|
+
)
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
function scheduleReverseSync(absolutePath: string) {
|
|
907
|
+
if (!/\.(jsx|tsx)$/i.test(absolutePath)) return
|
|
908
|
+
|
|
909
|
+
const previousTimer = reverseSyncTimers.get(absolutePath)
|
|
910
|
+
if (previousTimer) clearTimeout(previousTimer)
|
|
911
|
+
|
|
912
|
+
reverseSyncTimers.set(
|
|
913
|
+
absolutePath,
|
|
914
|
+
setTimeout(() => {
|
|
915
|
+
reverseSyncTimers.delete(absolutePath)
|
|
916
|
+
void reverseSyncSourceFile(absolutePath)
|
|
917
|
+
}, 100)
|
|
918
|
+
)
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
function handleProjectFileChange(
|
|
922
|
+
action: FileSystemEvent['action'],
|
|
923
|
+
absolutePath: string
|
|
924
|
+
) {
|
|
925
|
+
const relativePath = path
|
|
926
|
+
.relative(projectRoot, absolutePath)
|
|
927
|
+
.split(path.sep)
|
|
928
|
+
.join('/')
|
|
929
|
+
|
|
930
|
+
if (
|
|
931
|
+
!relativePath ||
|
|
932
|
+
relativePath === '..' ||
|
|
933
|
+
relativePath.startsWith('../')
|
|
934
|
+
) {
|
|
935
|
+
return
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
broadcastVisualBuildEvent({
|
|
939
|
+
type: 'filesystem-change',
|
|
940
|
+
path: relativePath,
|
|
941
|
+
action,
|
|
942
|
+
})
|
|
943
|
+
|
|
944
|
+
const relativeToSource = path.relative(generatedSourceRoot, absolutePath)
|
|
945
|
+
const isInsideSource =
|
|
946
|
+
relativeToSource &&
|
|
947
|
+
relativeToSource !== '..' &&
|
|
948
|
+
!relativeToSource.startsWith(`..${path.sep}`) &&
|
|
949
|
+
!path.isAbsolute(relativeToSource)
|
|
950
|
+
|
|
951
|
+
if (isInsideSource && (action === 'add' || action === 'change')) {
|
|
952
|
+
scheduleReverseSync(absolutePath)
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
if (
|
|
956
|
+
isInsideSource &&
|
|
957
|
+
action === 'unlink' &&
|
|
958
|
+
!consumeSuppressedUnlink(relativePath)
|
|
959
|
+
) {
|
|
960
|
+
unregisterExternallyDeletedPage(relativePath)
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
const projectWatcher = watchFiles(projectRoot, {
|
|
965
|
+
ignoreInitial: true,
|
|
966
|
+
usePolling: process.platform === 'win32',
|
|
967
|
+
interval: 100,
|
|
968
|
+
ignored: (watchedPath) => {
|
|
969
|
+
const relativePath = path.relative(projectRoot, path.resolve(watchedPath))
|
|
970
|
+
const segments = relativePath.split(path.sep)
|
|
971
|
+
|
|
972
|
+
return segments.some((segment) =>
|
|
973
|
+
['.git', 'node_modules', 'dist', '.visualbuild-qa'].includes(segment)
|
|
974
|
+
)
|
|
975
|
+
},
|
|
976
|
+
awaitWriteFinish: {
|
|
977
|
+
stabilityThreshold: 140,
|
|
978
|
+
pollInterval: 25,
|
|
979
|
+
},
|
|
980
|
+
})
|
|
981
|
+
.on('add', (changedPath) => handleProjectFileChange('add', changedPath))
|
|
982
|
+
.on('change', (changedPath) =>
|
|
983
|
+
handleProjectFileChange('change', changedPath)
|
|
984
|
+
)
|
|
985
|
+
.on('unlink', (changedPath) =>
|
|
986
|
+
handleProjectFileChange('unlink', changedPath)
|
|
987
|
+
)
|
|
988
|
+
.on('addDir', (changedPath) =>
|
|
989
|
+
handleProjectFileChange('addDir', changedPath)
|
|
990
|
+
)
|
|
991
|
+
.on('unlinkDir', (changedPath) =>
|
|
992
|
+
handleProjectFileChange('unlinkDir', changedPath)
|
|
993
|
+
)
|
|
994
|
+
.on('ready', () => {
|
|
995
|
+
server.config.logger.info(
|
|
996
|
+
`[VisualBuild] Watching ${projectRoot} for project and managed source changes`
|
|
997
|
+
)
|
|
998
|
+
})
|
|
999
|
+
.on('error', (error) => {
|
|
1000
|
+
server.config.logger.warn(
|
|
1001
|
+
`[VisualBuild] Source watcher error: ${error.message}`
|
|
1002
|
+
)
|
|
1003
|
+
})
|
|
1004
|
+
|
|
1005
|
+
const keepAliveTimer = setInterval(() => {
|
|
1006
|
+
for (const client of eventClients) {
|
|
1007
|
+
client.write(': keep-alive\n\n')
|
|
1008
|
+
}
|
|
1009
|
+
}, 15000)
|
|
1010
|
+
|
|
1011
|
+
server.httpServer?.once('close', () => {
|
|
1012
|
+
void pagesWatcher.close()
|
|
1013
|
+
void projectWatcher.close()
|
|
1014
|
+
clearInterval(keepAliveTimer)
|
|
1015
|
+
|
|
1016
|
+
for (const client of eventClients) {
|
|
1017
|
+
client.end()
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
for (const timer of reverseSyncTimers.values()) {
|
|
1021
|
+
clearTimeout(timer)
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
if (generateTimer) {
|
|
1025
|
+
clearTimeout(generateTimer)
|
|
1026
|
+
}
|
|
1027
|
+
})
|
|
1028
|
+
|
|
1029
|
+
void generateProjectFiles('dev server start').catch(() => {})
|
|
1030
|
+
|
|
1031
|
+
server.middlewares.use('/__visualbuild/events', (req, res) => {
|
|
1032
|
+
if (req.method !== 'GET') {
|
|
1033
|
+
res.statusCode = 405
|
|
1034
|
+
res.end()
|
|
1035
|
+
return
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
res.statusCode = 200
|
|
1039
|
+
res.setHeader('Content-Type', 'text/event-stream')
|
|
1040
|
+
res.setHeader('Cache-Control', 'no-cache, no-transform')
|
|
1041
|
+
res.setHeader('Connection', 'keep-alive')
|
|
1042
|
+
res.write('retry: 1500\n\n')
|
|
1043
|
+
eventClients.add(res)
|
|
1044
|
+
|
|
1045
|
+
req.once('close', () => {
|
|
1046
|
+
eventClients.delete(res)
|
|
1047
|
+
})
|
|
1048
|
+
})
|
|
1049
|
+
|
|
1050
|
+
server.middlewares.use('/__visualbuild/files', async (req, res) => {
|
|
1051
|
+
const requestUrl = new URL(req.url ?? '/', 'http://visualbuild.local')
|
|
1052
|
+
|
|
1053
|
+
try {
|
|
1054
|
+
if (req.method === 'GET') {
|
|
1055
|
+
const result = await listProjectDirectory(
|
|
1056
|
+
requestUrl.searchParams.get('path') ?? ''
|
|
1057
|
+
)
|
|
1058
|
+
sendJson(res, 200, result)
|
|
1059
|
+
return
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
if (req.method === 'POST') {
|
|
1063
|
+
const payload = await readJsonBody(req)
|
|
1064
|
+
const parentPath = getProjectPath(
|
|
1065
|
+
typeof payload.parentPath === 'string' ? payload.parentPath : ''
|
|
1066
|
+
)
|
|
1067
|
+
const parentStats = await fs.lstat(parentPath.absolutePath)
|
|
1068
|
+
|
|
1069
|
+
if (!parentStats.isDirectory() || parentStats.isSymbolicLink()) {
|
|
1070
|
+
throw new Error('Select a valid parent folder')
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
await assertRealPathInsideProject(parentPath.absolutePath)
|
|
1074
|
+
const name = validateEntryName(payload.name)
|
|
1075
|
+
const targetPath = getProjectPath(
|
|
1076
|
+
parentPath.relativePath
|
|
1077
|
+
? `${parentPath.relativePath}/${name}`
|
|
1078
|
+
: name
|
|
1079
|
+
)
|
|
1080
|
+
|
|
1081
|
+
if (payload.kind === 'directory') {
|
|
1082
|
+
await fs.mkdir(targetPath.absolutePath)
|
|
1083
|
+
} else if (payload.kind === 'file') {
|
|
1084
|
+
await fs.writeFile(targetPath.absolutePath, '', {
|
|
1085
|
+
encoding: 'utf8',
|
|
1086
|
+
flag: 'wx',
|
|
1087
|
+
})
|
|
1088
|
+
} else {
|
|
1089
|
+
throw new Error('Entry type must be file or directory')
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
sendJson(res, 201, {
|
|
1093
|
+
name,
|
|
1094
|
+
path: targetPath.relativePath,
|
|
1095
|
+
kind: payload.kind,
|
|
1096
|
+
})
|
|
1097
|
+
return
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
if (req.method === 'PATCH') {
|
|
1101
|
+
const payload = await readJsonBody(req)
|
|
1102
|
+
const sourcePath = getProjectPath(
|
|
1103
|
+
typeof payload.path === 'string' ? payload.path : ''
|
|
1104
|
+
)
|
|
1105
|
+
|
|
1106
|
+
if (!sourcePath.relativePath) {
|
|
1107
|
+
throw new Error('The project root cannot be renamed')
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
const sourceStats = await fs.lstat(sourcePath.absolutePath)
|
|
1111
|
+
|
|
1112
|
+
if (!sourceStats.isSymbolicLink()) {
|
|
1113
|
+
await assertRealPathInsideProject(sourcePath.absolutePath)
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
const name = validateEntryName(payload.name)
|
|
1117
|
+
const currentParentPath = path.posix.dirname(
|
|
1118
|
+
sourcePath.relativePath
|
|
1119
|
+
)
|
|
1120
|
+
const requestedParentPath =
|
|
1121
|
+
typeof payload.parentPath === 'string'
|
|
1122
|
+
? getProjectPath(payload.parentPath)
|
|
1123
|
+
: getProjectPath(
|
|
1124
|
+
currentParentPath === '.' ? '' : currentParentPath
|
|
1125
|
+
)
|
|
1126
|
+
const parentStats = await fs.lstat(
|
|
1127
|
+
requestedParentPath.absolutePath
|
|
1128
|
+
)
|
|
1129
|
+
|
|
1130
|
+
if (
|
|
1131
|
+
!parentStats.isDirectory() ||
|
|
1132
|
+
parentStats.isSymbolicLink()
|
|
1133
|
+
) {
|
|
1134
|
+
throw new Error('Select a valid destination folder')
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
await assertRealPathInsideProject(
|
|
1138
|
+
requestedParentPath.absolutePath
|
|
1139
|
+
)
|
|
1140
|
+
const targetPath = getProjectPath(
|
|
1141
|
+
requestedParentPath.relativePath
|
|
1142
|
+
? `${requestedParentPath.relativePath}/${name}`
|
|
1143
|
+
: name
|
|
1144
|
+
)
|
|
1145
|
+
|
|
1146
|
+
if (targetPath.relativePath === sourcePath.relativePath) {
|
|
1147
|
+
sendJson(res, 200, {
|
|
1148
|
+
name,
|
|
1149
|
+
path: targetPath.relativePath,
|
|
1150
|
+
kind: sourceStats.isSymbolicLink()
|
|
1151
|
+
? 'symlink'
|
|
1152
|
+
: sourceStats.isDirectory()
|
|
1153
|
+
? 'directory'
|
|
1154
|
+
: 'file',
|
|
1155
|
+
managedPages: [],
|
|
1156
|
+
})
|
|
1157
|
+
return
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
if (
|
|
1161
|
+
sourceStats.isDirectory() &&
|
|
1162
|
+
targetPath.relativePath.startsWith(
|
|
1163
|
+
`${sourcePath.relativePath}/`
|
|
1164
|
+
)
|
|
1165
|
+
) {
|
|
1166
|
+
throw new Error('A folder cannot be moved inside itself')
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
const targetExists = await fs
|
|
1170
|
+
.lstat(targetPath.absolutePath)
|
|
1171
|
+
.then(() => true)
|
|
1172
|
+
.catch((error: NodeJS.ErrnoException) => {
|
|
1173
|
+
if (error.code === 'ENOENT') return false
|
|
1174
|
+
throw error
|
|
1175
|
+
})
|
|
1176
|
+
|
|
1177
|
+
if (targetExists) {
|
|
1178
|
+
throw new Error('A file or folder with that name already exists')
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
const managedPages = await persistManagedPageMove(
|
|
1182
|
+
sourcePath.relativePath,
|
|
1183
|
+
targetPath.relativePath,
|
|
1184
|
+
sourceStats.isDirectory() && !sourceStats.isSymbolicLink(),
|
|
1185
|
+
() =>
|
|
1186
|
+
fs.rename(sourcePath.absolutePath, targetPath.absolutePath),
|
|
1187
|
+
() =>
|
|
1188
|
+
fs.rename(targetPath.absolutePath, sourcePath.absolutePath)
|
|
1189
|
+
)
|
|
1190
|
+
sendJson(res, 200, {
|
|
1191
|
+
name,
|
|
1192
|
+
path: targetPath.relativePath,
|
|
1193
|
+
kind: sourceStats.isSymbolicLink()
|
|
1194
|
+
? 'symlink'
|
|
1195
|
+
: sourceStats.isDirectory()
|
|
1196
|
+
? 'directory'
|
|
1197
|
+
: 'file',
|
|
1198
|
+
managedPages,
|
|
1199
|
+
})
|
|
1200
|
+
return
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
if (req.method === 'DELETE') {
|
|
1204
|
+
const targetPath = getProjectPath(
|
|
1205
|
+
requestUrl.searchParams.get('path') ?? ''
|
|
1206
|
+
)
|
|
1207
|
+
|
|
1208
|
+
if (!targetPath.relativePath) {
|
|
1209
|
+
throw new Error('The project root cannot be deleted')
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
const targetStats = await fs.lstat(targetPath.absolutePath)
|
|
1213
|
+
|
|
1214
|
+
if (!targetStats.isSymbolicLink()) {
|
|
1215
|
+
await assertRealPathInsideProject(targetPath.absolutePath)
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
const deleteTarget = () =>
|
|
1219
|
+
fs.rm(targetPath.absolutePath, {
|
|
1220
|
+
recursive: targetStats.isDirectory(),
|
|
1221
|
+
})
|
|
1222
|
+
const removedPages = await persistManagedPageDeletion(
|
|
1223
|
+
targetPath.relativePath,
|
|
1224
|
+
targetStats.isDirectory(),
|
|
1225
|
+
deleteTarget
|
|
1226
|
+
)
|
|
1227
|
+
|
|
1228
|
+
sendJson(res, 200, {
|
|
1229
|
+
path: targetPath.relativePath,
|
|
1230
|
+
removedPageIds: removedPages.map((page) => page.id),
|
|
1231
|
+
})
|
|
1232
|
+
return
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
res.statusCode = 405
|
|
1236
|
+
res.end()
|
|
1237
|
+
} catch (error) {
|
|
1238
|
+
sendJson(res, getErrorStatus(error), {
|
|
1239
|
+
error:
|
|
1240
|
+
error instanceof Error
|
|
1241
|
+
? error.message
|
|
1242
|
+
: 'Project file operation failed',
|
|
1243
|
+
})
|
|
1244
|
+
}
|
|
1245
|
+
})
|
|
1246
|
+
|
|
1247
|
+
server.middlewares.use('/__visualbuild/file', async (req, res) => {
|
|
1248
|
+
const requestUrl = new URL(req.url ?? '/', 'http://visualbuild.local')
|
|
1249
|
+
|
|
1250
|
+
try {
|
|
1251
|
+
if (req.method === 'GET') {
|
|
1252
|
+
sendJson(
|
|
1253
|
+
res,
|
|
1254
|
+
200,
|
|
1255
|
+
await readProjectFile(requestUrl.searchParams.get('path') ?? '')
|
|
1256
|
+
)
|
|
1257
|
+
return
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
if (req.method === 'PUT') {
|
|
1261
|
+
const payload = await readJsonBody(req)
|
|
1262
|
+
const targetPath = getProjectPath(
|
|
1263
|
+
typeof payload.path === 'string' ? payload.path : ''
|
|
1264
|
+
)
|
|
1265
|
+
const stats = await fs.lstat(targetPath.absolutePath)
|
|
1266
|
+
|
|
1267
|
+
if (!stats.isFile() || stats.isSymbolicLink()) {
|
|
1268
|
+
throw new Error('The selected path is not an editable file')
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
await assertRealPathInsideProject(targetPath.absolutePath)
|
|
1272
|
+
|
|
1273
|
+
if (typeof payload.content !== 'string') {
|
|
1274
|
+
throw new Error('File content must be text')
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
if (Buffer.byteLength(payload.content, 'utf8') > maxEditableFileBytes) {
|
|
1278
|
+
throw new Error('File content exceeds the 2 MB editor limit')
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
await fs.writeFile(targetPath.absolutePath, payload.content, 'utf8')
|
|
1282
|
+
res.statusCode = 204
|
|
1283
|
+
res.end()
|
|
1284
|
+
return
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
res.statusCode = 405
|
|
1288
|
+
res.end()
|
|
1289
|
+
} catch (error) {
|
|
1290
|
+
sendJson(res, getErrorStatus(error), {
|
|
1291
|
+
error:
|
|
1292
|
+
error instanceof Error
|
|
1293
|
+
? error.message
|
|
1294
|
+
: 'Project file operation failed',
|
|
1295
|
+
})
|
|
1296
|
+
}
|
|
1297
|
+
})
|
|
1298
|
+
|
|
1299
|
+
server.middlewares.use('/__visualbuild/import-page', async (req, res) => {
|
|
1300
|
+
try {
|
|
1301
|
+
if (req.method !== 'POST') {
|
|
1302
|
+
res.statusCode = 405
|
|
1303
|
+
res.end()
|
|
1304
|
+
return
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
const payload = await readJsonBody(req)
|
|
1308
|
+
const name =
|
|
1309
|
+
typeof payload.name === 'string' ? payload.name.trim() : ''
|
|
1310
|
+
const route =
|
|
1311
|
+
typeof payload.route === 'string' ? payload.route.trim() : ''
|
|
1312
|
+
const sourcePath =
|
|
1313
|
+
typeof payload.path === 'string' ? payload.path : ''
|
|
1314
|
+
|
|
1315
|
+
if (!name) {
|
|
1316
|
+
throw new Error('Page name is required')
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
if (!route.startsWith('/')) {
|
|
1320
|
+
throw new Error('Page route must start with "/"')
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
sendJson(
|
|
1324
|
+
res,
|
|
1325
|
+
201,
|
|
1326
|
+
await importPageFromSource(sourcePath, name, route)
|
|
1327
|
+
)
|
|
1328
|
+
} catch (error) {
|
|
1329
|
+
sendJson(res, getErrorStatus(error), {
|
|
1330
|
+
error:
|
|
1331
|
+
error instanceof Error
|
|
1332
|
+
? error.message
|
|
1333
|
+
: 'Failed to import page source',
|
|
1334
|
+
})
|
|
1335
|
+
}
|
|
1336
|
+
})
|
|
1337
|
+
|
|
1338
|
+
server.middlewares.use('/__visualbuild/tailwind', (req, res) => {
|
|
1339
|
+
if (req.method !== 'POST') {
|
|
1340
|
+
res.statusCode = 405
|
|
1341
|
+
res.end()
|
|
1342
|
+
return
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
let body = ''
|
|
1346
|
+
|
|
1347
|
+
req.on('data', (chunk) => {
|
|
1348
|
+
body += chunk
|
|
1349
|
+
})
|
|
1350
|
+
|
|
1351
|
+
req.on('end', async () => {
|
|
1352
|
+
try {
|
|
1353
|
+
const current = await fs
|
|
1354
|
+
.readFile(runtimeClassesPath, 'utf8')
|
|
1355
|
+
.catch(() => '')
|
|
1356
|
+
|
|
1357
|
+
if (current !== body) {
|
|
1358
|
+
suppressReloadUntil = Date.now() + 1500
|
|
1359
|
+
await fs.writeFile(runtimeClassesPath, body)
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
res.statusCode = 204
|
|
1363
|
+
res.end()
|
|
1364
|
+
} catch (error) {
|
|
1365
|
+
res.statusCode = 500
|
|
1366
|
+
res.end(
|
|
1367
|
+
error instanceof Error
|
|
1368
|
+
? error.message
|
|
1369
|
+
: 'Failed to update Tailwind classes'
|
|
1370
|
+
)
|
|
1371
|
+
}
|
|
1372
|
+
})
|
|
1373
|
+
})
|
|
1374
|
+
|
|
1375
|
+
server.middlewares.use('/__visualbuild/pages', async (req, res) => {
|
|
1376
|
+
if (req.method === 'GET') {
|
|
1377
|
+
try {
|
|
1378
|
+
const json = await fs.readFile(pagesPath, 'utf8')
|
|
1379
|
+
res.setHeader('Content-Type', 'application/json')
|
|
1380
|
+
res.setHeader(
|
|
1381
|
+
'X-VisualBuild-Project-Id',
|
|
1382
|
+
encodeURIComponent(projectRoot)
|
|
1383
|
+
)
|
|
1384
|
+
res.end(json)
|
|
1385
|
+
} catch (error) {
|
|
1386
|
+
res.statusCode = 500
|
|
1387
|
+
res.end(
|
|
1388
|
+
error instanceof Error
|
|
1389
|
+
? error.message
|
|
1390
|
+
: 'Failed to read pages.json'
|
|
1391
|
+
)
|
|
1392
|
+
}
|
|
1393
|
+
return
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
if (req.method === 'POST') {
|
|
1397
|
+
let body = ''
|
|
1398
|
+
|
|
1399
|
+
req.on('data', (chunk) => {
|
|
1400
|
+
body += chunk
|
|
1401
|
+
})
|
|
1402
|
+
|
|
1403
|
+
req.on('end', async () => {
|
|
1404
|
+
let previousSchema = ''
|
|
1405
|
+
let wroteSchema = false
|
|
1406
|
+
|
|
1407
|
+
try {
|
|
1408
|
+
await fs.mkdir(path.dirname(pagesPath), { recursive: true })
|
|
1409
|
+
previousSchema = await fs
|
|
1410
|
+
.readFile(pagesPath, 'utf8')
|
|
1411
|
+
.catch(() => '')
|
|
1412
|
+
|
|
1413
|
+
if (previousSchema !== body) {
|
|
1414
|
+
suppressWatchUntil = Date.now() + 1000
|
|
1415
|
+
suppressReloadUntil = Date.now() + 5000
|
|
1416
|
+
await fs.writeFile(pagesPath, body)
|
|
1417
|
+
wroteSchema = true
|
|
1418
|
+
await generateProjectFiles('pages.json write')
|
|
1419
|
+
suppressReloadUntil = Date.now() + 1000
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
res.statusCode = 204
|
|
1423
|
+
res.end()
|
|
1424
|
+
} catch (error) {
|
|
1425
|
+
if (wroteSchema) {
|
|
1426
|
+
suppressWatchUntil = Date.now() + 1000
|
|
1427
|
+
|
|
1428
|
+
if (previousSchema) {
|
|
1429
|
+
await fs.writeFile(pagesPath, previousSchema)
|
|
1430
|
+
await generateProjectFiles('pages.json rollback').catch(
|
|
1431
|
+
(rollbackError) => {
|
|
1432
|
+
server.config.logger.error(
|
|
1433
|
+
`[VisualBuild] Failed to restore generated files: ${
|
|
1434
|
+
rollbackError instanceof Error
|
|
1435
|
+
? rollbackError.message
|
|
1436
|
+
: String(rollbackError)
|
|
1437
|
+
}`
|
|
1438
|
+
)
|
|
1439
|
+
}
|
|
1440
|
+
)
|
|
1441
|
+
} else {
|
|
1442
|
+
await fs.rm(pagesPath, { force: true })
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
const diagnostic = getGeneratorDiagnostic(error)
|
|
1447
|
+
sendJson(res, 500, {
|
|
1448
|
+
error:
|
|
1449
|
+
diagnostic?.message ??
|
|
1450
|
+
(error instanceof Error
|
|
1451
|
+
? error.message
|
|
1452
|
+
: 'Failed to write pages.json'),
|
|
1453
|
+
diagnostic,
|
|
1454
|
+
})
|
|
1455
|
+
}
|
|
1456
|
+
})
|
|
1457
|
+
|
|
1458
|
+
return
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
res.statusCode = 405
|
|
1462
|
+
res.end()
|
|
1463
|
+
})
|
|
1464
|
+
},
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
export default defineConfig({
|
|
1469
|
+
root: builderRoot,
|
|
1470
|
+
define: {
|
|
1471
|
+
'process.env.BABEL_TYPES_8_BREAKING': 'false',
|
|
1472
|
+
},
|
|
1473
|
+
plugins: [react(), tailwindcss(), visualbuildDevApi()],
|
|
1474
|
+
server: {
|
|
1475
|
+
watch: {
|
|
1476
|
+
ignored: ['**/node_modules/**', isGeneratedProjectPath],
|
|
1477
|
+
},
|
|
1478
|
+
},
|
|
1479
|
+
})
|