@dfosco/storyboard-react 2.0.0 → 2.2.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.
@@ -1,24 +1,68 @@
1
1
  import fs from 'node:fs'
2
2
  import path from 'node:path'
3
+ import { execSync } from 'node:child_process'
3
4
  import { globSync } from 'glob'
4
5
  import { parse as parseJsonc } from 'jsonc-parser'
5
6
 
6
7
  const VIRTUAL_MODULE_ID = 'virtual:storyboard-data-index'
7
8
  const RESOLVED_ID = '\0' + VIRTUAL_MODULE_ID
8
9
 
9
- const SUFFIXES = ['scene', 'object', 'record']
10
- const GLOB_PATTERN = '**/*.{scene,object,record}.{json,jsonc}'
10
+ const GLOB_PATTERN = '**/*.{flow,scene,object,record,prototype}.{json,jsonc}'
11
11
 
12
12
  /**
13
13
  * Extract the data name and type suffix from a file path.
14
- * e.g. "src/data/default.scene.json" → { name: "default", suffix: "scene" }
15
- * "anywhere/posts.record.jsonc" { name: "posts", suffix: "record" }
14
+ * Flows and records inside src/prototypes/{Name}/ get prefixed with the
15
+ * prototype name (e.g. "Dashboard/default"). Objects are never prefixed.
16
+ *
17
+ * e.g. "src/data/default.flow.json" → { name: "default", suffix: "flow" }
18
+ * "src/prototypes/Dashboard/default.flow.json" → { name: "Dashboard/default", suffix: "flow" }
19
+ * "src/prototypes/Dashboard/helpers.object.json"→ { name: "helpers", suffix: "object" }
16
20
  */
