@dfosco/storyboard-core 3.6.0 → 3.7.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.
Files changed (50) hide show
  1. package/dist/storyboard-ui.css +1 -1
  2. package/dist/storyboard-ui.js +12274 -11387
  3. package/dist/storyboard-ui.js.map +1 -1
  4. package/dist/tailwind.css +1 -1
  5. package/package.json +1 -1
  6. package/src/CanvasZoomControl.svelte +8 -8
  7. package/src/CommentsMenuButton.svelte +7 -21
  8. package/src/CoreUIBar.svelte +19 -3
  9. package/src/CreateMenuButton.svelte +8 -12
  10. package/src/InspectorPanel.svelte +12 -15
  11. package/src/SidePanel.svelte +14 -14
  12. package/src/assets/fonts/IoskeleyMono-Bold.woff2 +0 -0
  13. package/src/assets/fonts/IoskeleyMono-Italic.woff2 +0 -0
  14. package/src/assets/fonts/IoskeleyMono-Medium.woff2 +0 -0
  15. package/src/assets/fonts/IoskeleyMono-Regular.woff2 +0 -0
  16. package/src/assets/fonts/IoskeleyMono-SemiBold.woff2 +0 -0
  17. package/src/comments/ui/AuthModal.svelte +45 -12
  18. package/src/comments/ui/authModal.js +6 -1
  19. package/src/comments/ui/comment-layout.css +15 -15
  20. package/src/comments/ui/commentWindow.js +6 -1
  21. package/src/comments/ui/comments.css +57 -57
  22. package/src/comments/ui/commentsDrawer.js +2 -0
  23. package/src/comments/ui/composer.js +7 -2
  24. package/src/comments/ui/mount.js +252 -33
  25. package/src/comments/ui/mount.test.js +138 -0
  26. package/src/core-ui-colors.css +28 -28
  27. package/src/inspector/mouseMode.js +2 -2
  28. package/src/lib/components/ui/button/button.svelte +9 -9
  29. package/src/lib/components/ui/panel/panel-content.svelte +2 -2
  30. package/src/lib/components/ui/select/select-trigger.svelte +1 -1
  31. package/src/lib/components/ui/toggle/toggle.svelte +1 -1
  32. package/src/lib/components/ui/toggle-group/toggle-group.svelte +2 -2
  33. package/src/lib/components/ui/trigger-button/trigger-button.svelte +13 -13
  34. package/src/modes.css +21 -21
  35. package/src/mountStoryboardCore.js +4 -4
  36. package/src/sidepanel.css +11 -11
  37. package/src/styles/tailwind.css +89 -1
  38. package/src/svelte-plugin-ui/components/ModeSwitch.svelte +3 -3
  39. package/src/svelte-plugin-ui/components/Viewfinder.svelte +31 -11
  40. package/src/svelte-plugin-ui/styles/base.css +41 -41
  41. package/src/workshop/features/createFlow/CreateFlowForm.svelte +187 -25
  42. package/src/workshop/features/createFlow/server.js +437 -40
  43. package/src/workshop/features/createPage/CreatePageForm.svelte +249 -0
  44. package/src/workshop/features/createPage/index.js +11 -0
  45. package/src/workshop/features/createPrototype/CreatePrototypeForm.svelte +77 -24
  46. package/src/workshop/features/createPrototype/server.js +14 -16
  47. package/src/workshop/features/registry-server.js +1 -0
  48. package/src/workshop/features/registry.js +2 -0
  49. package/src/workshop/features/templateIndex.js +155 -0
  50. package/toolbar.config.json +2 -1
@@ -9,6 +9,10 @@
9
9
  import fs from 'node:fs'
10
10
  import path from 'node:path'
11
11
  import { parse as parseJsonc } from 'jsonc-parser'
12
+ import {
13
+ buildTemplateRecipeIndex,
14
+ resolveTemplateRecipeEntry,
15
+ } from '../templateIndex.js'
12
16
 
13
17
  // ---------------------------------------------------------------------------
14
18
  // Helpers
