@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 CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@dfosco/storyboard-react",
3
- "version": "2.4.0",
3
+ "version": "2.5.0",
4
4
  "type": "module",
5
5
  "dependencies": {
6
- "@dfosco/storyboard-core": "2.4.0",
6
+ "@dfosco/storyboard-core": "2.5.0",
7
7
  "glob": "^11.0.0",
8
8
  "jsonc-parser": "^3.3.1"
9
9
  },
@@ -66,7 +66,22 @@ function parseDataFile(filePath) {
66
66
  }
67
67
  }
68
68
 
69
- 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 }
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 })