@dfosco/storyboard-core 2.0.0 → 2.1.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/modes.config.json +17 -0
- package/package.json +8 -3
- package/src/bodyClasses.js +11 -8
- package/src/bodyClasses.test.js +26 -12
- package/src/devtools.js +7 -7
- package/src/devtools.test.js +2 -2
- package/src/index.js +20 -5
- package/src/loader.js +116 -35
- package/src/loader.test.js +189 -48
- package/src/modes.js +170 -0
- package/src/modes.test.js +216 -0
- package/src/sceneDebug.js +15 -12
- package/src/sceneDebug.test.js +42 -29
- package/src/svelte-plugin-ui/__tests__/ToolbarShell.test.ts +77 -19
- package/src/svelte-plugin-ui/__tests__/designModes.test.ts +1 -1
- package/src/svelte-plugin-ui/components/ToolbarShell.svelte +59 -19
- package/src/svelte-plugin-ui/components/Viewfinder.svelte +573 -0
- package/src/svelte-plugin-ui/index.ts +3 -0
- package/src/svelte-plugin-ui/stores/toolStore.ts +71 -0
- package/src/svelte-plugin-ui/stores/types.ts +22 -8
- package/src/{svelte-plugin-ui/plugins → ui}/design-modes.ts +9 -7
- package/src/ui/viewfinder.ts +58 -0
- package/src/viewfinder.js +99 -19
- package/src/viewfinder.test.js +64 -42
- package/src/workshop/features/createPage/server.js +8 -8
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"modes": [
|
|
3
|
+
{ "name": "prototype", "label": "Navigate" },
|
|
4
|
+
{ "name": "inspect", "label": "Develop" },
|
|
5
|
+
{ "name": "present", "label": "Collaborate" },
|
|
6
|
+
{ "name": "plan", "label": "Canvas" }
|
|
7
|
+
],
|
|
8
|
+
"tools": {
|
|
9
|
+
"*": [
|
|
10
|
+
{ "id": "viewfinder", "label": "Viewfinder", "group": "dev" },
|
|
11
|
+
{ "id": "scene-info", "label": "Show scene info", "group": "dev" },
|
|
12
|
+
{ "id": "reset-params", "label": "Reset all params", "group": "dev" },
|
|
13
|
+
{ "id": "hide-mode", "label": "Hide mode", "group": "dev" },
|
|
14
|
+
{ "id": "feature-flags", "label": "Feature flags", "group": "dev" }
|
|
15
|
+
]
|
|
16
|
+
}
|
|
17
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dfosco/storyboard-core",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.1.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -9,17 +9,22 @@
|
|
|
9
9
|
"directory": "packages/core"
|
|
10
10
|
},
|
|
11
11
|
"files": [
|
|
12
|
-
"src"
|
|
12
|
+
"src",
|
|
13
|
+
"modes.config.json"
|
|
13
14
|
],
|
|
14
15
|
"exports": {
|
|
15
16
|
".": "./src/index.js",
|
|
17
|
+
"./modes.config.json": "./modes.config.json",
|
|
16
18
|
"./vite/server": "./src/vite/server-plugin.js",
|
|
17
19
|
"./comments": "./src/comments/index.js",
|
|
18
20
|
"./comments/ui/comments.css": "./src/comments/ui/comments.css",
|
|
19
21
|
"./workshop/ui/mount.js": "./src/workshop/ui/mount.js",
|
|
20
22
|
"./modes.css": "./src/modes.css",
|
|
21
23
|
"./svelte-plugin-ui": "./src/svelte-plugin-ui/index.ts",
|
|
22
|
-
"./svelte-plugin-ui/design-modes": "./src/
|
|
24
|
+
"./svelte-plugin-ui/design-modes": "./src/ui/design-modes.ts",
|
|
25
|
+
"./svelte-plugin-ui/viewfinder": "./src/ui/viewfinder.ts",
|
|
26
|
+
"./ui/design-modes": "./src/ui/design-modes.ts",
|
|
27
|
+
"./ui/viewfinder": "./src/ui/viewfinder.ts",
|
|
23
28
|
"./svelte-plugin-ui/styles/base.css": "./src/svelte-plugin-ui/styles/base.css"
|
|
24
29
|
},
|
|
25
30
|
"dependencies": {
|
package/src/bodyClasses.js
CHANGED
|
@@ -14,7 +14,7 @@ import { subscribeToStorage } from './localStorage.js'
|
|
|
14
14
|
import { syncFlagBodyClasses } from './featureFlags.js'
|
|
15
15
|
|
|
16
16
|
const PREFIX = 'sb-'
|
|
17
|
-
const
|
|
17
|
+
const FLOW_PREFIX = 'sb-scene--'
|
|
18
18
|
const FF_PREFIX = 'sb-ff-'
|
|
19
19
|
|
|
20
20
|
/**
|
|
@@ -49,7 +49,7 @@ function overrideClass(key, value) {
|
|
|
49
49
|
function getCurrentOverrideClasses() {
|
|
50
50
|
const classes = new Set()
|
|
51
51
|
for (const cls of document.body.classList) {
|
|
52
|
-
if (cls.startsWith(PREFIX) && !cls.startsWith(
|
|
52
|
+
if (cls.startsWith(PREFIX) && !cls.startsWith(FLOW_PREFIX) && !cls.startsWith(FF_PREFIX)) {
|
|
53
53
|
classes.add(cls)
|
|
54
54
|
}
|
|
55
55
|
}
|
|
@@ -86,21 +86,24 @@ export function syncOverrideClasses() {
|
|
|
86
86
|
}
|
|
87
87
|
|
|
88
88
|
/**
|
|
89
|
-
* Set the
|
|
90
|
-
* @param {string} name -
|
|
89
|
+
* Set the flow class on <body>. Removes any previous flow class.
|
|
90
|
+
* @param {string} name - Flow name (e.g. "Dashboard")
|
|
91
91
|
*/
|
|
92
|
-
export function
|
|
93
|
-
// Remove any existing
|
|
92
|
+
export function setFlowClass(name) {
|
|
93
|
+
// Remove any existing flow classes
|
|
94
94
|
for (const cls of [...document.body.classList]) {
|
|
95
|
-
if (cls.startsWith(
|
|
95
|
+
if (cls.startsWith(FLOW_PREFIX)) {
|
|
96
96
|
document.body.classList.remove(cls)
|
|
97
97
|
}
|
|
98
98
|
}
|
|
99
99
|
if (name) {
|
|
100
|
-
document.body.classList.add(`${
|
|
100
|
+
document.body.classList.add(`${FLOW_PREFIX}${sanitize(name)}`)
|
|
101
101
|
}
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
+
/** @deprecated Use setFlowClass() */
|
|
105
|
+
export const setSceneClass = setFlowClass
|
|
106
|
+
|
|
104
107
|
/**
|
|
105
108
|
* Install listeners that keep body classes in sync with overrides.
|
|
106
109
|
* Subscribes to both hashchange (normal mode) and storage (hide mode).
|
package/src/bodyClasses.test.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
syncOverrideClasses,
|
|
3
|
+
setFlowClass,
|
|
3
4
|
setSceneClass,
|
|
4
5
|
installBodyClassSync,
|
|
5
6
|
} from './bodyClasses.js'
|
|
@@ -90,37 +91,50 @@ describe('Override body classes', () => {
|
|
|
90
91
|
})
|
|
91
92
|
})
|
|
92
93
|
|
|
93
|
-
// ──
|
|
94
|
+
// ── Flow Classes ──
|
|
94
95
|
|
|
95
|
-
describe('
|
|
96
|
+
describe('Flow body classes', () => {
|
|
96
97
|
it('sets sb-scene-- class', () => {
|
|
97
|
-
|
|
98
|
+
setFlowClass('Dashboard')
|
|
98
99
|
expect(getSbClasses()).toContain('sb-scene--dashboard')
|
|
99
100
|
})
|
|
100
101
|
|
|
101
|
-
it('replaces previous
|
|
102
|
-
|
|
103
|
-
|
|
102
|
+
it('replaces previous flow class', () => {
|
|
103
|
+
setFlowClass('Dashboard')
|
|
104
|
+
setFlowClass('Settings')
|
|
104
105
|
expect(getSbClasses()).not.toContain('sb-scene--dashboard')
|
|
105
106
|
expect(getSbClasses()).toContain('sb-scene--settings')
|
|
106
107
|
})
|
|
107
108
|
|
|
108
|
-
it('removes
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
const
|
|
112
|
-
expect(
|
|
109
|
+
it('removes flow class when called with empty string', () => {
|
|
110
|
+
setFlowClass('Dashboard')
|
|
111
|
+
setFlowClass('')
|
|
112
|
+
const flowClasses = getSbClasses().filter((c) => c.startsWith('sb-scene--'))
|
|
113
|
+
expect(flowClasses).toEqual([])
|
|
113
114
|
})
|
|
114
115
|
|
|
115
116
|
it('does not interfere with override classes', () => {
|
|
116
117
|
window.location.hash = '#theme=dark'
|
|
117
118
|
syncOverrideClasses()
|
|
118
|
-
|
|
119
|
+
setFlowClass('Dashboard')
|
|
119
120
|
expect(getSbClasses()).toContain('sb-theme--dark')
|
|
120
121
|
expect(getSbClasses()).toContain('sb-scene--dashboard')
|
|
121
122
|
})
|
|
122
123
|
})
|
|
123
124
|
|
|
125
|
+
// ── setSceneClass (deprecated alias) ──
|
|
126
|
+
|
|
127
|
+
describe('setSceneClass (deprecated alias)', () => {
|
|
128
|
+
it('is the same function as setFlowClass', () => {
|
|
129
|
+
expect(setSceneClass).toBe(setFlowClass)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('sets sb-scene-- class', () => {
|
|
133
|
+
setSceneClass('Dashboard')
|
|
134
|
+
expect(getSbClasses()).toContain('sb-scene--dashboard')
|
|
135
|
+
})
|
|
136
|
+
})
|
|
137
|
+
|
|
124
138
|
// ── Hide Mode ──
|
|
125
139
|
|
|
126
140
|
describe('Hide mode body classes', () => {
|
package/src/devtools.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Features:
|
|
7
7
|
* - Floating beaker button (bottom-right) that opens a menu
|
|
8
|
-
* - "Show
|
|
8
|
+
* - "Show flow info" — overlay panel with resolved scene JSON
|
|
9
9
|
* - "Reset all params" — clears all URL hash session params
|
|
10
10
|
* - Cmd+. (Mac) / Ctrl+. (other) toggles visibility
|
|
11
11
|
*
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* import { mountDevTools } from '@dfosco/storyboard-core'
|
|
14
14
|
* mountDevTools() // call once at app startup
|
|
15
15
|
*/
|
|
16
|
-
import {
|
|
16
|
+
import { loadFlow } from './loader.js'
|
|
17
17
|
import { isCommentsEnabled } from './comments/config.js'
|
|
18
18
|
import { isHideMode, activateHideMode, deactivateHideMode } from './hideMode.js'
|
|
19
19
|
import { getAllFlags, toggleFlag, getFlagKeys } from './featureFlags.js'
|
|
@@ -180,7 +180,7 @@ const EYE_CLOSED_ICON = '<svg viewBox="0 0 16 16"><path d="M.143 2.31a.75.75 0 0
|
|
|
180
180
|
const CHECK_ICON = '<svg viewBox="0 0 16 16"><path d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z"/></svg>'
|
|
181
181
|
const ZAP_ICON = '<svg viewBox="0 0 16 16"><path d="M9.504.43a1.516 1.516 0 0 1 2.437 1.713L10.415 5.5h2.123c1.57 0 2.346 1.909 1.22 3.004l-7.34 7.142a1.249 1.249 0 0 1-.871.354h-.302a1.25 1.25 0 0 1-1.157-1.723L5.633 10.5H3.462c-1.57 0-2.346-1.909-1.22-3.004Z"/></svg>'
|
|
182
182
|
|
|
183
|
-
function
|
|
183
|
+
function getFlowName() {
|
|
184
184
|
return new URLSearchParams(window.location.search).get('scene') || 'default'
|
|
185
185
|
}
|
|
186
186
|
|
|
@@ -235,7 +235,7 @@ export function mountDevTools(options = {}) {
|
|
|
235
235
|
|
|
236
236
|
const showInfoBtn = document.createElement('button')
|
|
237
237
|
showInfoBtn.className = 'sb-devtools-menu-item'
|
|
238
|
-
showInfoBtn.innerHTML = `${INFO_ICON} Show
|
|
238
|
+
showInfoBtn.innerHTML = `${INFO_ICON} Show flow info`
|
|
239
239
|
|
|
240
240
|
const resetBtn = document.createElement('button')
|
|
241
241
|
resetBtn.className = 'sb-devtools-menu-item'
|
|
@@ -391,11 +391,11 @@ export function mountDevTools(options = {}) {
|
|
|
391
391
|
|
|
392
392
|
if (overlay) overlay.remove()
|
|
393
393
|
|
|
394
|
-
const sceneName =
|
|
394
|
+
const sceneName = getFlowName()
|
|
395
395
|
let sceneJson = ''
|
|
396
396
|
let error = null
|
|
397
397
|
try {
|
|
398
|
-
sceneJson = JSON.stringify(
|
|
398
|
+
sceneJson = JSON.stringify(loadFlow(sceneName), null, 2)
|
|
399
399
|
} catch (err) {
|
|
400
400
|
error = err.message
|
|
401
401
|
}
|
|
@@ -412,7 +412,7 @@ export function mountDevTools(options = {}) {
|
|
|
412
412
|
|
|
413
413
|
const header = document.createElement('div')
|
|
414
414
|
header.className = 'sb-devtools-panel-header'
|
|
415
|
-
header.innerHTML = `<span class="sb-devtools-panel-title">
|
|
415
|
+
header.innerHTML = `<span class="sb-devtools-panel-title">Flow: ${sceneName}</span>`
|
|
416
416
|
|
|
417
417
|
const closeBtn = document.createElement('button')
|
|
418
418
|
closeBtn.className = 'sb-devtools-panel-close'
|
package/src/devtools.test.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { vi } from 'vitest'
|
|
2
2
|
|
|
3
3
|
vi.mock('./loader.js', () => ({
|
|
4
|
-
|
|
4
|
+
loadFlow: vi.fn(() => ({ test: true })),
|
|
5
5
|
}))
|
|
6
6
|
|
|
7
7
|
import { mountDevTools } from './devtools.js'
|
|
@@ -68,7 +68,7 @@ describe('mountDevTools', () => {
|
|
|
68
68
|
expect(trigger.getAttribute('aria-label')).toBe('Storyboard DevTools')
|
|
69
69
|
})
|
|
70
70
|
|
|
71
|
-
it('contains a menu with
|
|
71
|
+
it('contains a menu with flow info and reset buttons', () => {
|
|
72
72
|
mountDevTools()
|
|
73
73
|
|
|
74
74
|
const menuItems = document.body.querySelectorAll('.sb-devtools-menu-item')
|
package/src/index.js
CHANGED
|
@@ -8,8 +8,14 @@
|
|
|
8
8
|
// Data index initialization
|
|
9
9
|
export { init } from './loader.js'
|
|
10
10
|
|
|
11
|
-
//
|
|
12
|
-
export {
|
|
11
|
+
// Flow, object & record loading
|
|
12
|
+
export { loadFlow, listFlows, flowExists, loadRecord, findRecord, loadObject, deepMerge } from './loader.js'
|
|
13
|
+
// Scoped name resolution
|
|
14
|
+
export { resolveFlowName, resolveRecordName } from './loader.js'
|
|
15
|
+
// Prototype metadata
|
|
16
|
+
export { listPrototypes, getPrototypeMetadata } from './loader.js'
|
|
17
|
+
// Deprecated scene aliases
|
|
18
|
+
export { loadScene, listScenes, sceneExists } from './loader.js'
|
|
13
19
|
|
|
14
20
|
// Dot-notation path utilities
|
|
15
21
|
export { getByPath, setByPath, deepClone } from './dotPath.js'
|
|
@@ -27,18 +33,27 @@ export { interceptHideParams, installHideParamListener } from './interceptHidePa
|
|
|
27
33
|
// Hash change subscription (for reactive frameworks)
|
|
28
34
|
export { subscribeToHash, getHashSnapshot } from './hashSubscribe.js'
|
|
29
35
|
|
|
30
|
-
// Body class sync (overrides +
|
|
31
|
-
export { installBodyClassSync,
|
|
36
|
+
// Body class sync (overrides + flow → <body> classes)
|
|
37
|
+
export { installBodyClassSync, setFlowClass, syncOverrideClasses } from './bodyClasses.js'
|
|
38
|
+
// Deprecated alias
|
|
39
|
+
export { setSceneClass } from './bodyClasses.js'
|
|
32
40
|
|
|
33
41
|
// Design modes (mode registry, switching, event bus)
|
|
34
42
|
export { registerMode, unregisterMode, getRegisteredModes, getCurrentMode, activateMode, deactivateMode, subscribeToMode, getModeSnapshot, syncModeClasses, on, off, emit, initModesConfig, isModesEnabled } from './modes.js'
|
|
35
43
|
|
|
44
|
+
// Tool registry (declared in modes.config.json, state managed at runtime)
|
|
45
|
+
export { initTools, setToolAction, setToolState, getToolState, getToolsForMode, subscribeToTools, getToolsSnapshot } from './modes.js'
|
|
46
|
+
|
|
36
47
|
// Dev tools (vanilla JS, framework-agnostic)
|
|
37
48
|
export { mountDevTools } from './devtools.js'
|
|
49
|
+
export { mountFlowDebug } from './sceneDebug.js'
|
|
50
|
+
// Deprecated alias
|
|
38
51
|
export { mountSceneDebug } from './sceneDebug.js'
|
|
39
52
|
|
|
40
53
|
// Viewfinder utilities
|
|
41
|
-
export { hash,
|
|
54
|
+
export { hash, resolveFlowRoute, getFlowMeta, buildPrototypeIndex } from './viewfinder.js'
|
|
55
|
+
// Deprecated aliases
|
|
56
|
+
export { resolveSceneRoute, getSceneMeta } from './viewfinder.js'
|
|
42
57
|
|
|
43
58
|
// Feature flags
|
|
44
59
|
export { initFeatureFlags, getFlag, setFlag, toggleFlag, getAllFlags, resetFlags, getFlagKeys, syncFlagBodyClasses } from './featureFlags.js'
|
package/src/loader.js
CHANGED
|
@@ -28,24 +28,25 @@ function deepMerge(target, source) {
|
|
|
28
28
|
|
|
29
29
|
/**
|
|
30
30
|
* Module-level data index, seeded by init().
|
|
31
|
-
* Shape: {
|
|
31
|
+
* Shape: { flows: {}, objects: {}, records: {} }
|
|
32
32
|
*/
|
|
33
|
-
let dataIndex = {
|
|
33
|
+
let dataIndex = { flows: {}, objects: {}, records: {}, prototypes: {} }
|
|
34
34
|
|
|
35
35
|
/**
|
|
36
36
|
* Seed the data index. Call once at app startup before any load functions.
|
|
37
37
|
* The Vite data plugin calls this automatically via the generated virtual module.
|
|
38
38
|
*
|
|
39
|
-
* @param {{ scenes
|
|
39
|
+
* @param {{ flows?: object, scenes?: object, objects: object, records: object, prototypes?: object }} index
|
|
40
40
|
*/
|
|
41
41
|
export function init(index) {
|
|
42
42
|
if (!index || typeof index !== 'object') {
|
|
43
|
-
throw new Error('[storyboard-core] init() requires {
|
|
43
|
+
throw new Error('[storyboard-core] init() requires { flows, objects, records }')
|
|
44
44
|
}
|
|
45
45
|
dataIndex = {
|
|
46
|
-
|
|
46
|
+
flows: index.flows || index.scenes || {},
|
|
47
47
|
objects: index.objects || {},
|
|
48
48
|
records: index.records || {},
|
|
49
|
+
prototypes: index.prototypes || {},
|
|
49
50
|
}
|
|
50
51
|
}
|
|
51
52
|
|
|
@@ -63,24 +64,29 @@ function loadDataFile(name, type) {
|
|
|
63
64
|
|
|
64
65
|
// Search all types if no specific type given
|
|
65
66
|
if (!type) {
|
|
66
|
-
for (const t of ['
|
|
67
|
+
for (const t of ['flows', 'objects', 'records']) {
|
|
67
68
|
if (dataIndex[t]?.[name] != null) {
|
|
68
69
|
return dataIndex[t][name]
|
|
69
70
|
}
|
|
70
71
|
}
|
|
71
72
|
}
|
|
72
73
|
|
|
73
|
-
// Case-insensitive fallback for
|
|
74
|
-
if (type === '
|
|
74
|
+
// Case-insensitive fallback for flows
|
|
75
|
+
if (type === 'flows' || !type) {
|
|
75
76
|
const lower = name.toLowerCase()
|
|
76
|
-
for (const key of Object.keys(dataIndex.
|
|
77
|
+
for (const key of Object.keys(dataIndex.flows)) {
|
|
77
78
|
if (key.toLowerCase() === lower) {
|
|
78
|
-
return dataIndex.
|
|
79
|
+
return dataIndex.flows[key]
|
|
79
80
|
}
|
|
80
81
|
}
|
|
81
82
|
}
|
|
82
83
|
|
|
83
|
-
|
|
84
|
+
const available = Object.keys(dataIndex[type] || {})
|
|
85
|
+
const scopedHints = available.filter(k => k.includes('/')).slice(0, 5)
|
|
86
|
+
const hint = scopedHints.length > 0
|
|
87
|
+
? `\n Scoped names in index: ${scopedHints.join(', ')}`
|
|
88
|
+
: ''
|
|
89
|
+
throw new Error(`Data file not found: ${name}${type ? ` (type: ${type})` : ''}${hint}`)
|
|
84
90
|
}
|
|
85
91
|
|
|
86
92
|
/**
|
|
@@ -117,49 +123,62 @@ function resolveRefs(node, seen = new Set()) {
|
|
|
117
123
|
}
|
|
118
124
|
|
|
119
125
|
/**
|
|
120
|
-
* Returns the names of all registered
|
|
126
|
+
* Returns the names of all registered flows.
|
|
121
127
|
* @returns {string[]}
|
|
122
128
|
*/
|
|
123
|
-
export function
|
|
124
|
-
return Object.keys(dataIndex.
|
|
129
|
+
export function listFlows() {
|
|
130
|
+
return Object.keys(dataIndex.flows)
|
|
125
131
|
}
|
|
126
132
|
|
|
133
|
+
/** @deprecated Use listFlows() */
|
|
134
|
+
export const listScenes = listFlows
|
|
135
|
+
|
|
127
136
|
/**
|
|
128
|
-
* Checks whether a
|
|
129
|
-
* @param {string}
|
|
137
|
+
* Checks whether a flow file exists for the given name.
|
|
138
|
+
* @param {string} flowName - e.g., "Overview"
|
|
130
139
|
* @returns {boolean}
|
|
131
140
|
*/
|
|
132
|
-
export function
|
|
133
|
-
if (dataIndex.
|
|
134
|
-
const lower =
|
|
135
|
-
for (const key of Object.keys(dataIndex.
|
|
141
|
+
export function flowExists(flowName) {
|
|
142
|
+
if (dataIndex.flows[flowName] != null) return true
|
|
143
|
+
const lower = flowName.toLowerCase()
|
|
144
|
+
for (const key of Object.keys(dataIndex.flows)) {
|
|
136
145
|
if (key.toLowerCase() === lower) return true
|
|
137
146
|
}
|
|
138
147
|
return false
|
|
139
148
|
}
|
|
140
149
|
|
|
150
|
+
/** @deprecated Use flowExists() */
|
|
151
|
+
export const sceneExists = flowExists
|
|
152
|
+
|
|
141
153
|
/**
|
|
142
|
-
* Loads a
|
|
154
|
+
* Loads a flow file and resolves $global and $ref references.
|
|
143
155
|
*
|
|
144
|
-
* - $global: array of data names merged into root (
|
|
156
|
+
* - $global: array of data names merged into root (flow wins on conflicts)
|
|
145
157
|
* - $ref: inline object replacement at any nesting level
|
|
146
158
|
*
|
|
147
|
-
* @param {string}
|
|
148
|
-
* @returns {object} Resolved
|
|
159
|
+
* @param {string} flowName - Name of the flow (e.g., "default")
|
|
160
|
+
* @returns {object} Resolved flow data
|
|
149
161
|
*/
|
|
150
|
-
export function
|
|
151
|
-
let
|
|
162
|
+
export function loadFlow(flowName = 'default') {
|
|
163
|
+
let flowData
|
|
152
164
|
|
|
153
165
|
try {
|
|
154
|
-
|
|
166
|
+
flowData = structuredClone(loadDataFile(flowName, 'flows'))
|
|
155
167
|
} catch {
|
|
156
|
-
|
|
168
|
+
const available = listFlows()
|
|
169
|
+
const related = available.filter(k =>
|
|
170
|
+
k.endsWith('/' + flowName) || k.startsWith(flowName + '/')
|
|
171
|
+
)
|
|
172
|
+
const hint = related.length > 0
|
|
173
|
+
? ` Did you mean: ${related.join(', ')}?`
|
|
174
|
+
: ''
|
|
175
|
+
throw new Error(`Failed to load flow: ${flowName}.${hint}`)
|
|
157
176
|
}
|
|
158
177
|
|
|
159
178
|
// Handle $global: root-level merge from referenced data files
|
|
160
|
-
if (Array.isArray(
|
|
161
|
-
const globalNames =
|
|
162
|
-
delete
|
|
179
|
+
if (Array.isArray(flowData.$global)) {
|
|
180
|
+
const globalNames = flowData.$global
|
|
181
|
+
delete flowData.$global
|
|
163
182
|
|
|
164
183
|
let mergedGlobals = {}
|
|
165
184
|
for (const name of globalNames) {
|
|
@@ -172,16 +191,19 @@ export function loadScene(sceneName = 'default') {
|
|
|
172
191
|
}
|
|
173
192
|
}
|
|
174
193
|
|
|
175
|
-
|
|
194
|
+
flowData = deepMerge(mergedGlobals, flowData)
|
|
176
195
|
}
|
|
177
196
|
|
|
178
|
-
|
|
197
|
+
flowData = resolveRefs(flowData)
|
|
179
198
|
|
|
180
199
|
// Single clone at the boundary — resolveRefs builds new objects internally,
|
|
181
200
|
// so the index data is safe. Clone here to prevent consumer mutation.
|
|
182
|
-
return structuredClone(
|
|
201
|
+
return structuredClone(flowData)
|
|
183
202
|
}
|
|
184
203
|
|
|
204
|
+
/** @deprecated Use loadFlow() */
|
|
205
|
+
export const loadScene = loadFlow
|
|
206
|
+
|
|
185
207
|
/**
|
|
186
208
|
* Loads a record collection by name.
|
|
187
209
|
* @param {string} recordName - Name of the record file (e.g., "posts")
|
|
@@ -190,7 +212,14 @@ export function loadScene(sceneName = 'default') {
|
|
|
190
212
|
export function loadRecord(recordName) {
|
|
191
213
|
const data = dataIndex.records[recordName]
|
|
192
214
|
if (data == null) {
|
|
193
|
-
|
|
215
|
+
const available = Object.keys(dataIndex.records)
|
|
216
|
+
const related = available.filter(k =>
|
|
217
|
+
k.endsWith('/' + recordName) || k.startsWith(recordName + '/')
|
|
218
|
+
)
|
|
219
|
+
const hint = related.length > 0
|
|
220
|
+
? ` Did you mean: ${related.join(', ')}?`
|
|
221
|
+
: ''
|
|
222
|
+
throw new Error(`Record not found: ${recordName}.${hint}`)
|
|
194
223
|
}
|
|
195
224
|
if (!Array.isArray(data)) {
|
|
196
225
|
throw new Error(`Record "${recordName}" must be an array, got ${typeof data}`)
|
|
@@ -222,4 +251,56 @@ export function loadObject(objectName) {
|
|
|
222
251
|
return resolved
|
|
223
252
|
}
|
|
224
253
|
|
|
254
|
+
/**
|
|
255
|
+
* Resolve a flow name within a prototype scope.
|
|
256
|
+
* Tries the scoped name first ({scope}/{name}), then falls back to the plain name.
|
|
257
|
+
*
|
|
258
|
+
* @param {string|null} scope - Prototype name (e.g. "Dashboard"), or null for global-only
|
|
259
|
+
* @param {string} name - Flow name (e.g. "default" or "Dashboard/signup")
|
|
260
|
+
* @returns {string} The resolved flow name that exists in the index
|
|
261
|
+
*/
|
|
262
|
+
export function resolveFlowName(scope, name) {
|
|
263
|
+
if (scope) {
|
|
264
|
+
const scoped = `${scope}/${name}`
|
|
265
|
+
if (flowExists(scoped)) return scoped
|
|
266
|
+
}
|
|
267
|
+
if (flowExists(name)) return name
|
|
268
|
+
// Return the scoped name for better error messages even if it doesn't exist
|
|
269
|
+
return scope ? `${scope}/${name}` : name
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Resolve a record name within a prototype scope.
|
|
274
|
+
* Tries the scoped name first ({scope}/{name}), then falls back to the plain name.
|
|
275
|
+
*
|
|
276
|
+
* @param {string|null} scope - Prototype name (e.g. "Dashboard"), or null for global-only
|
|
277
|
+
* @param {string} name - Record name (e.g. "posts")
|
|
278
|
+
* @returns {string} The resolved record name that exists in the index
|
|
279
|
+
*/
|
|
280
|
+
export function resolveRecordName(scope, name) {
|
|
281
|
+
if (scope) {
|
|
282
|
+
const scoped = `${scope}/${name}`
|
|
283
|
+
if (dataIndex.records[scoped] != null) return scoped
|
|
284
|
+
}
|
|
285
|
+
if (dataIndex.records[name] != null) return name
|
|
286
|
+
return scope ? `${scope}/${name}` : name
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Returns the names of all registered prototypes.
|
|
291
|
+
* @returns {string[]}
|
|
292
|
+
*/
|
|
293
|
+
export function listPrototypes() {
|
|
294
|
+
return Object.keys(dataIndex.prototypes)
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Returns prototype metadata by name.
|
|
299
|
+
* @param {string} name - Prototype name (e.g. "Example")
|
|
300
|
+
* @returns {object|null} Metadata from the .prototype.json file, or null
|
|
301
|
+
*/
|
|
302
|
+
export function getPrototypeMetadata(name) {
|
|
303
|
+
return dataIndex.prototypes[name] ?? null
|
|
304
|
+
}
|
|
305
|
+
|
|
225
306
|
export { deepMerge }
|