@@ -24,13 +28,6 @@ function toKebabCase(str) {
24
28
  .replace(/^-|-$/g, '')
25
29
  }
26
30
 
27
- function humanize(kebab) {
28
- return kebab
29
- .split('-')
30
- .map((s) => s.charAt(0).toUpperCase() + s.slice(1))
31
- .join(' ')
32
- }
33
-
34
31
  function validateFlowName(name) {
35
32
  if (!name || typeof name !== 'string') {
36
33
  return { valid: false, error: 'Flow name is required' }
@@ -50,10 +47,280 @@ function validateFlowName(name) {
50
47
  }
51
48
 
52
49
  const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'packages'])
50
+ const PAGE_EXT_RE = /\.(jsx|tsx|js|ts)$/
51
+
52
+ function toPascalCase(input) {
53
+ return input
54
+ .replace(/\[|\]/g, '')
55
+ .replace(/[^a-zA-Z0-9\s_-]/g, '')
56
+ .trim()
57
+ .replace(/[\s_-]+/g, ' ')
58
+ .split(' ')
59
+ .filter(Boolean)
60
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
61
+ .join('')
62
+ }
63
+
64
+ function humanize(kebab) {
65
+ if (!kebab) return 'Page'
66
+ return kebab
67
+ .replace(/\[|\]/g, '')
68
+ .split('-')
69
+ .map((s) => s.charAt(0).toUpperCase() + s.slice(1))
70
+ .join(' ')
71
+ }
72
+
73
+ function normalizePathSlashes(value) {
74
+ return value.replaceAll('\\', '/')
75
+ }
76
+
77
+ function isPathInside(parent, candidate) {
78
+ const relative = path.relative(parent, candidate)
79
+ return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative))
80
+ }
81
+
82
+ function parsePrototypeFromFlowPath(relPath) {
83
+ const normalized = normalizePathSlashes(relPath)
84
+ if (!normalized.startsWith('src/prototypes/')) return null
85
+
86
+ const parts = normalized.split('/').filter(Boolean)
87
+ if (parts.length < 4) return null
88
+
89
+ if (parts[2].endsWith('.folder') && parts.length >= 5) {
90
+ return {
91
+ folder: parts[2].replace(/\.folder$/, ''),
92
+ prototype: parts[3],
93
+ }
94
+ }
95
+
96
+ return {
97
+ folder: undefined,
98
+ prototype: parts[2],
99
+ }
100
+ }
101
+
102
+ function isExternalPrototype(prototypePath) {
103
+ try {
104
+ const raw = fs.readFileSync(prototypePath, 'utf-8')
105
+ const parsed = parseJsonc(raw)
106
+ return typeof parsed?.url === 'string' && parsed.url.trim().length > 0
107
+ } catch {
108
+ return false
109
+ }
110
+ }
111
+
112
+ function findPrototypeDir(root, protoName, folderName) {
113
+ const prototypesDir = path.join(root, 'src', 'prototypes')
114
+ if (!fs.existsSync(prototypesDir)) {
115
+ return { error: 'No prototypes directory found' }
116
+ }
117
+
118
+ const matches = []
119
+
120
+ function scanDir(dir, folder) {
121
+ if (!fs.existsSync(dir)) return
122
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
123
+ if (!entry.isDirectory()) continue
124
+ if (entry.name.endsWith('.folder')) {
125
+ scanDir(path.join(dir, entry.name), entry.name.replace(/\.folder$/, ''))
126
+ continue
127
+ }
128
+
129
+ if (entry.name !== protoName) continue
130
+ if (folderName && folder !== folderName) continue
131
+
132
+ const protoDir = path.join(dir, entry.name)
133
+ const prototypeFile = fs.readdirSync(protoDir).find((f) => f.endsWith('.prototype.json'))
134
+ if (!prototypeFile) continue
135
+ if (isExternalPrototype(path.join(protoDir, prototypeFile))) continue
136
+
137
+ matches.push({ dir: protoDir, folder })
138
+ }
139
+ }
140
+
141
+ scanDir(prototypesDir)
142
+
143
+ if (matches.length === 0) {
144
+ return { error: `Prototype "${protoName}" not found` }
145
+ }
146
+
147
+ if (!folderName && matches.length > 1) {
148
+ return { error: `Prototype "${protoName}" is ambiguous; include its folder` }
149
+ }
150
+
151
+ return { dir: matches[0].dir, folder: matches[0].folder }
152
+ }
153
+
154
+ function normalizeRouteForPrototype(protoName, route) {
155
+ if (typeof route !== 'string') return null
156
+ const trimmed = route.trim()
157
+ if (!trimmed) return null
158
+ if (trimmed.startsWith('/')) return trimmed
159
+ return `/${protoName}/${trimmed.replace(/^\/+/, '')}`
160
+ }
161
+
162
+ function isRouteInPrototype(protoName, route) {
163
+ return route === `/${protoName}` || route.startsWith(`/${protoName}/`)
164
+ }
165
+
166
+ function findComponentFile(dir) {
167
+ if (!fs.existsSync(dir)) return null
168
+ const files = fs.readdirSync(dir)
169
+ const component = files.find((f) => /\.(jsx|tsx)$/.test(f) && !f.startsWith('_') && !f.includes('.test.'))
170
+ return component ? component.replace(/\.(jsx|tsx)$/, '') : null
171
+ }
172
+
173
+ function generateBlankPageJsx(componentName, title) {
174
+ return `export default function ${componentName}() {
175
+ return (
176
+ <div>
177
+ <h1>${title}</h1>
178
+ <p>Start building your page here.</p>
179
+ </div>
180
+ )
181
+ }
182
+ `
183
+ }
184
+
185
+ function generateTemplatePageJsx({ partialEntry, componentFile, componentName, title }) {
186
+ const importPath = `@/${partialEntry.baseDir}/${partialEntry.name}/${componentFile}`
187
+ return `import ${componentFile} from '${importPath}'
188
+
189
+ export default function ${componentName}() {
190
+ return (
191
+ <${componentFile} title="${title}">
192
+ <h1>${title}</h1>
193
+ <p>Start building your page here.</p>
194
+ </${componentFile}>
195
+ )
196
+ }
197
+ `
198
+ }
199
+
200
+ function createPageInPrototype({
201
+ root,
202
+ targetDir,
203
+ protoName,
204
+ protoFolder,
205
+ createPage,
206
+ templateRecipes,
207
+ }) {
208
+ if (typeof createPage?.path !== 'string' || !createPage.path.trim()) {
209
+ return { ok: false, status: 400, error: 'New page path is required' }
210
+ }
211
+
212
+ const rawPath = createPage.path.trim().replace(/^\/+/, '')
213
+ const normalizedPath = createPage.path.trim().startsWith('/')
214
+ ? createPage.path.trim()
215
+ : `/${protoName}/${rawPath}`
216
+
217
+ if (!isRouteInPrototype(protoName, normalizedPath)) {
218
+ return { ok: false, status: 400, error: `New page path must be within "/${protoName}"` }
219
+ }
220
+
221
+ const relativePageRoute = normalizedPath.replace(new RegExp(`^/${protoName}/?`), '')
222
+ if (!relativePageRoute) {
223
+ return { ok: false, status: 400, error: 'New page path must include at least one segment after the prototype prefix' }
224
+ }
225
+
226
+ const parts = relativePageRoute.split('/').filter(Boolean)
227
+ if (parts.some((part) => part === '.' || part === '..')) {
228
+ return { ok: false, status: 400, error: 'New page path contains invalid segments' }
229
+ }
230
+
231
+ const safePartRe = /^[a-zA-Z0-9\-_.[\]]+$/
232
+ if (parts.some((part) => !safePartRe.test(part))) {
233
+ return {
234
+ ok: false,
235
+ status: 400,
236
+ error: 'New page path can only include letters, numbers, "-", "_", ".", and [] for dynamic segments',
237
+ }
238
+ }
239
+
240
+ const pageDirParts = parts.slice(0, -1)
241
+ const pageName = parts[parts.length - 1]
242
+ const pageFile = `${pageName}.jsx`
243
+ const pageDir = path.join(targetDir, ...pageDirParts)
244
+ const pageFilePath = path.join(pageDir, pageFile)
245
+
246
+ if (fs.existsSync(pageFilePath)) {
247
+ return { ok: false, status: 409, error: `Page "${normalizedPath}" already exists` }
248
+ }
249
+
250
+ let pageContent
251
+ const titleBase = humanize(pageName)
252
+ const componentName = toPascalCase(pageName) || 'Page'
253
+ if (createPage.template) {
254
+ const partialEntry = resolveTemplateRecipeEntry(templateRecipes, createPage.template, {
255
+ prototype: protoName,
256
+ folder: protoFolder,
257
+ })
258
+ if (!partialEntry) {
259
+ const validNames = templateRecipes.map((p) => p.id).join(', ')
260
+ return {
261
+ ok: false,
262
+ status: 400,
263
+ error: `Unknown template/recipe "${createPage.template}". Available: ${validNames}`,
264
+ }
265
+ }
266
+
267
+ const partialDir = path.join(root, 'src', partialEntry.baseDir, partialEntry.name)
268
+ const componentFile = findComponentFile(partialDir)
269
+ if (!componentFile) {
270
+ return {
271
+ ok: false,
272
+ status: 400,
273
+ error: `No .jsx or .tsx file found in src/${partialEntry.directory}/${partialEntry.name}/`,
274
+ }
275
+ }
276
+
277
+ pageContent = generateTemplatePageJsx({
278
+ partialEntry,
279
+ componentFile,
280
+ componentName,
281
+ title: titleBase,
282
+ })
283
+ } else {
284
+ pageContent = generateBlankPageJsx(componentName, titleBase)
285
+ }
286
+
287
+ fs.mkdirSync(pageDir, { recursive: true })
288
+ fs.writeFileSync(pageFilePath, pageContent, 'utf-8')
289
+
290
+ return {
291
+ ok: true,
292
+ createdPagePath: path.relative(root, pageFilePath),
293
+ createdPageRoute: normalizedPath,
294
+ }
295
+ }
296
+
297
+ function listPrototypeRoutes(protoDir, protoName) {
298
+ const routes = []
299
+
300
+ function scanDir(dir, routeParts = []) {
301
+ if (!fs.existsSync(dir)) return
302
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
303
+ if (entry.isDirectory()) {
304
+ scanDir(path.join(dir, entry.name), [...routeParts, entry.name])
305
+ continue
306
+ }
307
+ if (!PAGE_EXT_RE.test(entry.name)) continue
308
+ if (entry.name.startsWith('_')) continue
309
+
310
+ const baseName = entry.name.replace(PAGE_EXT_RE, '')
311
+ const fileRouteParts = baseName === 'index' ? routeParts : [...routeParts, baseName]
312
+ const suffix = fileRouteParts.length > 0 ? `/${fileRouteParts.join('/')}` : ''
313
+ routes.push(`/${protoName}${suffix}`)
314
+ }
315
+ }
316
+
317
+ scanDir(protoDir)
318
+ return Array.from(new Set(routes)).sort((a, b) => a.localeCompare(b))
319
+ }
53
320
 
