@dfosco/storyboard-core 1.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 +18 -0
- package/src/comments/api.js +196 -0
- package/src/comments/api.test.js +194 -0
- package/src/comments/auth.js +79 -0
- package/src/comments/auth.test.js +60 -0
- package/src/comments/commentMode.js +63 -0
- package/src/comments/commentMode.test.js +87 -0
- package/src/comments/config.js +43 -0
- package/src/comments/config.test.js +76 -0
- package/src/comments/graphql.js +65 -0
- package/src/comments/graphql.test.js +95 -0
- package/src/comments/index.js +40 -0
- package/src/comments/metadata.js +52 -0
- package/src/comments/metadata.test.js +110 -0
- package/src/comments/queries.js +182 -0
- package/src/comments/ui/CommentOverlay.js +52 -0
- package/src/comments/ui/authModal.js +349 -0
- package/src/comments/ui/commentWindow.js +872 -0
- package/src/comments/ui/commentsDrawer.js +389 -0
- package/src/comments/ui/composer.js +248 -0
- package/src/comments/ui/mount.js +364 -0
- package/src/devtools.js +365 -0
- package/src/devtools.test.js +81 -0
- package/src/dotPath.js +53 -0
- package/src/dotPath.test.js +114 -0
- package/src/hashSubscribe.js +19 -0
- package/src/hashSubscribe.test.js +62 -0
- package/src/hideMode.js +421 -0
- package/src/hideMode.test.js +224 -0
- package/src/index.js +38 -0
- package/src/interceptHideParams.js +35 -0
- package/src/interceptHideParams.test.js +90 -0
- package/src/loader.js +212 -0
- package/src/loader.test.js +232 -0
- package/src/localStorage.js +134 -0
- package/src/localStorage.test.js +148 -0
- package/src/sceneDebug.js +108 -0
- package/src/sceneDebug.test.js +128 -0
- package/src/session.js +76 -0
- package/src/session.test.js +91 -0
- package/src/viewfinder.js +47 -0
- package/src/viewfinder.test.js +87 -0
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getLocal,
|
|
3
|
+
setLocal,
|
|
4
|
+
removeLocal,
|
|
5
|
+
getAllLocal,
|
|
6
|
+
subscribeToStorage,
|
|
7
|
+
getStorageSnapshot,
|
|
8
|
+
} from './localStorage.js'
|
|
9
|
+
|
|
10
|
+
describe('getLocal', () => {
|
|
11
|
+
it('returns null for missing key', () => {
|
|
12
|
+
expect(getLocal('nonexistent')).toBeNull()
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('reads stored value using "storyboard:" prefix internally', () => {
|
|
16
|
+
localStorage.setItem('storyboard:mykey', 'hello')
|
|
17
|
+
expect(getLocal('mykey')).toBe('hello')
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('returns null if localStorage throws', () => {
|
|
21
|
+
const original = localStorage.getItem
|
|
22
|
+
localStorage.getItem = () => { throw new Error('fail') }
|
|
23
|
+
expect(getLocal('anything')).toBeNull()
|
|
24
|
+
localStorage.getItem = original
|
|
25
|
+
})
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
describe('setLocal', () => {
|
|
29
|
+
it('stores value with prefix', () => {
|
|
30
|
+
setLocal('color', 'blue')
|
|
31
|
+
expect(localStorage.getItem('storyboard:color')).toBe('blue')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('converts value to string', () => {
|
|
35
|
+
setLocal('num', 42)
|
|
36
|
+
expect(localStorage.getItem('storyboard:num')).toBe('42')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('dispatches storyboard-storage event on window', () => {
|
|
40
|
+
const cb = vi.fn()
|
|
41
|
+
window.addEventListener('storyboard-storage', cb)
|
|
42
|
+
setLocal('x', '1')
|
|
43
|
+
expect(cb).toHaveBeenCalledTimes(1)
|
|
44
|
+
window.removeEventListener('storyboard-storage', cb)
|
|
45
|
+
})
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
describe('removeLocal', () => {
|
|
49
|
+
it('removes prefixed key', () => {
|
|
50
|
+
setLocal('temp', 'val')
|
|
51
|
+
expect(localStorage.getItem('storyboard:temp')).toBe('val')
|
|
52
|
+
removeLocal('temp')
|
|
53
|
+
expect(localStorage.getItem('storyboard:temp')).toBeNull()
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('dispatches storyboard-storage event', () => {
|
|
57
|
+
setLocal('temp', 'val')
|
|
58
|
+
const cb = vi.fn()
|
|
59
|
+
window.addEventListener('storyboard-storage', cb)
|
|
60
|
+
removeLocal('temp')
|
|
61
|
+
expect(cb).toHaveBeenCalled()
|
|
62
|
+
window.removeEventListener('storyboard-storage', cb)
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
describe('getAllLocal', () => {
|
|
67
|
+
it('returns empty object when no storyboard keys', () => {
|
|
68
|
+
expect(getAllLocal()).toEqual({})
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('returns all prefixed entries with prefix stripped', () => {
|
|
72
|
+
setLocal('a', '1')
|
|
73
|
+
setLocal('b', '2')
|
|
74
|
+
expect(getAllLocal()).toEqual({ a: '1', b: '2' })
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('ignores non-storyboard keys', () => {
|
|
78
|
+
localStorage.setItem('other-key', 'nope')
|
|
79
|
+
setLocal('only', 'this')
|
|
80
|
+
const result = getAllLocal()
|
|
81
|
+
expect(result).toEqual({ only: 'this' })
|
|
82
|
+
expect(result['other-key']).toBeUndefined()
|
|
83
|
+
})
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
describe('subscribeToStorage', () => {
|
|
87
|
+
it('calls callback on storyboard-storage event', () => {
|
|
88
|
+
const cb = vi.fn()
|
|
89
|
+
const unsub = subscribeToStorage(cb)
|
|
90
|
+
window.dispatchEvent(new Event('storyboard-storage'))
|
|
91
|
+
expect(cb).toHaveBeenCalledTimes(1)
|
|
92
|
+
unsub()
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('calls callback on storage event', () => {
|
|
96
|
+
const cb = vi.fn()
|
|
97
|
+
const unsub = subscribeToStorage(cb)
|
|
98
|
+
window.dispatchEvent(new Event('storage'))
|
|
99
|
+
expect(cb).toHaveBeenCalledTimes(1)
|
|
100
|
+
unsub()
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('returns unsubscribe function that works', () => {
|
|
104
|
+
const cb = vi.fn()
|
|
105
|
+
const unsub = subscribeToStorage(cb)
|
|
106
|
+
unsub()
|
|
107
|
+
window.dispatchEvent(new Event('storyboard-storage'))
|
|
108
|
+
window.dispatchEvent(new Event('storage'))
|
|
109
|
+
expect(cb).not.toHaveBeenCalled()
|
|
110
|
+
})
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
describe('getStorageSnapshot', () => {
|
|
114
|
+
it('returns serialized string of all entries', () => {
|
|
115
|
+
setLocal('z', '3')
|
|
116
|
+
setLocal('a', '1')
|
|
117
|
+
// Force cache invalidation so snapshot recomputes
|
|
118
|
+
window.dispatchEvent(new Event('storyboard-storage'))
|
|
119
|
+
const snap = getStorageSnapshot()
|
|
120
|
+
// Entries are sorted alphabetically
|
|
121
|
+
expect(snap).toBe('storyboard:a=1&storyboard:z=3')
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('caches result (same reference on repeated calls)', () => {
|
|
125
|
+
setLocal('k', 'v')
|
|
126
|
+
// Invalidate cache first
|
|
127
|
+
window.dispatchEvent(new Event('storyboard-storage'))
|
|
128
|
+
const snap1 = getStorageSnapshot()
|
|
129
|
+
const snap2 = getStorageSnapshot()
|
|
130
|
+
expect(snap1).toBe(snap2)
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('invalidates cache on storage event', () => {
|
|
134
|
+
setLocal('k', 'v')
|
|
135
|
+
// Subscribe so events invalidate the cache
|
|
136
|
+
const unsub = subscribeToStorage(() => {})
|
|
137
|
+
|
|
138
|
+
const snap1 = getStorageSnapshot()
|
|
139
|
+
// Directly mutate localStorage and fire event to invalidate
|
|
140
|
+
localStorage.setItem('storyboard:k', 'changed')
|
|
141
|
+
window.dispatchEvent(new Event('storyboard-storage'))
|
|
142
|
+
const snap2 = getStorageSnapshot()
|
|
143
|
+
|
|
144
|
+
expect(snap1).not.toBe(snap2)
|
|
145
|
+
expect(snap2).toContain('storyboard:k=changed')
|
|
146
|
+
unsub()
|
|
147
|
+
})
|
|
148
|
+
})
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Storyboard SceneDebug — a vanilla JS debug panel that displays scene data.
|
|
3
|
+
*
|
|
4
|
+
* Framework-agnostic: creates a DOM element, no React/Vue/etc. needed.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* import { mountSceneDebug } from '@dfosco/storyboard-core'
|
|
8
|
+
* mountSceneDebug(document.getElementById('debug'))
|
|
9
|
+
* // or
|
|
10
|
+
* mountSceneDebug() // appends to document.body
|
|
11
|
+
*/
|
|
12
|
+
import { loadScene } from './loader.js'
|
|
13
|
+
|
|
14
|
+
const STYLES = `
|
|
15
|
+
.sb-scene-debug {
|
|
16
|
+
padding: 16px;
|
|
17
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
|
18
|
+
}
|
|
19
|
+
.sb-scene-debug-title {
|
|
20
|
+
font-size: 20px;
|
|
21
|
+
font-weight: 600;
|
|
22
|
+
margin-bottom: 8px;
|
|
23
|
+
color: #c9d1d9;
|
|
24
|
+
}
|
|
25
|
+
.sb-scene-debug-code {
|
|
26
|
+
padding: 16px;
|
|
27
|
+
background-color: #161b22;
|
|
28
|
+
border-radius: 8px;
|
|
29
|
+
overflow: auto;
|
|
30
|
+
font-size: 13px;
|
|
31
|
+
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
|
|
32
|
+
line-height: 1.5;
|
|
33
|
+
max-height: 70vh;
|
|
34
|
+
color: #c9d1d9;
|
|
35
|
+
white-space: pre-wrap;
|
|
36
|
+
word-break: break-word;
|
|
37
|
+
}
|
|
38
|
+
.sb-scene-debug-error {
|
|
39
|
+
padding: 16px;
|
|
40
|
+
background-color: rgba(248, 81, 73, 0.1);
|
|
41
|
+
border-radius: 8px;
|
|
42
|
+
}
|
|
43
|
+
.sb-scene-debug-error-title {
|
|
44
|
+
color: #f85149;
|
|
45
|
+
font-weight: 600;
|
|
46
|
+
}
|
|
47
|
+
.sb-scene-debug-error-message {
|
|
48
|
+
color: #f85149;
|
|
49
|
+
margin-top: 4px;
|
|
50
|
+
}
|
|
51
|
+
`
|
|
52
|
+
|
|
53
|
+
let stylesInjected = false
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Mount a scene debug panel into the DOM.
|
|
57
|
+
*
|
|
58
|
+
* @param {HTMLElement} [container=document.body] - Where to mount
|
|
59
|
+
* @param {string} [sceneName] - Scene name override (defaults to ?scene= param or "default")
|
|
60
|
+
* @returns {HTMLElement} The created debug element
|
|
61
|
+
*/
|
|
62
|
+
export function mountSceneDebug(container, sceneName) {
|
|
63
|
+
const target = container || document.body
|
|
64
|
+
const activeSceneName = sceneName
|
|
65
|
+
|| new URLSearchParams(window.location.search).get('scene')
|
|
66
|
+
|| 'default'
|
|
67
|
+
|
|
68
|
+
// Inject styles once
|
|
69
|
+
if (!stylesInjected) {
|
|
70
|
+
const styleEl = document.createElement('style')
|
|
71
|
+
styleEl.textContent = STYLES
|
|
72
|
+
document.head.appendChild(styleEl)
|
|
73
|
+
stylesInjected = true
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const el = document.createElement('div')
|
|
77
|
+
el.className = 'sb-scene-debug'
|
|
78
|
+
|
|
79
|
+
let data = null
|
|
80
|
+
let error = null
|
|
81
|
+
try {
|
|
82
|
+
data = loadScene(activeSceneName)
|
|
83
|
+
} catch (err) {
|
|
84
|
+
error = err.message
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (error) {
|
|
88
|
+
el.innerHTML = `
|
|
89
|
+
<div class="sb-scene-debug-error">
|
|
90
|
+
<div class="sb-scene-debug-error-title">Error loading scene</div>
|
|
91
|
+
<p class="sb-scene-debug-error-message">${error}</p>
|
|
92
|
+
</div>`
|
|
93
|
+
} else {
|
|
94
|
+
const title = document.createElement('h2')
|
|
95
|
+
title.className = 'sb-scene-debug-title'
|
|
96
|
+
title.textContent = `Scene: ${activeSceneName}`
|
|
97
|
+
|
|
98
|
+
const pre = document.createElement('pre')
|
|
99
|
+
pre.className = 'sb-scene-debug-code'
|
|
100
|
+
pre.textContent = JSON.stringify(data, null, 2)
|
|
101
|
+
|
|
102
|
+
el.appendChild(title)
|
|
103
|
+
el.appendChild(pre)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
target.appendChild(el)
|
|
107
|
+
return el
|
|
108
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
const { mockLoadScene } = vi.hoisted(() => ({
|
|
4
|
+
mockLoadScene: vi.fn(() => ({ hello: 'world', count: 42 })),
|
|
5
|
+
}))
|
|
6
|
+
|
|
7
|
+
vi.mock('./loader.js', () => ({
|
|
8
|
+
loadScene: mockLoadScene,
|
|
9
|
+
}))
|
|
10
|
+
|
|
11
|
+
// We need a fresh module for each test since sceneDebug has a module-level
|
|
12
|
+
// `stylesInjected` boolean. We test style injection on the very first call,
|
|
13
|
+
// then subsequent tests just verify other behavior.
|
|
14
|
+
import { mountSceneDebug } from './sceneDebug.js'
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
document.body.innerHTML = ''
|
|
18
|
+
mockLoadScene.mockReset()
|
|
19
|
+
mockLoadScene.mockReturnValue({ hello: 'world', count: 42 })
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
describe('mountSceneDebug', () => {
|
|
23
|
+
it('injects styles into document.head on first call', () => {
|
|
24
|
+
// This MUST run first to capture the stylesInjected=false → true transition
|
|
25
|
+
mountSceneDebug()
|
|
26
|
+
|
|
27
|
+
const styles = document.head.querySelectorAll('style')
|
|
28
|
+
const hasDebugStyle = Array.from(styles).some((el) =>
|
|
29
|
+
el.textContent.includes('.sb-scene-debug')
|
|
30
|
+
)
|
|
31
|
+
expect(hasDebugStyle).toBe(true)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('creates an element with class sb-scene-debug', () => {
|
|
35
|
+
const el = mountSceneDebug()
|
|
36
|
+
|
|
37
|
+
expect(el.classList.contains('sb-scene-debug')).toBe(true)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('appends to document.body by default', () => {
|
|
41
|
+
mountSceneDebug()
|
|
42
|
+
|
|
43
|
+
expect(document.body.querySelector('.sb-scene-debug')).toBeInTheDocument()
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('appends to a custom container', () => {
|
|
47
|
+
const container = document.createElement('div')
|
|
48
|
+
document.body.appendChild(container)
|
|
49
|
+
|
|
50
|
+
mountSceneDebug(container)
|
|
51
|
+
|
|
52
|
+
expect(container.querySelector('.sb-scene-debug')).not.toBeNull()
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('returns the created element', () => {
|
|
56
|
+
const el = mountSceneDebug()
|
|
57
|
+
|
|
58
|
+
expect(el).toBeInstanceOf(HTMLElement)
|
|
59
|
+
expect(el.className).toBe('sb-scene-debug')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('renders the scene name in the title', () => {
|
|
63
|
+
mountSceneDebug(undefined, 'my-scene')
|
|
64
|
+
|
|
65
|
+
const title = document.body.querySelector('.sb-scene-debug-title')
|
|
66
|
+
expect(title).not.toBeNull()
|
|
67
|
+
expect(title.textContent).toContain('my-scene')
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('defaults scene name to "default" when none is provided', () => {
|
|
71
|
+
mountSceneDebug()
|
|
72
|
+
|
|
73
|
+
const title = document.body.querySelector('.sb-scene-debug-title')
|
|
74
|
+
expect(title.textContent).toContain('default')
|
|
75
|
+
expect(mockLoadScene).toHaveBeenCalledWith('default')
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('renders JSON data in a pre element', () => {
|
|
79
|
+
mountSceneDebug()
|
|
80
|
+
|
|
81
|
+
const pre = document.body.querySelector('.sb-scene-debug-code')
|
|
82
|
+
expect(pre).not.toBeNull()
|
|
83
|
+
expect(pre.tagName).toBe('PRE')
|
|
84
|
+
|
|
85
|
+
const parsed = JSON.parse(pre.textContent)
|
|
86
|
+
expect(parsed).toEqual({ hello: 'world', count: 42 })
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('shows error when loadScene throws', () => {
|
|
90
|
+
mockLoadScene.mockImplementation(() => {
|
|
91
|
+
throw new Error('Scene not found')
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
const el = mountSceneDebug()
|
|
95
|
+
|
|
96
|
+
const errorTitle = el.querySelector('.sb-scene-debug-error-title')
|
|
97
|
+
expect(errorTitle).not.toBeNull()
|
|
98
|
+
expect(errorTitle.textContent).toContain('Error')
|
|
99
|
+
|
|
100
|
+
const errorMsg = el.querySelector('.sb-scene-debug-error-message')
|
|
101
|
+
expect(errorMsg.textContent).toContain('Scene not found')
|
|
102
|
+
|
|
103
|
+
// Should NOT render the normal title/pre
|
|
104
|
+
expect(el.querySelector('.sb-scene-debug-title')).toBeNull()
|
|
105
|
+
expect(el.querySelector('.sb-scene-debug-code')).toBeNull()
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('uses ?scene query param when no sceneName argument is given', () => {
|
|
109
|
+
window.history.pushState(null, '', '?scene=overview')
|
|
110
|
+
|
|
111
|
+
mountSceneDebug()
|
|
112
|
+
|
|
113
|
+
expect(mockLoadScene).toHaveBeenCalledWith('overview')
|
|
114
|
+
const title = document.body.querySelector('.sb-scene-debug-title')
|
|
115
|
+
expect(title.textContent).toContain('overview')
|
|
116
|
+
|
|
117
|
+
// Clean up
|
|
118
|
+
window.history.pushState(null, '', '/')
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('allows multiple debug panels to be mounted', () => {
|
|
122
|
+
mountSceneDebug()
|
|
123
|
+
mountSceneDebug()
|
|
124
|
+
|
|
125
|
+
const panels = document.body.querySelectorAll('.sb-scene-debug')
|
|
126
|
+
expect(panels).toHaveLength(2)
|
|
127
|
+
})
|
|
128
|
+
})
|
package/src/session.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* URL hash-based utilities for storyboard session state.
|
|
3
|
+
*
|
|
4
|
+
* Session params are stored in the URL hash fragment (after #) to avoid
|
|
5
|
+
* triggering React Router re-renders. React Router (used by generouted)
|
|
6
|
+
* patches history.replaceState/pushState, so any search-param change
|
|
7
|
+
* causes a full route tree re-render. The hash is invisible to the router.
|
|
8
|
+
*
|
|
9
|
+
* Format: #key1=value1&key2=value2
|
|
10
|
+
* Example: /page?scene=default#user.name=Alice&settings.theme=dark
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Parse the current hash into a Map of key→value pairs.
|
|
15
|
+
* @returns {URLSearchParams}
|
|
16
|
+
*/
|
|
17
|
+
function parseHash() {
|
|
18
|
+
const raw = window.location.hash.replace(/^#/, '')
|
|
19
|
+
return new URLSearchParams(raw)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Write a URLSearchParams back to the hash.
|
|
24
|
+
* Uses window.location.hash (NOT history.replaceState) because
|
|
25
|
+
* generouted/React Router patches replaceState and would trigger
|
|
26
|
+
* a full route re-render. Native hash assignment only fires
|
|
27
|
+
* 'hashchange' which React Router ignores.
|
|
28
|
+
* @param {URLSearchParams} params
|
|
29
|
+
*/
|
|
30
|
+
function writeHash(params) {
|
|
31
|
+
const str = params.toString()
|
|
32
|
+
window.location.hash = str
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Read a single session param value.
|
|
37
|
+
* @param {string} key
|
|
38
|
+
* @returns {string|null}
|
|
39
|
+
*/
|
|
40
|
+
export function getParam(key) {
|
|
41
|
+
return parseHash().get(key)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Write a single session param. Updates the hash in-place.
|
|
46
|
+
* @param {string} key
|
|
47
|
+
* @param {string} value
|
|
48
|
+
*/
|
|
49
|
+
export function setParam(key, value) {
|
|
50
|
+
const params = parseHash()
|
|
51
|
+
params.set(key, String(value))
|
|
52
|
+
writeHash(params)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Return all session params as a plain object.
|
|
57
|
+
* @returns {Record<string, string>}
|
|
58
|
+
*/
|
|
59
|
+
export function getAllParams() {
|
|
60
|
+
const params = parseHash()
|
|
61
|
+
const result = {}
|
|
62
|
+
for (const [key, value] of params.entries()) {
|
|
63
|
+
result[key] = value
|
|
64
|
+
}
|
|
65
|
+
return result
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Remove a single session param from the hash.
|
|
70
|
+
* @param {string} key
|
|
71
|
+
*/
|
|
72
|
+
export function removeParam(key) {
|
|
73
|
+
const params = parseHash()
|
|
74
|
+
params.delete(key)
|
|
75
|
+
writeHash(params)
|
|
76
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { getParam, setParam, getAllParams, removeParam } from './session.js'
|
|
2
|
+
|
|
3
|
+
describe('getParam', () => {
|
|
4
|
+
it('returns null when hash is empty', () => {
|
|
5
|
+
window.location.hash = ''
|
|
6
|
+
expect(getParam('key')).toBeNull()
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
it('returns value for existing param', () => {
|
|
10
|
+
window.location.hash = 'foo=bar'
|
|
11
|
+
expect(getParam('foo')).toBe('bar')
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('returns null for missing param', () => {
|
|
15
|
+
window.location.hash = 'foo=bar'
|
|
16
|
+
expect(getParam('missing')).toBeNull()
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('handles URL-encoded values', () => {
|
|
20
|
+
window.location.hash = 'name=hello%20world'
|
|
21
|
+
expect(getParam('name')).toBe('hello world')
|
|
22
|
+
})
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
describe('setParam', () => {
|
|
26
|
+
it('sets a new param in hash', () => {
|
|
27
|
+
window.location.hash = ''
|
|
28
|
+
setParam('key', 'value')
|
|
29
|
+
expect(getParam('key')).toBe('value')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('updates existing param', () => {
|
|
33
|
+
window.location.hash = 'key=old'
|
|
34
|
+
setParam('key', 'new')
|
|
35
|
+
expect(getParam('key')).toBe('new')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('preserves other params', () => {
|
|
39
|
+
window.location.hash = 'a=1&b=2'
|
|
40
|
+
setParam('c', '3')
|
|
41
|
+
expect(getParam('a')).toBe('1')
|
|
42
|
+
expect(getParam('b')).toBe('2')
|
|
43
|
+
expect(getParam('c')).toBe('3')
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('converts value to string', () => {
|
|
47
|
+
window.location.hash = ''
|
|
48
|
+
setParam('num', 42)
|
|
49
|
+
expect(getParam('num')).toBe('42')
|
|
50
|
+
})
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
describe('getAllParams', () => {
|
|
54
|
+
it('returns empty object for empty hash', () => {
|
|
55
|
+
window.location.hash = ''
|
|
56
|
+
expect(getAllParams()).toEqual({})
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('returns all params', () => {
|
|
60
|
+
window.location.hash = 'a=1&b=2'
|
|
61
|
+
expect(getAllParams()).toEqual({ a: '1', b: '2' })
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('handles multiple params', () => {
|
|
65
|
+
window.location.hash = 'x=hello&y=world&z=test'
|
|
66
|
+
const params = getAllParams()
|
|
67
|
+
expect(Object.keys(params)).toHaveLength(3)
|
|
68
|
+
expect(params).toEqual({ x: 'hello', y: 'world', z: 'test' })
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
describe('removeParam', () => {
|
|
73
|
+
it('removes existing param', () => {
|
|
74
|
+
window.location.hash = 'a=1&b=2'
|
|
75
|
+
removeParam('a')
|
|
76
|
+
expect(getParam('a')).toBeNull()
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('preserves other params', () => {
|
|
80
|
+
window.location.hash = 'a=1&b=2&c=3'
|
|
81
|
+
removeParam('b')
|
|
82
|
+
expect(getParam('a')).toBe('1')
|
|
83
|
+
expect(getParam('c')).toBe('3')
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('no-ops for missing param', () => {
|
|
87
|
+
window.location.hash = 'a=1'
|
|
88
|
+
removeParam('nonexistent')
|
|
89
|
+
expect(getParam('a')).toBe('1')
|
|
90
|
+
})
|
|
91
|
+
})
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { loadScene } from './loader.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Deterministic hash from a string — used for seeding generative placeholders.
|
|
5
|
+
* @param {string} str
|
|
6
|
+
* @returns {number}
|
|
7
|
+
*/
|
|
8
|
+
export function hash(str) {
|
|
9
|
+
let h = 0
|
|
10
|
+
for (let i = 0; i < str.length; i++) {
|
|
11
|
+
h = ((h << 5) - h + str.charCodeAt(i)) | 0
|
|
12
|
+
}
|
|
13
|
+
return Math.abs(h)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Resolve the target route path for a scene.
|
|
18
|
+
*
|
|
19
|
+
* 1. If scene name matches a known route (case-insensitive), use that route
|
|
20
|
+
* 2. If scene data has a `route` key, use that
|
|
21
|
+
* 3. Fall back to root "/"
|
|
22
|
+
*
|
|
23
|
+
* @param {string} sceneName
|
|
24
|
+
* @param {string[]} knownRoutes - Array of route names (e.g. ["Dashboard", "Repositories"])
|
|
25
|
+
* @returns {string} Full path with ?scene= param
|
|
26
|
+
*/
|
|
27
|
+
export function resolveSceneRoute(sceneName, knownRoutes = []) {
|
|
28
|
+
// Case-insensitive match against known routes
|
|
29
|
+
for (const route of knownRoutes) {
|
|
30
|
+
if (route.toLowerCase() === sceneName.toLowerCase()) {
|
|
31
|
+
return `/${route}?scene=${encodeURIComponent(sceneName)}`
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Check for explicit `route` key in scene data
|
|
36
|
+
try {
|
|
37
|
+
const data = loadScene(sceneName)
|
|
38
|
+
if (data?.route) {
|
|
39
|
+
const route = data.route.startsWith('/') ? data.route : `/${data.route}`
|
|
40
|
+
return `${route}?scene=${encodeURIComponent(sceneName)}`
|
|
41
|
+
}
|
|
42
|
+
} catch {
|
|
43
|
+
// ignore load errors
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return `/?scene=${encodeURIComponent(sceneName)}`
|
|
47
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { init } from './loader.js'
|
|
2
|
+
import { hash, resolveSceneRoute } from './viewfinder.js'
|
|
3
|
+
|
|
4
|
+
const makeIndex = () => ({
|
|
5
|
+
scenes: {
|
|
6
|
+
default: { title: 'Default Scene' },
|
|
7
|
+
Dashboard: { heading: 'Dashboard' },
|
|
8
|
+
'custom-route': { route: 'Overview', title: 'Custom' },
|
|
9
|
+
'absolute-route': { route: '/Forms', title: 'Absolute' },
|
|
10
|
+
'no-route': { title: 'No route key' },
|
|
11
|
+
},
|
|
12
|
+
objects: {},
|
|
13
|
+
records: {},
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
init(makeIndex())
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
describe('hash', () => {
|
|
21
|
+
it('returns a number', () => {
|
|
22
|
+
expect(typeof hash('test')).toBe('number')
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('is deterministic', () => {
|
|
26
|
+
expect(hash('hello')).toBe(hash('hello'))
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('produces different values for different strings', () => {
|
|
30
|
+
expect(hash('foo')).not.toBe(hash('bar'))
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('returns non-negative values', () => {
|
|
34
|
+
expect(hash('abc')).toBeGreaterThanOrEqual(0)
|
|
35
|
+
expect(hash('')).toBeGreaterThanOrEqual(0)
|
|
36
|
+
expect(hash('a very long string with lots of characters')).toBeGreaterThanOrEqual(0)
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
describe('resolveSceneRoute', () => {
|
|
41
|
+
const routes = ['Dashboard', 'Overview', 'Forms', 'Repositories']
|
|
42
|
+
|
|
43
|
+
it('matches scene name to route (exact case)', () => {
|
|
44
|
+
expect(resolveSceneRoute('Dashboard', routes)).toBe('/Dashboard?scene=Dashboard')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('matches scene name to route (case-insensitive)', () => {
|
|
48
|
+
expect(resolveSceneRoute('dashboard', routes)).toBe('/Dashboard?scene=dashboard')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('uses route key from scene data when no route matches', () => {
|
|
52
|
+
expect(resolveSceneRoute('custom-route', routes)).toBe('/Overview?scene=custom-route')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('handles absolute route key (with leading slash)', () => {
|
|
56
|
+
expect(resolveSceneRoute('absolute-route', routes)).toBe('/Forms?scene=absolute-route')
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('falls back to root when no match and no route key', () => {
|
|
60
|
+
expect(resolveSceneRoute('no-route', routes)).toBe('/?scene=no-route')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('falls back to root for default scene', () => {
|
|
64
|
+
expect(resolveSceneRoute('default', routes)).toBe('/?scene=default')
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('falls back to root when scene does not exist', () => {
|
|
68
|
+
expect(resolveSceneRoute('nonexistent', routes)).toBe('/?scene=nonexistent')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('works with empty routes array', () => {
|
|
72
|
+
expect(resolveSceneRoute('Dashboard', [])).toBe('/?scene=Dashboard')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('works with no routes argument', () => {
|
|
76
|
+
expect(resolveSceneRoute('custom-route')).toBe('/Overview?scene=custom-route')
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('encodes special characters in scene name', () => {
|
|
80
|
+
init({
|
|
81
|
+
scenes: { 'has spaces': { title: 'Spaces' } },
|
|
82
|
+
objects: {},
|
|
83
|
+
records: {},
|
|
84
|
+
})
|
|
85
|
+
expect(resolveSceneRoute('has spaces', [])).toBe('/?scene=has%20spaces')
|
|
86
|
+
})
|
|
87
|
+
})
|