@dfosco/storyboard-react 2.4.0 → 2.6.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.4.0",
3
+ "version": "2.6.0",
4
4
  "type": "module",
5
5
  "dependencies": {
6
- "@dfosco/storyboard-core": "2.4.0",
6
+ "@dfosco/storyboard-core": "2.6.0",
7
7
  "glob": "^11.0.0",
8
8
  "jsonc-parser": "^3.3.1"
9
9
  },
@@ -0,0 +1,30 @@
1
+ .container {
2
+ padding: 80px 24px 24px 24px;
3
+ max-width: 700px;
4
+ margin: 0 auto;
5
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
6
+ font-size: 14px;
7
+ }
8
+
9
+ .banner {
10
+ padding: 12px 16px;
11
+ margin-bottom: 16px;
12
+ border-radius: 6px;
13
+ border: 1px solid var(--borderColor-danger-emphasis, #da3633);
14
+ background-color: var(--bgColor-danger-muted, rgba(248, 81, 73, 0.1));
15
+ color: var(--fgColor-danger, #f85149);
16
+ }
17
+
18
+ .banner strong {
19
+ display: block;
20
+ margin-bottom: 4px;
21
+ }
22
+
23
+ .meta {
24
+ color: var(--fgColor-muted, #9198a1);
25
+ }
26
+
27
+ .meta a,
28
+ .homeLink {
29
+ color: var(--fgColor-accent, #4493f8);
30
+ }
package/src/context.jsx CHANGED
@@ -4,6 +4,7 @@ import { useParams, useLocation } from 'react-router-dom'
4
4
  import 'virtual:storyboard-data-index'
5
5
  import { loadFlow, flowExists, findRecord, deepMerge, setFlowClass, installBodyClassSync, resolveFlowName, resolveRecordName, isModesEnabled } from '@dfosco/storyboard-core'
6
6
  import { StoryboardContext } from './StoryboardContext.js'
7
+ import styles from './FlowError.module.css'
7
8
 
8
9
  export { StoryboardContext }
9
10
 
@@ -116,7 +117,24 @@ export default function StoryboardProvider({ flowName, sceneName, recordName, re
116
117
  }
117
118
 
118
119
  if (error) {
119
- return <span style={{ color: 'var(--fgColor-danger, #f85149)' }}>Error loading flow: {error}</span>
120
+ const currentUrl = `${location.pathname}${location.search}`
121
+ const truncatedUrl = currentUrl.length > 60
122
+ ? currentUrl.slice(0, 60) + '…'
123
+ : currentUrl
124
+
125
+ return (
126
+ <div className={styles.container}>
127
+ <div className={styles.banner}>
128
+ <strong>Error loading flow</strong>
129
+ {error}
130
+ </div>
131
+ <p className={styles.meta}>
132
+ Tried to load{' '}
133
+ <a href={currentUrl} title={currentUrl}>{truncatedUrl}</a>
134
+ </p>
135
+ <a className={styles.homeLink} href="/">← Go to homepage</a>
136
+ </div>
137
+ )
120
138
  }
121
139
 
122
140
  return (
@@ -1,14 +1,16 @@
1
- import { useMemo, useSyncExternalStore } from 'react'
2
- import { loadObject } from '@dfosco/storyboard-core'
1
+ import { useContext, useMemo, useSyncExternalStore } from 'react'
2
+ import { loadObject, resolveObjectName } from '@dfosco/storyboard-core'
3
3
  import { getByPath, deepClone, setByPath } from '@dfosco/storyboard-core'
4
4
  import { getParam, getAllParams } from '@dfosco/storyboard-core'
5
5
  import { isHideMode, getShadow, getAllShadows } from '@dfosco/storyboard-core'
6
6
  import { subscribeToHash, getHashSnapshot } from '@dfosco/storyboard-core'
7
7
  import { subscribeToStorage, getStorageSnapshot } from '@dfosco/storyboard-core'
8
+ import { StoryboardContext } from '../StoryboardContext.js'
8
9
 
9
10
  /**
10
11
  * Load an object data file directly by name, without going through a scene.
11
12
  * Supports dot-notation path access and URL hash overrides.
13
+ * Objects inside prototypes are automatically resolved with prototype scope.
12
14
  *
13
15
  * Hash override convention: object.{objectName}.{field}=value
14
16
  *
@@ -23,13 +25,16 @@ import { subscribeToStorage, getStorageSnapshot } from '@dfosco/storyboard-core'
23
25
  * // Override via URL hash: #object.jane-doe.name=Alice
24
26
  */
25
27
  export function useObject(objectName, path) {
28
+ const context = useContext(StoryboardContext)
29
+ const prototypeName = context?.prototypeName ?? null
26
30
  const hashString = useSyncExternalStore(subscribeToHash, getHashSnapshot)
27
31
  const storageString = useSyncExternalStore(subscribeToStorage, getStorageSnapshot)
28
32
 
29
33
  return useMemo(() => {
34
+ const resolvedName = resolveObjectName(prototypeName, objectName)
30
35
  let data
31
36
  try {
32
- data = loadObject(objectName)
37
+ data = loadObject(resolvedName)
33
38
  } catch (err) {
34
39
  console.error(`[useObject] ${err.message}`)
35
40
  return undefined
@@ -39,35 +44,48 @@ export function useObject(objectName, path) {
39
44
  const readParam = hidden ? getShadow : getParam
40
45
  const readAllParams = hidden ? getAllShadows : getAllParams
41
46
 
42
- // Apply overrides scoped to this object
43
- const prefix = `object.${objectName}.`
47
+ // Apply overrides scoped to this object.
48
+ // Check both the resolved (scoped) prefix and the plain (unscoped) prefix
49
+ // so overrides work whether written with the bare or scoped name.
50
+ const resolvedPrefix = `object.${resolvedName}.`
51
+ const plainPrefix = objectName !== resolvedName ? `object.${objectName}.` : null
44
52
  const allParams = readAllParams()
45
- const overrideKeys = Object.keys(allParams).filter(k => k.startsWith(prefix))
53
+ const overrideKeys = Object.keys(allParams).filter(k =>
54
+ k.startsWith(resolvedPrefix) || (plainPrefix && k.startsWith(plainPrefix))
55
+ )
46
56
 
47
57
  if (overrideKeys.length > 0) {
48
58
  data = deepClone(data)
49
59
  for (const key of overrideKeys) {
50
- const fieldPath = key.slice(prefix.length)
60
+ const fieldPath = key.startsWith(resolvedPrefix)
61
+ ? key.slice(resolvedPrefix.length)
62
+ : key.slice(plainPrefix.length)
51
63
  setByPath(data, fieldPath, allParams[key])
52
64
  }
53
65
  }
54
66
 
55
67
  if (!path) return data
56
68
 
57
- // Exact match for this sub-path override
58
- const exactKey = `${prefix}${path}`
59
- const exact = readParam(exactKey)
69
+ // Exact match for this sub-path override (check both prefixes)
70
+ const exactResolved = `${resolvedPrefix}${path}`
71
+ const exactPlain = plainPrefix ? `${plainPrefix}${path}` : null
72
+ const exact = readParam(exactResolved) ?? (exactPlain ? readParam(exactPlain) : null)
60
73
  if (exact !== null) return exact
61
74
 
62
75
  // Child overrides under the sub-path
63
- const subPrefix = exactKey + '.'
64
- const childKeys = overrideKeys.filter(k => k.startsWith(subPrefix))
76
+ const subResolved = exactResolved + '.'
77
+ const subPlain = exactPlain ? exactPlain + '.' : null
78
+ const childKeys = overrideKeys.filter(k =>
79
+ k.startsWith(subResolved) || (subPlain && k.startsWith(subPlain))
80
+ )
65
81
  const baseValue = getByPath(data, path)
66
82
 
67
83
  if (childKeys.length > 0 && baseValue !== undefined) {
68
84
  const merged = deepClone(baseValue)
69
85
  for (const key of childKeys) {
70
- const relativePath = key.slice(subPrefix.length)
86
+ const relativePath = key.startsWith(subResolved)
87
+ ? key.slice(subResolved.length)
88
+ : key.slice(subPlain.length)
71
89
  setByPath(merged, relativePath, allParams[key])
72
90
  }
73
91
  return merged
@@ -79,5 +97,5 @@ export function useObject(objectName, path) {
79
97
  }
80
98
 
81
99
  return baseValue
82
- }, [objectName, path, hashString, storageString]) // eslint-disable-line react-hooks/exhaustive-deps
100
+ }, [objectName, prototypeName, path, hashString, storageString]) // eslint-disable-line react-hooks/exhaustive-deps
83
101
  }
@@ -11,13 +11,13 @@ const GLOB_PATTERN = '**/*.{flow,scene,object,record,prototype,folder}.{json,jso
11
11
 
12
12
  /**
13
13
  * Extract the data name and type suffix from a file path.
14
- * Flows and records inside src/prototypes/{Name}/ get prefixed with the
15
- * prototype name (e.g. "Dashboard/default"). Objects are never prefixed.
14
+ * Flows, records, and objects inside src/prototypes/{Name}/ get prefixed with
15
+ * the prototype name (e.g. "Dashboard/default", "Dashboard/helpers").
16
16
  * Directories ending in .folder/ are skipped when extracting prototype scope.
17
17
  *
18
18
  * e.g. "src/data/default.flow.json" → { name: "default", suffix: "flow" }
19
19
  * "src/prototypes/Dashboard/default.flow.json" → { name: "Dashboard/default", suffix: "flow" }
20
- * "src/prototypes/Dashboard/helpers.object.json"→ { name: "helpers", suffix: "object" }
20
+ * "src/prototypes/Dashboard/helpers.object.json"→ { name: "Dashboard/helpers", suffix: "object" }
21
21
  * "src/prototypes/X.folder/Dashboard/default.flow.json" → { name: "Dashboard/default", suffix: "flow", folder: "X" }
22
22
  */
23
23
  function parseDataFile(filePath) {
@@ -57,16 +57,29 @@ function parseDataFile(filePath) {
57
57
  return { name, suffix, ext: match[3], folder: folderName }
58
58
  }
59
59
 
60
- // Scope flows and records inside src/prototypes/{Name}/ with a prefix
60
+ // Scope flows, records, and objects inside src/prototypes/{Name}/ with a prefix
61
61
  // (skip .folder/ segments when determining prototype name)
62
- if (suffix !== 'object') {
63
- const protoMatch = normalized.match(/(?:^|\/)src\/prototypes\/(?:[^/]+\.folder\/)?([^/]+)\//)
64
- if (protoMatch) {
65
- name = `${protoMatch[1]}/${name}`
62
+ const protoMatch = normalized.match(/(?:^|\/)src\/prototypes\/(?:[^/]+\.folder\/)?([^/]+)\//)
63
+ if (protoMatch) {
64
+ name = `${protoMatch[1]}/${name}`
65
+ }
66
+
67
+ // Infer route for prototype-scoped flows from their file path.
68
+ // Mirrors the generouted route regex: strip src/prototypes/ and *.folder/ segments.
69
+ let inferredRoute = null
70
+ if (suffix === 'flow') {
71
+ const protoCheck = normalized.match(/(?:^|\/)src\/prototypes\//)
72
+ if (protoCheck) {
73
+ const dirPath = normalized.substring(0, normalized.lastIndexOf('/'))
74
+ inferredRoute = '/' + dirPath
75
+ .replace(/^.*?src\/prototypes\//, '')
76
+ .replace(/[^/]*\.folder\//g, '')
77
+ // Normalize trailing slash and double slashes
78
+ inferredRoute = inferredRoute.replace(/\/+/g, '/').replace(/\/$/, '') || '/'
66
79
  }
67
80
  }
68
81
 
69
- return { name, suffix, ext: match[3] }
82
+ return { name, suffix, ext: match[3], inferredRoute }
70
83
  }
71
84
 
72
85
  /**
@@ -127,6 +140,7 @@ function buildIndex(root) {
127
140
  const index = { flow: {}, object: {}, record: {}, prototype: {}, folder: {} }
128
141
  const seen = {} // "name.suffix" → absolute path (for duplicate detection)
129
142
  const protoFolders = {} // prototype name → folder name (for injection)
143
+ const flowRoutes = {} // flow name → inferred route (for _route injection)
130
144
 
131
145
  for (const relPath of files) {
132
146
  const parsed = parseDataFile(relPath)
@@ -136,12 +150,9 @@ function buildIndex(root) {
136
150
  const absPath = path.resolve(root, relPath)
137
151
 
138
152
  if (seen[key]) {
139
- const hint = parsed.suffix === 'object'
140
- ? ' Objects are globally scoped — even inside src/prototypes/ they share a single namespace.\n' +
141
- ' Rename one of the files to avoid the collision.'
142
- : parsed.suffix === 'folder'
153
+ const hint = parsed.suffix === 'folder'
143
154
  ? ' Folder names must be unique across the project.'
144
- : ' Flows and records are scoped to their prototype directory.\n' +
155
+ : ' Flows, records, and objects are scoped to their prototype directory.\n' +
145
156
  ' If both files are global (outside src/prototypes/), rename one to avoid the collision.'
146
157
 
147
158
  throw new Error(
@@ -159,9 +170,58 @@ function buildIndex(root) {
159
170
  if (parsed.suffix === 'prototype' && parsed.folder) {
160
171
  protoFolders[parsed.name] = parsed.folder
161
172
  }
173
+
174
+ // Track inferred routes for flows
175
+ if (parsed.suffix === 'flow' && parsed.inferredRoute) {
176
+ flowRoutes[parsed.name] = parsed.inferredRoute
177
+ }
178
+ }
179
+
180
+ return { index, protoFolders, flowRoutes }
181
+ }
182
+
183
+ /**
184
+ * Recursively walk a parsed JSON value and replace `${varName}` patterns
185
+ * in every string value. Only string values are processed — keys, numbers,
186
+ * booleans, and null are left untouched.
187
+ */
188
+ function resolveTemplateVars(obj, vars) {
189
+ if (typeof obj === 'string') {
190
+ let result = obj
191
+ for (const [key, value] of Object.entries(vars)) {
192
+ result = result.replaceAll(`\${${key}}`, value)
193
+ }
194
+ return result
162
195
  }
196
+ if (Array.isArray(obj)) return obj.map(item => resolveTemplateVars(item, vars))
197
+ if (obj !== null && typeof obj === 'object') {
198
+ const out = {}
199
+ for (const [key, value] of Object.entries(obj)) {
200
+ out[key] = resolveTemplateVars(value, vars)
201
+ }
202
+ return out
203
+ }
204
+ return obj
205
+ }
206
+
207
+ /**
208
+ * Compute path-based template variables for a data file.
209
+ *
210
+ * - currentDir: directory of the file, relative to project root
211
+ * - currentProto: path to the prototype directory (e.g. src/prototypes/main.folder/Example)
212
+ * - currentProtoDir: path to the first parent *.folder directory (e.g. src/prototypes/main.folder)
213
+ */
214
+ function computeTemplateVars(absPath, root) {
215
+ const relPath = path.relative(root, absPath).replace(/\\/g, '/')
216
+ const currentDir = path.dirname(relPath).replace(/\\/g, '/')
217
+
218
+ const protoMatch = relPath.match(/^(src\/prototypes\/(?:[^/]+\.folder\/)?[^/]+)\//)
219
+ const currentProto = protoMatch && !protoMatch[1].endsWith('.folder') ? protoMatch[1] : ''
163
220
 
164
- return { index, protoFolders }
221
+ const folderMatch = relPath.match(/^(src\/prototypes\/[^/]+\.folder)\//)
222
+ const currentProtoDir = folderMatch ? folderMatch[1] : ''
223
+
224
+ return { currentDir, currentProto, currentProtoDir }
165
225
  }
166
226
 
167
227
  /**
@@ -224,10 +284,11 @@ function readModesConfig(root) {
224
284
  return fallback
225
285
  }
226
286
 
227
- function generateModule({ index, protoFolders }, root) {
287
+ function generateModule({ index, protoFolders, flowRoutes }, root) {
228
288
  const declarations = []
229
289
  const INDEX_KEYS = ['flow', 'object', 'record', 'prototype', 'folder']
230
290
  const entries = { flow: [], object: [], record: [], prototype: [], folder: [] }
291
+ const resolvedFlowRoutes = {} // flow name → resolved route (for multi-flow logging)
231
292
  let i = 0
232
293
 
233
294
  for (const suffix of INDEX_KEYS) {
@@ -258,6 +319,35 @@ function generateModule({ index, protoFolders }, root) {
258
319
  parsed = { ...parsed, folder: protoFolders[name] }
259
320
  }
260
321
 
322
+ // Inject inferred _route into flow data (explicit route takes precedence)
323
+ if (suffix === 'flow' && flowRoutes[name] && !parsed?.route) {
324
+ parsed = { ...parsed, _route: flowRoutes[name] }
325
+ }
326
+
327
+ // Track resolved route for multi-flow logging
328
+ if (suffix === 'flow') {
329
+ const route = parsed?.route || parsed?._route || null
330
+ if (route) {
331
+ resolvedFlowRoutes[name] = { route, isDefault: parsed?.meta?.default === true }
332
+ }
333
+ }
334
+
335
+ // Resolve template variables (${currentDir}, ${currentProto}, ${currentProtoDir})
336
+ const templateVars = computeTemplateVars(absPath, root)
337
+ if (!templateVars.currentProto && raw.includes('${currentProto}')) {
338
+ console.warn(
339
+ `[storyboard-data] \${currentProto} used in "${path.relative(root, absPath)}" ` +
340
+ `but file is not inside a prototype directory. Variable resolves to empty string.`
341
+ )
342
+ }
343
+ if (!templateVars.currentProtoDir && raw.includes('${currentProtoDir}')) {
344
+ console.warn(
345
+ `[storyboard-data] \${currentProtoDir} used in "${path.relative(root, absPath)}" ` +
346
+ `but file is not inside a .folder directory. Variable resolves to empty string.`
347
+ )
348
+ }
349
+ parsed = resolveTemplateVars(parsed, templateVars)
350
+
261
351
  declarations.push(`const ${varName} = ${JSON.stringify(parsed)}`)
262
352
  entries[suffix].push(` ${JSON.stringify(name)}: ${varName}`)
263
353
  }
@@ -302,6 +392,26 @@ function generateModule({ index, protoFolders }, root) {
302
392
  }
303
393
  }
304
394
 
395
+ // Log info when multiple flows target the same route
396
+ const routeGroups = {}
397
+ for (const [name, { route, isDefault }] of Object.entries(resolvedFlowRoutes)) {
398
+ if (!routeGroups[route]) routeGroups[route] = []
399
+ routeGroups[route].push({ name, isDefault })
400
+ }
401
+ for (const [route, flows] of Object.entries(routeGroups)) {
402
+ if (flows.length > 1) {
403
+ const labels = flows.map(f => ` - ${f.name}${f.isDefault ? ' (default)' : ''}`).join('\n')
404
+ console.log(`[storyboard-data] Route "${route}" has ${flows.length} flows:\n${labels}`)
405
+ const defaults = flows.filter(f => f.isDefault)
406
+ if (defaults.length > 1) {
407
+ console.warn(
408
+ `[storyboard-data] Warning: Route "${route}" has ${defaults.length} flows with meta.default: true.\n` +
409
+ ` Only one flow per route should be marked as default.`
410
+ )
411
+ }
412
+ }
413
+ }
414
+
305
415
  return [
306
416
  imports.join('\n'),
307
417
  '',
@@ -408,3 +518,6 @@ export default function storyboardDataPlugin() {
408
518
  },
409
519
  }
410
520
  }
521
+
522
+ // Exported for testing
523
+ export { resolveTemplateVars, computeTemplateVars }
@@ -1,7 +1,7 @@
1
1
  import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'node:fs'
2
2
  import { tmpdir } from 'node:os'
3
3
  import path from 'node:path'
4
- import storyboardDataPlugin from './data-plugin.js'
4
+ import storyboardDataPlugin, { resolveTemplateVars, computeTemplateVars } from './data-plugin.js'
5
5
 
6
6
  const RESOLVED_ID = '\0virtual:storyboard-data-index'
7
7
 
@@ -99,7 +99,7 @@ describe('storyboardDataPlugin', () => {
99
99
  expect(() => plugin.load(RESOLVED_ID)).toThrow(/Duplicate flow "dup"/)
100
100
  })
101
101
 
102
- it('duplicate objects show globally-scoped hint', () => {
102
+ it('allows same object name in global and prototype without clash', () => {
103
103
  mkdirSync(path.join(tmpDir, 'src', 'data'), { recursive: true })
104
104
  mkdirSync(path.join(tmpDir, 'src', 'prototypes', 'Dashboard'), { recursive: true })
105
105
  writeFileSync(
@@ -112,8 +112,32 @@ describe('storyboardDataPlugin', () => {
112
112
  )
113
113
 
114
114
  const plugin = createPlugin()
115
- expect(() => plugin.load(RESOLVED_ID)).toThrow(/Duplicate object "user"/)
116
- expect(() => plugin.load(RESOLVED_ID)).toThrow(/globally scoped/)
115
+ const code = plugin.load(RESOLVED_ID)
116
+
117
+ // Both should exist without error
118
+ expect(code).toContain('"user"')
119
+ expect(code).toContain('"Dashboard/user"')
120
+ expect(code).toContain('"Global"')
121
+ expect(code).toContain('"Local"')
122
+ })
123
+
124
+ it('allows same object name in different prototypes without clash', () => {
125
+ mkdirSync(path.join(tmpDir, 'src', 'prototypes', 'A'), { recursive: true })
126
+ mkdirSync(path.join(tmpDir, 'src', 'prototypes', 'B'), { recursive: true })
127
+ writeFileSync(
128
+ path.join(tmpDir, 'src', 'prototypes', 'A', 'nav.object.json'),
129
+ JSON.stringify({ from: 'A' }),
130
+ )
131
+ writeFileSync(
132
+ path.join(tmpDir, 'src', 'prototypes', 'B', 'nav.object.json'),
133
+ JSON.stringify({ from: 'B' }),
134
+ )
135
+
136
+ const plugin = createPlugin()
137
+ const code = plugin.load(RESOLVED_ID)
138
+
139
+ expect(code).toContain('"A/nav"')
140
+ expect(code).toContain('"B/nav"')
117
141
  })
118
142
 
119
143
  it('handles JSONC files (with comments)', () => {
@@ -215,7 +239,7 @@ describe('prototype scoping', () => {
215
239
  expect(code).toContain('"Global Post"')
216
240
  })
217
241
 
218
- it('does NOT prefix objects inside src/prototypes/{Name}/', () => {
242
+ it('prefixes objects inside src/prototypes/{Name}/', () => {
219
243
  mkdirSync(path.join(tmpDir, 'src', 'prototypes', 'Dashboard'), { recursive: true })
220
244
  writeFileSync(
221
245
  path.join(tmpDir, 'src', 'prototypes', 'Dashboard', 'helpers.object.json'),
@@ -225,9 +249,8 @@ describe('prototype scoping', () => {
225
249
  const plugin = createPlugin()
226
250
  const code = plugin.load(RESOLVED_ID)
227
251
 
228
- // Object should be plain "helpers", NOT "Dashboard/helpers"
229
- expect(code).toContain('"helpers"')
230
- expect(code).not.toContain('"Dashboard/helpers"')
252
+ // Object should be scoped as "Dashboard/helpers"
253
+ expect(code).toContain('"Dashboard/helpers"')
231
254
  })
232
255
 
233
256
  it('allows same flow name in different prototypes without clash', () => {
@@ -265,6 +288,123 @@ describe('prototype scoping', () => {
265
288
  })
266
289
  })
267
290
 
291
+ describe('flow route inference', () => {
292
+ it('injects _route for flows inside src/prototypes/', () => {
293
+ mkdirSync(path.join(tmpDir, 'src', 'prototypes', 'Dashboard'), { recursive: true })
294
+ writeFileSync(
295
+ path.join(tmpDir, 'src', 'prototypes', 'Dashboard', 'default.flow.json'),
296
+ JSON.stringify({ title: 'Dashboard Flow' }),
297
+ )
298
+
299
+ const plugin = createPlugin()
300
+ const code = plugin.load(RESOLVED_ID)
301
+
302
+ expect(code).toContain('"_route":"/Dashboard"')
303
+ })
304
+
305
+ it('injects _route for flows inside .folder/ directories', () => {
306
+ mkdirSync(path.join(tmpDir, 'src', 'prototypes', 'MyFolder.folder', 'Example'), { recursive: true })
307
+ writeFileSync(
308
+ path.join(tmpDir, 'src', 'prototypes', 'MyFolder.folder', 'Example', 'basic.flow.json'),
309
+ JSON.stringify({ title: 'Example Flow' }),
310
+ )
311
+
312
+ const plugin = createPlugin()
313
+ const code = plugin.load(RESOLVED_ID)
314
+
315
+ // .folder/ should be stripped from the inferred route
316
+ expect(code).toContain('"_route":"/Example"')
317
+ expect(code).not.toContain('MyFolder')
318
+ })
319
+
320
+ it('injects _route with nested path for deeply placed flows', () => {
321
+ mkdirSync(path.join(tmpDir, 'src', 'prototypes', 'App', 'settings'), { recursive: true })
322
+ writeFileSync(
323
+ path.join(tmpDir, 'src', 'prototypes', 'App', 'settings', 'prefs.flow.json'),
324
+ JSON.stringify({ title: 'Settings Prefs' }),
325
+ )
326
+
327
+ const plugin = createPlugin()
328
+ const code = plugin.load(RESOLVED_ID)
329
+
330
+ expect(code).toContain('"_route":"/App/settings"')
331
+ })
332
+
333
+ it('does NOT inject _route for global flows outside src/prototypes/', () => {
334
+ mkdirSync(path.join(tmpDir, 'src', 'data'), { recursive: true })
335
+ writeFileSync(
336
+ path.join(tmpDir, 'src', 'data', 'global.flow.json'),
337
+ JSON.stringify({ title: 'Global Flow' }),
338
+ )
339
+
340
+ const plugin = createPlugin()
341
+ const code = plugin.load(RESOLVED_ID)
342
+
343
+ expect(code).not.toContain('"_route"')
344
+ })
345
+
346
+ it('does NOT inject _route when flow has explicit route field', () => {
347
+ mkdirSync(path.join(tmpDir, 'src', 'prototypes', 'Dashboard'), { recursive: true })
348
+ writeFileSync(
349
+ path.join(tmpDir, 'src', 'prototypes', 'Dashboard', 'custom.flow.json'),
350
+ JSON.stringify({ route: '/custom-page', title: 'Custom Route' }),
351
+ )
352
+
353
+ const plugin = createPlugin()
354
+ const code = plugin.load(RESOLVED_ID)
355
+
356
+ // Should have the explicit route but NOT _route
357
+ expect(code).toContain('"route":"/custom-page"')
358
+ expect(code).not.toContain('"_route"')
359
+ })
360
+
361
+ it('logs info when multiple flows share the same route', () => {
362
+ mkdirSync(path.join(tmpDir, 'src', 'prototypes', 'Dashboard'), { recursive: true })
363
+ writeFileSync(
364
+ path.join(tmpDir, 'src', 'prototypes', 'Dashboard', 'happy.flow.json'),
365
+ JSON.stringify({ title: 'Happy Path' }),
366
+ )
367
+ writeFileSync(
368
+ path.join(tmpDir, 'src', 'prototypes', 'Dashboard', 'error.flow.json'),
369
+ JSON.stringify({ title: 'Error State' }),
370
+ )
371
+
372
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
373
+ const plugin = createPlugin()
374
+ plugin.load(RESOLVED_ID)
375
+
376
+ const routeLog = logSpy.mock.calls.find(call =>
377
+ typeof call[0] === 'string' && call[0].includes('Route "/Dashboard" has 2 flows')
378
+ )
379
+ expect(routeLog).toBeTruthy()
380
+ logSpy.mockRestore()
381
+ })
382
+
383
+ it('warns when multiple flows on same route have meta.default: true', () => {
384
+ mkdirSync(path.join(tmpDir, 'src', 'prototypes', 'Dashboard'), { recursive: true })
385
+ writeFileSync(
386
+ path.join(tmpDir, 'src', 'prototypes', 'Dashboard', 'a.flow.json'),
387
+ JSON.stringify({ meta: { default: true }, title: 'A' }),
388
+ )
389
+ writeFileSync(
390
+ path.join(tmpDir, 'src', 'prototypes', 'Dashboard', 'b.flow.json'),
391
+ JSON.stringify({ meta: { default: true }, title: 'B' }),
392
+ )
393
+
394
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
395
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
396
+ const plugin = createPlugin()
397
+ plugin.load(RESOLVED_ID)
398
+
399
+ const warnCall = warnSpy.mock.calls.find(call =>
400
+ typeof call[0] === 'string' && call[0].includes('meta.default: true')
401
+ )
402
+ expect(warnCall).toBeTruthy()
403
+ logSpy.mockRestore()
404
+ warnSpy.mockRestore()
405
+ })
406
+ })
407
+
268
408
  describe('folder grouping', () => {
269
409
  it('discovers .folder.json files and keys them by folder directory name', () => {
270
410
  mkdirSync(path.join(tmpDir, 'src', 'prototypes', 'Getting Started.folder'), { recursive: true })
@@ -303,7 +443,7 @@ describe('folder grouping', () => {
303
443
  expect(code).toContain('"folder":"MyFolder"')
304
444
  })
305
445
 
306
- it('does NOT prefix objects inside .folder/ directories', () => {
446
+ it('scopes objects inside .folder/ directories to their prototype', () => {
307
447
  mkdirSync(path.join(tmpDir, 'src', 'prototypes', 'X.folder', 'Proto'), { recursive: true })
308
448
  writeFileSync(
309
449
  path.join(tmpDir, 'src', 'prototypes', 'X.folder', 'Proto', 'helpers.object.json'),
@@ -313,9 +453,9 @@ describe('folder grouping', () => {
313
453
  const plugin = createPlugin()
314
454
  const code = plugin.load(RESOLVED_ID)
315
455
 
316
- expect(code).toContain('"helpers"')
456
+ // Object should be scoped to prototype, not folder
457
+ expect(code).toContain('"Proto/helpers"')
317
458
  expect(code).not.toContain('"X/helpers"')
318
- expect(code).not.toContain('"Proto/helpers"')
319
459
  })
320
460
 
321
461
  it('scopes records inside .folder/ directories to their prototype', () => {
@@ -435,3 +575,249 @@ describe('underscore prefix ignoring', () => {
435
575
  expect(code).toContain('"Has Underscore"')
436
576
  })
437
577
  })
578
+
579
+ describe('resolveTemplateVars', () => {
580
+ it('replaces variables in a simple string', () => {
581
+ const result = resolveTemplateVars('/${currentDir}/page', { currentDir: 'src/data' })
582
+ expect(result).toBe('/src/data/page')
583
+ })
584
+
585
+ it('replaces multiple variables in one string', () => {
586
+ const result = resolveTemplateVars('${currentProto} in ${currentProtoDir}', {
587
+ currentProto: 'src/prototypes/main.folder/Example',
588
+ currentProtoDir: 'src/prototypes/main.folder',
589
+ })
590
+ expect(result).toBe('src/prototypes/main.folder/Example in src/prototypes/main.folder')
591
+ })
592
+
593
+ it('replaces variables in nested objects', () => {
594
+ const input = {
595
+ nav: { url: '/${currentDir}/page', label: 'Go' },
596
+ meta: { proto: '${currentProto}' },
597
+ }
598
+ const vars = { currentDir: 'src/data', currentProto: 'src/prototypes/App' }
599
+ const result = resolveTemplateVars(input, vars)
600
+
601
+ expect(result.nav.url).toBe('/src/data/page')
602
+ expect(result.nav.label).toBe('Go')
603
+ expect(result.meta.proto).toBe('src/prototypes/App')
604
+ })
605
+
606
+ it('replaces variables in arrays', () => {
607
+ const input = ['/${currentDir}/a', '/${currentDir}/b']
608
+ const result = resolveTemplateVars(input, { currentDir: 'here' })
609
+ expect(result).toEqual(['/here/a', '/here/b'])
610
+ })
611
+
612
+ it('replaces variables in deeply nested structures', () => {
613
+ const input = {
614
+ items: [
615
+ { links: [{ url: '/${currentDir}/x' }] },
616
+ ],
617
+ }
618
+ const result = resolveTemplateVars(input, { currentDir: 'deep' })
619
+ expect(result.items[0].links[0].url).toBe('/deep/x')
620
+ })
621
+
622
+ it('does not modify non-string values', () => {
623
+ const input = { count: 42, active: true, empty: null }
624
+ const result = resolveTemplateVars(input, { currentDir: 'test' })
625
+ expect(result).toEqual({ count: 42, active: true, empty: null })
626
+ })
627
+
628
+ it('returns input unchanged when no variables match', () => {
629
+ const input = { url: '/static/path', name: 'no vars here' }
630
+ const result = resolveTemplateVars(input, { currentDir: 'test' })
631
+ expect(result).toEqual(input)
632
+ })
633
+
634
+ it('leaves unknown variable patterns as-is', () => {
635
+ const result = resolveTemplateVars('${unknownVar}/path', { currentDir: 'test' })
636
+ expect(result).toBe('${unknownVar}/path')
637
+ })
638
+
639
+ it('does not mutate the original object', () => {
640
+ const input = { url: '/${currentDir}/page' }
641
+ const original = JSON.parse(JSON.stringify(input))
642
+ resolveTemplateVars(input, { currentDir: 'test' })
643
+ expect(input).toEqual(original)
644
+ })
645
+
646
+ it('handles empty vars object', () => {
647
+ const input = { url: '/${currentDir}/page' }
648
+ const result = resolveTemplateVars(input, {})
649
+ expect(result.url).toBe('/${currentDir}/page')
650
+ })
651
+
652
+ it('handles multiple occurrences of the same variable', () => {
653
+ const result = resolveTemplateVars('${currentDir}/${currentDir}', { currentDir: 'x' })
654
+ expect(result).toBe('x/x')
655
+ })
656
+ })
657
+
658
+ describe('computeTemplateVars', () => {
659
+ it('computes currentDir for a file in src/data/', () => {
660
+ const root = '/project'
661
+ const absPath = '/project/src/data/nav.object.json'
662
+ const vars = computeTemplateVars(absPath, root)
663
+
664
+ expect(vars.currentDir).toBe('src/data')
665
+ expect(vars.currentProto).toBe('')
666
+ expect(vars.currentProtoDir).toBe('')
667
+ })
668
+
669
+ it('computes all three vars for a file in a prototype inside a folder', () => {
670
+ const root = '/project'
671
+ const absPath = '/project/src/prototypes/main.folder/Example/sidenav.object.json'
672
+ const vars = computeTemplateVars(absPath, root)
673
+
674
+ expect(vars.currentDir).toBe('src/prototypes/main.folder/Example')
675
+ expect(vars.currentProto).toBe('src/prototypes/main.folder/Example')
676
+ expect(vars.currentProtoDir).toBe('src/prototypes/main.folder')
677
+ })
678
+
679
+ it('computes vars for a file in a subdirectory of a prototype', () => {
680
+ const root = '/project'
681
+ const absPath = '/project/src/prototypes/main.folder/Example/data/deep.object.json'
682
+ const vars = computeTemplateVars(absPath, root)
683
+
684
+ expect(vars.currentDir).toBe('src/prototypes/main.folder/Example/data')
685
+ expect(vars.currentProto).toBe('src/prototypes/main.folder/Example')
686
+ expect(vars.currentProtoDir).toBe('src/prototypes/main.folder')
687
+ })
688
+
689
+ it('computes vars for a file in a prototype without a folder', () => {
690
+ const root = '/project'
691
+ const absPath = '/project/src/prototypes/Dashboard/nav.object.json'
692
+ const vars = computeTemplateVars(absPath, root)
693
+
694
+ expect(vars.currentDir).toBe('src/prototypes/Dashboard')
695
+ expect(vars.currentProto).toBe('src/prototypes/Dashboard')
696
+ expect(vars.currentProtoDir).toBe('')
697
+ })
698
+
699
+ it('computes vars for a root-level file', () => {
700
+ const root = '/project'
701
+ const absPath = '/project/config.object.json'
702
+ const vars = computeTemplateVars(absPath, root)
703
+
704
+ expect(vars.currentDir).toBe('.')
705
+ expect(vars.currentProto).toBe('')
706
+ expect(vars.currentProtoDir).toBe('')
707
+ })
708
+
709
+ it('returns empty currentProto for a file directly inside a .folder (not in a prototype)', () => {
710
+ const root = '/project'
711
+ const absPath = '/project/src/prototypes/main.folder/nav.object.json'
712
+ const vars = computeTemplateVars(absPath, root)
713
+
714
+ expect(vars.currentDir).toBe('src/prototypes/main.folder')
715
+ expect(vars.currentProto).toBe('')
716
+ expect(vars.currentProtoDir).toBe('src/prototypes/main.folder')
717
+ })
718
+ })
719
+
720
+ describe('template variable integration', () => {
721
+ it('resolves ${currentDir} in object files', () => {
722
+ mkdirSync(path.join(tmpDir, 'src', 'data'), { recursive: true })
723
+ writeFileSync(
724
+ path.join(tmpDir, 'src', 'data', 'nav.object.json'),
725
+ JSON.stringify({ url: '/${currentDir}/page' }),
726
+ )
727
+
728
+ const plugin = createPlugin()
729
+ const code = plugin.load(RESOLVED_ID)
730
+
731
+ expect(code).toContain('/src/data/page')
732
+ expect(code).not.toContain('${currentDir}')
733
+ })
734
+
735
+ it('resolves ${currentProto} and ${currentProtoDir} in prototype files', () => {
736
+ mkdirSync(path.join(tmpDir, 'src', 'prototypes', 'App.folder', 'Dashboard'), { recursive: true })
737
+ writeFileSync(
738
+ path.join(tmpDir, 'src', 'prototypes', 'App.folder', 'Dashboard', 'nav.object.json'),
739
+ JSON.stringify({
740
+ proto: '${currentProto}',
741
+ folder: '${currentProtoDir}',
742
+ dir: '${currentDir}',
743
+ }),
744
+ )
745
+
746
+ const plugin = createPlugin()
747
+ const code = plugin.load(RESOLVED_ID)
748
+
749
+ expect(code).toContain('src/prototypes/App.folder/Dashboard')
750
+ expect(code).toContain('src/prototypes/App.folder')
751
+ expect(code).not.toContain('${currentProto}')
752
+ expect(code).not.toContain('${currentProtoDir}')
753
+ expect(code).not.toContain('${currentDir}')
754
+ })
755
+
756
+ it('resolves variables in flow files', () => {
757
+ mkdirSync(path.join(tmpDir, 'src', 'prototypes', 'Example.folder', 'Demo'), { recursive: true })
758
+ writeFileSync(
759
+ path.join(tmpDir, 'src', 'prototypes', 'Example.folder', 'Demo', 'default.flow.json'),
760
+ JSON.stringify({
761
+ nav: [{ label: 'Home', url: '/${currentDir}' }],
762
+ }),
763
+ )
764
+
765
+ const plugin = createPlugin()
766
+ const code = plugin.load(RESOLVED_ID)
767
+
768
+ expect(code).toContain('/src/prototypes/Example.folder/Demo')
769
+ expect(code).not.toContain('${currentDir}')
770
+ })
771
+
772
+ it('resolves variables in record files', () => {
773
+ mkdirSync(path.join(tmpDir, 'src', 'prototypes', 'Blog'), { recursive: true })
774
+ writeFileSync(
775
+ path.join(tmpDir, 'src', 'prototypes', 'Blog', 'posts.record.json'),
776
+ JSON.stringify([
777
+ { id: '1', link: '/${currentProto}/post/1' },
778
+ ]),
779
+ )
780
+
781
+ const plugin = createPlugin()
782
+ const code = plugin.load(RESOLVED_ID)
783
+
784
+ expect(code).toContain('/src/prototypes/Blog/post/1')
785
+ expect(code).not.toContain('${currentProto}')
786
+ })
787
+
788
+ it('warns when ${currentProto} is used outside a prototype', () => {
789
+ mkdirSync(path.join(tmpDir, 'src', 'data'), { recursive: true })
790
+ writeFileSync(
791
+ path.join(tmpDir, 'src', 'data', 'nav.object.json'),
792
+ JSON.stringify({ url: '/${currentProto}/page' }),
793
+ )
794
+
795
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
796
+ const plugin = createPlugin()
797
+ plugin.load(RESOLVED_ID)
798
+
799
+ const warnCall = warnSpy.mock.calls.find(call =>
800
+ typeof call[0] === 'string' && call[0].includes('${currentProto}')
801
+ )
802
+ expect(warnCall).toBeTruthy()
803
+ warnSpy.mockRestore()
804
+ })
805
+
806
+ it('warns when ${currentProtoDir} is used outside a .folder', () => {
807
+ mkdirSync(path.join(tmpDir, 'src', 'prototypes', 'Dashboard'), { recursive: true })
808
+ writeFileSync(
809
+ path.join(tmpDir, 'src', 'prototypes', 'Dashboard', 'nav.object.json'),
810
+ JSON.stringify({ folder: '${currentProtoDir}' }),
811
+ )
812
+
813
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
814
+ const plugin = createPlugin()
815
+ plugin.load(RESOLVED_ID)
816
+
817
+ const warnCall = warnSpy.mock.calls.find(call =>
818
+ typeof call[0] === 'string' && call[0].includes('${currentProtoDir}')
819
+ )
820
+ expect(warnCall).toBeTruthy()
821
+ warnSpy.mockRestore()
822
+ })
823
+ })