@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 CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@dfosco/storyboard-react",
3
- "version": "2.3.0",
3
+ "version": "2.5.0",
4
4
  "type": "module",
5
5
  "dependencies": {
6
- "@dfosco/storyboard-core": "2.3.0",
6
+ "@dfosco/storyboard-core": "2.5.0",
7
7
  "glob": "^11.0.0",
8
8
  "jsonc-parser": "^3.3.1"
9
9
  },
@@ -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
- return { name, suffix, ext: match[3] }
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
+ })