@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 +2 -2
- package/src/vite/data-plugin.js +96 -24
- package/src/vite/data-plugin.test.js +106 -3
package/package.json
CHANGED
package/src/vite/data-plugin.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
-
:
|
|
90
|
-
'
|
|
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
|
|
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 (!
|
|
287
|
-
return generateModule(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
})
|