@dfosco/storyboard-react 2.2.0 → 2.4.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.2.0",
3
+ "version": "2.4.0",
4
4
  "type": "module",
5
5
  "dependencies": {
6
- "@dfosco/storyboard-core": "2.2.0",
6
+ "@dfosco/storyboard-core": "2.4.0",
7
7
  "glob": "^11.0.0",
8
8
  "jsonc-parser": "^3.3.1"
9
9
  },
@@ -7,39 +7,60 @@ import { parse as parseJsonc } from 'jsonc-parser'
7
7
  const VIRTUAL_MODULE_ID = 'virtual:storyboard-data-index'
8
8
  const RESOLVED_ID = '\0' + VIRTUAL_MODULE_ID
9
9
 
10
- const GLOB_PATTERN = '**/*.{flow,scene,object,record,prototype}.{json,jsonc}'
10
+ const GLOB_PATTERN = '**/*.{flow,scene,object,record,prototype,folder}.{json,jsonc}'
11
11
 
12
12
  /**
13
13
  * Extract the data name and type suffix from a file path.
14
14
  * Flows and records inside src/prototypes/{Name}/ get prefixed with the
15
15
  * prototype name (e.g. "Dashboard/default"). Objects are never prefixed.
16
+ * Directories ending in .folder/ are skipped when extracting prototype scope.
16
17
  *
17
18
  * e.g. "src/data/default.flow.json" → { name: "default", suffix: "flow" }
18
19
  * "src/prototypes/Dashboard/default.flow.json" → { name: "Dashboard/default", suffix: "flow" }
19
20
  * "src/prototypes/Dashboard/helpers.object.json"→ { name: "helpers", suffix: "object" }
21
+ * "src/prototypes/X.folder/Dashboard/default.flow.json" → { name: "Dashboard/default", suffix: "flow", folder: "X" }
20
22
  */