54
321
  /**
55
322
  * List prototypes by scanning src/prototypes/, following .folder directories.
56
- * Returns array of { name, folder? }.
323
+ * Returns array of { name, folder?, routes }.
57
324
  */
58
325
  function listPrototypes(root) {
59
326
  const prototypesDir = path.join(root, 'src', 'prototypes')
@@ -69,9 +336,16 @@ function listPrototypes(root) {
69
336
  scanDir(path.join(dir, entry.name), entry.name.replace('.folder', ''))
70
337
  } else {
71
338
  const protoDir = path.join(dir, entry.name)
72
- const hasProtoJson = fs.readdirSync(protoDir).some((f) => f.endsWith('.prototype.json'))
73
- if (hasProtoJson) {
74
- results.push({ name: entry.name, ...(folder ? { folder } : {}) })
339
+ const prototypeFile = fs.readdirSync(protoDir).find((f) => f.endsWith('.prototype.json'))
340
+ if (!prototypeFile) continue
341
+
342
+ const prototypePath = path.join(protoDir, prototypeFile)
343
+ if (!isExternalPrototype(prototypePath)) {
344
+ results.push({
345
+ name: entry.name,
346
+ ...(folder ? { folder } : {}),
347
+ routes: listPrototypeRoutes(protoDir, entry.name),
348
+ })
75
349
  }
76
350
  }
77
351
  }
@@ -83,7 +357,7 @@ function listPrototypes(root) {
83
357
 
84
358
  /**
85
359
  * List existing flow files from the src/ tree.
86
- * Returns array of { name, title, path }.
360
+ * Returns array of { name, title, path, prototype?, folder?, route? }.
87
361
  */
88
362
  function listFlows(root) {
89
363
  const results = []
@@ -98,15 +372,25 @@ function listFlows(root) {
98
372
  const flowName = entry.name.replace(/\.flow\.jsonc?$/, '')
99
373
  const filePath = path.join(dir, entry.name)
100
374
  const relPath = path.relative(root, filePath)
375
+ const flowPrototype = parsePrototypeFromFlowPath(relPath)
101
376
 
102
377
  let title = flowName
378
+ let route
103
379
  try {
104
380
  const raw = fs.readFileSync(filePath, 'utf-8')
105
381
  const parsed = parseJsonc(raw)
106
382
  if (parsed?.meta?.title) title = parsed.meta.title
383
+ if (typeof parsed?.meta?.route === 'string') route = parsed.meta.route
107
384
  } catch { /* ignore */ }
108
385
 
109
- results.push({ name: flowName, title, path: relPath })
386
+ results.push({
387
+ name: flowName,
388
+ title,
389
+ path: relPath,
390
+ ...(flowPrototype ? { prototype: flowPrototype.prototype } : {}),
391
+ ...(flowPrototype?.folder ? { folder: flowPrototype.folder } : {}),
392
+ ...(route ? { route } : {}),
393
+ })
110
394
  }
111
395
  }
112
396
  }
@@ -137,7 +421,7 @@ function listObjects(root) {
137
421
  return results
138
422
  }
139
423
 
140
- function generateFlowJson({ title, author, description, globals, sourceData }) {
424
+ function generateFlowJson({ title, author, description, globals, sourceData, route }) {
141
425
  let data = sourceData ? { ...sourceData } : {}
142
426
 
143
427
  data.meta = {
@@ -154,6 +438,10 @@ function generateFlowJson({ title, author, description, globals, sourceData }) {
154
438
  data.meta.description = description
155
439
  }
156
440
 
441
+ if (route) {
442
+ data.meta.route = route
443
+ }
444
+
157
445
  if (globals && globals.length > 0) {
158
446
  data.$global = globals
159
447
  } else if (!sourceData) {
@@ -172,14 +460,65 @@ function generateFlowJson({ title, author, description, globals, sourceData }) {
172
460
  * @param {object} ctx - Server context ({ root, sendJson, workshopConfig })
173
461
  */
174
462
  export function createFlowsHandler(ctx) {
175
- const { root, sendJson } = ctx
463
+ const { root, sendJson, workshopConfig = {} } = ctx
464
+ const getTemplateRecipes = () => buildTemplateRecipeIndex(root, workshopConfig.partials)
176
465
 
177
466
  return async (req, res, { body, path: routePath, method }) => {
467
+ const templateRecipes = getTemplateRecipes()
468
+
178
469
  if (routePath === '/flows' && method === 'GET') {
179
470
  const prototypes = listPrototypes(root)
180
471
  const flows = listFlows(root)
181
472
  const objects = listObjects(root)
182
- sendJson(res, 200, { prototypes, flows, objects })
473
+ sendJson(res, 200, { prototypes, flows, objects, partials: templateRecipes })
474
+ return
475
+ }
476
+
477
+ if (routePath === '/pages' && method === 'GET') {
478
+ const prototypes = listPrototypes(root)
479
+ sendJson(res, 200, { prototypes, partials: templateRecipes })
480
+ return
481
+ }
482
+
483
+ if (routePath === '/pages' && method === 'POST') {
484
+ const {
485
+ prototype: protoName,
486
+ folder: folderName,
487
+ path: pagePath,
488
+ template,
489
+ } = body
490
+
491
+ if (!protoName || typeof protoName !== 'string' || !protoName.trim()) {
492
+ sendJson(res, 400, { error: 'Prototype is required when creating a page' })
493
+ return
494
+ }
495
+
496
+ const resolvedPrototype = findPrototypeDir(root, protoName.trim(), folderName)
497
+ if (!resolvedPrototype.dir) {
498
+ sendJson(res, 400, { error: resolvedPrototype.error || `Prototype "${protoName}" not found` })
499
+ return
500
+ }
501
+
502
+ const pageResult = createPageInPrototype({
503
+ root,
504
+ targetDir: resolvedPrototype.dir,
505
+ protoName: protoName.trim(),
506
+ protoFolder: resolvedPrototype.folder,
507
+ createPage: { path: pagePath, template },
508
+ templateRecipes,
509
+ })
510
+
511
+ if (!pageResult.ok) {
512
+ sendJson(res, pageResult.status, { error: pageResult.error })
513
+ return
514
+ }
515
+
516
+ sendJson(res, 201, {
517
+ success: true,
518
+ path: pageResult.createdPagePath,
519
+ route: pageResult.createdPageRoute,
520
+ files: [pageResult.createdPagePath],
521
+ })
183
522
  return
184
523
  }
185
524
 
@@ -189,10 +528,13 @@ export function createFlowsHandler(ctx) {
189
528
  title: customTitle,
190
529
  prototype: protoName,
191
530
  folder: folderName,
531
+ existingFlow,
192
532
  author,
193
533
  description,
194
534
  globals = [],
195
535
  copyFrom,
536
+ startingPage,
537
+ createPage,
196
538
  } = body
197
539
 
198
540
  // Validate name
@@ -205,22 +547,18 @@ export function createFlowsHandler(ctx) {
205
547
  const { kebab } = validation
206
548
  const title = customTitle || humanize(kebab)
207
549
 
550
+ if (!protoName || typeof protoName !== 'string' || !protoName.trim()) {
551
+ sendJson(res, 400, { error: 'Prototype is required when creating a flow' })
552
+ return
553
+ }
554
+
208
555
  // Determine target directory
209
- let targetDir
210
- if (protoName) {
211
- const prototypesDir = path.join(root, 'src', 'prototypes')
212
- if (folderName) {
213
- targetDir = path.join(prototypesDir, `${folderName}.folder`, protoName)
214
- } else {
215
- targetDir = path.join(prototypesDir, protoName)
216
- }
217
- if (!fs.existsSync(targetDir)) {
218
- sendJson(res, 400, { error: `Prototype "${protoName}" not found` })
219
- return
220
- }
221
- } else {
222
- targetDir = path.join(root, 'src', 'data')
556
+ const resolvedPrototype = findPrototypeDir(root, protoName.trim(), folderName)
557
+ if (!resolvedPrototype.dir) {
558
+ sendJson(res, 400, { error: resolvedPrototype.error || `Prototype "${protoName}" not found` })
559
+ return
223
560
  }
561
+ const targetDir = resolvedPrototype.dir
224
562
 
225
563
  // Check for existing flow file
226
564
  const flowFileName = `${kebab}.flow.json`
@@ -232,31 +570,90 @@ export function createFlowsHandler(ctx) {
232
570
 
233
571
  // Load source flow data if copying
234
572
  let sourceData = null
235
- if (copyFrom) {
236
- const sourceFlowPath = path.join(root, copyFrom)
237
- if (fs.existsSync(sourceFlowPath)) {
238
- try {
239
- const raw = fs.readFileSync(sourceFlowPath, 'utf-8')
240
- sourceData = parseJsonc(raw)
241
- } catch {
242
- sendJson(res, 400, { error: `Failed to read source flow: ${copyFrom}` })
573
+ const selectedExistingFlow = typeof existingFlow === 'string' && existingFlow.trim()
574
+ ? existingFlow.trim()
575
+ : (typeof copyFrom === 'string' && copyFrom.trim() ? copyFrom.trim() : '')
576
+ if (selectedExistingFlow) {
577
+ let sourceFlowPath
578
+ if (selectedExistingFlow.includes('/') || selectedExistingFlow.includes('\\')) {
579
+ if (path.isAbsolute(selectedExistingFlow)) {
580
+ sendJson(res, 400, { error: 'Existing flow path must be relative to repository root' })
243
581
  return
244
582
  }
583
+ sourceFlowPath = path.resolve(root, selectedExistingFlow)
584
+ } else {
585
+ sourceFlowPath = path.join(targetDir, `${selectedExistingFlow.replace(/\.flow\.jsonc?$/, '')}.flow.json`)
586
+ if (!fs.existsSync(sourceFlowPath)) {
587
+ const jsoncPath = sourceFlowPath.replace(/\.flow\.json$/, '.flow.jsonc')
588
+ if (fs.existsSync(jsoncPath)) sourceFlowPath = jsoncPath
589
+ }
245
590
  }
591
+
592
+ if (!sourceFlowPath || !fs.existsSync(sourceFlowPath)) {
593
+ sendJson(res, 400, { error: `Existing flow not found: ${selectedExistingFlow}` })
594
+ return
595
+ }
596
+
597
+ if (!/\.flow\.jsonc?$/.test(sourceFlowPath)) {
598
+ sendJson(res, 400, { error: 'Existing flow must be a .flow.json or .flow.jsonc file' })
599
+ return
600
+ }
601
+
602
+ if (!isPathInside(targetDir, sourceFlowPath)) {
603
+ sendJson(res, 400, { error: 'Existing flow must belong to the selected prototype' })
604
+ return
605
+ }
606
+
607
+ try {
608
+ const raw = fs.readFileSync(sourceFlowPath, 'utf-8')
609
+ sourceData = parseJsonc(raw)
610
+ } catch {
611
+ sendJson(res, 400, { error: `Failed to read source flow: ${selectedExistingFlow}` })
612
+ return
613
+ }
614
+ }
615
+
616
+ let createdPagePath = null
617
+ let createdPageRoute = null
618
+ if (createPage) {
619
+ const pageResult = createPageInPrototype({
620
+ root,
621
+ targetDir,
622
+ protoName,
623
+ protoFolder: resolvedPrototype.folder,
624
+ createPage,
625
+ templateRecipes,
626
+ })
627
+ if (!pageResult.ok) {
628
+ sendJson(res, pageResult.status, { error: pageResult.error })
629
+ return
630
+ }
631
+ createdPagePath = pageResult.createdPagePath
632
+ createdPageRoute = pageResult.createdPageRoute
633
+ }
634
+
635
+ const normalizedStartingPage = normalizeRouteForPrototype(protoName, startingPage)
636
+ if (normalizedStartingPage && !isRouteInPrototype(protoName, normalizedStartingPage)) {
637
+ sendJson(res, 400, { error: `Starting page must be within "/${protoName}"` })
638
+ return
246
639
  }
247
640
 
248
641
  // Ensure target directory exists
249
642
  fs.mkdirSync(targetDir, { recursive: true })
250
643
 
251
644
  // Generate and write flow file
252
- const content = generateFlowJson({ title, author, description, globals, sourceData })
645
+ const route = normalizedStartingPage || createdPageRoute
646
+ const content = generateFlowJson({ title, author, description, globals, sourceData, route })
253
647
  fs.writeFileSync(flowFilePath, content, 'utf-8')
254
648
 
255
649
  const relPath = path.relative(root, flowFilePath)
650
+ const files = [relPath]
651
+ if (createdPagePath) files.push(createdPagePath)
256
652
  sendJson(res, 201, {
257
653
  success: true,
258
654
  path: relPath,
259
- files: [relPath],
655
+ files,
656
+ ...(createdPagePath ? { createdPagePath } : {}),
260
657
  })
261
658
  return
262
659
  }