@dfosco/storyboard-core 2.5.0 → 2.7.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 +1 -1
- package/src/index.js +1 -1
- package/src/loader.js +37 -14
- package/src/loader.test.js +82 -1
- package/src/svelte-plugin-ui/components/Viewfinder.svelte +15 -5
package/package.json
CHANGED
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
|
-
|
|
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(
|
|
114
|
-
const refData = loadDataFile(
|
|
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
|
-
|
|
188
|
-
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
|
|
251
|
-
const
|
|
252
|
-
return
|
|
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[]}
|
package/src/loader.test.js
CHANGED
|
@@ -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({
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
<script lang="ts">
|
|
12
12
|
import { buildPrototypeIndex } from '../../viewfinder.js'
|
|
13
|
+
import { getLocal, setLocal } from '../../localStorage.js'
|
|
13
14
|
import Octicon from './Octicon.svelte'
|
|
14
15
|
|
|
15
16
|
interface Props {
|
|
@@ -77,8 +78,16 @@
|
|
|
77
78
|
const sortedProtos = $derived(prototypeIndex.sorted?.[sortBy]?.prototypes ?? ungroupedProtos)
|
|
78
79
|
const sortedFolders = $derived(prototypeIndex.sorted?.[sortBy]?.folders ?? folders)
|
|
79
80
|
|
|
80
|
-
// Expanded state —
|
|
81
|
-
|
|
81
|
+
// Expanded state — persisted in localStorage
|
|
82
|
+
const EXPANDED_KEY = 'viewfinder.expanded'
|
|
83
|
+
|
|
84
|
+
function loadExpanded(): Record<string, boolean> {
|
|
85
|
+
const raw = getLocal(EXPANDED_KEY)
|
|
86
|
+
if (!raw) return {}
|
|
87
|
+
try { return JSON.parse(raw) } catch { return {} }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
let expanded: Record<string, boolean> = $state(loadExpanded())
|
|
82
91
|
|
|
83
92
|
function isExpanded(dirName: string): boolean {
|
|
84
93
|
return expanded[dirName] ?? true
|
|
@@ -86,6 +95,7 @@
|
|
|
86
95
|
|
|
87
96
|
function toggle(dirName: string) {
|
|
88
97
|
expanded[dirName] = !isExpanded(dirName)
|
|
98
|
+
setLocal(EXPANDED_KEY, JSON.stringify(expanded))
|
|
89
99
|
}
|
|
90
100
|
|
|
91
101
|
function protoRoute(dirName: string): string {
|
|
@@ -192,7 +202,7 @@
|
|
|
192
202
|
class:sortButtonActive={sortBy === 'updated'}
|
|
193
203
|
onclick={() => sortBy = 'updated'}
|
|
194
204
|
>
|
|
195
|
-
<Octicon name="clock" size={14}
|
|
205
|
+
<Octicon name="clock" size={14} color="var(--fgColor-muted)" />
|
|
196
206
|
Last updated
|
|
197
207
|
</button>
|
|
198
208
|
<button
|
|
@@ -200,7 +210,7 @@
|
|
|
200
210
|
class:sortButtonActive={sortBy === 'title'}
|
|
201
211
|
onclick={() => sortBy = 'title'}
|
|
202
212
|
>
|
|
203
|
-
<Octicon name="sort-asc" size={14}
|
|
213
|
+
<Octicon name="sort-asc" size={14} color="var(--fgColor-muted)" />
|
|
204
214
|
Title A–Z
|
|
205
215
|
</button>
|
|
206
216
|
</div>
|
|
@@ -458,7 +468,7 @@
|
|
|
458
468
|
display: inline-flex;
|
|
459
469
|
align-items: center;
|
|
460
470
|
border-radius: 9999px;
|
|
461
|
-
gap:
|
|
471
|
+
gap: 6px;
|
|
462
472
|
padding: 6px 10px;
|
|
463
473
|
font-size: 12px;
|
|
464
474
|
font-family: inherit;
|