@dfosco/storyboard-react 2.3.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 +65 -5
- package/src/vite/data-plugin.test.js +185 -0
package/package.json
CHANGED
package/src/vite/data-plugin.js
CHANGED
|
@@ -24,12 +24,17 @@ function parseDataFile(filePath) {
|
|
|
24
24
|
const base = path.basename(filePath)
|
|
25
25
|
const match = base.match(/^(.+)\.(flow|scene|object|record|prototype|folder)\.(jsonc?)$/)
|
|
26
26
|
if (!match) return null
|
|
27
|
+
|
|
28
|
+
// Skip _-prefixed files (drafts/internal)
|
|
29
|
+
if (match[1].startsWith('_')) return null
|
|
30
|
+
|
|
31
|
+
// Skip files inside _-prefixed directories
|
|
32
|
+
const normalized = filePath.replace(/\\/g, '/')
|
|
33
|
+
if (normalized.split('/').some(seg => seg.startsWith('_'))) return null
|
|
27
34
|
// Normalize .scene → .flow for backward compatibility
|
|
28
35
|
const suffix = match[2] === 'scene' ? 'flow' : match[2]
|
|
29
36
|
let name = match[1]
|
|
30
37
|
|
|
31
|
-
const normalized = filePath.replace(/\\/g, '/')
|
|
32
|
-
|
|
33
38
|
// Detect if this file is inside a .folder/ directory
|
|
34
39
|
const folderDirMatch = normalized.match(/(?:^|\/)src\/prototypes\/([^/]+)\.folder\//)
|
|
35
40
|
const folderName = folderDirMatch ? folderDirMatch[1] : null
|
|
@@ -61,7 +66,22 @@ function parseDataFile(filePath) {
|
|
|
61
66
|
}
|
|
62
67
|
}
|
|
63
68
|
|
|
64
|
-
|
|
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 }
|
|
65
85
|
}
|
|
66
86
|
|
|
67
87
|
/**
|
|
@@ -122,6 +142,7 @@ function buildIndex(root) {
|
|
|
122
142
|
const index = { flow: {}, object: {}, record: {}, prototype: {}, folder: {} }
|
|
123
143
|
const seen = {} // "name.suffix" → absolute path (for duplicate detection)
|
|
124
144
|
const protoFolders = {} // prototype name → folder name (for injection)
|
|
145
|
+
const flowRoutes = {} // flow name → inferred route (for _route injection)
|
|
125
146
|
|
|
126
147
|
for (const relPath of files) {
|
|
127
148
|
const parsed = parseDataFile(relPath)
|
|
@@ -154,9 +175,14 @@ function buildIndex(root) {
|
|
|
154
175
|
if (parsed.suffix === 'prototype' && parsed.folder) {
|
|
155
176
|
protoFolders[parsed.name] = parsed.folder
|
|
156
177
|
}
|
|
178
|
+
|
|
179
|
+
// Track inferred routes for flows
|
|
180
|
+
if (parsed.suffix === 'flow' && parsed.inferredRoute) {
|
|
181
|
+
flowRoutes[parsed.name] = parsed.inferredRoute
|
|
182
|
+
}
|
|
157
183
|
}
|
|
158
184
|
|
|
159
|
-
return { index, protoFolders }
|
|
185
|
+
return { index, protoFolders, flowRoutes }
|
|
160
186
|
}
|
|
161
187
|
|
|
162
188
|
/**
|
|
@@ -219,10 +245,11 @@ function readModesConfig(root) {
|
|
|
219
245
|
return fallback
|
|
220
246
|
}
|
|
221
247
|
|
|
222
|
-
function generateModule({ index, protoFolders }, root) {
|
|
248
|
+
function generateModule({ index, protoFolders, flowRoutes }, root) {
|
|
223
249
|
const declarations = []
|
|
224
250
|
const INDEX_KEYS = ['flow', 'object', 'record', 'prototype', 'folder']
|
|
225
251
|
const entries = { flow: [], object: [], record: [], prototype: [], folder: [] }
|
|
252
|
+
const resolvedFlowRoutes = {} // flow name → resolved route (for multi-flow logging)
|
|
226
253
|
let i = 0
|
|
227
254
|
|
|
228
255
|
for (const suffix of INDEX_KEYS) {
|
|
@@ -253,6 +280,19 @@ function generateModule({ index, protoFolders }, root) {
|
|
|
253
280
|
parsed = { ...parsed, folder: protoFolders[name] }
|
|
254
281
|
}
|
|
255
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
|
+
|
|
256
296
|
declarations.push(`const ${varName} = ${JSON.stringify(parsed)}`)
|
|
257
297
|
entries[suffix].push(` ${JSON.stringify(name)}: ${varName}`)
|
|
258
298
|
}
|
|
@@ -297,6 +337,26 @@ function generateModule({ index, protoFolders }, root) {
|
|
|
297
337
|
}
|
|
298
338
|
}
|
|
299
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
|
+
|
|
300
360
|
return [
|
|
301
361
|
imports.join('\n'),
|
|
302
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 })
|
|
@@ -367,3 +484,71 @@ describe('folder grouping', () => {
|
|
|
367
484
|
expect(() => plugin.load(RESOLVED_ID)).toThrow(/Nested .folder directories are not supported/)
|
|
368
485
|
})
|
|
369
486
|
})
|
|
487
|
+
|
|
488
|
+
describe('underscore prefix ignoring', () => {
|
|
489
|
+
it('ignores _-prefixed data files', () => {
|
|
490
|
+
writeFileSync(
|
|
491
|
+
path.join(tmpDir, '_draft.flow.json'),
|
|
492
|
+
JSON.stringify({ title: 'Draft' }),
|
|
493
|
+
)
|
|
494
|
+
writeFileSync(
|
|
495
|
+
path.join(tmpDir, 'visible.flow.json'),
|
|
496
|
+
JSON.stringify({ title: 'Visible' }),
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
const plugin = createPlugin()
|
|
500
|
+
const code = plugin.load(RESOLVED_ID)
|
|
501
|
+
|
|
502
|
+
expect(code).toContain('"Visible"')
|
|
503
|
+
expect(code).not.toContain('"Draft"')
|
|
504
|
+
})
|
|
505
|
+
|
|
506
|
+
it('ignores data files inside _-prefixed directories', () => {
|
|
507
|
+
mkdirSync(path.join(tmpDir, '_archive'), { recursive: true })
|
|
508
|
+
writeFileSync(
|
|
509
|
+
path.join(tmpDir, '_archive', 'old.flow.json'),
|
|
510
|
+
JSON.stringify({ title: 'Archived' }),
|
|
511
|
+
)
|
|
512
|
+
writeFileSync(
|
|
513
|
+
path.join(tmpDir, 'current.flow.json'),
|
|
514
|
+
JSON.stringify({ title: 'Current' }),
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
const plugin = createPlugin()
|
|
518
|
+
const code = plugin.load(RESOLVED_ID)
|
|
519
|
+
|
|
520
|
+
expect(code).toContain('"Current"')
|
|
521
|
+
expect(code).not.toContain('"Archived"')
|
|
522
|
+
})
|
|
523
|
+
|
|
524
|
+
it('ignores prototype.json inside _-prefixed directories', () => {
|
|
525
|
+
mkdirSync(path.join(tmpDir, 'src', 'prototypes', '_WIP'), { recursive: true })
|
|
526
|
+
writeFileSync(
|
|
527
|
+
path.join(tmpDir, 'src', 'prototypes', '_WIP', 'wip.prototype.json'),
|
|
528
|
+
JSON.stringify({ meta: { title: 'Work in Progress' } }),
|
|
529
|
+
)
|
|
530
|
+
mkdirSync(path.join(tmpDir, 'src', 'prototypes', 'Live'), { recursive: true })
|
|
531
|
+
writeFileSync(
|
|
532
|
+
path.join(tmpDir, 'src', 'prototypes', 'Live', 'live.prototype.json'),
|
|
533
|
+
JSON.stringify({ meta: { title: 'Live' } }),
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
const plugin = createPlugin()
|
|
537
|
+
const code = plugin.load(RESOLVED_ID)
|
|
538
|
+
|
|
539
|
+
expect(code).toContain('"Live"')
|
|
540
|
+
expect(code).not.toContain('"Work in Progress"')
|
|
541
|
+
})
|
|
542
|
+
|
|
543
|
+
it('does not ignore files with _ in the middle of the name', () => {
|
|
544
|
+
writeFileSync(
|
|
545
|
+
path.join(tmpDir, 'my_flow.flow.json'),
|
|
546
|
+
JSON.stringify({ title: 'Has Underscore' }),
|
|
547
|
+
)
|
|
548
|
+
|
|
549
|
+
const plugin = createPlugin()
|
|
550
|
+
const code = plugin.load(RESOLVED_ID)
|
|
551
|
+
|
|
552
|
+
expect(code).toContain('"Has Underscore"')
|
|
553
|
+
})
|
|
554
|
+
})
|