@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 +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 +129 -16
- package/src/vite/data-plugin.test.js +397 -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,16 +57,29 @@ 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
|
-
|
|
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 === '
|
|
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
|
|
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
|
-
|
|
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('
|
|
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', () => {
|
|
@@ -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('
|
|
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
|
-
|
|
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
|
+
})
|