@dfosco/storyboard-react 2.5.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.5.0",
3
+ "version": "2.6.0",
4
4
  "type": "module",
5
5
  "dependencies": {
6
- "@dfosco/storyboard-core": "2.5.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,13 +57,11 @@ 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}`
66
- }
62
+ const protoMatch = normalized.match(/(?:^|\/)src\/prototypes\/(?:[^/]+\.folder\/)?([^/]+)\//)
63
+ if (protoMatch) {
64
+ name = `${protoMatch[1]}/${name}`
67
65
  }
68
66
 
69
67
  // Infer route for prototype-scoped flows from their file path.
@@ -152,12 +150,9 @@ function buildIndex(root) {
152
150
  const absPath = path.resolve(root, relPath)
153
151
 
154
152
  if (seen[key]) {
155
- const hint = parsed.suffix === 'object'
156
- ? ' Objects are globally scoped — even inside src/prototypes/ they share a single namespace.\n' +
157
- ' Rename one of the files to avoid the collision.'
158
- : parsed.suffix === 'folder'
153
+ const hint = parsed.suffix === 'folder'
159
154
  ? ' Folder names must be unique across the project.'
160
- : ' Flows and records are scoped to their prototype directory.\n' +
155
+ : ' Flows, records, and objects are scoped to their prototype directory.\n' +
161
156
  ' If both files are global (outside src/prototypes/), rename one to avoid the collision.'
162
157
 
163
158
  throw new Error(
@@ -185,6 +180,50 @@ function buildIndex(root) {
185
180
  return { index, protoFolders, flowRoutes }
186
181
  }
187
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
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] : ''
220
+
221
+ const folderMatch = relPath.match(/^(src\/prototypes\/[^/]+\.folder)\//)
222
+ const currentProtoDir = folderMatch ? folderMatch[1] : ''
223
+
224
+ return { currentDir, currentProto, currentProtoDir }
225
+ }
226
+
188
227
  /**
189
228
  * Generate the virtual module source code.
190
229
  * Reads each data file, parses JSONC at build time, and emits pre-parsed
@@ -293,6 +332,22 @@ function generateModule({ index, protoFolders, flowRoutes }, root) {
293
332
  }
294
333
  }
295
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
+
296
351
  declarations.push(`const ${varName} = ${JSON.stringify(parsed)}`)
297
352
  entries[suffix].push(` ${JSON.stringify(name)}: ${varName}`)
298
353
  }
@@ -463,3 +518,6 @@ export default function storyboardDataPlugin() {
463
518
  },
464
519
  }
465
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', () => {
@@ -420,7 +443,7 @@ describe('folder grouping', () => {
420
443
  expect(code).toContain('"folder":"MyFolder"')
421
444
  })
422
445
 
423
- it('does NOT prefix objects inside .folder/ directories', () => {
446
+ it('scopes objects inside .folder/ directories to their prototype', () => {
424
447
  mkdirSync(path.join(tmpDir, 'src', 'prototypes', 'X.folder', 'Proto'), { recursive: true })
425
448
  writeFileSync(
426
449
  path.join(tmpDir, 'src', 'prototypes', 'X.folder', 'Proto', 'helpers.object.json'),
@@ -430,9 +453,9 @@ describe('folder grouping', () => {
430
453
  const plugin = createPlugin()
431
454
  const code = plugin.load(RESOLVED_ID)
432
455
 
433
- expect(code).toContain('"helpers"')
456
+ // Object should be scoped to prototype, not folder
457
+ expect(code).toContain('"Proto/helpers"')
434
458
  expect(code).not.toContain('"X/helpers"')
435
- expect(code).not.toContain('"Proto/helpers"')
436
459
  })
437
460
 
438
461
  it('scopes records inside .folder/ directories to their prototype', () => {
@@ -552,3 +575,249 @@ describe('underscore prefix ignoring', () => {
552
575
  expect(code).toContain('"Has Underscore"')
553
576
  })
554
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
+ })