@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 +2 -2
- package/src/FlowError.module.css +30 -0
- package/src/context.jsx +19 -1
- package/src/hooks/useObject.js +32 -14
- package/src/vite/data-plugin.js +72 -14
- package/src/vite/data-plugin.test.js +280 -11
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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 (
|
package/src/hooks/useObject.js
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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 =>
|
|
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.
|
|
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
|
|
59
|
-
const
|
|
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
|
|
64
|
-
const
|
|
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.
|
|
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
|
}
|
package/src/vite/data-plugin.js
CHANGED
|
@@ -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
|
|
15
|
-
* prototype name (e.g. "Dashboard/default").
|
|
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",
|
|
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
|
|
60
|
+
// Scope flows, records, and objects inside src/prototypes/{Name}/ with a prefix
|
|
61
61
|
// (skip .folder/ segments when determining prototype name)
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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 === '
|
|
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
|
|
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('
|
|
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
|
-
|
|
116
|
-
|
|
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('
|
|
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
|
|
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('
|
|
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
|
-
|
|
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
|
+
})
|