17
21
  function parseDataFile(filePath) {
18
22
  const base = path.basename(filePath)
19
- const match = base.match(/^(.+)\.(scene|object|record)\.(jsonc?)$/)
23
+ const match = base.match(/^(.+)\.(flow|scene|object|record|prototype)\.(jsonc?)$/)
20
24
  if (!match) return null
21
- return { name: match[1], suffix: match[2], ext: match[3] }
25
+ // Normalize .scene .flow for backward compatibility
26
+ const suffix = match[2] === 'scene' ? 'flow' : match[2]
27
+ let name = match[1]
28
+
29
+ // Prototype metadata files are keyed by their prototype directory name
30
+ if (suffix === 'prototype') {
31
+ const normalized = filePath.replace(/\\/g, '/')
32
+ const protoMatch = normalized.match(/(?:^|\/)src\/prototypes\/([^/]+)\//)
33
+ if (protoMatch) {
34
+ name = protoMatch[1]
35
+ }
36
+ return { name, suffix, ext: match[3] }
37
+ }
38
+
39
+ // Scope flows and records inside src/prototypes/{Name}/ with a prefix
40
+ if (suffix !== 'object') {
41
+ const normalized = filePath.replace(/\\/g, '/')
42
+ const protoMatch = normalized.match(/(?:^|\/)src\/prototypes\/([^/]+)\//)
43
+ if (protoMatch) {
44
+ name = `${protoMatch[1]}/${name}`
45
+ }
46
+ }
47
+
48
+ return { name, suffix, ext: match[3] }
49
+ }
50
+
51
+ /**
52
+ * Look up the git author who first created a file.
53
+ * Used to auto-fill the author field in .prototype.json when missing.
54
+ */
55
+ function getGitAuthor(root, filePath) {
56
+ try {
57
+ const result = execSync(
58
+ `git log --follow --diff-filter=A --format="%aN" -- "${filePath}"`,
59
+ { cwd: root, encoding: 'utf-8', timeout: 5000 },
60
+ ).trim()
61
+ const lines = result.split('\n').filter(Boolean)
62
+ return lines.length > 0 ? lines[lines.length - 1] : null
63
+ } catch {
64
+ return null
65
+ }
22
66
  }
23
67
 
24
68
  /**
@@ -28,7 +72,7 @@ function buildIndex(root) {
28
72
  const ignore = ['node_modules/**', 'dist/**', '.git/**']
29
73
  const files = globSync(GLOB_PATTERN, { cwd: root, ignore, absolute: false })
30
74
 
31
- const index = { scene: {}, object: {}, record: {} }
75
+ const index = { flow: {}, object: {}, record: {}, prototype: {} }
32
76
  const seen = {} // "name.suffix" → absolute path (for duplicate detection)
33
77
 
34
78
  for (const relPath of files) {
@@ -39,11 +83,17 @@ function buildIndex(root) {
39
83
  const absPath = path.resolve(root, relPath)
40
84
 
41
85
  if (seen[key]) {
86
+ const hint = parsed.suffix === 'object'
87
+ ? ' Objects are globally scoped — even inside src/prototypes/ they share a single namespace.\n' +
88
+ ' 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.'
91
+
42
92
  throw new Error(
43
- `[storyboard-data] Duplicate data file: "${key}.json"\n` +
93
+ `[storyboard-data] Duplicate ${parsed.suffix} "${parsed.name}"\n` +
44
94
  ` Found at: ${seen[key]}\n` +
45
95
  ` And at: ${absPath}\n` +
46
- ` Every data file name+suffix must be unique across the repo.`
96
+ hint
47
97
  )
48
98
  }
49
99
 
@@ -77,23 +127,70 @@ function readConfig(root) {
77
127
  }
78
128
  }
79
129
 
130
+ /**
131
+ * Read modes.config.json from @dfosco/storyboard-core.
132
+ * Returns the full config object { modes, tools }.
133
+ * Falls back to hardcoded defaults if not found.
134
+ */
135
+ function readModesConfig(root) {
136
+ const fallback = {
137
+ modes: [
138
+ { name: 'prototype', label: 'Navigate' },
139
+ { name: 'inspect', label: 'Develop' },
140
+ { name: 'present', label: 'Collaborate' },
141
+ { name: 'plan', label: 'Canvas' },
142
+ ],
143
+ tools: {},
144
+ }
145
+
146
+ // Try local workspace path first (monorepo), then node_modules
147
+ const candidates = [
148
+ path.resolve(root, 'packages/core/modes.config.json'),
149
+ path.resolve(root, 'node_modules/@dfosco/storyboard-core/modes.config.json'),
150
+ ]
151
+
152
+ for (const filePath of candidates) {
153
+ try {
154
+ const raw = fs.readFileSync(filePath, 'utf-8')
155
+ const parsed = JSON.parse(raw)
156
+ if (Array.isArray(parsed.modes) && parsed.modes.length > 0) {
157
+ return { modes: parsed.modes, tools: parsed.tools ?? {} }
158
+ }
159
+ } catch {
160
+ // try next candidate
161
+ }
162
+ }
163
+
164
+ return fallback
165
+ }
166
+
80
167
  function generateModule(index, root) {
81
168
  const declarations = []
82
- const entries = { scene: [], object: [], record: [] }
169
+ const INDEX_KEYS = ['flow', 'object', 'record', 'prototype']
170
+ const entries = { flow: [], object: [], record: [], prototype: [] }
83
171
  let i = 0
84
172
 
85
- for (const suffix of SUFFIXES) {
173
+ for (const suffix of INDEX_KEYS) {
86
174
  for (const [name, absPath] of Object.entries(index[suffix])) {
87
175
  const varName = `_d${i++}`
88
176
  const raw = fs.readFileSync(absPath, 'utf-8')
89
- const parsed = parseJsonc(raw)
177
+ let parsed = parseJsonc(raw)
178
+
179
+ // Auto-fill gitAuthor for prototype metadata from git history
180
+ if (suffix === 'prototype' && parsed && !parsed.gitAuthor) {
181
+ const gitAuthor = getGitAuthor(root, absPath)
182
+ if (gitAuthor) {
183
+ parsed = { ...parsed, gitAuthor }
184
+ }
185
+ }
186
+
90
187
  declarations.push(`const ${varName} = ${JSON.stringify(parsed)}`)
91
188
  entries[suffix].push(` ${JSON.stringify(name)}: ${varName}`)
92
189
  }
93
190
  }
94
191
 
95
192
  const imports = [`import { init } from '@dfosco/storyboard-core'`]
96
- const initCalls = [`init({ scenes, objects, records })`]
193
+ const initCalls = [`init({ flows, objects, records, prototypes })`]
97
194
 
98
195
  // Feature flags from storyboard.config.json
99
196
  const { config } = readConfig(root)
@@ -110,8 +207,25 @@ function generateModule(index, root) {
110
207
 
111
208
  // Modes configuration from storyboard.config.json
112
209
  if (config?.modes) {
113
- imports.push(`import { initModesConfig } from '@dfosco/storyboard-core'`)
210
+ imports.push(`import { initModesConfig, registerMode, syncModeClasses, initTools } from '@dfosco/storyboard-core'`)
114
211
  initCalls.push(`initModesConfig(${JSON.stringify(config.modes)})`)
212
+
213
+ if (config.modes.enabled) {
214
+ imports.push(`import '@dfosco/storyboard-core/modes.css'`)
215
+
216
+ const modesConfig = readModesConfig(root)
217
+ const modes = config.modes.defaults || modesConfig.modes
218
+ for (const m of modes) {
219
+ initCalls.push(`registerMode(${JSON.stringify(m.name)}, { label: ${JSON.stringify(m.label)} })`)
220
+ }
221
+
222
+ // Seed tool registry from modes.config.json
223
+ if (Object.keys(modesConfig.tools).length > 0) {
224
+ initCalls.push(`initTools(${JSON.stringify(modesConfig.tools)})`)
225
+ }
226
+
227
+ initCalls.push(`syncModeClasses()`)
228
+ }
115
229
  }
116
230
 
117
231
  return [
@@ -119,14 +233,18 @@ function generateModule(index, root) {
119
233
  '',
120
234
  declarations.join('\n'),
121
235
  '',
122
- `const scenes = {\n${entries.scene.join(',\n')}\n}`,
236
+ `const flows = {\n${entries.flow.join(',\n')}\n}`,
123
237
  `const objects = {\n${entries.object.join(',\n')}\n}`,
124
238
  `const records = {\n${entries.record.join(',\n')}\n}`,
239
+ `const prototypes = {\n${entries.prototype.join(',\n')}\n}`,
240
+ '',
241
+ '// Backward-compatible alias',
242
+ 'const scenes = flows',
125
243
  '',
126
244
  initCalls.join('\n'),
127
245
  '',
128
- `export { scenes, objects, records }`,
129
- `export const index = { scenes, objects, records }`,
246
+ `export { flows, scenes, objects, records, prototypes }`,
247
+ `export const index = { flows, scenes, objects, records, prototypes }`,
130
248
  `export default index`,
131
249
  ].join('\n')
132
250
  }
@@ -134,7 +252,7 @@ function generateModule(index, root) {
134
252
  /**
135
253
  * Vite plugin for storyboard data discovery.
136
254
  *
137
- * - Scans the repo for *.scene.json, *.object.json, *.record.json
255
+ * - Scans the repo for *.flow.json, *.scene.json (compat), *.object.json, *.record.json
138
256
  * - Validates no two files share the same name+suffix (hard build error)
139
257
  * - Generates a virtual module `virtual:storyboard-data-index`
140
258
  * - Watches for file additions/removals in dev mode
@@ -69,10 +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({ scenes, objects, records })')
72
+ expect(code).toContain('init({ flows, objects, records, prototypes })')
73
73
  expect(code).toContain('"Test"')
74
74
  expect(code).toContain('"Jane"')
75
75
  expect(code).toContain('"First"')
76
+ // Backward-compat alias
77
+ expect(code).toContain('const scenes = flows')
78
+ expect(code).toContain('export { flows, scenes, objects, records, prototypes }')
76
79
  })
77
80
 
78
81
  it('load returns null for other IDs', () => {
@@ -93,7 +96,24 @@ describe('storyboardDataPlugin', () => {
93
96
  )
94
97
 
95
98
  const plugin = createPlugin()
96
- expect(() => plugin.load(RESOLVED_ID)).toThrow(/Duplicate data file/)
99
+ expect(() => plugin.load(RESOLVED_ID)).toThrow(/Duplicate flow "dup"/)
100
+ })
101
+
102
+ it('duplicate objects show globally-scoped hint', () => {
103
+ mkdirSync(path.join(tmpDir, 'src', 'data'), { recursive: true })
104
+ mkdirSync(path.join(tmpDir, 'src', 'prototypes', 'Dashboard'), { recursive: true })
105
+ writeFileSync(
106
+ path.join(tmpDir, 'src', 'data', 'user.object.json'),
107
+ JSON.stringify({ name: 'Global' }),
108
+ )
109
+ writeFileSync(
110
+ path.join(tmpDir, 'src', 'prototypes', 'Dashboard', 'user.object.json'),
111
+ JSON.stringify({ name: 'Local' }),
112
+ )
113
+
114
+ const plugin = createPlugin()
115
+ expect(() => plugin.load(RESOLVED_ID)).toThrow(/Duplicate object "user"/)
116
+ expect(() => plugin.load(RESOLVED_ID)).toThrow(/globally scoped/)
97
117
  })
98
118
 
99
119
  it('handles JSONC files (with comments)', () => {
@@ -107,6 +127,19 @@ describe('storyboardDataPlugin', () => {
107
127
  expect(code).toContain('"JSONC Scene"')
108
128
  })
109
129
 
130
+ it('normalizes .scene files into flow category in the index', () => {
131
+ writeFileSync(
132
+ path.join(tmpDir, 'legacy.scene.json'),
133
+ JSON.stringify({ title: 'Legacy Scene' }),
134
+ )
135
+ const plugin = createPlugin()
136
+ const code = plugin.load(RESOLVED_ID)
137
+
138
+ // .scene.json files should be normalized to the flows category
139
+ expect(code).toContain('"Legacy Scene"')
140
+ expect(code).toContain('init({ flows, objects, records, prototypes })')
141
+ })
142
+
110
143
  it('buildStart resets the index cache', () => {
111
144
  writeDataFiles(tmpDir)
112
145
  const plugin = createPlugin()
@@ -131,3 +164,103 @@ describe('storyboardDataPlugin', () => {
131
164
  expect(code3).toContain('"Extra"')
132
165
  })
133
166
  })
167
+
168
+ describe('prototype scoping', () => {
169
+ it('prefixes flows inside src/prototypes/{Name}/ with the prototype name', () => {
170
+ mkdirSync(path.join(tmpDir, 'src', 'prototypes', 'Dashboard'), { recursive: true })
171
+ writeFileSync(
172
+ path.join(tmpDir, 'src', 'prototypes', 'Dashboard', 'default.flow.json'),
173
+ JSON.stringify({ title: 'Dashboard Default' }),
174
+ )
175
+ writeFileSync(
176
+ path.join(tmpDir, 'src', 'prototypes', 'Dashboard', 'signup.flow.json'),
177
+ JSON.stringify({ title: 'Dashboard Signup' }),
178
+ )
179
+ // Global flow in src/data/
180
+ mkdirSync(path.join(tmpDir, 'src', 'data'), { recursive: true })
181
+ writeFileSync(
182
+ path.join(tmpDir, 'src', 'data', 'default.flow.json'),
183
+ JSON.stringify({ title: 'Global Default' }),
184
+ )
185
+
186
+ const plugin = createPlugin()
187
+ const code = plugin.load(RESOLVED_ID)
188
+
189
+ expect(code).toContain('"Dashboard/default"')
190
+ expect(code).toContain('"Dashboard/signup"')
191
+ expect(code).toContain('"default"')
192
+ expect(code).toContain('"Dashboard Default"')
193
+ expect(code).toContain('"Global Default"')
194
+ })
195
+
196
+ it('prefixes records inside src/prototypes/{Name}/ with the prototype name', () => {
197
+ mkdirSync(path.join(tmpDir, 'src', 'prototypes', 'Blog'), { recursive: true })
198
+ writeFileSync(
199
+ path.join(tmpDir, 'src', 'prototypes', 'Blog', 'posts.record.json'),
200
+ JSON.stringify([{ id: '1', title: 'Scoped Post' }]),
201
+ )
202
+ // Global record
203
+ mkdirSync(path.join(tmpDir, 'src', 'data'), { recursive: true })
204
+ writeFileSync(
205
+ path.join(tmpDir, 'src', 'data', 'posts.record.json'),
206
+ JSON.stringify([{ id: '1', title: 'Global Post' }]),
207
+ )
208
+
209
+ const plugin = createPlugin()
210
+ const code = plugin.load(RESOLVED_ID)
211
+
212
+ expect(code).toContain('"Blog/posts"')
213
+ expect(code).toContain('"posts"')
214
+ expect(code).toContain('"Scoped Post"')
215
+ expect(code).toContain('"Global Post"')
216
+ })
217
+
218
+ it('does NOT prefix objects inside src/prototypes/{Name}/', () => {
219
+ mkdirSync(path.join(tmpDir, 'src', 'prototypes', 'Dashboard'), { recursive: true })
220
+ writeFileSync(
221
+ path.join(tmpDir, 'src', 'prototypes', 'Dashboard', 'helpers.object.json'),
222
+ JSON.stringify({ util: true }),
223
+ )
224
+
225
+ const plugin = createPlugin()
226
+ const code = plugin.load(RESOLVED_ID)
227
+
228
+ // Object should be plain "helpers", NOT "Dashboard/helpers"
229
+ expect(code).toContain('"helpers"')
230
+ expect(code).not.toContain('"Dashboard/helpers"')
231
+ })
232
+
233
+ it('allows same flow name in different prototypes without clash', () => {
234
+ mkdirSync(path.join(tmpDir, 'src', 'prototypes', 'A'), { recursive: true })
235
+ mkdirSync(path.join(tmpDir, 'src', 'prototypes', 'B'), { recursive: true })
236
+ writeFileSync(
237
+ path.join(tmpDir, 'src', 'prototypes', 'A', 'default.flow.json'),
238
+ JSON.stringify({ from: 'A' }),
239
+ )
240
+ writeFileSync(
241
+ path.join(tmpDir, 'src', 'prototypes', 'B', 'default.flow.json'),
242
+ JSON.stringify({ from: 'B' }),
243
+ )
244
+
245
+ const plugin = createPlugin()
246
+ // Should not throw (no duplicate)
247
+ const code = plugin.load(RESOLVED_ID)
248
+ expect(code).toContain('"A/default"')
249
+ expect(code).toContain('"B/default"')
250
+ })
251
+
252
+ it('normalizes .scene.json inside prototypes to scoped flow', () => {
253
+ mkdirSync(path.join(tmpDir, 'src', 'prototypes', 'Legacy'), { recursive: true })
254
+ writeFileSync(
255
+ path.join(tmpDir, 'src', 'prototypes', 'Legacy', 'old.scene.json'),
256
+ JSON.stringify({ compat: true }),
257
+ )
258
+
259
+ const plugin = createPlugin()
260
+ const code = plugin.load(RESOLVED_ID)
261
+
262
+ // Should be indexed as a scoped flow, not a scene
263
+ expect(code).toContain('"Legacy/old"')
264
+ expect(code).toContain('flows')
265
+ })
266
+ })