@dfosco/storyboard-react 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/package.json +2 -2
- package/src/Viewfinder.jsx +46 -215
- package/src/context.jsx +71 -19
- package/src/context.test.jsx +97 -14
- package/src/hooks/useRecord.js +16 -8
- package/src/hooks/useScene.js +19 -10
- package/src/hooks/useScene.test.js +40 -13
- package/src/hooks/useSceneData.js +17 -11
- package/src/hooks/useSceneData.test.js +60 -32
- package/src/index.js +3 -1
- package/src/test-utils.js +8 -5
- package/src/vite/data-plugin.js +136 -18
- package/src/vite/data-plugin.test.js +135 -2
package/src/hooks/useScene.js
CHANGED
|
@@ -2,27 +2,36 @@ import { useContext, useCallback } from 'react'
|
|
|
2
2
|
import { StoryboardContext } from '../StoryboardContext.js'
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* Read the current
|
|
5
|
+
* Read the current flow name and programmatically switch flows.
|
|
6
6
|
*
|
|
7
|
-
* @returns {{
|
|
8
|
-
* -
|
|
9
|
-
* -
|
|
7
|
+
* @returns {{ flowName: string, switchFlow: (name: string) => void }}
|
|
8
|
+
* - flowName – current active flow (e.g. "default")
|
|
9
|
+
* - switchFlow – navigate to a different flow by updating ?scene= param
|
|
10
10
|
*/
|
|
11
|
-
export function
|
|
11
|
+
export function useFlow() {
|
|
12
12
|
const context = useContext(StoryboardContext)
|
|
13
13
|
if (context === null) {
|
|
14
|
-
throw new Error('
|
|
14
|
+
throw new Error('useFlow must be used within a <StoryboardProvider>')
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
const
|
|
17
|
+
const switchFlow = useCallback((name) => {
|
|
18
18
|
const url = new URL(window.location.href)
|
|
19
19
|
url.searchParams.set('scene', name)
|
|
20
|
-
// Preserve hash params across
|
|
20
|
+
// Preserve hash params across flow switches
|
|
21
21
|
window.location.href = url.toString()
|
|
22
22
|
}, [])
|
|
23
23
|
|
|
24
24
|
return {
|
|
25
|
-
|
|
26
|
-
|
|
25
|
+
flowName: context.flowName,
|
|
26
|
+
switchFlow,
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** @deprecated Use useFlow() */
|
|
31
|
+
export function useScene() {
|
|
32
|
+
const { flowName, switchFlow } = useFlow()
|
|
33
|
+
return {
|
|
34
|
+
sceneName: flowName,
|
|
35
|
+
switchScene: switchFlow,
|
|
27
36
|
}
|
|
28
37
|
}
|
|
@@ -1,39 +1,66 @@
|
|
|
1
1
|
import { renderHook } from '@testing-library/react'
|
|
2
|
+
import { useFlow } from './useScene.js'
|
|
2
3
|
import { useScene } from './useScene.js'
|
|
3
|
-
import { seedTestData, createWrapper,
|
|
4
|
+
import { seedTestData, createWrapper, TEST_FLOWS } from '../test-utils.js'
|
|
4
5
|
|
|
5
|
-
const
|
|
6
|
+
const flowData = TEST_FLOWS.default
|
|
6
7
|
|
|
7
8
|
beforeEach(() => {
|
|
8
9
|
seedTestData()
|
|
9
10
|
})
|
|
10
11
|
|
|
11
|
-
describe('
|
|
12
|
+
describe('useFlow', () => {
|
|
13
|
+
it('returns { flowName, switchFlow }', () => {
|
|
14
|
+
const { result } = renderHook(() => useFlow(), {
|
|
15
|
+
wrapper: createWrapper(flowData),
|
|
16
|
+
})
|
|
17
|
+
expect(result.current).toHaveProperty('flowName')
|
|
18
|
+
expect(result.current).toHaveProperty('switchFlow')
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('flowName matches the value from context', () => {
|
|
22
|
+
const { result } = renderHook(() => useFlow(), {
|
|
23
|
+
wrapper: createWrapper(flowData, 'other'),
|
|
24
|
+
})
|
|
25
|
+
expect(result.current.flowName).toBe('other')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('switchFlow is a function', () => {
|
|
29
|
+
const { result } = renderHook(() => useFlow(), {
|
|
30
|
+
wrapper: createWrapper(flowData),
|
|
31
|
+
})
|
|
32
|
+
expect(typeof result.current.switchFlow).toBe('function')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('throws when used outside StoryboardProvider', () => {
|
|
36
|
+
expect(() => {
|
|
37
|
+
renderHook(() => useFlow())
|
|
38
|
+
}).toThrow('useFlow must be used within a <StoryboardProvider>')
|
|
39
|
+
})
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
// ── useScene (deprecated alias) ──
|
|
43
|
+
|
|
44
|
+
describe('useScene (deprecated alias)', () => {
|
|
12
45
|
it('returns { sceneName, switchScene }', () => {
|
|
13
46
|
const { result } = renderHook(() => useScene(), {
|
|
14
|
-
wrapper: createWrapper(
|
|
47
|
+
wrapper: createWrapper(flowData),
|
|
15
48
|
})
|
|
16
49
|
expect(result.current).toHaveProperty('sceneName')
|
|
17
50
|
expect(result.current).toHaveProperty('switchScene')
|
|
18
51
|
})
|
|
19
52
|
|
|
20
|
-
it('sceneName matches the
|
|
53
|
+
it('sceneName matches the flow name from context', () => {
|
|
21
54
|
const { result } = renderHook(() => useScene(), {
|
|
22
|
-
wrapper: createWrapper(
|
|
55
|
+
wrapper: createWrapper(flowData, 'other'),
|
|
23
56
|
})
|
|
24
57
|
expect(result.current.sceneName).toBe('other')
|
|
25
58
|
})
|
|
26
59
|
|
|
27
60
|
it('switchScene is a function', () => {
|
|
28
61
|
const { result } = renderHook(() => useScene(), {
|
|
29
|
-
wrapper: createWrapper(
|
|
62
|
+
wrapper: createWrapper(flowData),
|
|
30
63
|
})
|
|
31
64
|
expect(typeof result.current.switchScene).toBe('function')
|
|
32
65
|
})
|
|
33
|
-
|
|
34
|
-
it('throws when used outside StoryboardProvider', () => {
|
|
35
|
-
expect(() => {
|
|
36
|
-
renderHook(() => useScene())
|
|
37
|
-
}).toThrow('useScene must be used within a <StoryboardProvider>')
|
|
38
|
-
})
|
|
39
66
|
})
|
|
@@ -7,24 +7,24 @@ import { isHideMode, getShadow, getAllShadows } from '@dfosco/storyboard-core'
|
|
|
7
7
|
import { subscribeToStorage, getStorageSnapshot } from '@dfosco/storyboard-core'
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
|
-
* Access
|
|
11
|
-
* Hash params override
|
|
10
|
+
* Access flow data by dot-notation path.
|
|
11
|
+
* Hash params override flow data — both exact matches and nested paths.
|
|
12
12
|
*
|
|
13
13
|
* Examples:
|
|
14
|
-
*
|
|
15
|
-
*
|
|
14
|
+
* useFlowData('user.name') with #user.name=Alice → "Alice"
|
|
15
|
+
* useFlowData('repositories') with #repositories.0.name=Foo
|
|
16
16
|
* → deep clone of repositories array with [0].name overridden to "Foo"
|
|
17
17
|
*
|
|
18
18
|
* @param {string} [path] - Dot-notation path (e.g. 'user.profile.name').
|
|
19
|
-
* Omit to get the entire
|
|
19
|
+
* Omit to get the entire flow object.
|
|
20
20
|
* @returns {*} The resolved value. Returns {} if path is missing after loading.
|
|
21
21
|
* @throws If used outside a StoryboardProvider.
|
|
22
22
|
*/
|
|
23
|
-
export function
|
|
23
|
+
export function useFlowData(path) {
|
|
24
24
|
const context = useContext(StoryboardContext)
|
|
25
25
|
|
|
26
26
|
if (context === null) {
|
|
27
|
-
throw new Error('
|
|
27
|
+
throw new Error('useFlowData must be used within a <StoryboardProvider>')
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
const { data, loading, error } = context
|
|
@@ -73,7 +73,7 @@ export function useSceneData(path) {
|
|
|
73
73
|
}
|
|
74
74
|
|
|
75
75
|
if (sceneValue === undefined) {
|
|
76
|
-
console.warn(`[
|
|
76
|
+
console.warn(`[useFlowData] Path "${path}" not found in flow data.`)
|
|
77
77
|
return {}
|
|
78
78
|
}
|
|
79
79
|
|
|
@@ -83,15 +83,21 @@ export function useSceneData(path) {
|
|
|
83
83
|
return result
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
/** @deprecated Use useFlowData() */
|
|
87
|
+
export const useSceneData = useFlowData
|
|
88
|
+
|
|
86
89
|
/**
|
|
87
|
-
* Returns true while
|
|
90
|
+
* Returns true while flow data is still loading.
|
|
88
91
|
*/
|
|
89
|
-
export function
|
|
92
|
+
export function useFlowLoading() {
|
|
90
93
|
const context = useContext(StoryboardContext)
|
|
91
94
|
|
|
92
95
|
if (context === null) {
|
|
93
|
-
throw new Error('
|
|
96
|
+
throw new Error('useFlowLoading must be used within a <StoryboardProvider>')
|
|
94
97
|
}
|
|
95
98
|
|
|
96
99
|
return context.loading
|
|
97
100
|
}
|
|
101
|
+
|
|
102
|
+
/** @deprecated Use useFlowLoading() */
|
|
103
|
+
export const useSceneLoading = useFlowLoading
|
|
@@ -1,38 +1,38 @@
|
|
|
1
1
|
import { renderHook } from '@testing-library/react'
|
|
2
|
-
import { useSceneData, useSceneLoading } from './useSceneData.js'
|
|
3
|
-
import { seedTestData, createWrapper,
|
|
2
|
+
import { useFlowData, useFlowLoading, useSceneData, useSceneLoading } from './useSceneData.js'
|
|
3
|
+
import { seedTestData, createWrapper, TEST_FLOWS } from '../test-utils.js'
|
|
4
4
|
|
|
5
|
-
const
|
|
5
|
+
const flowData = TEST_FLOWS.default
|
|
6
6
|
|
|
7
7
|
beforeEach(() => {
|
|
8
8
|
seedTestData()
|
|
9
9
|
})
|
|
10
10
|
|
|
11
|
-
describe('
|
|
12
|
-
it('returns entire
|
|
13
|
-
const { result } = renderHook(() =>
|
|
14
|
-
wrapper: createWrapper(
|
|
11
|
+
describe('useFlowData', () => {
|
|
12
|
+
it('returns entire flow object when no path given', () => {
|
|
13
|
+
const { result } = renderHook(() => useFlowData(), {
|
|
14
|
+
wrapper: createWrapper(flowData),
|
|
15
15
|
})
|
|
16
|
-
expect(result.current).toEqual(
|
|
16
|
+
expect(result.current).toEqual(flowData)
|
|
17
17
|
})
|
|
18
18
|
|
|
19
19
|
it('returns nested value by dot-notation path', () => {
|
|
20
|
-
const { result } = renderHook(() =>
|
|
21
|
-
wrapper: createWrapper(
|
|
20
|
+
const { result } = renderHook(() => useFlowData('user.name'), {
|
|
21
|
+
wrapper: createWrapper(flowData),
|
|
22
22
|
})
|
|
23
23
|
expect(result.current).toBe('Jane')
|
|
24
24
|
})
|
|
25
25
|
|
|
26
26
|
it('returns deep nested value', () => {
|
|
27
|
-
const { result } = renderHook(() =>
|
|
28
|
-
wrapper: createWrapper(
|
|
27
|
+
const { result } = renderHook(() => useFlowData('user.profile.bio'), {
|
|
28
|
+
wrapper: createWrapper(flowData),
|
|
29
29
|
})
|
|
30
30
|
expect(result.current).toBe('Dev')
|
|
31
31
|
})
|
|
32
32
|
|
|
33
33
|
it('returns array by path', () => {
|
|
34
|
-
const { result } = renderHook(() =>
|
|
35
|
-
wrapper: createWrapper(
|
|
34
|
+
const { result } = renderHook(() => useFlowData('projects'), {
|
|
35
|
+
wrapper: createWrapper(flowData),
|
|
36
36
|
})
|
|
37
37
|
expect(result.current).toEqual([
|
|
38
38
|
{ id: 1, name: 'alpha' },
|
|
@@ -41,16 +41,16 @@ describe('useSceneData', () => {
|
|
|
41
41
|
})
|
|
42
42
|
|
|
43
43
|
it('returns array element by index path', () => {
|
|
44
|
-
const { result } = renderHook(() =>
|
|
45
|
-
wrapper: createWrapper(
|
|
44
|
+
const { result } = renderHook(() => useFlowData('projects.0'), {
|
|
45
|
+
wrapper: createWrapper(flowData),
|
|
46
46
|
})
|
|
47
47
|
expect(result.current).toEqual({ id: 1, name: 'alpha' })
|
|
48
48
|
})
|
|
49
49
|
|
|
50
50
|
it('returns empty object and warns for missing path', () => {
|
|
51
51
|
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
52
|
-
const { result } = renderHook(() =>
|
|
53
|
-
wrapper: createWrapper(
|
|
52
|
+
const { result } = renderHook(() => useFlowData('nonexistent.path'), {
|
|
53
|
+
wrapper: createWrapper(flowData),
|
|
54
54
|
})
|
|
55
55
|
expect(result.current).toEqual({})
|
|
56
56
|
expect(spy).toHaveBeenCalledWith(
|
|
@@ -61,48 +61,76 @@ describe('useSceneData', () => {
|
|
|
61
61
|
|
|
62
62
|
it('throws when used outside StoryboardProvider', () => {
|
|
63
63
|
expect(() => {
|
|
64
|
-
renderHook(() =>
|
|
65
|
-
}).toThrow('
|
|
64
|
+
renderHook(() => useFlowData())
|
|
65
|
+
}).toThrow('useFlowData must be used within a <StoryboardProvider>')
|
|
66
66
|
})
|
|
67
67
|
|
|
68
68
|
it('returns hash override value when param matches path', () => {
|
|
69
69
|
window.location.hash = '#user.name=Alice'
|
|
70
|
-
const { result } = renderHook(() =>
|
|
71
|
-
wrapper: createWrapper(
|
|
70
|
+
const { result } = renderHook(() => useFlowData('user.name'), {
|
|
71
|
+
wrapper: createWrapper(flowData),
|
|
72
72
|
})
|
|
73
73
|
expect(result.current).toBe('Alice')
|
|
74
74
|
})
|
|
75
75
|
|
|
76
76
|
it('applies child overrides to arrays', () => {
|
|
77
77
|
window.location.hash = '#projects.0.name=gamma'
|
|
78
|
-
const { result } = renderHook(() =>
|
|
79
|
-
wrapper: createWrapper(
|
|
78
|
+
const { result } = renderHook(() => useFlowData('projects'), {
|
|
79
|
+
wrapper: createWrapper(flowData),
|
|
80
80
|
})
|
|
81
81
|
expect(result.current[0].name).toBe('gamma')
|
|
82
82
|
expect(result.current[1].name).toBe('beta')
|
|
83
83
|
})
|
|
84
84
|
|
|
85
|
-
it('returns full
|
|
85
|
+
it('returns full flow with all overrides applied when no path', () => {
|
|
86
86
|
window.location.hash = '#user.name=Alice'
|
|
87
|
-
const { result } = renderHook(() =>
|
|
88
|
-
wrapper: createWrapper(
|
|
87
|
+
const { result } = renderHook(() => useFlowData(), {
|
|
88
|
+
wrapper: createWrapper(flowData),
|
|
89
89
|
})
|
|
90
90
|
expect(result.current.user.name).toBe('Alice')
|
|
91
91
|
expect(result.current.settings.theme).toBe('dark')
|
|
92
92
|
})
|
|
93
93
|
})
|
|
94
94
|
|
|
95
|
-
describe('
|
|
95
|
+
describe('useFlowLoading', () => {
|
|
96
96
|
it('returns false when not loading', () => {
|
|
97
|
-
const { result } = renderHook(() =>
|
|
98
|
-
wrapper: createWrapper(
|
|
97
|
+
const { result } = renderHook(() => useFlowLoading(), {
|
|
98
|
+
wrapper: createWrapper(flowData),
|
|
99
99
|
})
|
|
100
100
|
expect(result.current).toBe(false)
|
|
101
101
|
})
|
|
102
102
|
|
|
103
103
|
it('throws when used outside StoryboardProvider', () => {
|
|
104
104
|
expect(() => {
|
|
105
|
-
renderHook(() =>
|
|
106
|
-
}).toThrow('
|
|
105
|
+
renderHook(() => useFlowLoading())
|
|
106
|
+
}).toThrow('useFlowLoading must be used within a <StoryboardProvider>')
|
|
107
|
+
})
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
// ── Deprecated aliases ──
|
|
111
|
+
|
|
112
|
+
describe('useSceneData (deprecated alias)', () => {
|
|
113
|
+
it('is the same function as useFlowData', () => {
|
|
114
|
+
expect(useSceneData).toBe(useFlowData)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('returns flow data', () => {
|
|
118
|
+
const { result } = renderHook(() => useSceneData(), {
|
|
119
|
+
wrapper: createWrapper(flowData),
|
|
120
|
+
})
|
|
121
|
+
expect(result.current).toEqual(flowData)
|
|
122
|
+
})
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
describe('useSceneLoading (deprecated alias)', () => {
|
|
126
|
+
it('is the same function as useFlowLoading', () => {
|
|
127
|
+
expect(useSceneLoading).toBe(useFlowLoading)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('returns loading state', () => {
|
|
131
|
+
const { result } = renderHook(() => useSceneLoading(), {
|
|
132
|
+
wrapper: createWrapper(flowData),
|
|
133
|
+
})
|
|
134
|
+
expect(result.current).toBe(false)
|
|
107
135
|
})
|
|
108
136
|
})
|
package/src/index.js
CHANGED
|
@@ -10,10 +10,12 @@ export { default as StoryboardProvider } from './context.jsx'
|
|
|
10
10
|
export { StoryboardContext } from './StoryboardContext.js'
|
|
11
11
|
|
|
12
12
|
// Hooks
|
|
13
|
+
export { useFlowData, useFlowLoading } from './hooks/useSceneData.js'
|
|
14
|
+
// Deprecated aliases
|
|
13
15
|
export { useSceneData, useSceneLoading } from './hooks/useSceneData.js'
|
|
14
16
|
export { useOverride } from './hooks/useOverride.js'
|
|
15
17
|
export { useOverride as useSession } from './hooks/useOverride.js' // deprecated alias
|
|
16
|
-
export { useScene } from './hooks/useScene.js'
|
|
18
|
+
export { useFlow, useScene } from './hooks/useScene.js'
|
|
17
19
|
export { useRecord, useRecords } from './hooks/useRecord.js'
|
|
18
20
|
export { useObject } from './hooks/useObject.js'
|
|
19
21
|
export { useLocalStorage } from './hooks/useLocalStorage.js'
|
package/src/test-utils.js
CHANGED
|
@@ -3,7 +3,7 @@ import { StoryboardContext } from './StoryboardContext.js'
|
|
|
3
3
|
import { init } from '@dfosco/storyboard-core'
|
|
4
4
|
|
|
5
5
|
// Default test data
|
|
6
|
-
export const
|
|
6
|
+
export const TEST_FLOWS = {
|
|
7
7
|
default: {
|
|
8
8
|
user: { name: 'Jane', profile: { bio: 'Dev' } },
|
|
9
9
|
settings: { theme: 'dark' },
|
|
@@ -15,6 +15,9 @@ export const TEST_SCENES = {
|
|
|
15
15
|
},
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
/** @deprecated Use TEST_FLOWS */
|
|
19
|
+
export const TEST_SCENES = TEST_FLOWS
|
|
20
|
+
|
|
18
21
|
export const TEST_OBJECTS = {
|
|
19
22
|
'jane-doe': { name: 'Jane Doe', role: 'admin' },
|
|
20
23
|
}
|
|
@@ -27,15 +30,15 @@ export const TEST_RECORDS = {
|
|
|
27
30
|
}
|
|
28
31
|
|
|
29
32
|
export function seedTestData() {
|
|
30
|
-
init({
|
|
33
|
+
init({ flows: TEST_FLOWS, objects: TEST_OBJECTS, records: TEST_RECORDS })
|
|
31
34
|
}
|
|
32
35
|
|
|
33
|
-
// Wrapper that provides StoryboardContext with given
|
|
34
|
-
export function createWrapper(
|
|
36
|
+
// Wrapper that provides StoryboardContext with given flow data
|
|
37
|
+
export function createWrapper(flowData, flowName = 'default') {
|
|
35
38
|
return function Wrapper({ children }) {
|
|
36
39
|
return createElement(
|
|
37
40
|
StoryboardContext.Provider,
|
|
38
|
-
{ value: { data:
|
|
41
|
+
{ value: { data: flowData, error: null, loading: false, flowName, sceneName: flowName } },
|
|
39
42
|
children
|
|
40
43
|
)
|
|
41
44
|
}
|
package/src/vite/data-plugin.js
CHANGED
|
@@ -1,24 +1,68 @@
|
|
|
1
1
|
import fs from 'node:fs'
|
|
2
2
|
import path from 'node:path'
|
|
3
|
+
import { execSync } from 'node:child_process'
|
|
3
4
|
import { globSync } from 'glob'
|
|
4
5
|
import { parse as parseJsonc } from 'jsonc-parser'
|
|
5
6
|
|
|
6
7
|
const VIRTUAL_MODULE_ID = 'virtual:storyboard-data-index'
|
|
7
8
|
const RESOLVED_ID = '\0' + VIRTUAL_MODULE_ID
|
|
8
9
|
|
|
9
|
-
const
|
|
10
|
-
const GLOB_PATTERN = '**/*.{scene,object,record}.{json,jsonc}'
|
|
10
|
+
const GLOB_PATTERN = '**/*.{flow,scene,object,record,prototype}.{json,jsonc}'
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
13
|
* Extract the data name and type suffix from a file path.
|
|
14
|
-
*
|
|
15
|
-
*
|
|
14
|
+
* Flows and records inside src/prototypes/{Name}/ get prefixed with the
|
|
15
|
+
* prototype name (e.g. "Dashboard/default"). Objects are never prefixed.
|
|
16
|
+
*
|
|
17
|
+
* e.g. "src/data/default.flow.json" → { name: "default", suffix: "flow" }
|
|
18
|
+
* "src/prototypes/Dashboard/default.flow.json" → { name: "Dashboard/default", suffix: "flow" }
|
|
19
|
+
* "src/prototypes/Dashboard/helpers.object.json"→ { name: "helpers", suffix: "object" }
|
|
16
20
|
*/
|
|
17
21
|
function parseDataFile(filePath) {
|
|
18
22
|
const base = path.basename(filePath)
|
|
19
|
-
const match = base.match(/^(.+)\.(scene|object|record)\.(jsonc?)$/)
|
|
23
|
+
const match = base.match(/^(.+)\.(flow|scene|object|record|prototype)\.(jsonc?)$/)
|
|
20
24
|
if (!match) return null
|
|
21
|
-
|
|
25
|
+
// Normalize .scene → .flow for backward compatibility
|
|
26
|
+
const suffix = match[2] === 'scene' ? 'flow' : match[2]
|
|
27
|
+
let name = match[1]
|
|
28
|
+
|
|
29
|
+
// Prototype metadata files are keyed by their prototype directory name
|
|
30
|
+
if (suffix === 'prototype') {
|
|
31
|
+
const normalized = filePath.replace(/\\/g, '/')
|
|
32
|
+
const protoMatch = normalized.match(/(?:^|\/)src\/prototypes\/([^/]+)\//)
|
|
33
|
+
if (protoMatch) {
|
|
34
|
+
name = protoMatch[1]
|
|
35
|
+
}
|
|
36
|
+
return { name, suffix, ext: match[3] }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Scope flows and records inside src/prototypes/{Name}/ with a prefix
|
|
40
|
+
if (suffix !== 'object') {
|
|
41
|
+
const normalized = filePath.replace(/\\/g, '/')
|
|
42
|
+
const protoMatch = normalized.match(/(?:^|\/)src\/prototypes\/([^/]+)\//)
|
|
43
|
+
if (protoMatch) {
|
|
44
|
+
name = `${protoMatch[1]}/${name}`
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return { name, suffix, ext: match[3] }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Look up the git author who first created a file.
|
|
53
|
+
* Used to auto-fill the author field in .prototype.json when missing.
|
|
54
|
+
*/
|
|
55
|
+
function getGitAuthor(root, filePath) {
|
|
56
|
+
try {
|
|
57
|
+
const result = execSync(
|
|
58
|
+
`git log --follow --diff-filter=A --format="%aN" -- "${filePath}"`,
|
|
59
|
+
{ cwd: root, encoding: 'utf-8', timeout: 5000 },
|
|
60
|
+
).trim()
|
|
61
|
+
const lines = result.split('\n').filter(Boolean)
|
|
62
|
+
return lines.length > 0 ? lines[lines.length - 1] : null
|
|
63
|
+
} catch {
|
|
64
|
+
return null
|
|
65
|
+
}
|
|
22
66
|
}
|
|
23
67
|
|
|
24
68
|
/**
|
|
@@ -28,7 +72,7 @@ function buildIndex(root) {
|
|
|
28
72
|
const ignore = ['node_modules/**', 'dist/**', '.git/**']
|
|
29
73
|
const files = globSync(GLOB_PATTERN, { cwd: root, ignore, absolute: false })
|
|
30
74
|
|
|
31
|
-
const index = {
|
|
75
|
+
const index = { flow: {}, object: {}, record: {}, prototype: {} }
|
|
32
76
|
const seen = {} // "name.suffix" → absolute path (for duplicate detection)
|
|
33
77
|
|
|
34
78
|
for (const relPath of files) {
|
|
@@ -39,11 +83,17 @@ function buildIndex(root) {
|
|
|
39
83
|
const absPath = path.resolve(root, relPath)
|
|
40
84
|
|
|
41
85
|
if (seen[key]) {
|
|
86
|
+
const hint = parsed.suffix === 'object'
|
|
87
|
+
? ' Objects are globally scoped — even inside src/prototypes/ they share a single namespace.\n' +
|
|
88
|
+
' Rename one of the files to avoid the collision.'
|
|
89
|
+
: ' Flows and records are scoped to their prototype directory.\n' +
|
|
90
|
+
' If both files are global (outside src/prototypes/), rename one to avoid the collision.'
|
|
91
|
+
|
|
42
92
|
throw new Error(
|
|
43
|
-
`[storyboard-data] Duplicate
|
|
93
|
+
`[storyboard-data] Duplicate ${parsed.suffix} "${parsed.name}"\n` +
|
|
44
94
|
` Found at: ${seen[key]}\n` +
|
|
45
95
|
` And at: ${absPath}\n` +
|
|
46
|
-
|
|
96
|
+
hint
|
|
47
97
|
)
|
|
48
98
|
}
|
|
49
99
|
|
|
@@ -77,23 +127,70 @@ function readConfig(root) {
|
|
|
77
127
|
}
|
|
78
128
|
}
|
|
79
129
|
|
|
130
|
+
/**
|
|
131
|
+
* Read modes.config.json from @dfosco/storyboard-core.
|
|
132
|
+
* Returns the full config object { modes, tools }.
|
|
133
|
+
* Falls back to hardcoded defaults if not found.
|
|
134
|
+
*/
|
|
135
|
+
function readModesConfig(root) {
|
|
136
|
+
const fallback = {
|
|
137
|
+
modes: [
|
|
138
|
+
{ name: 'prototype', label: 'Navigate' },
|
|
139
|
+
{ name: 'inspect', label: 'Develop' },
|
|
140
|
+
{ name: 'present', label: 'Collaborate' },
|
|
141
|
+
{ name: 'plan', label: 'Canvas' },
|
|
142
|
+
],
|
|
143
|
+
tools: {},
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Try local workspace path first (monorepo), then node_modules
|
|
147
|
+
const candidates = [
|
|
148
|
+
path.resolve(root, 'packages/core/modes.config.json'),
|
|
149
|
+
path.resolve(root, 'node_modules/@dfosco/storyboard-core/modes.config.json'),
|
|
150
|
+
]
|
|
151
|
+
|
|
152
|
+
for (const filePath of candidates) {
|
|
153
|
+
try {
|
|
154
|
+
const raw = fs.readFileSync(filePath, 'utf-8')
|
|
155
|
+
const parsed = JSON.parse(raw)
|
|
156
|
+
if (Array.isArray(parsed.modes) && parsed.modes.length > 0) {
|
|
157
|
+
return { modes: parsed.modes, tools: parsed.tools ?? {} }
|
|
158
|
+
}
|
|
159
|
+
} catch {
|
|
160
|
+
// try next candidate
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return fallback
|
|
165
|
+
}
|
|
166
|
+
|
|
80
167
|
function generateModule(index, root) {
|
|
81
168
|
const declarations = []
|
|
82
|
-
const
|
|
169
|
+
const INDEX_KEYS = ['flow', 'object', 'record', 'prototype']
|
|
170
|
+
const entries = { flow: [], object: [], record: [], prototype: [] }
|
|
83
171
|
let i = 0
|
|
84
172
|
|
|
85
|
-
for (const suffix of
|
|
173
|
+
for (const suffix of INDEX_KEYS) {
|
|
86
174
|
for (const [name, absPath] of Object.entries(index[suffix])) {
|
|
87
175
|
const varName = `_d${i++}`
|
|
88
176
|
const raw = fs.readFileSync(absPath, 'utf-8')
|
|
89
|
-
|
|
177
|
+
let parsed = parseJsonc(raw)
|
|
178
|
+
|
|
179
|
+
// Auto-fill gitAuthor for prototype metadata from git history
|
|
180
|
+
if (suffix === 'prototype' && parsed && !parsed.gitAuthor) {
|
|
181
|
+
const gitAuthor = getGitAuthor(root, absPath)
|
|
182
|
+
if (gitAuthor) {
|
|
183
|
+
parsed = { ...parsed, gitAuthor }
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
90
187
|
declarations.push(`const ${varName} = ${JSON.stringify(parsed)}`)
|
|
91
188
|
entries[suffix].push(` ${JSON.stringify(name)}: ${varName}`)
|
|
92
189
|
}
|
|
93
190
|
}
|
|
94
191
|
|
|
95
192
|
const imports = [`import { init } from '@dfosco/storyboard-core'`]
|
|
96
|
-
const initCalls = [`init({
|
|
193
|
+
const initCalls = [`init({ flows, objects, records, prototypes })`]
|
|
97
194
|
|
|
98
195
|
// Feature flags from storyboard.config.json
|
|
99
196
|
const { config } = readConfig(root)
|
|
@@ -110,8 +207,25 @@ function generateModule(index, root) {
|
|
|
110
207
|
|
|
111
208
|
// Modes configuration from storyboard.config.json
|
|
112
209
|
if (config?.modes) {
|
|
113
|
-
imports.push(`import { initModesConfig } from '@dfosco/storyboard-core'`)
|
|
210
|
+
imports.push(`import { initModesConfig, registerMode, syncModeClasses, initTools } from '@dfosco/storyboard-core'`)
|
|
114
211
|
initCalls.push(`initModesConfig(${JSON.stringify(config.modes)})`)
|
|
212
|
+
|
|
213
|
+
if (config.modes.enabled) {
|
|
214
|
+
imports.push(`import '@dfosco/storyboard-core/modes.css'`)
|
|
215
|
+
|
|
216
|
+
const modesConfig = readModesConfig(root)
|
|
217
|
+
const modes = config.modes.defaults || modesConfig.modes
|
|
218
|
+
for (const m of modes) {
|
|
219
|
+
initCalls.push(`registerMode(${JSON.stringify(m.name)}, { label: ${JSON.stringify(m.label)} })`)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Seed tool registry from modes.config.json
|
|
223
|
+
if (Object.keys(modesConfig.tools).length > 0) {
|
|
224
|
+
initCalls.push(`initTools(${JSON.stringify(modesConfig.tools)})`)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
initCalls.push(`syncModeClasses()`)
|
|
228
|
+
}
|
|
115
229
|
}
|
|
116
230
|
|
|
117
231
|
return [
|
|
@@ -119,14 +233,18 @@ function generateModule(index, root) {
|
|
|
119
233
|
'',
|
|
120
234
|
declarations.join('\n'),
|
|
121
235
|
'',
|
|
122
|
-
`const
|
|
236
|
+
`const flows = {\n${entries.flow.join(',\n')}\n}`,
|
|
123
237
|
`const objects = {\n${entries.object.join(',\n')}\n}`,
|
|
124
238
|
`const records = {\n${entries.record.join(',\n')}\n}`,
|
|
239
|
+
`const prototypes = {\n${entries.prototype.join(',\n')}\n}`,
|
|
240
|
+
'',
|
|
241
|
+
'// Backward-compatible alias',
|
|
242
|
+
'const scenes = flows',
|
|
125
243
|
'',
|
|
126
244
|
initCalls.join('\n'),
|
|
127
245
|
'',
|
|
128
|
-
`export { scenes, objects, records }`,
|
|
129
|
-
`export const index = { scenes, objects, records }`,
|
|
246
|
+
`export { flows, scenes, objects, records, prototypes }`,
|
|
247
|
+
`export const index = { flows, scenes, objects, records, prototypes }`,
|
|
130
248
|
`export default index`,
|
|
131
249
|
].join('\n')
|
|
132
250
|
}
|
|
@@ -134,7 +252,7 @@ function generateModule(index, root) {
|
|
|
134
252
|
/**
|
|
135
253
|
* Vite plugin for storyboard data discovery.
|
|
136
254
|
*
|
|
137
|
-
* - Scans the repo for *.scene.json, *.object.json, *.record.json
|
|
255
|
+
* - Scans the repo for *.flow.json, *.scene.json (compat), *.object.json, *.record.json
|
|
138
256
|
* - Validates no two files share the same name+suffix (hard build error)
|
|
139
257
|
* - Generates a virtual module `virtual:storyboard-data-index`
|
|
140
258
|
* - Watches for file additions/removals in dev mode
|