@dfosco/storyboard-core 2.4.0 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dfosco/storyboard-core",
3
- "version": "2.4.0",
3
+ "version": "2.6.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
package/src/index.js CHANGED
@@ -11,7 +11,7 @@ export { init } from './loader.js'
11
11
  // Flow, object & record loading
12
12
  export { loadFlow, listFlows, flowExists, loadRecord, findRecord, loadObject, deepMerge } from './loader.js'
13
13
  // Scoped name resolution
14
- export { resolveFlowName, resolveRecordName } from './loader.js'
14
+ export { resolveFlowName, resolveRecordName, resolveObjectName } from './loader.js'
15
15
  // Prototype metadata
16
16
  export { listPrototypes, getPrototypeMetadata } from './loader.js'
17
17
  // Folder metadata
package/src/loader.js CHANGED
@@ -98,27 +98,28 @@ function loadDataFile(name, type) {
98
98
  * @param {Set} seen - Tracks visited names to prevent circular refs
99
99
  * @returns {*} Resolved data
100
100
  */
101
- function resolveRefs(node, seen = new Set()) {
101
+ function resolveRefs(node, seen = new Set(), scope = null) {
102
102
  if (node === null || typeof node !== 'object') return node
103
103
  if (Array.isArray(node)) {
104
- return node.map((item) => resolveRefs(item, seen))
104
+ return node.map((item) => resolveRefs(item, seen, scope))
105
105
  }
106
106
 
107
107
  // Handle $ref replacement
108
108
  if (node.$ref && typeof node.$ref === 'string') {
109
109
  const refName = node.$ref
110
- if (seen.has(refName)) {
110
+ const resolvedRef = scope ? resolveObjectName(scope, refName) : refName
111
+ if (seen.has(resolvedRef)) {
111
112
  throw new Error(`Circular $ref detected: ${refName}`)
112
113
  }
113
- seen.add(refName)
114
- const refData = loadDataFile(refName, 'objects')
115
- return resolveRefs(refData, seen)
114
+ seen.add(resolvedRef)
115
+ const refData = loadDataFile(resolvedRef, 'objects')
116
+ return resolveRefs(refData, seen, scope)
116
117
  }
117
118
 
118
119
  // Recurse into object values
119
120
  const result = {}
120
121
  for (const [key, value] of Object.entries(node)) {
121
- result[key] = resolveRefs(value, seen)
122
+ result[key] = resolveRefs(value, seen, scope)
122
123
  }
123
124
  return result
124
125
  }
@@ -163,6 +164,9 @@ export const sceneExists = flowExists
163
164
  export function loadFlow(flowName = 'default') {
164
165
  let flowData
165
166
 
167
+ // Extract prototype scope from the flow name (e.g. "Dashboard/default" → "Dashboard")
168
+ const scope = flowName.includes('/') ? flowName.split('/')[0] : null
169
+
166
170
  try {
167
171
  flowData = structuredClone(loadDataFile(flowName, 'flows'))
168
172
  } catch {
@@ -184,8 +188,9 @@ export function loadFlow(flowName = 'default') {
184
188
  let mergedGlobals = {}
185
189
  for (const name of globalNames) {
186
190
  try {
187
- let globalData = loadDataFile(name)
188
- globalData = resolveRefs(globalData)
191
+ const resolvedName = scope ? resolveObjectName(scope, name) : name
192
+ let globalData = loadDataFile(resolvedName)
193
+ globalData = resolveRefs(globalData, new Set(), scope)
189
194
  mergedGlobals = deepMerge(mergedGlobals, globalData)
190
195
  } catch (err) {
191
196
  console.warn(`Failed to load $global: ${name}`, err)
@@ -195,7 +200,7 @@ export function loadFlow(flowName = 'default') {
195
200
  flowData = deepMerge(mergedGlobals, flowData)
196
201
  }
197
202
 
198
- flowData = resolveRefs(flowData)
203
+ flowData = resolveRefs(flowData, new Set(), scope)
199
204
 
200
205
  // Single clone at the boundary — resolveRefs builds new objects internally,
201
206
  // so the index data is safe. Clone here to prevent consumer mutation.
@@ -244,12 +249,13 @@ export function findRecord(recordName, id) {
244
249
  * and returns a deep clone.
245
250
  *
246
251
  * @param {string} objectName - Name of the object file (e.g., "jane-doe")
252
+ * @param {string|null} [scope] - Optional prototype scope for name resolution
247
253
  * @returns {object|Array} Resolved object data
248
254
  */
249
- export function loadObject(objectName) {
250
- const data = loadDataFile(objectName, 'objects')
251
- const resolved = resolveRefs(structuredClone(data))
252
- return resolved
255
+ export function loadObject(objectName, scope) {
256
+ const resolved = scope ? resolveObjectName(scope, objectName) : objectName
257
+ const data = loadDataFile(resolved, 'objects')
258
+ return resolveRefs(structuredClone(data), new Set(), scope)
253
259
  }
254
260
 
255
261
  /**
@@ -287,6 +293,23 @@ export function resolveRecordName(scope, name) {
287
293
  return scope ? `${scope}/${name}` : name
288
294
  }
289
295
 
296
+ /**
297
+ * Resolve an object name within a prototype scope.
298
+ * Tries the scoped name first ({scope}/{name}), then falls back to the plain name.
299
+ *
300
+ * @param {string|null} scope - Prototype name (e.g. "Dashboard"), or null for global-only
301
+ * @param {string} name - Object name (e.g. "jane-doe")
302
+ * @returns {string} The resolved object name that exists in the index
303
+ */
304
+ export function resolveObjectName(scope, name) {
305
+ if (scope) {
306
+ const scoped = `${scope}/${name}`
307
+ if (dataIndex.objects[scoped] != null) return scoped
308
+ }
309
+ if (dataIndex.objects[name] != null) return name
310
+ return scope ? `${scope}/${name}` : name
311
+ }
312
+
290
313
  /**
291
314
  * Returns the names of all registered prototypes.
292
315
  * @returns {string[]}
@@ -1,4 +1,4 @@
1
- import { init, loadFlow, listFlows, flowExists, loadScene, listScenes, sceneExists, loadRecord, findRecord, loadObject, deepMerge, resolveFlowName, resolveRecordName, listFolders, getFolderMetadata } from './loader.js'
1
+ import { init, loadFlow, listFlows, flowExists, loadScene, listScenes, sceneExists, loadRecord, findRecord, loadObject, deepMerge, resolveFlowName, resolveRecordName, resolveObjectName, listFolders, getFolderMetadata } from './loader.js'
2
2
 
3
3
  const makeIndex = () => ({
4
4
  flows: {
@@ -383,6 +383,87 @@ describe('resolveRecordName', () => {
383
383
  })
384
384
  })
385
385
 
386
+ describe('resolveObjectName', () => {
387
+ beforeEach(() => {
388
+ init({
389
+ flows: {},
390
+ objects: {
391
+ 'jane-doe': { name: 'Jane Global' },
392
+ 'Dashboard/jane-doe': { name: 'Jane Dashboard' },
393
+ 'Dashboard/helpers': { util: true },
394
+ },
395
+ records: {},
396
+ })
397
+ })
398
+
399
+ it('returns scoped name when it exists', () => {
400
+ expect(resolveObjectName('Dashboard', 'jane-doe')).toBe('Dashboard/jane-doe')
401
+ expect(resolveObjectName('Dashboard', 'helpers')).toBe('Dashboard/helpers')
402
+ })
403
+
404
+ it('falls back to global when scoped does not exist', () => {
405
+ expect(resolveObjectName('Blog', 'jane-doe')).toBe('jane-doe')
406
+ })
407
+
408
+ it('returns global when scope is null', () => {
409
+ expect(resolveObjectName(null, 'jane-doe')).toBe('jane-doe')
410
+ })
411
+
412
+ it('returns scoped name for error messages when neither exists', () => {
413
+ expect(resolveObjectName('Dashboard', 'nonexistent')).toBe('Dashboard/nonexistent')
414
+ })
415
+
416
+ it('returns plain name when scope is null and name does not exist', () => {
417
+ expect(resolveObjectName(null, 'nonexistent')).toBe('nonexistent')
418
+ })
419
+ })
420
+
421
+ describe('scoped object loading', () => {
422
+ beforeEach(() => {
423
+ init({
424
+ flows: {
425
+ 'Dashboard/default': {
426
+ $global: ['nav'],
427
+ user: { $ref: 'jane-doe' },
428
+ heading: 'Dashboard',
429
+ },
430
+ },
431
+ objects: {
432
+ 'jane-doe': { name: 'Jane Global' },
433
+ nav: { links: ['home'] },
434
+ 'Dashboard/jane-doe': { name: 'Jane Dashboard' },
435
+ 'Dashboard/nav': { links: ['dashboard-home', 'settings'] },
436
+ },
437
+ records: {},
438
+ })
439
+ })
440
+
441
+ it('loadObject with scope resolves scoped object', () => {
442
+ const obj = loadObject('jane-doe', 'Dashboard')
443
+ expect(obj.name).toBe('Jane Dashboard')
444
+ })
445
+
446
+ it('loadObject with scope falls back to global', () => {
447
+ const obj = loadObject('jane-doe', 'Blog')
448
+ expect(obj.name).toBe('Jane Global')
449
+ })
450
+
451
+ it('loadObject without scope uses global', () => {
452
+ const obj = loadObject('jane-doe')
453
+ expect(obj.name).toBe('Jane Global')
454
+ })
455
+
456
+ it('loadFlow resolves $ref with prototype scope', () => {
457
+ const flow = loadFlow('Dashboard/default')
458
+ expect(flow.user.name).toBe('Jane Dashboard')
459
+ })
460
+
461
+ it('loadFlow resolves $global with prototype scope', () => {
462
+ const flow = loadFlow('Dashboard/default')
463
+ expect(flow.links).toEqual(['dashboard-home', 'settings'])
464
+ })
465
+ })
466
+
386
467
  describe('error hints for scoped data', () => {
387
468
  beforeEach(() => {
388
469
  init({
package/src/viewfinder.js CHANGED
@@ -16,13 +16,17 @@ export function hash(str) {
16
16
  /**
17
17
  * Resolve the target route path for a flow.
18
18
  *
19
+ * Priority:
19
20
  * 1. If flow name matches a known route (case-insensitive), use that route
20
- * 2. If flow data has a top-level `route`, or `meta.route` / `sceneMeta.route`, use that
21
- * 3. Fall back to root "/"
21
+ * 2. If flow data has an explicit top-level `route`, or `meta.route` / `flowMeta.route`, use that
22
+ * 3. If flow data has `_route` (inferred from file path by Vite plugin), use that
23
+ * 4. Fall back to root "/"
24
+ *
25
+ * Flows with `meta.default: true` targeting a route omit the `?flow=` param.
22
26
  *
23
27
  * @param {string} flowName
24
28
  * @param {string[]} knownRoutes - Array of route names (e.g. ["Dashboard", "Repositories"])
25
- * @returns {string} Full path with ?flow= param
29
+ * @returns {string} Full path with optional ?flow= param
26
30
  */
27
31
  export function resolveFlowRoute(flowName, knownRoutes = []) {
28
32
  // Case-insensitive match against known routes
@@ -34,14 +38,22 @@ export function resolveFlowRoute(flowName, knownRoutes = []) {
34
38
  }
35
39
  }
36
40
 
37
- // Check for explicit route: top-level `route`, then meta.route, then legacy sceneMeta.route
38
41
  try {
39
42
  const data = loadFlow(flowName)
40
- const route = data?.route || data?.meta?.route || data?.flowMeta?.route || data?.sceneMeta?.route
41
- if (route) {
42
- const normalized = route.startsWith('/') ? route : `/${route}`
43
+
44
+ // Check for explicit route: top-level `route`, then meta.route, then legacy sceneMeta.route
45
+ const explicitRoute = data?.route || data?.meta?.route || data?.flowMeta?.route || data?.sceneMeta?.route
46
+ if (explicitRoute) {
47
+ const normalized = explicitRoute.startsWith('/') ? explicitRoute : `/${explicitRoute}`
48
+ if (data?.meta?.default === true) return normalized
43
49
  return `${normalized}?flow=${encodeURIComponent(flowName)}`
44
50
  }
51
+
52
+ // Use inferred route from file path (injected by Vite data plugin)
53
+ if (data?._route) {
54
+ if (data?.meta?.default === true) return data._route
55
+ return `${data._route}?flow=${encodeURIComponent(flowName)}`
56
+ }
45
57
  } catch {
46
58
  // ignore load errors
47
59
  }
@@ -12,6 +12,9 @@ const makeIndex = () => ({
12
12
  'meta-author': { flowMeta: { author: 'dfosco' }, title: 'With Author' },
13
13
  'meta-authors': { flowMeta: { author: ['dfosco', 'heyamie', 'branonconor'] }, title: 'Multi Author' },
14
14
  'meta-both': { flowMeta: { route: '/Overview', author: 'octocat' }, title: 'Both' },
15
+ 'inferred-route': { _route: '/Dashboard', title: 'Inferred' },
16
+ 'inferred-default': { _route: '/Settings', meta: { default: true }, title: 'Default Flow' },
17
+ 'explicit-wins': { route: '/Forms', _route: '/Dashboard', title: 'Explicit Wins' },
15
18
  },
16
19
  objects: {},
17
20
  records: {},
@@ -105,6 +108,31 @@ describe('resolveFlowRoute', () => {
105
108
  })
106
109
  expect(resolveFlowRoute('conflict', [])).toBe('/Forms?flow=conflict')
107
110
  })
111
+
112
+ it('uses _route when no explicit route exists', () => {
113
+ expect(resolveFlowRoute('inferred-route', routes)).toBe('/Dashboard?flow=inferred-route')
114
+ })
115
+
116
+ it('prefers explicit route over _route', () => {
117
+ expect(resolveFlowRoute('explicit-wins', routes)).toBe('/Forms?flow=explicit-wins')
118
+ })
119
+
120
+ it('omits ?flow= when meta.default is true (inferred route)', () => {
121
+ expect(resolveFlowRoute('inferred-default', routes)).toBe('/Settings')
122
+ })
123
+
124
+ it('omits ?flow= when meta.default is true (explicit route)', () => {
125
+ init({
126
+ flows: { 'default-explicit': { route: '/Overview', meta: { default: true } } },
127
+ objects: {},
128
+ records: {},
129
+ })
130
+ expect(resolveFlowRoute('default-explicit', [])).toBe('/Overview')
131
+ })
132
+
133
+ it('still appends ?flow= when meta.default is absent even with _route', () => {
134
+ expect(resolveFlowRoute('inferred-route', [])).toBe('/Dashboard?flow=inferred-route')
135
+ })
108
136
  })
109
137
 
110
138
  describe('getFlowMeta', () => {