@dfosco/storyboard-react 4.0.0-beta.13 → 4.0.0-beta.15

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.
@@ -4,12 +4,16 @@ import { execSync } from 'node:child_process'
4
4
  import { globSync } from 'glob'
5
5
  import { parse as parseJsonc } from 'jsonc-parser'
6
6
  import { materializeFromText } from '@dfosco/storyboard-core/canvas/materializer'
7
+ import { toCanvasId } from '../../../core/src/canvas/identity.js'
8
+ import { getConfig } from '../../../core/src/configSchema.js'
7
9
 
8
10
  const VIRTUAL_MODULE_ID = 'virtual:storyboard-data-index'
9
11
  const RESOLVED_ID = '\0' + VIRTUAL_MODULE_ID
10
12
 
11
13
  const GLOB_PATTERN = '**/*.{flow,scene,object,record,prototype,folder}.{json,jsonc}'
12
14
  const CANVAS_GLOB_PATTERN = '**/*.canvas.jsonl'
15
+ const CANVAS_META_GLOB_PATTERN = '**/*.meta.json'
16
+ const STORY_GLOB_PATTERN = '**/*.story.{jsx,tsx}'
13
17
 
14
18
  /**
15
19
  * Extract the data name and type suffix from a file path.
@@ -32,7 +36,8 @@ function parseDataFile(filePath) {
32
36
  const normalized = filePath.replace(/\\/g, '/')
33
37
  if (normalized.split('/').some(seg => seg.startsWith('_'))) return null
34
38
 
35
- const name = canvasJsonlMatch[1]
39
+ const baseName = canvasJsonlMatch[1]
40
+ let name = baseName
36
41
  let inferredRoute = null
37
42
  const canvasFolderMatch = normalized.match(/(?:^|\/)src\/canvas\/([^/]+)\.folder\//)
38
43
  const canvasFolderName = canvasFolderMatch ? canvasFolderMatch[1] : null
@@ -46,20 +51,81 @@ function parseDataFile(filePath) {
46
51
  .replace(/^.*?src\/canvas\//, '')
47
52
  .replace(/[^/]*\.folder\/?/g, '')
48
53
  .replace(/\/$/, '')
49
- inferredRoute = '/canvas/' + (routeBase ? routeBase + '/' : '') + name
54
+ // Path-based ID: include folder context for uniqueness.
55
+ // .folder dirs contribute their name (sans .folder suffix) to the ID.
56
+ const idBase = (dirPath + '/')
57
+ .replace(/^.*?src\/canvas\//, '')
58
+ .replace(/\.folder\/?/g, '/')
59
+ .replace(/\/+/g, '/')
60
+ .replace(/\/$/, '')
61
+ name = idBase ? `${idBase}/${baseName}` : baseName
62
+ inferredRoute = '/canvas/' + name
50
63
  inferredRoute = inferredRoute.replace(/\/+/g, '/').replace(/\/$/, '') || '/canvas'
51
64
  }
52
65
  const protoCheck = normalized.match(/(?:^|\/)src\/prototypes\//)
53
66
  if (!canvasCheck && protoCheck) {
54
67
  const dirPath = normalized.substring(0, normalized.lastIndexOf('/'))
55
- const routeBase = (dirPath + '/')
68
+ // For prototypes, .folder is purely organizational — strip entirely
69
+ const idBase = (dirPath + '/')
56
70
  .replace(/^.*?src\/prototypes\//, '')
57
71
  .replace(/[^/]*\.folder\/?/g, '')
72
+ .replace(/\/+/g, '/')
58
73
  .replace(/\/$/, '')
59
- inferredRoute = '/canvas/' + (routeBase ? routeBase + '/' : '') + name
74
+ name = idBase ? `${idBase}/${baseName}` : baseName
75
+ inferredRoute = '/canvas/' + name
60
76
  inferredRoute = inferredRoute.replace(/\/+/g, '/').replace(/\/$/, '') || '/canvas'
61
77
  }
62
- return { name, suffix: 'canvas', ext: 'jsonl', folder: canvasFolderName || folderName, inferredRoute }
78
+ // Derive group: canvases sharing a directory form a group
79
+ const slashIdx = name.lastIndexOf('/')
80
+ const group = canvasFolderName || (slashIdx > 0 ? name.substring(0, slashIdx) : null)
81
+ return { name, suffix: 'canvas', ext: 'jsonl', folder: canvasFolderName || folderName, inferredRoute, id: toCanvasId(filePath), group }
82
+ }
83
+
84
+ // Handle canvas .meta.json files
85
+ const metaMatch = base.match(/^(.+)\.meta\.json$/)
86
+ if (metaMatch) {
87
+ const normalized = filePath.replace(/\\/g, '/')
88
+ // Only handle meta files inside src/canvas/ directories
89
+ const canvasCheck = normalized.match(/(?:^|\/)src\/canvas\//)
90
+ if (!canvasCheck) return null
91
+ // Skip _-prefixed
92
+ if (metaMatch[1].startsWith('_')) return null
93
+ if (normalized.split('/').some(seg => seg.startsWith('_'))) return null
94
+ return { name: metaMatch[1], suffix: 'canvas-meta', ext: 'json', inferredRoute: null }
95
+ }
96
+
97
+ // Handle .story.jsx / .story.tsx files
98
+ const storyMatch = base.match(/^(.+)\.story\.(jsx|tsx)$/)
99
+ if (storyMatch) {
100
+ if (storyMatch[1].startsWith('_')) return null
101
+ const normalized = filePath.replace(/\\/g, '/')
102
+ if (normalized.split('/').some(seg => seg.startsWith('_'))) return null
103
+
104
+ const name = storyMatch[1]
105
+ let inferredRoute = null
106
+
107
+ // All stories route under /components/ regardless of directory location
108
+ const canvasCheck = normalized.match(/(?:^|\/)src\/canvas\//)
109
+ const componentsCheck = normalized.match(/(?:^|\/)src\/components\//)
110
+ if (canvasCheck) {
111
+ const dirPath = normalized.substring(0, normalized.lastIndexOf('/'))
112
+ const routeBase = (dirPath + '/')
113
+ .replace(/^.*?src\/canvas\//, '')
114
+ .replace(/[^/]*\.folder\/?/g, '')
115
+ .replace(/\/$/, '')
116
+ inferredRoute = '/components/' + (routeBase ? routeBase + '/' : '') + name
117
+ inferredRoute = inferredRoute.replace(/\/+/g, '/').replace(/\/$/, '') || '/components'
118
+ } else if (componentsCheck) {
119
+ const dirPath = normalized.substring(0, normalized.lastIndexOf('/'))
120
+ const routeBase = (dirPath + '/')
121
+ .replace(/^.*?src\/components\//, '')
122
+ .replace(/[^/]*\.folder\/?/g, '')
123
+ .replace(/\/$/, '')
124
+ inferredRoute = '/components/' + (routeBase ? routeBase + '/' : '') + name
125
+ inferredRoute = inferredRoute.replace(/\/+/g, '/').replace(/\/$/, '') || '/components'
126
+ }
127
+
128
+ return { name, suffix: 'story', ext: storyMatch[2], inferredRoute }
63
129
  }
64
130
 
65
131
  const match = base.match(/^(.+)\.(flow|scene|object|record|prototype|folder)\.(jsonc?)$/)
@@ -250,6 +316,8 @@ function buildIndex(root) {
250
316
  const ignore = ['node_modules/**', 'dist/**', '.git/**', '.worktrees/**', 'public/**']
251
317
  const files = globSync(GLOB_PATTERN, { cwd: root, ignore, absolute: false })
252
318
  const canvasFiles = globSync(CANVAS_GLOB_PATTERN, { cwd: root, ignore, absolute: false })
319
+ const canvasMetaFiles = globSync(CANVAS_META_GLOB_PATTERN, { cwd: root, ignore, absolute: false })
320
+ const storyFiles = globSync(STORY_GLOB_PATTERN, { cwd: root, ignore, absolute: false })
253
321
 
254
322
  // Detect nested .folder/ directories (not supported)
255
323
  // Scan directories directly since empty nested folders have no data files
@@ -266,35 +334,58 @@ function buildIndex(root) {
266
334
  }
267
335
  }
268
336
 
269
- const index = { flow: {}, object: {}, record: {}, prototype: {}, folder: {}, canvas: {} }
270
- const seen = {} // "name.suffix" → absolute path (for duplicate detection)
337
+ const index = { flow: {}, object: {}, record: {}, prototype: {}, folder: {}, canvas: {}, 'canvas-meta': {}, story: {} }
338
+ const seen = {} // "name.suffix" or "id.suffix" → absolute path (for duplicate detection)
271
339
  const protoFolders = {} // prototype name → folder name (for injection)
272
340
  const flowRoutes = {} // flow name → inferred route (for _route injection)
273
341
  const canvasRoutes = {} // canvas name → inferred route
342
+ const canvasAliases = {} // basename → canonical ID (only when unique)
343
+ const canvasNameCount = {} // canvas basename → count (for ambiguity detection)
344
+ const canvasGroups = {} // canvas name → group name (shared folder prefix)
345
+ const storyRoutes = {} // story name → inferred route
274
346
 
275
- for (const relPath of [...files, ...canvasFiles]) {
347
+ for (const relPath of [...files, ...canvasFiles, ...canvasMetaFiles, ...storyFiles]) {
276
348
  const parsed = parseDataFile(relPath)
277
349
  if (!parsed) continue
278
350
 
279
- const key = `${parsed.name}.${parsed.suffix}`
351
+ // Canvas files use path-based IDs for dedup; others use basename
352
+ const dedupKey = parsed.suffix === 'canvas' && parsed.id
353
+ ? `${parsed.id}.${parsed.suffix}`
354
+ : `${parsed.name}.${parsed.suffix}`
280
355
  const absPath = path.resolve(root, relPath)
281
356
 
282
- if (seen[key]) {
357
+ if (seen[dedupKey]) {
283
358
  const hint = parsed.suffix === 'folder'
284
359
  ? ' Folder names must be unique across the project.'
360
+ : parsed.suffix === 'canvas'
361
+ ? ' Canvas IDs must be unique. Move or rename one file to resolve the collision.'
285
362
  : ' Flows, records, and objects are scoped to their prototype directory.\n' +
286
363
  ' If both files are global (outside src/prototypes/), rename one to avoid the collision.'
287
364
 
288
365
  throw new Error(
289
- `[storyboard-data] Duplicate ${parsed.suffix} "${parsed.name}"\n` +
290
- ` Found at: ${seen[key]}\n` +
366
+ `[storyboard-data] Duplicate ${parsed.suffix} "${parsed.id || parsed.name}"\n` +
367
+ ` Found at: ${seen[dedupKey]}\n` +
291
368
  ` And at: ${absPath}\n` +
292
369
  hint
293
370
  )
294
371
  }
295
372
 
296
- seen[key] = absPath
297
- index[parsed.suffix][parsed.name] = absPath
373
+ seen[dedupKey] = absPath
374
+
375
+ // Canvas: index only by canonical ID. Basename aliases go in a separate map
376
+ // so listCanvases() and viewfinder don't show duplicates.
377
+ if (parsed.suffix === 'canvas' && parsed.id) {
378
+ index.canvas[parsed.id] = absPath
379
+ // Track basename for alias resolution (only when unique)
380
+ canvasNameCount[parsed.name] = (canvasNameCount[parsed.name] || 0) + 1
381
+ if (canvasNameCount[parsed.name] === 1) {
382
+ canvasAliases[parsed.name] = parsed.id
383
+ } else {
384
+ delete canvasAliases[parsed.name]
385
+ }
386
+ } else {
387
+ index[parsed.suffix][parsed.name] = absPath
388
+ }
298
389
 
299
390
  // Track which folder a prototype belongs to
300
391
  if (parsed.suffix === 'prototype' && parsed.folder) {
@@ -306,13 +397,24 @@ function buildIndex(root) {
306
397
  flowRoutes[parsed.name] = parsed.inferredRoute
307
398
  }
308
399
 
309
- // Track inferred routes for canvases
400
+ // Track inferred routes for canvases (keyed by canonical ID)
310
401
  if (parsed.suffix === 'canvas' && parsed.inferredRoute) {
311
- canvasRoutes[parsed.name] = parsed.inferredRoute
402
+ const canvasKey = parsed.id || parsed.name
403
+ canvasRoutes[canvasKey] = parsed.inferredRoute
404
+ }
405
+
406
+ // Track canvas groups (canvases sharing a folder prefix)
407
+ if (parsed.suffix === 'canvas' && parsed.group) {
408
+ canvasGroups[parsed.name] = parsed.group
409
+ }
410
+
411
+ // Track inferred routes for stories
412
+ if (parsed.suffix === 'story' && parsed.inferredRoute) {
413
+ storyRoutes[parsed.name] = parsed.inferredRoute
312
414
  }
313
415
  }
314
416
 
315
- return { index, protoFolders, flowRoutes, canvasRoutes }
417
+ return { index, protoFolders, flowRoutes, canvasRoutes, canvasAliases, canvasGroups, storyRoutes }
316
418
  }
317
419
 
318
420
  /**
@@ -366,7 +468,7 @@ function computeTemplateVars(absPath, root) {
366
468
  */
