@dfosco/storyboard-react 2.4.0 → 2.5.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/package.json +2 -2
- package/src/vite/data-plugin.js +58 -3
- package/src/vite/data-plugin.test.js +117 -0
package/package.json
CHANGED
package/src/vite/data-plugin.js
CHANGED
|
@@ -66,7 +66,22 @@ function parseDataFile(filePath) {
|
|
|
66
66
|
}
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
|
|
69
|
+
// Infer route for prototype-scoped flows from their file path.
|
|
70
|
+
// Mirrors the generouted route regex: strip src/prototypes/ and *.folder/ segments.
|
|
71
|
+
let inferredRoute = null
|
|
72
|
+
if (suffix === 'flow') {
|
|
73
|
+
const protoCheck = normalized.match(/(?:^|\/)src\/prototypes\//)
|
|
74
|
+
if (protoCheck) {
|
|
75
|
+
const dirPath = normalized.substring(0, normalized.lastIndexOf('/'))
|
|
76
|
+
inferredRoute = '/' + dirPath
|
|
77
|
+
.replace(/^.*?src\/prototypes\//, '')
|
|
78
|
+
.replace(/[^/]*\.folder\//g, '')
|
|
79
|
+
// Normalize trailing slash and double slashes
|
|
80
|
+
inferredRoute = inferredRoute.replace(/\/+/g, '/').replace(/\/$/, '') || '/'
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return { name, suffix, ext: match[3], inferredRoute }
|
|
70
85
|
}
|
|
71
86
|
|
|
72
87
|
/**
|
|
@@ -127,6 +142,7 @@ function buildIndex(root) {
|
|
|
127
142
|
const index = { flow: {}, object: {}, record: {}, prototype: {}, folder: {} }
|
|
128
143
|
const seen = {} // "name.suffix" → absolute path (for duplicate detection)
|
|
129
144
|
const protoFolders = {} // prototype name → folder name (for injection)
|
|
145
|
+
const flowRoutes = {} // flow name → inferred route (for _route injection)
|
|
130
146
|
|
|
131
147
|
for (const relPath of files) {
|
|
132
148
|
const parsed = parseDataFile(relPath)
|
|
@@ -159,9 +175,14 @@ function buildIndex(root) {
|
|
|
159
175
|
if (parsed.suffix === 'prototype' && parsed.folder) {
|
|
160
176
|
protoFolders[parsed.name] = parsed.folder
|
|
161
177
|
}
|
|
178
|
+
|
|
179
|
+
// Track inferred routes for flows
|
|
180
|
+
if (parsed.suffix === 'flow' && parsed.inferredRoute) {
|
|
181
|
+
flowRoutes[parsed.name] = parsed.inferredRoute
|
|
182
|
+
}
|
|
162
183
|
}
|
|
163
184
|
|
|
164
|
-
return { index, protoFolders }
|
|
185
|
+
return { index, protoFolders, flowRoutes }
|
|
165
186
|
}
|
|
166
187
|
|
|
167
188
|
/**
|
|
@@ -224,10 +245,11 @@ function readModesConfig(root) {
|
|
|
224
245
|
return fallback
|
|
225
246
|
}
|
|
226
247
|
|
|
227
|
-
function generateModule({ index, protoFolders }, root) {
|
|
248
|
+
function generateModule({ index, protoFolders, flowRoutes }, root) {
|
|
228
249
|
const declarations = []
|
|
229
250
|
const INDEX_KEYS = ['flow', 'object', 'record', 'prototype', 'folder']
|
|
230
251
|
const entries = { flow: [], object: [], record: [], prototype: [], folder: [] }
|
|
252
|
+
const resolvedFlowRoutes = {} // flow name → resolved route (for multi-flow logging)
|
|
231
253
|
let i = 0
|
|
232
254
|
|
|
233
255
|
for (const suffix of INDEX_KEYS) {
|
|
@@ -258,6 +280,19 @@ function generateModule({ index, protoFolders }, root) {
|
|
|
258
280
|
parsed = { ...parsed, folder: protoFolders[name] }
|
|
259
281
|
}
|
|
260
282
|
|
|
283
|
+
// Inject inferred _route into flow data (explicit route takes precedence)
|
|
284
|
+
if (suffix === 'flow' && flowRoutes[name] && !parsed?.route) {
|
|
285
|
+
parsed = { ...parsed, _route: flowRoutes[name] }
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Track resolved route for multi-flow logging
|
|
289
|
+
if (suffix === 'flow') {
|
|
290
|
+
const route = parsed?.route || parsed?._route || null
|
|
291
|
+
if (route) {
|
|
292
|
+
resolvedFlowRoutes[name] = { route, isDefault: parsed?.meta?.default === true }
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
261
296
|
declarations.push(`const ${varName} = ${JSON.stringify(parsed)}`)
|
|
262
297
|
entries[suffix].push(` ${JSON.stringify(name)}: ${varName}`)
|
|
263
298
|
}
|
|
@@ -302,6 +337,26 @@ function generateModule({ index, protoFolders }, root) {
|
|
|
302
337
|
}
|
|
303
338
|
}
|
|
304
339
|
|
|
340
|
+
// Log info when multiple flows target the same route
|
|
341
|
+
const routeGroups = {}
|
|
342
|
+
for (const [name, { route, isDefault }] of Object.entries(resolvedFlowRoutes)) {
|
|
343
|
+
if (!routeGroups[route]) routeGroups[route] = []
|
|
344
|
+
routeGroups[route].push({ name, isDefault })
|
|
345
|
+
}
|
|
346
|
+
for (const [route, flows] of Object.entries(routeGroups)) {
|
|
347
|
+
if (flows.length > 1) {
|
|
348
|
+
const labels = flows.map(f => ` - ${f.name}${f.isDefault ? ' (default)' : ''}`).join('\n')
|
|
349
|
+
console.log(`[storyboard-data] Route "${route}" has ${flows.length} flows:\n${labels}`)
|
|
350
|
+
const defaults = flows.filter(f => f.isDefault)
|
|
351
|
+
if (defaults.length > 1) {
|
|
352
|
+
console.warn(
|
|
353
|
+
`[storyboard-data] Warning: Route "${route}" has ${defaults.length} flows with meta.default: true.\n` +
|
|
354
|
+
` Only one flow per route should be marked as default.`
|
|
355
|
+
)
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
305
360
|
return [
|
|
306
361
|
imports.join('\n'),
|
|
307
362
|
'',
|
|
@@ -265,6 +265,123 @@ describe('prototype scoping', () => {
|
|
|
265
265
|
})
|
|
266
266
|
})
|
|
267
267
|
|
|
268
|
+
describe('flow route inference', () => {
|
|
269
|
+
it('injects _route for flows inside src/prototypes/', () => {
|
|
270
|
+
mkdirSync(path.join(tmpDir, 'src', 'prototypes', 'Dashboard'), { recursive: true })
|
|
271
|
+
writeFileSync(
|
|
272
|
+
path.join(tmpDir, 'src', 'prototypes', 'Dashboard', 'default.flow.json'),
|
|
273
|
+
JSON.stringify({ title: 'Dashboard Flow' }),
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
const plugin = createPlugin()
|
|
277
|
+
const code = plugin.load(RESOLVED_ID)
|
|
278
|
+
|
|
279
|
+
expect(code).toContain('"_route":"/Dashboard"')
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
it('injects _route for flows inside .folder/ directories', () => {
|
|
283
|
+
mkdirSync(path.join(tmpDir, 'src', 'prototypes', 'MyFolder.folder', 'Example'), { recursive: true })
|
|
284
|
+
writeFileSync(
|
|
285
|
+
path.join(tmpDir, 'src', 'prototypes', 'MyFolder.folder', 'Example', 'basic.flow.json'),
|
|
286
|
+
JSON.stringify({ title: 'Example Flow' }),
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
const plugin = createPlugin()
|
|
290
|
+
const code = plugin.load(RESOLVED_ID)
|
|
291
|
+
|
|
292
|
+
// .folder/ should be stripped from the inferred route
|
|
293
|
+
expect(code).toContain('"_route":"/Example"')
|
|
294
|
+
expect(code).not.toContain('MyFolder')
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
it('injects _route with nested path for deeply placed flows', () => {
|
|
298
|
+
mkdirSync(path.join(tmpDir, 'src', 'prototypes', 'App', 'settings'), { recursive: true })
|
|
299
|
+
writeFileSync(
|
|
300
|
+
path.join(tmpDir, 'src', 'prototypes', 'App', 'settings', 'prefs.flow.json'),
|
|
301
|
+
JSON.stringify({ title: 'Settings Prefs' }),
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
const plugin = createPlugin()
|
|
305
|
+
const code = plugin.load(RESOLVED_ID)
|
|
306
|
+
|
|
307
|
+
expect(code).toContain('"_route":"/App/settings"')
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
it('does NOT inject _route for global flows outside src/prototypes/', () => {
|
|
311
|
+
mkdirSync(path.join(tmpDir, 'src', 'data'), { recursive: true })
|
|
312
|
+
writeFileSync(
|
|
313
|
+
path.join(tmpDir, 'src', 'data', 'global.flow.json'),
|
|
314
|
+
JSON.stringify({ title: 'Global Flow' }),
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
const plugin = createPlugin()
|
|
318
|
+
const code = plugin.load(RESOLVED_ID)
|
|
319
|
+
|
|
320
|
+
expect(code).not.toContain('"_route"')
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
it('does NOT inject _route when flow has explicit route field', () => {
|
|
324
|
+
mkdirSync(path.join(tmpDir, 'src', 'prototypes', 'Dashboard'), { recursive: true })
|
|
325
|
+
writeFileSync(
|
|
326
|
+
path.join(tmpDir, 'src', 'prototypes', 'Dashboard', 'custom.flow.json'),
|
|
327
|
+
JSON.stringify({ route: '/custom-page', title: 'Custom Route' }),
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
const plugin = createPlugin()
|
|
331
|
+
const code = plugin.load(RESOLVED_ID)
|
|
332
|
+
|
|
333
|
+
// Should have the explicit route but NOT _route
|
|
334
|
+
expect(code).toContain('"route":"/custom-page"')
|
|
335
|
+
expect(code).not.toContain('"_route"')
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
it('logs info when multiple flows share the same route', () => {
|
|
339
|
+
mkdirSync(path.join(tmpDir, 'src', 'prototypes', 'Dashboard'), { recursive: true })
|
|
340
|
+
writeFileSync(
|
|
341
|
+
path.join(tmpDir, 'src', 'prototypes', 'Dashboard', 'happy.flow.json'),
|
|
342
|
+
JSON.stringify({ title: 'Happy Path' }),
|
|
343
|
+
)
|
|
344
|
+
writeFileSync(
|
|
345
|
+
path.join(tmpDir, 'src', 'prototypes', 'Dashboard', 'error.flow.json'),
|
|
346
|
+
JSON.stringify({ title: 'Error State' }),
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
350
|
+
const plugin = createPlugin()
|
|
351
|
+
plugin.load(RESOLVED_ID)
|
|
352
|
+
|
|
353
|
+
const routeLog = logSpy.mock.calls.find(call =>
|
|
354
|
+
typeof call[0] === 'string' && call[0].includes('Route "/Dashboard" has 2 flows')
|
|
355
|
+
)
|
|
356
|
+
expect(routeLog).toBeTruthy()
|
|
357
|
+
logSpy.mockRestore()
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
it('warns when multiple flows on same route have meta.default: true', () => {
|
|
361
|
+
mkdirSync(path.join(tmpDir, 'src', 'prototypes', 'Dashboard'), { recursive: true })
|
|
362
|
+
writeFileSync(
|
|
363
|
+
path.join(tmpDir, 'src', 'prototypes', 'Dashboard', 'a.flow.json'),
|
|
364
|
+
JSON.stringify({ meta: { default: true }, title: 'A' }),
|
|
365
|
+
)
|
|
366
|
+
writeFileSync(
|
|
367
|
+
path.join(tmpDir, 'src', 'prototypes', 'Dashboard', 'b.flow.json'),
|
|
368
|
+
JSON.stringify({ meta: { default: true }, title: 'B' }),
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
372
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
373
|
+
const plugin = createPlugin()
|
|
374
|
+
plugin.load(RESOLVED_ID)
|
|
375
|
+
|
|
376
|
+
const warnCall = warnSpy.mock.calls.find(call =>
|
|
377
|
+
typeof call[0] === 'string' && call[0].includes('meta.default: true')
|
|
378
|
+
)
|
|
379
|
+
expect(warnCall).toBeTruthy()
|
|
380
|
+
logSpy.mockRestore()
|
|
381
|
+
warnSpy.mockRestore()
|
|
382
|
+
})
|
|
383
|
+
})
|
|
384
|
+
|
|
268
385
|
describe('folder grouping', () => {
|
|
269
386
|
it('discovers .folder.json files and keys them by folder directory name', () => {
|
|
270
387
|
mkdirSync(path.join(tmpDir, 'src', 'prototypes', 'Getting Started.folder'), { recursive: true })
|