@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.
- package/dist/storyboard-ui.css +1 -1
- package/dist/storyboard-ui.js +12274 -11387
- package/dist/storyboard-ui.js.map +1 -1
- package/dist/tailwind.css +1 -1
- package/package.json +1 -1
- package/src/CanvasZoomControl.svelte +8 -8
- package/src/CommentsMenuButton.svelte +7 -21
- package/src/CoreUIBar.svelte +19 -3
- package/src/CreateMenuButton.svelte +8 -12
- package/src/InspectorPanel.svelte +12 -15
- package/src/SidePanel.svelte +14 -14
- package/src/assets/fonts/IoskeleyMono-Bold.woff2 +0 -0
- package/src/assets/fonts/IoskeleyMono-Italic.woff2 +0 -0
- package/src/assets/fonts/IoskeleyMono-Medium.woff2 +0 -0
- package/src/assets/fonts/IoskeleyMono-Regular.woff2 +0 -0
- package/src/assets/fonts/IoskeleyMono-SemiBold.woff2 +0 -0
- package/src/comments/ui/AuthModal.svelte +45 -12
- package/src/comments/ui/authModal.js +6 -1
- package/src/comments/ui/comment-layout.css +15 -15
- package/src/comments/ui/commentWindow.js +6 -1
- package/src/comments/ui/comments.css +57 -57
- package/src/comments/ui/commentsDrawer.js +2 -0
- package/src/comments/ui/composer.js +7 -2
- package/src/comments/ui/mount.js +252 -33
- package/src/comments/ui/mount.test.js +138 -0
- package/src/core-ui-colors.css +28 -28
- package/src/inspector/mouseMode.js +2 -2
- package/src/lib/components/ui/button/button.svelte +9 -9
- package/src/lib/components/ui/panel/panel-content.svelte +2 -2
- package/src/lib/components/ui/select/select-trigger.svelte +1 -1
- package/src/lib/components/ui/toggle/toggle.svelte +1 -1
- package/src/lib/components/ui/toggle-group/toggle-group.svelte +2 -2
- package/src/lib/components/ui/trigger-button/trigger-button.svelte +13 -13
- package/src/modes.css +21 -21
- package/src/mountStoryboardCore.js +4 -4
- package/src/sidepanel.css +11 -11
- package/src/styles/tailwind.css +89 -1
- package/src/svelte-plugin-ui/components/ModeSwitch.svelte +3 -3
- package/src/svelte-plugin-ui/components/Viewfinder.svelte +31 -11
- package/src/svelte-plugin-ui/styles/base.css +41 -41
- package/src/workshop/features/createFlow/CreateFlowForm.svelte +187 -25
- package/src/workshop/features/createFlow/server.js +437 -40
- package/src/workshop/features/createPage/CreatePageForm.svelte +249 -0
- package/src/workshop/features/createPage/index.js +11 -0
- package/src/workshop/features/createPrototype/CreatePrototypeForm.svelte +77 -24
- package/src/workshop/features/createPrototype/server.js +14 -16
- package/src/workshop/features/registry-server.js +1 -0
- package/src/workshop/features/registry.js +2 -0
- package/src/workshop/features/templateIndex.js +155 -0
- 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
|
|
73
|
-
if (
|
|
74
|
-
|
|
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({
|
|
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
|
-
|
|
210
|
-
if (
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
sendJson(res, 400, { error:
|
|
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
|
|
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
|
|
655
|
+
files,
|
|
656
|
+
...(createdPagePath ? { createdPagePath } : {}),
|
|
260
657
|
})
|
|
261
658
|
return
|
|
262
659
|
}
|