@dfosco/storyboard-react 2.2.0 → 2.3.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.3.0",
4
4
  "type": "module",
5
5
  "dependencies": {
6
- "@dfosco/storyboard-core": "2.2.0",
6
+ "@dfosco/storyboard-core": "2.3.0",
7
7
  "glob": "^11.0.0",
8
8
  "jsonc-parser": "^3.3.1"
9
9
  },
@@ -7,39 +7,55 @@ 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
25
27
  // Normalize .scene → .flow for backward compatibility
26
28
  const suffix = match[2] === 'scene' ? 'flow' : match[2]
27
29
  let name = match[1]
28
30
 
31
+ const normalized = filePath.replace(/\\/g, '/')
32
+
33
+ // Detect if this file is inside a .folder/ directory
34
+ const folderDirMatch = normalized.match(/(?:^|\/)src\/prototypes\/([^/]+)\.folder\//)
35
+ const folderName = folderDirMatch ? folderDirMatch[1] : null
36
+
37
+ // Folder metadata files are keyed by their folder directory name (sans .folder suffix)
38
+ if (suffix === 'folder') {
39
+ if (folderName) {
40
+ name = folderName
41
+ }
42
+ return { name, suffix, ext: match[3] }
43
+ }
44
+
29
45
  // Prototype metadata files are keyed by their prototype directory name
46
+ // (skip .folder/ segments when determining prototype name)
30
47
  if (suffix === 'prototype') {
31
- const normalized = filePath.replace(/\\/g, '/')
32
- const protoMatch = normalized.match(/(?:^|\/)src\/prototypes\/([^/]+)\//)
48
+ const protoMatch = normalized.match(/(?:^|\/)src\/prototypes\/(?:[^/]+\.folder\/)?([^/]+)\//)
33
49
  if (protoMatch) {
34
50
  name = protoMatch[1]
35
51
  }
36
- return { name, suffix, ext: match[3] }
52
+ return { name, suffix, ext: match[3], folder: folderName }
37
53
  }
38
54
 
39
55
  // Scope flows and records inside src/prototypes/{Name}/ with a prefix
56
+ // (skip .folder/ segments when determining prototype name)
40
57
  if (suffix !== 'object') {
41
- const normalized = filePath.replace(/\\/g, '/')
42
- const protoMatch = normalized.match(/(?:^|\/)src\/prototypes\/([^/]+)\//)
58
+ const protoMatch = normalized.match(/(?:^|\/)src\/prototypes\/(?:[^/]+\.folder\/)?([^/]+)\//)
43
59
  if (protoMatch) {
44
60
  name = `${protoMatch[1]}/${name}`
45
61
  }
@@ -65,6 +81,22 @@ function getGitAuthor(root, filePath) {
65
81
  }
66
82
  }
67
83
 
84
+ /**
85
+ * Look up the most recent commit date for any file in a directory.
86
+ * Returns an ISO 8601 timestamp, or null if unavailable.
87
+ */
88
+ function getLastModified(root, dirPath) {
89
+ try {
90
+ const result = execSync(
91
+ `git log -1 --format="%aI" -- "${dirPath}"`,
92
+ { cwd: root, encoding: 'utf-8', timeout: 5000 },
93
+ ).trim()
94
+ return result || null
95
+ } catch {
96
+ return null
97
+ }
98
+ }
99
+
68
100
  /**
69
101
  * Scan the repo for all data files, validate uniqueness, return the index.
70
102
  */
@@ -72,8 +104,24 @@ function buildIndex(root) {
72
104
  const ignore = ['node_modules/**', 'dist/**', '.git/**']
73
105
  const files = globSync(GLOB_PATTERN, { cwd: root, ignore, absolute: false })
74
106
 
75
- const index = { flow: {}, object: {}, record: {}, prototype: {} }
107
+ // Detect nested .folder/ directories (not supported)
108
+ // Scan directories directly since empty nested folders have no data files
109
+ const folderDirs = globSync('src/prototypes/**/*.folder', { cwd: root, ignore, absolute: false })
110
+ for (const dir of folderDirs) {
111
+ const normalized = dir.replace(/\\/g, '/')
112
+ const segments = normalized.split('/').filter(s => s.endsWith('.folder'))
113
+ if (segments.length > 1) {
114
+ throw new Error(
115
+ `[storyboard-data] Nested .folder directories are not supported.\n` +
116
+ ` Found at: ${dir}\n` +
117
+ ` Folders can only be one level deep inside src/prototypes/.`
118
+ )
119
+ }
120
+ }
121
+
122
+ const index = { flow: {}, object: {}, record: {}, prototype: {}, folder: {} }
76
123
  const seen = {} // "name.suffix" → absolute path (for duplicate detection)
124
+ const protoFolders = {} // prototype name → folder name (for injection)
77
125
 
78
126
  for (const relPath of files) {
79
127
  const parsed = parseDataFile(relPath)
@@ -86,8 +134,10 @@ function buildIndex(root) {
86
134
  const hint = parsed.suffix === 'object'
87
135
  ? ' Objects are globally scoped — even inside src/prototypes/ they share a single namespace.\n' +
88
136
  ' 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.'
137
+ : parsed.suffix === 'folder'
138
+ ? ' Folder names must be unique across the project.'
139
+ : ' Flows and records are scoped to their prototype directory.\n' +
140
+ ' If both files are global (outside src/prototypes/), rename one to avoid the collision.'
91
141
 
92
142
  throw new Error(
93
143
  `[storyboard-data] Duplicate ${parsed.suffix} "${parsed.name}"\n` +
@@ -99,9 +149,14 @@ function buildIndex(root) {
99
149
 
100
150
  seen[key] = absPath
101
151
  index[parsed.suffix][parsed.name] = absPath
152
+
153
+ // Track which folder a prototype belongs to
154
+ if (parsed.suffix === 'prototype' && parsed.folder) {
155
+ protoFolders[parsed.name] = parsed.folder
156
+ }
102
157
  }
103
158
 
104
- return index
159
+ return { index, protoFolders }
105
160
  }
106
161
 
107
162
  /**
@@ -164,10 +219,10 @@ function readModesConfig(root) {
164
219
  return fallback
165
220
  }
166
221
 
167
- function generateModule(index, root) {
222
+ function generateModule({ index, protoFolders }, root) {
168
223
  const declarations = []
169
- const INDEX_KEYS = ['flow', 'object', 'record', 'prototype']
170
- const entries = { flow: [], object: [], record: [], prototype: [] }
224
+ const INDEX_KEYS = ['flow', 'object', 'record', 'prototype', 'folder']
225
+ const entries = { flow: [], object: [], record: [], prototype: [], folder: [] }
171
226
  let i = 0
172
227
 
173
228
  for (const suffix of INDEX_KEYS) {
@@ -184,13 +239,27 @@ function generateModule(index, root) {
184
239
  }
185
240
  }
186
241
 
242
+ // Auto-fill lastModified from git history for prototypes
243
+ if (suffix === 'prototype' && parsed) {
244
+ const protoDir = path.dirname(absPath)
245
+ const lastModified = getLastModified(root, protoDir)
246
+ if (lastModified) {
247
+ parsed = { ...parsed, lastModified }
248
+ }
249
+ }
250
+
251
+ // Inject folder association into prototype metadata
252
+ if (suffix === 'prototype' && protoFolders[name]) {
253
+ parsed = { ...parsed, folder: protoFolders[name] }
254
+ }
255
+
187
256
  declarations.push(`const ${varName} = ${JSON.stringify(parsed)}`)
188
257
  entries[suffix].push(` ${JSON.stringify(name)}: ${varName}`)
189
258
  }
190
259
  }
191
260
 
192
261
  const imports = [`import { init } from '@dfosco/storyboard-core'`]
193
- const initCalls = [`init({ flows, objects, records, prototypes })`]
262
+ const initCalls = [`init({ flows, objects, records, prototypes, folders })`]
194
263
 
195
264
  // Feature flags from storyboard.config.json
196
265
  const { config } = readConfig(root)
@@ -237,14 +306,15 @@ function generateModule(index, root) {
237
306
  `const objects = {\n${entries.object.join(',\n')}\n}`,
238
307
  `const records = {\n${entries.record.join(',\n')}\n}`,
239
308
  `const prototypes = {\n${entries.prototype.join(',\n')}\n}`,
309
+ `const folders = {\n${entries.folder.join(',\n')}\n}`,
240
310
  '',
241
311
  '// Backward-compatible alias',
242
312
  'const scenes = flows',
243
313
  '',
244
314
  initCalls.join('\n'),
245
315
  '',
246
- `export { flows, scenes, objects, records, prototypes }`,
247
- `export const index = { flows, scenes, objects, records, prototypes }`,
316
+ `export { flows, scenes, objects, records, prototypes, folders }`,
317
+ `export const index = { flows, scenes, objects, records, prototypes, folders }`,
248
318
  `export default index`,
249
319
  ].join('\n')
250
320
  }
@@ -259,7 +329,7 @@ function generateModule(index, root) {
259
329
  */
260
330
  export default function storyboardDataPlugin() {
261
331
  let root = ''
262
- let index = null
332
+ let buildResult = null
263
333
 
264
334
  return {
265
335
  name: 'storyboard-data',
@@ -283,8 +353,8 @@ export default function storyboardDataPlugin() {
283
353
 
284
354
  load(id) {
285
355
  if (id !== RESOLVED_ID) return null
286
- if (!index) index = buildIndex(root)
287
- return generateModule(index, root)
356
+ if (!buildResult) buildResult = buildIndex(root)
357
+ return generateModule(buildResult, root)
288
358
  },
289
359
 
290
360
  configureServer(server) {
@@ -293,9 +363,11 @@ export default function storyboardDataPlugin() {
293
363
 
294
364
  const invalidate = (filePath) => {
295
365
  const parsed = parseDataFile(filePath)
296
- if (!parsed) return
366
+ // Also invalidate when files are added/removed inside .folder/ directories
367
+ const inFolder = filePath.replace(/\\/g, '/').includes('.folder/')
368
+ if (!parsed && !inFolder) return
297
369
  // Rebuild index and invalidate virtual module
298
- index = null
370
+ buildResult = null
299
371
  const mod = server.moduleGraph.getModuleById(RESOLVED_ID)
300
372
  if (mod) {
301
373
  server.moduleGraph.invalidateModule(mod)
@@ -308,7 +380,7 @@ export default function storyboardDataPlugin() {
308
380
  watcher.add(configPath)
309
381
  const invalidateConfig = (filePath) => {
310
382
  if (path.resolve(filePath) === configPath) {
311
- index = null
383
+ buildResult = null
312
384
  const mod = server.moduleGraph.getModuleById(RESOLVED_ID)
313
385
  if (mod) {
314
386
  server.moduleGraph.invalidateModule(mod)
@@ -327,7 +399,7 @@ export default function storyboardDataPlugin() {
327
399
 
328
400
  // Rebuild index on each build start
329
401
  buildStart() {
330
- index = null
402
+ buildResult = null
331
403
  },
332
404
  }
333
405
  }
@@ -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,106 @@ 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
+ })