367
469
  /**
368
470
  * Read storyboard.config.json from the project root (if it exists).
369
- * Returns the parsed config object, or null if not found.
471
+ * Returns the parsed and defaulted config object, or null if not found.
370
472
  */
371
473
  function readConfig(root) {
372
474
  const configPath = path.resolve(root, 'storyboard.config.json')
@@ -376,7 +478,7 @@ function readConfig(root) {
376
478
  const config = parseJsonc(raw, errors)
377
479
  // Treat malformed JSON (e.g. mid-edit partial saves) as missing config
378
480
  if (errors.length > 0) return { config: null, configPath }
379
- return { config, configPath }
481
+ return { config: getConfig(config), configPath }
380
482
  } catch {
381
483
  return { config: null, configPath }
382
484
  }
@@ -420,10 +522,11 @@ function readModesConfig(root) {
420
522
  return fallback
421
523
  }
422
524
 
423
- function generateModule({ index, protoFolders, flowRoutes, canvasRoutes }, root) {
525
+ function generateModule({ index, protoFolders, flowRoutes, canvasRoutes, canvasAliases, canvasGroups, storyRoutes }, root) {
424
526
  const declarations = []
425
527
  const INDEX_KEYS = ['flow', 'object', 'record', 'prototype', 'folder', 'canvas']
426
528
  const entries = { flow: [], object: [], record: [], prototype: [], folder: [], canvas: [] }
529
+ const storyEntries = [] // handled separately (code modules, not JSON data)
427
530
  const resolvedFlowRoutes = {} // flow name → resolved route (for multi-flow logging)
428
531
  let i = 0
429
532
 
@@ -434,6 +537,21 @@ function generateModule({ index, protoFolders, flowRoutes, canvasRoutes }, root)
434
537
  ]
435
538
  const gitMeta = batchGitMetadata(root, gitPaths)
436
539
 
540
+ // Read canvas-meta files and build a directory-based lookup
541
+ const canvasMetaByDir = {}
542
+ for (const [, absPath] of Object.entries(index['canvas-meta'] || {})) {
543
+ try {
544
+ const raw = fs.readFileSync(absPath, 'utf-8')
545
+ const parsed = parseJsonc(raw)
546
+ if (parsed) {
547
+ // Key by the parent directory path relative to src/canvas/
548
+ const dirPath = path.dirname(absPath).replace(/\\/g, '/')
549
+ const canvasRelDir = dirPath.replace(/^.*?src\/canvas\//, '')
550
+ canvasMetaByDir[canvasRelDir] = parsed
551
+ }
552
+ } catch { /* skip invalid meta files */ }
553
+ }
554
+
437
555
  for (const suffix of INDEX_KEYS) {
438
556
  for (const [name, absPath] of Object.entries(index[suffix])) {
439
557
  const varName = `_d${i++}`
@@ -499,11 +617,18 @@ function generateModule({ index, protoFolders, flowRoutes, canvasRoutes }, root)
499
617
  }
500
618
  }
501
619
 
502
- // Inject inferred route and resolve JSX companion for canvases
620
+ // Inject inferred route, group, and resolve JSX companion for canvases
503
621
  if (suffix === 'canvas') {
504
622
  if (canvasRoutes[name]) {
505
623
  parsed = { ...parsed, _route: canvasRoutes[name] }
506
624
  }
625
+ if (canvasGroups[name]) {
626
+ parsed = { ...parsed, _group: canvasGroups[name] }
627
+ }
628
+ // Inject canvas folder metadata from .meta.json
629
+ if (canvasGroups[name] && canvasMetaByDir[canvasGroups[name]]) {
630
+ parsed = { ...parsed, _canvasMeta: canvasMetaByDir[canvasGroups[name]] }
631
+ }
507
632
  // Inject folder association
508
633
  const folderDirMatch = path.relative(root, absPath).replace(/\\/g, '/').match(/(?:^|\/)src\/(?:prototypes|canvas)\/([^/]+)\.folder\//)
509
634
  if (folderDirMatch) {
@@ -555,8 +680,22 @@ function generateModule({ index, protoFolders, flowRoutes, canvasRoutes }, root)
555
680
  }
556
681
  }
557
682
 
683
+ // Generate story entries (code modules with dynamic imports, not JSON data)
684
+ for (const [name, absPath] of Object.entries(index.story || {})) {
685
+ const varName = `_d${i++}`
686
+ const relModule = '/' + path.relative(root, absPath).replace(/\\/g, '/')
687
+ const storyMeta = { _storyModule: relModule }
688
+ if (storyRoutes[name]) {
689
+ storyMeta._route = storyRoutes[name]
690
+ }
691
+ declarations.push(
692
+ `const ${varName} = Object.assign(${JSON.stringify(storyMeta)}, { _storyImport: () => import(${JSON.stringify(relModule)}) })`
693
+ )
694
+ storyEntries.push(` ${JSON.stringify(name)}: ${varName}`)
695
+ }
696
+
558
697
  const imports = [`import { init } from '@dfosco/storyboard-core'`]
559
- const initCalls = [`init({ flows, objects, records, prototypes, folders, canvases })`]
698
+ const initCalls = [`init({ flows, objects, records, prototypes, folders, canvases, stories })`]
560
699
 
561
700
  // Feature flags from storyboard.config.json
562
701
  const { config } = readConfig(root)
@@ -624,14 +763,18 @@ function generateModule({ index, protoFolders, flowRoutes, canvasRoutes }, root)
624
763
  `const prototypes = {\n${entries.prototype.join(',\n')}\n}`,
625
764
  `const folders = {\n${entries.folder.join(',\n')}\n}`,
626
765
  `const canvases = {\n${entries.canvas.join(',\n')}\n}`,
766
+ `const stories = {\n${storyEntries.join(',\n')}\n}`,
767
+ '',
768
+ `// Legacy basename → canonical ID aliases (only unique basenames)`,
769
+ `const canvasAliases = ${JSON.stringify(canvasAliases || {})}`,
627
770
  '',
628
771
  '// Backward-compatible alias',
629
772
  'const scenes = flows',
630
773
  '',
631
774
  initCalls.join('\n'),
632
775
  '',
633
- `export { flows, scenes, objects, records, prototypes, folders, canvases }`,
634
- `export const index = { flows, scenes, objects, records, prototypes, folders, canvases }`,
776
+ `export { flows, scenes, objects, records, prototypes, folders, canvases, canvasAliases, stories }`,
777
+ `export const index = { flows, scenes, objects, records, prototypes, folders, canvases, canvasAliases, stories }`,
635
778
  `export default index`,
636
779
  '',
637
780
  '// Live-patch canvas data on HMR events so SPA navigation shows fresh state',
@@ -646,7 +789,18 @@ function generateModule({ index, protoFolders, flowRoutes, canvasRoutes }, root)
646
789
  ' ? Object.assign({}, canvases[data.name], data.metadata)',
647
790
  ' : data.metadata',
648
791
  ' }',
649
- ' init({ flows, objects, records, prototypes, folders, canvases })',
792
+ ' init({ flows, objects, records, prototypes, folders, canvases, stories })',
793
+ ' })',
794
+ ' import.meta.hot.on("storyboard:story-file-changed", (data) => {',
795
+ ' if (!data) return',
796
+ ' if (data.removed) {',
797
+ ' delete stories[data.name]',
798
+ ' } else {',
799
+ ' stories[data.name] = { _storyModule: data._storyModule, _route: data._route,',
800
+ ' _storyImport: () => import(/* @vite-ignore */ data._storyModule) }',
801
+ ' }',
802
+ ' init({ flows, objects, records, prototypes, folders, canvases, stories })',
803
+ ' document.dispatchEvent(new CustomEvent("storyboard:story-index-changed"))',
650
804
  ' })',
651
805
  '}',
652
806
  ].join('\n')
@@ -655,7 +809,7 @@ function generateModule({ index, protoFolders, flowRoutes, canvasRoutes }, root)
655
809
  /**
656
810
  * Vite plugin for storyboard data discovery.
657
811
  *
658
- * - Scans the repo for *.flow.json, *.scene.json (compat), *.object.json, *.record.json, *.canvas.jsonl
812
+ * - Scans the repo for *.flow.json, *.scene.json (compat), *.object.json, *.record.json, *.canvas.jsonl, *.story.{jsx,tsx}
659
813
  * - Validates no two files share the same name+suffix (hard build error)
660
814
  * - Generates a virtual module `virtual:storyboard-data-index`
661
815
  * - Watches for file additions/removals in dev mode
@@ -731,6 +885,29 @@ export default function storyboardDataPlugin() {
731
885
  }
732
886
  })
733
887
 
888
+ // ── Stories list API ──────────────────────────────────────────
889
+ // Serves the list of discovered stories for the CLI and UI story picker.
890
+ server.middlewares.use(async (req, res, next) => {
891
+ if (!req.url) return next()
892
+ let url = req.url
893
+ const baseNoTrail = (server.config.base || '/').replace(/\/$/, '')
894
+ if (baseNoTrail && url.startsWith(baseNoTrail)) {
895
+ url = url.slice(baseNoTrail.length) || '/'
896
+ }
897
+ if (!url.startsWith('/_storyboard/stories/list')) return next()
898
+
899
+ if (!buildResult) buildResult = buildIndex(root)
900
+ const storyEntries = Object.entries(buildResult.index.story || {})
901
+ const storyRoutes = buildResult.storyRoutes || {}
902
+ const stories = storyEntries.map(([name]) => ({
903
+ name,
904
+ route: storyRoutes[name] || null,
905
+ }))
906
+
907
+ res.writeHead(200, { 'Content-Type': 'application/json' })
908
+ res.end(JSON.stringify({ stories }))
909
+ })
910
+
734
911
  // Watch for data file changes in dev mode
735
912
  const watcher = server.watcher
736
913
  if (!buildResult) buildResult = buildIndex(root)
@@ -879,6 +1056,36 @@ export default function storyboardDataPlugin() {
879
1056
  }
880
1057
  }
881
1058
 
1059
+ // Story add/remove: soft-invalidate + custom HMR event (full-reload
1060
+ // is blocked by the canvas reload guard). The virtual module HMR
1061
+ // handler live-patches `stories` and re-runs init().
1062
+ if (parsed?.suffix === 'story') {
1063
+ softInvalidate()
1064
+ if (!buildResult) buildResult = buildIndex(root)
1065
+ const storyRoutes = buildResult.storyRoutes || {}
1066
+ const storyIndex = buildResult.index.story || {}
1067
+ const name = parsed.name
1068
+ if (eventType === 'unlink') {
1069
+ server.ws.send({
1070
+ type: 'custom',
1071
+ event: 'storyboard:story-file-changed',
1072
+ data: { name, removed: true },
1073
+ })
1074
+ } else if (eventType === 'add' && storyIndex[name]) {
1075
+ const relModule = '/' + path.relative(root, storyIndex[name]).replace(/\\/g, '/')
1076
+ server.ws.send({
1077
+ type: 'custom',
1078
+ event: 'storyboard:story-file-changed',
1079
+ data: {
1080
+ name,
1081
+ _storyModule: relModule,
1082
+ _route: storyRoutes[name] || null,
1083
+ },
1084
+ })
1085
+ }
1086
+ return
1087
+ }
1088
+
882
1089
  // Non-canvas additions/removals and folder changes update the route/data graph.
883
1090
  triggerFullReload()
884
1091
  }
@@ -924,4 +1131,4 @@ export default function storyboardDataPlugin() {
924
1131
  }
925
1132
 
926
1133
  // Exported for testing
927
- export { resolveTemplateVars, computeTemplateVars }
1134
+ export { resolveTemplateVars, computeTemplateVars, parseDataFile }
@@ -1,7 +1,7 @@
1
1
  import { mkdtempSync, writeFileSync, mkdirSync, rmSync, readFileSync } from 'node:fs'
2
2
  import { tmpdir } from 'node:os'
3
3
  import path from 'node:path'
4
- import storyboardDataPlugin, { resolveTemplateVars, computeTemplateVars } from './data-plugin.js'
4
+ import storyboardDataPlugin, { resolveTemplateVars, computeTemplateVars, parseDataFile } from './data-plugin.js'
5
5
 
6
6
  const RESOLVED_ID = '\0virtual:storyboard-data-index'
7
7
 
@@ -77,13 +77,13 @@ describe('storyboardDataPlugin', () => {
77
77
  const code = plugin.load(RESOLVED_ID)
78
78
 
79
79
  expect(code).toContain("import { init } from '@dfosco/storyboard-core'")
80
- expect(code).toContain('init({ flows, objects, records, prototypes, folders, canvases })')
80
+ expect(code).toContain('init({ flows, objects, records, prototypes, folders, canvases, stories })')
81
81
  expect(code).toContain('"Test"')
82
82
  expect(code).toContain('"Jane"')
83
83
  expect(code).toContain('"First"')
84
84
  // Backward-compat alias
85
85
  expect(code).toContain('const scenes = flows')
86
- expect(code).toContain('export { flows, scenes, objects, records, prototypes, folders, canvases }')
86
+ expect(code).toContain('export { flows, scenes, objects, records, prototypes, folders, canvases, canvasAliases, stories }')
87
87
  })
88
88
 
89
89
  it('load returns null for other IDs', () => {
@@ -169,7 +169,7 @@ describe('storyboardDataPlugin', () => {
169
169
 
170
170
  // .scene.json files should be normalized to the flows category
171
171
  expect(code).toContain('"Legacy Scene"')
172
- expect(code).toContain('init({ flows, objects, records, prototypes, folders, canvases })')
172
+ expect(code).toContain('init({ flows, objects, records, prototypes, folders, canvases, stories })')
173
173
  })
174
174
 
175
175
  it('buildStart resets the index cache', () => {
@@ -1045,4 +1045,179 @@ describe('canvas watcher behavior', () => {
1045
1045
  expect(code2).toContain('"refresh-canvas"')
1046
1046
  expect(code2).toContain('After Refresh')
1047
1047
  })
1048
+
1049
+ // ── Story file discovery ──────────────────────────────────────────
1050
+
1051
+ it('discovers .story.jsx files and generates _storyImport', () => {
1052
+ writeDataFiles(tmpDir)
1053
+ writeFileSync(
1054
+ path.join(tmpDir, 'button-patterns.story.jsx'),
1055
+ 'export function Primary() { return null }',
1056
+ )
1057
+ const plugin = createPlugin()
1058
+ const code = plugin.load(RESOLVED_ID)
1059
+
1060
+ expect(code).toContain('"button-patterns"')
1061
+ expect(code).toContain('_storyModule')
1062
+ expect(code).toContain('_storyImport')
1063
+ expect(code).toContain('.story.jsx')
1064
+ })
1065
+
1066
+ it('discovers .story.tsx files', () => {
1067
+ writeDataFiles(tmpDir)
1068
+ writeFileSync(
1069
+ path.join(tmpDir, 'card.story.tsx'),
1070
+ 'export function Default() { return null }',
1071
+ )
1072
+ const plugin = createPlugin()
1073
+ const code = plugin.load(RESOLVED_ID)
1074
+
1075
+ expect(code).toContain('"card"')
1076
+ expect(code).toContain('card.story.tsx')
1077
+ })
1078
+
1079
+ it('skips _-prefixed story files', () => {
1080
+ writeDataFiles(tmpDir)
1081
+ writeFileSync(
1082
+ path.join(tmpDir, '_draft.story.jsx'),
1083
+ 'export function Draft() { return null }',
1084
+ )
1085
+ const plugin = createPlugin()
1086
+ const code = plugin.load(RESOLVED_ID)
1087
+
1088
+ expect(code).not.toContain('"_draft"')
1089
+ })
1090
+
1091
+ it('throws on duplicate story names', () => {
1092
+ writeDataFiles(tmpDir)
1093
+ mkdirSync(path.join(tmpDir, 'a'), { recursive: true })
1094
+ mkdirSync(path.join(tmpDir, 'b'), { recursive: true })
1095
+ writeFileSync(
1096
+ path.join(tmpDir, 'a', 'dupe.story.jsx'),
1097
+ 'export function A() { return null }',
1098
+ )
1099
+ writeFileSync(
1100
+ path.join(tmpDir, 'b', 'dupe.story.jsx'),
1101
+ 'export function B() { return null }',
1102
+ )
1103
+ const plugin = createPlugin()
1104
+ expect(() => plugin.load(RESOLVED_ID)).toThrow(/Duplicate story "dupe"/)
1105
+ })
1106
+
1107
+ it('includes stories in the init() call and exports', () => {
1108
+ writeDataFiles(tmpDir)
1109
+ writeFileSync(
1110
+ path.join(tmpDir, 'test.story.jsx'),
1111
+ 'export function Test() { return null }',
1112
+ )
1113
+ const plugin = createPlugin()
1114
+ const code = plugin.load(RESOLVED_ID)
1115
+
1116
+ expect(code).toContain('const stories = {')
1117
+ expect(code).toContain('init({ flows, objects, records, prototypes, folders, canvases, stories })')
1118
+ expect(code).toContain('export { flows, scenes, objects, records, prototypes, folders, canvases, stories }')
1119
+ })
1120
+
1121
+ it('infers /components/ route for stories in src/canvas/', () => {
1122
+ writeDataFiles(tmpDir)
1123
+ mkdirSync(path.join(tmpDir, 'src', 'canvas'), { recursive: true })
1124
+ writeFileSync(
1125
+ path.join(tmpDir, 'src', 'canvas', 'button-patterns.story.jsx'),
1126
+ 'export function Primary() { return null }',
1127
+ )
1128
+ const plugin = createPlugin()
1129
+ const code = plugin.load(RESOLVED_ID)
1130
+
1131
+ expect(code).toContain('"button-patterns"')
1132
+ expect(code).toContain('"/components/button-patterns"')
1133
+ expect(code).toContain('_route')
1134
+ })
1135
+
1136
+ it('infers /components/ route for stories in src/components/', () => {
1137
+ writeDataFiles(tmpDir)
1138
+ mkdirSync(path.join(tmpDir, 'src', 'components'), { recursive: true })
1139
+ writeFileSync(
1140
+ path.join(tmpDir, 'src', 'components', 'text-input.story.jsx'),
1141
+ 'export function Default() { return null }',
1142
+ )
1143
+ const plugin = createPlugin()
1144
+ const code = plugin.load(RESOLVED_ID)
1145
+
1146
+ expect(code).toContain('"text-input"')
1147
+ expect(code).toContain('"/components/text-input"')
1148
+ })
1149
+
1150
+ it('stories outside src/canvas/ or src/components/ have no inferred route', () => {
1151
+ writeDataFiles(tmpDir)
1152
+ writeFileSync(
1153
+ path.join(tmpDir, 'orphan.story.jsx'),
1154
+ 'export function Default() { return null }',
1155
+ )
1156
+ const plugin = createPlugin()
1157
+ const code = plugin.load(RESOLVED_ID)
1158
+
1159
+ expect(code).toContain('"orphan"')
1160
+ // Should not have _route since it's not in a recognized directory
1161
+ expect(code).not.toContain('"/orphan"')
1162
+ })
1163
+ })
1164
+
1165
+ describe('parseDataFile — canvas path-based IDs', () => {
1166
+ it('flat canvas in src/canvas/ gets basename-only ID', () => {
1167
+ const result = parseDataFile('src/canvas/overview.canvas.jsonl')
1168
+ expect(result.name).toBe('overview')
1169
+ expect(result.inferredRoute).toBe('/canvas/overview')
1170
+ expect(result.group).toBeNull()
1171
+ })
1172
+
1173
+ it('canvas inside .folder/ gets path-based ID', () => {
1174
+ const result = parseDataFile('src/canvas/research.folder/interviews.canvas.jsonl')
1175
+ expect(result.name).toBe('research/interviews')
1176
+ expect(result.inferredRoute).toBe('/canvas/research/interviews')
1177
+ expect(result.group).toBe('research')
1178
+ })
1179
+
1180
+ it('duplicate basenames in different folders get distinct IDs', () => {
1181
+ const a = parseDataFile('src/canvas/alpha.folder/overview.canvas.jsonl')
1182
+ const b = parseDataFile('src/canvas/beta.folder/overview.canvas.jsonl')
1183
+ expect(a.name).toBe('alpha/overview')
1184
+ expect(b.name).toBe('beta/overview')
1185
+ expect(a.name).not.toBe(b.name)
1186
+ })
1187
+
1188
+ it('prototype-scoped canvas gets path-based ID', () => {
1189
+ const result = parseDataFile('src/prototypes/Dashboard/plan.canvas.jsonl')
1190
+ expect(result.name).toBe('Dashboard/plan')
1191
+ expect(result.inferredRoute).toBe('/canvas/Dashboard/plan')
1192
+ })
1193
+
1194
+ it('prototype inside .folder/ strips folder from ID', () => {
1195
+ const result = parseDataFile('src/prototypes/main.folder/Dashboard/plan.canvas.jsonl')
1196
+ expect(result.name).toBe('Dashboard/plan')
1197
+ expect(result.inferredRoute).toBe('/canvas/Dashboard/plan')
1198
+ })
1199
+
1200
+ it('skips _-prefixed canvas files', () => {
1201
+ expect(parseDataFile('src/canvas/_draft.canvas.jsonl')).toBeNull()
1202
+ })
1203
+
1204
+ it('skips canvas files in _-prefixed directories', () => {
1205
+ expect(parseDataFile('src/canvas/_hidden/public.canvas.jsonl')).toBeNull()
1206
+ })
1207
+
1208
+ it('canvas outside known directories gets basename-only ID', () => {
1209
+ const result = parseDataFile('random/path/notes.canvas.jsonl')
1210
+ expect(result.name).toBe('notes')
1211
+ expect(result.inferredRoute).toBeNull()
1212
+ })
1213
+
1214
+ it('sets group for grouped canvases', () => {
1215
+ const result = parseDataFile('src/canvas/ux.folder/onboarding.canvas.jsonl')
1216
+ expect(result.group).toBe('ux')
1217
+ })
1218
+
1219
+ it('sets group to null for ungrouped canvases', () => {
1220
+ const result = parseDataFile('src/canvas/standalone.canvas.jsonl')
1221
+ expect(result.group).toBeNull()
1222
+ })
1048
1223
  })