@dfosco/storyboard-core 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,6 +1,6 @@
1
1
  {
2
2
  "name": "@dfosco/storyboard-core",
3
- "version": "2.5.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({