21
23
  function parseDataFile(filePath) {
22
24
  const base = path.basename(filePath)
23
- const match = base.match(/^(.+)\.(flow|scene|object|record|prototype)\.(jsonc?)$/)
25
+ const match = base.match(/^(.+)\.(flow|scene|object|record|prototype|folder)\.(jsonc?)$/)
24
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
25
34
  // Normalize .scene → .flow for backward compatibility
26
35
  const suffix = match[2] === 'scene' ? 'flow' : match[2]
27
36
  let name = match[1]
28
37
 
38
+ // Detect if this file is inside a .folder/ directory
39
+ const folderDirMatch = normalized.match(/(?:^|\/)src\/prototypes\/([^/]+)\.folder\//)
40
+ const folderName = folderDirMatch ? folderDirMatch[1] : null
41
+
42
+ // Folder metadata files are keyed by their folder directory name (sans .folder suffix)
43
+ if (suffix === 'folder') {
44
+ if (folderName) {
45
+ name = folderName
46
+ }
47
+ return { name, suffix, ext: match[3] }
48
+ }
49
+
29
50
  // Prototype metadata files are keyed by their prototype directory name
51
+ // (skip .folder/ segments when determining prototype name)
30
52
  if (suffix === 'prototype') {
31
- const normalized = filePath.replace(/\\/g, '/')
32
- const protoMatch = normalized.match(/(?:^|\/)src\/prototypes\/([^/]+)\//)
53
+ const protoMatch = normalized.match(/(?:^|\/)src\/prototypes\/(?:[^/]+\.folder\/)?([^/]+)\//)
33
54
  if (protoMatch) {
34
55
  name = protoMatch[1]
35
56
  }
36
- return { name, suffix, ext: match[3] }
57
+ return { name, suffix, ext: match[3], folder: folderName }
37
58
  }
38
59
 
39
60
  // Scope flows and records inside src/prototypes/{Name}/ with a prefix
61
+ // (skip .folder/ segments when determining prototype name)
40
62
  if (suffix !== 'object') {
41
- const normalized = filePath.replace(/\\/g, '/')
42
- const protoMatch = normalized.match(/(?:^|\/)src\/prototypes\/([^/]+)\//)
63
+ const protoMatch = normalized.match(/(?:^|\/)src\/prototypes\/(?:[^/]+\.folder\/)?([^/]+)\//)
43
64
  if (protoMatch) {
44
65
  name = `${protoMatch[1]}/${name}`
45
66
  }
@@ -65,6 +86,22 @@ function getGitAuthor(root, filePath) {
65
86
  }
66
87
  }
67
88
 
89
+ /**
90
+ * Look up the most recent commit date for any file in a directory.
91
+ * Returns an ISO 8601 timestamp, or null if unavailable.
92
+ */
93
+ function getLastModified(root, dirPath) {
94
+ try {
95
+ const result = execSync(
96
+ `git log -1 --format="%aI" -- "${dirPath}"`,
97
+ { cwd: root, encoding: 'utf-8', timeout: 5000 },
98
+ ).trim()
99
+ return result || null
100
+ } catch {
101
+ return null
102
+ }
103
+ }
104
+
68
105
  /**
69
106
  * Scan the repo for all data files, validate uniqueness, return the index.
70
107
  */
@@ -72,8 +109,24 @@ function buildIndex(root) {
72
109
  const ignore = ['node_modules/**', 'dist/**', '.git/**']
73
110
  const files = globSync(GLOB_PATTERN, { cwd: root, ignore, absolute: false })
74
111
 
75
- const index = { flow: {}, object: {}, record: {}, prototype: {} }
112
+ // Detect nested .folder/ directories (not supported)
113
+ // Scan directories directly since empty nested folders have no data files
114
+ const folderDirs = globSync('src/prototypes/**/*.folder', { cwd: root, ignore, absolute: false })
115
+ for (const dir of folderDirs) {
116
+ const normalized = dir.replace(/\\/g, '/')
117
+ const segments = normalized.split('/').filter(s => s.endsWith('.folder'))
118
+ if (segments.length > 1) {
119
+ throw new Error(
120
+ `[storyboard-data] Nested .folder directories are not supported.\n` +
121
+ ` Found at: ${dir}\n` +
122
+ ` Folders can only be one level deep inside src/prototypes/.`
123
+ )
124
+ }
125
+ }
126
+
127
+ const index = { flow: {}, object: {}, record: {}, prototype: {}, folder: {} }
76
128
  const seen = {} // "name.suffix" → absolute path (for duplicate detection)
129
+ const protoFolders = {} // prototype name → folder name (for injection)
77
130
 
78
131
  for (const relPath of files) {
79
132
  const parsed = parseDataFile(relPath)
@@ -86,8 +139,10 @@ function buildIndex(root) {
86
139
  const hint = parsed.suffix === 'object'
87
140
  ? ' Objects are globally scoped — even inside src/prototypes/ they share a single namespace.\n' +
88
141
  ' Rename one of the files to avoid the collision.'
89
- : ' Flows and records are scoped to their prototype directory.\n' +
90
- ' If both files are global (outside src/prototypes/), rename one to avoid the collision.'
142
+ : parsed.suffix === 'folder'
143
+ ? ' Folder names must be unique across the project.'
144
+ : ' Flows and records are scoped to their prototype directory.\n' +
145
+ ' If both files are global (outside src/prototypes/), rename one to avoid the collision.'
91
146
 
92
147
  throw new Error(
93
148
  `[storyboard-data] Duplicate ${parsed.suffix} "${parsed.name}"\n` +
@@ -99,9 +154,14 @@ function buildIndex(root) {
99
154
 
100
155
  seen[key] = absPath
101
156
  index[parsed.suffix][parsed.name] = absPath
157
+
158
+ // Track which folder a prototype belongs to
159
+ if (parsed.suffix === 'prototype' && parsed.folder) {
160
+ protoFolders[parsed.name] = parsed.folder
161
+ }
102
162
  }
103
163
 
104
- return index
164
+ return { index, protoFolders }
105
165
  }
106
166
 
107
167
  /**
@@ -164,10 +224,10 @@ function readModesConfig(root) {
164
224
  return fallback
165
225
  }
166
226
 
167
- function generateModule(index, root) {
227
+ function generateModule({ index, protoFolders }, root) {
168
228
  const declarations = []
169
- const INDEX_KEYS = ['flow', 'object', 'record', 'prototype']
170
- const entries = { flow: [], object: [], record: [], prototype: [] }
229
+ const INDEX_KEYS = ['flow', 'object', 'record', 'prototype', 'folder']
230
+ const entries = { flow: [], object: [], record: [], prototype: [], folder: [] }
171
231
  let i = 0
172
232
 
173
233
  for (const suffix of INDEX_KEYS) {
@@ -184,13 +244,27 @@ function generateModule(index, root) {
184
244
  }
185
245
  }
186
246
 
247
+ // Auto-fill lastModified from git history for prototypes
248
+ if (suffix === 'prototype' && parsed) {
249
+ const protoDir = path.dirname(absPath)
250
+ const lastModified = getLastModified(root, protoDir)
251
+ if (lastModified) {
252
+ parsed = { ...parsed, lastModified }
253
+ }
254
+ }
255
+
256
+ // Inject folder association into prototype metadata
257
+ if (suffix === 'prototype' && protoFolders[name]) {
258
+ parsed = { ...parsed, folder: protoFolders[name] }
259
+ }
260
+
187
261
  declarations.push(`const ${varName} = ${JSON.stringify(parsed)}`)
188
262
  entries[suffix].push(` ${JSON.stringify(name)}: ${varName}`)
189
263
  }
190
264
  }
191
265
 
192
266
  const imports = [`import { init } from '@dfosco/storyboard-core'`]
193
- const initCalls = [`init({ flows, objects, records, prototypes })`]
267
+ const initCalls = [`init({ flows, objects, records, prototypes, folders })`]
194
268
 
195
269
  // Feature flags from storyboard.config.json
196
270
  const { config } = readConfig(root)
@@ -237,14 +311,15 @@ function generateModule(index, root) {
237
311
  `const objects = {\n${entries.object.join(',\n')}\n}`,
238
312
  `const records = {\n${entries.record.join(',\n')}\n}`,
239
313
  `const prototypes = {\n${entries.prototype.join(',\n')}\n}`,
314
+ `const folders = {\n${entries.folder.join(',\n')}\n}`,
240
315
  '',
241
316
  '// Backward-compatible alias',
242
317
  'const scenes = flows',
243
318
  '',
244
319
  initCalls.join('\n'),
245
320
  '',
246
- `export { flows, scenes, objects, records, prototypes }`,
247
- `export const index = { flows, scenes, objects, records, prototypes }`,
321
+ `export { flows, scenes, objects, records, prototypes, folders }`,
322
+ `export const index = { flows, scenes, objects, records, prototypes, folders }`,
248
323
  `export default index`,
249
324
  ].join('\n')
250
325
  }
@@ -259,7 +334,7 @@ function generateModule(index, root) {
259
334
  */
260
335
  export default function storyboardDataPlugin() {
261
336
  let root = ''
262
- let index = null
337
+ let buildResult = null
263
338
 
264
339
  return {
265
340
  name: 'storyboard-data',
@@ -283,8 +358,8 @@ export default function storyboardDataPlugin() {
283
358
 
284
359
  load(id) {
285
360
  if (id !== RESOLVED_ID) return null
286
- if (!index) index = buildIndex(root)
287
- return generateModule(index, root)
361
+ if (!buildResult) buildResult = buildIndex(root)
362
+ return generateModule(buildResult, root)
288
363
  },
289
364
 
290
365
  configureServer(server) {
@@ -293,9 +368,11 @@ export default function storyboardDataPlugin() {
293
368
 
294
369
  const invalidate = (filePath) => {
295
370
  const parsed = parseDataFile(filePath)
296
- if (!parsed) return
371
+ // Also invalidate when files are added/removed inside .folder/ directories
372
+ const inFolder = filePath.replace(/\\/g, '/').includes('.folder/')
373
+ if (!parsed && !inFolder) return
297
374
  // Rebuild index and invalidate virtual module
298
- index = null
375
+ buildResult = null
299
376
  const mod = server.moduleGraph.getModuleById(RESOLVED_ID)
300
377
  if (mod) {
301
378
  server.moduleGraph.invalidateModule(mod)
@@ -308,7 +385,7 @@ export default function storyboardDataPlugin() {
308
385
  watcher.add(configPath)
309
386
  const invalidateConfig = (filePath) => {
310
387
  if (path.resolve(filePath) === configPath) {
311
- index = null
388
+ buildResult = null
312
389
  const mod = server.moduleGraph.getModuleById(RESOLVED_ID)
313
390
  if (mod) {
314
391
  server.moduleGraph.invalidateModule(mod)
@@ -327,7 +404,7 @@ export default function storyboardDataPlugin() {
327
404
 
328
405
  // Rebuild index on each build start
329
406
  buildStart() {
330
- index = null
407
+ buildResult = null
331
408
  },
332
409
  }
333
410
  }
@@ -69,13 +69,13 @@ describe('storyboardDataPlugin', () => {
69
69
  const code = plugin.load(RESOLVED_ID)
70
70
 
71
71
  expect(code).toContain("import { init } from '@dfosco/storyboard-core'")
72
- expect(code).toContain('init({ flows, objects, records, prototypes })')
72
+ expect(code).toContain('init({ flows, objects, records, prototypes, folders })')
73
73
  expect(code).toContain('"Test"')
74
74
  expect(code).toContain('"Jane"')
75
75
  expect(code).toContain('"First"')
76
76
  // Backward-compat alias
77
77
  expect(code).toContain('const scenes = flows')
78
- expect(code).toContain('export { flows, scenes, objects, records, prototypes }')
78
+ expect(code).toContain('export { flows, scenes, objects, records, prototypes, folders }')
79
79
  })
80
80
 
81
81
  it('load returns null for other IDs', () => {
@@ -137,7 +137,7 @@ describe('storyboardDataPlugin', () => {
137
137
 
138
138
  // .scene.json files should be normalized to the flows category
139
139
  expect(code).toContain('"Legacy Scene"')
140
- expect(code).toContain('init({ flows, objects, records, prototypes })')
140
+ expect(code).toContain('init({ flows, objects, records, prototypes, folders })')
141
141
  })
142
142
 
143
143
  it('buildStart resets the index cache', () => {
@@ -264,3 +264,174 @@ describe('prototype scoping', () => {
264
264
  expect(code).toContain('flows')
265
265
  })
266
266
  })
267
+
268
+ describe('folder grouping', () => {
269
+ it('discovers .folder.json files and keys them by folder directory name', () => {
270
+ mkdirSync(path.join(tmpDir, 'src', 'prototypes', 'Getting Started.folder'), { recursive: true })
271
+ writeFileSync(
272
+ path.join(tmpDir, 'src', 'prototypes', 'Getting Started.folder', 'getting-started.folder.json'),
273
+ JSON.stringify({ meta: { title: 'Getting Started', description: 'Intro' } }),
274
+ )
275
+
276
+ const plugin = createPlugin()
277
+ const code = plugin.load(RESOLVED_ID)
278
+
279
+ expect(code).toContain('"Getting Started"')
280
+ expect(code).toContain('"Intro"')
281
+ expect(code).toContain('folders')
282
+ })
283
+
284
+ it('scopes prototypes inside .folder/ directories correctly', () => {
285
+ mkdirSync(path.join(tmpDir, 'src', 'prototypes', 'MyFolder.folder', 'Dashboard'), { recursive: true })
286
+ writeFileSync(
287
+ path.join(tmpDir, 'src', 'prototypes', 'MyFolder.folder', 'Dashboard', 'default.flow.json'),
288
+ JSON.stringify({ title: 'Dashboard Default' }),
289
+ )
290
+ writeFileSync(
291
+ path.join(tmpDir, 'src', 'prototypes', 'MyFolder.folder', 'Dashboard', 'dashboard.prototype.json'),
292
+ JSON.stringify({ meta: { title: 'Dashboard' } }),
293
+ )
294
+
295
+ const plugin = createPlugin()
296
+ const code = plugin.load(RESOLVED_ID)
297
+
298
+ // Flow should be scoped to prototype, not folder
299
+ expect(code).toContain('"Dashboard/default"')
300
+ expect(code).not.toContain('"MyFolder.folder/default"')
301
+ expect(code).not.toContain('"MyFolder/default"')
302
+ // Prototype should have folder field injected
303
+ expect(code).toContain('"folder":"MyFolder"')
304
+ })
305
+
306
+ it('does NOT prefix objects inside .folder/ directories', () => {
307
+ mkdirSync(path.join(tmpDir, 'src', 'prototypes', 'X.folder', 'Proto'), { recursive: true })
308
+ writeFileSync(
309
+ path.join(tmpDir, 'src', 'prototypes', 'X.folder', 'Proto', 'helpers.object.json'),
310
+ JSON.stringify({ util: true }),
311
+ )
312
+
313
+ const plugin = createPlugin()
314
+ const code = plugin.load(RESOLVED_ID)
315
+
316
+ expect(code).toContain('"helpers"')
317
+ expect(code).not.toContain('"X/helpers"')
318
+ expect(code).not.toContain('"Proto/helpers"')
319
+ })
320
+
321
+ it('scopes records inside .folder/ directories to their prototype', () => {
322
+ mkdirSync(path.join(tmpDir, 'src', 'prototypes', 'X.folder', 'Blog'), { recursive: true })
323
+ writeFileSync(
324
+ path.join(tmpDir, 'src', 'prototypes', 'X.folder', 'Blog', 'posts.record.json'),
325
+ JSON.stringify([{ id: '1', title: 'Post' }]),
326
+ )
327
+
328
+ const plugin = createPlugin()
329
+ const code = plugin.load(RESOLVED_ID)
330
+
331
+ expect(code).toContain('"Blog/posts"')
332
+ expect(code).not.toContain('"X/posts"')
333
+ })
334
+
335
+ it('allows prototypes with same name in different folders without clash', () => {
336
+ mkdirSync(path.join(tmpDir, 'src', 'prototypes', 'A.folder', 'Settings'), { recursive: true })
337
+ mkdirSync(path.join(tmpDir, 'src', 'prototypes', 'B.folder', 'Settings'), { recursive: true })
338
+ writeFileSync(
339
+ path.join(tmpDir, 'src', 'prototypes', 'A.folder', 'Settings', 'default.flow.json'),
340
+ JSON.stringify({ from: 'A' }),
341
+ )
342
+ writeFileSync(
343
+ path.join(tmpDir, 'src', 'prototypes', 'B.folder', 'Settings', 'default.flow.json'),
344
+ JSON.stringify({ from: 'B' }),
345
+ )
346
+
347
+ const plugin = createPlugin()
348
+ // Same flow name in same prototype name → duplicate collision
349
+ expect(() => plugin.load(RESOLVED_ID)).toThrow(/Duplicate flow "Settings\/default"/)
350
+ })
351
+
352
+ it('throws on nested .folder/ directories', () => {
353
+ mkdirSync(path.join(tmpDir, 'src', 'prototypes', 'Outer.folder', 'Inner.folder', 'Proto'), { recursive: true })
354
+ writeFileSync(
355
+ path.join(tmpDir, 'src', 'prototypes', 'Outer.folder', 'Inner.folder', 'Proto', 'default.flow.json'),
356
+ JSON.stringify({ title: 'Nested' }),
357
+ )
358
+
359
+ const plugin = createPlugin()
360
+ expect(() => plugin.load(RESOLVED_ID)).toThrow(/Nested .folder directories are not supported/)
361
+ })
362
+
363
+ it('throws on empty nested .folder/ directories', () => {
364
+ mkdirSync(path.join(tmpDir, 'src', 'prototypes', 'Outer.folder', 'Inner.folder'), { recursive: true })
365
+
366
+ const plugin = createPlugin()
367
+ expect(() => plugin.load(RESOLVED_ID)).toThrow(/Nested .folder directories are not supported/)
368
+ })
369
+ })
370
+
371
+ describe('underscore prefix ignoring', () => {
372
+ it('ignores _-prefixed data files', () => {
373
+ writeFileSync(
374
+ path.join(tmpDir, '_draft.flow.json'),
375
+ JSON.stringify({ title: 'Draft' }),
376
+ )
377
+ writeFileSync(
378
+ path.join(tmpDir, 'visible.flow.json'),
379
+ JSON.stringify({ title: 'Visible' }),
380
+ )
381
+
382
+ const plugin = createPlugin()
383
+ const code = plugin.load(RESOLVED_ID)
384
+
385
+ expect(code).toContain('"Visible"')
386
+ expect(code).not.toContain('"Draft"')
387
+ })
388
+
389
+ it('ignores data files inside _-prefixed directories', () => {
390
+ mkdirSync(path.join(tmpDir, '_archive'), { recursive: true })
391
+ writeFileSync(
392
+ path.join(tmpDir, '_archive', 'old.flow.json'),
393
+ JSON.stringify({ title: 'Archived' }),
394
+ )
395
+ writeFileSync(
396
+ path.join(tmpDir, 'current.flow.json'),
397
+ JSON.stringify({ title: 'Current' }),
398
+ )
399
+
400
+ const plugin = createPlugin()
401
+ const code = plugin.load(RESOLVED_ID)
402
+
403
+ expect(code).toContain('"Current"')
404
+ expect(code).not.toContain('"Archived"')
405
+ })
406
+
407
+ it('ignores prototype.json inside _-prefixed directories', () => {
408
+ mkdirSync(path.join(tmpDir, 'src', 'prototypes', '_WIP'), { recursive: true })
409
+ writeFileSync(
410
+ path.join(tmpDir, 'src', 'prototypes', '_WIP', 'wip.prototype.json'),
411
+ JSON.stringify({ meta: { title: 'Work in Progress' } }),
412
+ )
413
+ mkdirSync(path.join(tmpDir, 'src', 'prototypes', 'Live'), { recursive: true })
414
+ writeFileSync(
415
+ path.join(tmpDir, 'src', 'prototypes', 'Live', 'live.prototype.json'),
416
+ JSON.stringify({ meta: { title: 'Live' } }),
417
+ )
418
+
419
+ const plugin = createPlugin()
420
+ const code = plugin.load(RESOLVED_ID)
421
+
422
+ expect(code).toContain('"Live"')
423
+ expect(code).not.toContain('"Work in Progress"')
424
+ })
425
+
426
+ it('does not ignore files with _ in the middle of the name', () => {
427
+ writeFileSync(
428
+ path.join(tmpDir, 'my_flow.flow.json'),
429
+ JSON.stringify({ title: 'Has Underscore' }),
430
+ )
431
+
432
+ const plugin = createPlugin()
433
+ const code = plugin.load(RESOLVED_ID)
434
+
435
+ expect(code).toContain('"Has Underscore"')
436
+ })
437
+ })