@hkdigital/lib-core 0.5.44 → 0.5.46

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.
@@ -0,0 +1,226 @@
1
+ # RouteStateContext
2
+
3
+ Base class for route-level state containers.
4
+
5
+ ## How it connects
6
+
7
+ ```
8
+ ┌─────────────────────────────────────────────────────────┐
9
+ │ PuzzleState (extends RouteStateContext) │
10
+ │ - Container for route-level concerns │
11
+ │ - Contains PageMachine instance │
12
+ │ - Optional: services, preload, reset, etc. │
13
+ └────────────────┬────────────────────────────────────────┘
14
+
15
+ │ Provided via Svelte context
16
+
17
+ ┌────────────────▼────────────────────────────────────────┐
18
+ │ +layout.svelte │
19
+ │ - Creates state with createOrGetPuzzleState() │
20
+ │ - IMPORTANT: Syncs URL with pageMachine.syncFromPath() │
21
+ │ - Optional: Calls validateAndRedirect() for protection │
22
+ └────────────────┬────────────────────────────────────────┘
23
+
24
+ │ Context available to children
25
+
26
+ ┌────────┴─────────┬────────────────┐
27
+ ▼ ▼ ▼
28
+ ┌──────────┐ ┌──────────┐ ┌──────────┐
29
+ │ +page │ │ +page │ │ Component│
30
+ │ .svelte │ │ .svelte │ │ │
31
+ └──────────┘ └──────────┘ └──────────┘
32
+ Gets state via getPuzzleState()
33
+ ```
34
+
35
+ ## Main Purposes
36
+
37
+ 1. **State container** - Hold route-level concerns in one place
38
+ 2. **Apply enforceStartPath** - Control navigation flow (users must visit
39
+ start path before accessing subroutes)
40
+ 3. **Provide validateAndRedirect** - Route protection via layout
41
+
42
+ ## Key Features
43
+
44
+ - Share state between layout and pages without prop drilling
45
+ - Persist state across navigation within the same route group
46
+ - Lifecycle methods for setup/teardown (preload, reset)
47
+
48
+ ## Basic Usage
49
+
50
+ ### 1. Create state container class
51
+
52
+ ```javascript
53
+ // routes/puzzle/puzzle.state.svelte.js
54
+ import { defineStateContext } from '@hkdigital/lib-core/state/context.js';
55
+ import { RouteStateContext } from '$lib/state/context.js';
56
+ import PuzzlePageMachine from './puzzle.machine.svelte.js';
57
+
58
+ export class PuzzleState extends RouteStateContext {
59
+ #pageMachine;
60
+
61
+ constructor() {
62
+ super({
63
+ startPath: '/puzzle',
64
+ enforceStartPath: true // Optional: enforce route protection
65
+ });
66
+ this.#pageMachine = new PuzzlePageMachine();
67
+ }
68
+
69
+ get pageMachine() {
70
+ return this.#pageMachine;
71
+ }
72
+
73
+ // Optional: Service accessors
74
+ getPuzzleService() {
75
+ return getPuzzleService();
76
+ }
77
+
78
+ // Optional: Lifecycle methods
79
+ preload(onProgress) {
80
+ return loadPuzzleAssets(onProgress);
81
+ }
82
+
83
+ reset() {
84
+ // Reset state when needed
85
+ }
86
+ }
87
+
88
+ // Export helper functions
89
+ export const [createOrGetPuzzleState, createPuzzleState, getPuzzleState] =
90
+ defineStateContext(PuzzleState);
91
+ ```
92
+
93
+ ### 2. Provide context in layout
94
+
95
+ ```svelte
96
+ <!-- routes/puzzle/+layout.svelte -->
97
+ <script>
98
+ import { page } from '$app/stores';
99
+ import { createOrGetPuzzleState } from './puzzle.state.svelte.js';
100
+
101
+ // Create or get existing state container
102
+ const puzzleState = createOrGetPuzzleState();
103
+
104
+ // IMPORTANT: Sync URL with PageMachine state
105
+ $effect(() => {
106
+ puzzleState.pageMachine.syncFromPath($page.url.pathname);
107
+ });
108
+
109
+ // Optional: Enforce start path (redirect if user skips intro)
110
+ $effect(() => {
111
+ puzzleState.validateAndRedirect($page.url.pathname);
112
+ });
113
+
114
+ // Optional: Preload assets
115
+ puzzleState.preload((progress) => {
116
+ console.log('Loading:', progress);
117
+ });
118
+ </script>
119
+
120
+ <slot />
121
+ ```
122
+
123
+ ### 3. Consume context in pages
124
+
125
+ ```svelte
126
+ <!-- routes/puzzle/level1/+page.svelte -->
127
+ <script>
128
+ import { getPuzzleState } from '../puzzle.state.svelte.js';
129
+
130
+ const puzzleState = getPuzzleState();
131
+ const pageMachine = puzzleState.pageMachine;
132
+ </script>
133
+
134
+ <div>Current state: {pageMachine.current}</div>
135
+ ```
136
+
137
+ ## Context Helpers
138
+
139
+ The `defineStateContext` helper creates three functions:
140
+
141
+ ```javascript
142
+ // Get existing or create new (use in layout)
143
+ const state = createOrGetPuzzleState();
144
+
145
+ // Force create new instance
146
+ const state = createPuzzleState();
147
+
148
+ // Get existing (throws if not found, use in pages/components)
149
+ const state = getPuzzleState();
150
+ ```
151
+
152
+ ## Constructor Options
153
+
154
+ ```javascript
155
+ constructor({ startPath, enforceStartPath })
156
+ ```
157
+
158
+ - `startPath` **(required)** - The start path for this route
159
+ (e.g., `/puzzle`)
160
+ - `enforceStartPath` **(optional, default: false)** - If true, users must
161
+ visit the start path before accessing subroutes
162
+
163
+ ## validateAndRedirect Method
164
+
165
+ Automatically redirects users if they try to access subroutes before visiting
166
+ the start path.
167
+
168
+ **How it works:**
169
+ - If `enforceStartPath: true` is set in constructor
170
+ - User tries to access a subroute (e.g., `/puzzle/level2`)
171
+ - But hasn't visited the start path yet (`/puzzle`)
172
+ - → Automatically redirects to start path
173
+
174
+ **Example use case:** Puzzle game where users must see the intro before
175
+ accessing puzzle levels.
176
+
177
+ ```javascript
178
+ // In state constructor
179
+ export class PuzzleState extends RouteStateContext {
180
+ constructor() {
181
+ super({
182
+ startPath: '/puzzle',
183
+ enforceStartPath: true
184
+ });
185
+ }
186
+ }
187
+ ```
188
+
189
+ ```svelte
190
+ <!-- In +layout.svelte -->
191
+ <script>
192
+ import { page } from '$app/stores';
193
+
194
+ const puzzleState = createOrGetPuzzleState();
195
+
196
+ // Enforce route protection
197
+ $effect(() => {
198
+ puzzleState.validateAndRedirect($page.url.pathname);
199
+ });
200
+ </script>
201
+ ```
202
+
203
+ **Result:** If user navigates directly to `/puzzle/level2`, they'll be
204
+ redirected to `/puzzle` first. After visiting `/puzzle`, they can freely
205
+ navigate to any subroute.
206
+
207
+ **Custom redirect URL:**
208
+ ```javascript
209
+ // Redirect to a different URL instead of startPath
210
+ puzzleState.validateAndRedirect($page.url.pathname, '/puzzle/welcome');
211
+ ```
212
+
213
+ ## Separation of Concerns
214
+
215
+ **State Container** = Route-level concerns (MULTIPLE responsibilities)
216
+ - PageMachine instance
217
+ - Game engines
218
+ - Service accessors
219
+ - Media preloading
220
+ - Any other route-level concern
221
+
222
+ **PageMachine** = Page/view state ONLY (SINGLE responsibility)
223
+ - Current page state
224
+ - Route mapping
225
+ - Visited states
226
+ - Computed properties for state checks
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Base class for route state containers
3
+ *
4
+ * Main purposes:
5
+ * - Container for route-level concerns (PageMachine, services, engines)
6
+ * - Apply enforceStartPath to control navigation flow
7
+ * - Provide validateAndRedirect for route protection
8
+ *
9
+ * @example
10
+ * ```javascript
11
+ * export class PuzzleState extends RouteStateContext {
12
+ * constructor() {
13
+ * super({
14
+ * startPath: '/puzzle',
15
+ * enforceStartPath: true
16
+ * });
17
+ * }
18
+ *
19
+ * preload(onProgress) {
20
+ * return loadAudioAndVideoScenes(onProgress);
21
+ * }
22
+ * }
23
+ * ```
24
+ */
25
+ export default class RouteStateContext {
26
+ /**
27
+ * @param {Object} options - Configuration options
28
+ * @param {string} options.startPath - Start path for this route (required)
29
+ * @param {boolean} [options.enforceStartPath=false] - If true, redirect to start path before allowing subroutes
30
+ */
31
+ constructor({ startPath, enforceStartPath }: {
32
+ startPath: string;
33
+ enforceStartPath?: boolean | undefined;
34
+ });
35
+ /**
36
+ * Validate current path and redirect if needed
37
+ * Call this in a $effect in the layout
38
+ *
39
+ * @param {string} currentPath - Current URL pathname
40
+ * @param {string} [redirectUrl] - Optional redirect URL (defaults to startPath)
41
+ */
42
+ validateAndRedirect(currentPath: string, redirectUrl?: string): void;
43
+ #private;
44
+ }
@@ -0,0 +1,119 @@
1
+ import { goto } from '$app/navigation';
2
+
3
+ /**
4
+ * Base class for route state containers
5
+ *
6
+ * Main purposes:
7
+ * - Container for route-level concerns (PageMachine, services, engines)
8
+ * - Apply enforceStartPath to control navigation flow
9
+ * - Provide validateAndRedirect for route protection
10
+ *
11
+ * @example
12
+ * ```javascript
13
+ * export class PuzzleState extends RouteStateContext {
14
+ * constructor() {
15
+ * super({
16
+ * startPath: '/puzzle',
17
+ * enforceStartPath: true
18
+ * });
19
+ * }
20
+ *
21
+ * preload(onProgress) {
22
+ * return loadAudioAndVideoScenes(onProgress);
23
+ * }
24
+ * }
25
+ * ```
26
+ */
27
+ export default class RouteStateContext {
28
+ /**
29
+ * Start path for this route
30
+ * @type {string}
31
+ */
32
+ #startPath;
33
+
34
+ /**
35
+ * Whether to enforce that users visit start path before subroutes
36
+ * @type {boolean}
37
+ */
38
+ #enforceStartPath = false;
39
+
40
+ /**
41
+ * Track which paths have been visited during this session
42
+ * Used for enforceStartPath validation
43
+ * @type {Set<string>}
44
+ */
45
+ // eslint-disable-next-line svelte/prefer-svelte-reactivity
46
+ #visitedPaths = new Set();
47
+
48
+ /**
49
+ * @param {Object} options - Configuration options
50
+ * @param {string} options.startPath - Start path for this route (required)
51
+ * @param {boolean} [options.enforceStartPath=false] - If true, redirect to start path before allowing subroutes
52
+ */
53
+ constructor({ startPath, enforceStartPath = false }) {
54
+ if (!startPath) {
55
+ throw new Error(
56
+ 'RouteStateContext requires startPath parameter'
57
+ );
58
+ }
59
+
60
+ this.#startPath = startPath;
61
+ this.#enforceStartPath = enforceStartPath;
62
+ }
63
+
64
+ /**
65
+ * Determine if current path needs redirection
66
+ * Private method - enforces sequential access to subroutes
67
+ *
68
+ * @param {string} currentPath - Current URL pathname
69
+ *
70
+ * @returns {string|null}
71
+ * Path to redirect to, or null if no redirect needed
72
+ */
73
+ #determineRedirect(currentPath) {
74
+ // No enforcement configured
75
+ if (!this.#enforceStartPath) {
76
+ return null;
77
+ }
78
+
79
+ // Currently on the start path - mark as visited and allow
80
+ if (currentPath === this.#startPath) {
81
+ this.#visitedPaths.add(currentPath);
82
+ return null;
83
+ }
84
+
85
+ // On a subroute - check if start path was visited
86
+ if (currentPath.startsWith(this.#startPath + '/')) {
87
+ // Allow if user has visited the start path
88
+ if (this.#visitedPaths.has(this.#startPath)) {
89
+ return null;
90
+ }
91
+ // Redirect to start path if not visited yet
92
+ return this.#startPath;
93
+ }
94
+
95
+ // Path is valid (not a subroute)
96
+ return null;
97
+ }
98
+
99
+ /**
100
+ * Validate current path and redirect if needed
101
+ * Call this in a $effect in the layout
102
+ *
103
+ * @param {string} currentPath - Current URL pathname
104
+ * @param {string} [redirectUrl] - Optional redirect URL (defaults to startPath)
105
+ */
106
+ validateAndRedirect(currentPath, redirectUrl = null) {
107
+ const redirectPath = this.#determineRedirect(currentPath);
108
+
109
+ if (redirectPath && redirectPath !== currentPath) {
110
+ const targetPath = redirectUrl || redirectPath;
111
+
112
+ console.debug(
113
+ `[${this.constructor.name}] Redirecting: ${currentPath} → ${targetPath}`
114
+ );
115
+
116
+ goto(targetPath, { replaceState: true });
117
+ }
118
+ }
119
+ }
@@ -1 +1,2 @@
1
- export { defineStateContext, DEFAULT_CONTEXT_KEY } from "./context/state-context.js";
1
+ export { default as RouteStateContext } from "./context/RouteStateContext.svelte.js";
2
+ export { defineStateContext, DEFAULT_CONTEXT_KEY } from "./context/util.js";
@@ -1 +1,2 @@
1
- export { defineStateContext, DEFAULT_CONTEXT_KEY } from './context/state-context.js';
1
+ export { default as RouteStateContext } from './context/RouteStateContext.svelte.js';
2
+ export { defineStateContext, DEFAULT_CONTEXT_KEY } from './context/util.js';
@@ -0,0 +1,270 @@
1
+ /**
2
+ * Base class for page state machines with URL route mapping
3
+ *
4
+ * Simple state tracker that maps states to URL routes.
5
+ * Does NOT enforce FSM transitions - allows free navigation
6
+ * (because users can navigate to any URL via browser).
7
+ *
8
+ * Features:
9
+ * - State-to-route mapping and sync
10
+ * - Data properties for business/domain state
11
+ * - Visited states tracking
12
+ * - onEnter hooks with abort/complete handlers for animations
13
+ *
14
+ * Basic usage:
15
+ * ```javascript
16
+ * const machine = new PageMachine({
17
+ * initialState: STATE_START,
18
+ * routeMap: {
19
+ * [STATE_START]: '/intro/start',
20
+ * [STATE_PROFILE]: '/intro/profile'
21
+ * }
22
+ * });
23
+ *
24
+ * // Sync machine state with URL changes
25
+ * $effect(() => {
26
+ * machine.syncFromPath($page.url.pathname);
27
+ * });
28
+ * ```
29
+ *
30
+ * With onEnter hooks (for animations):
31
+ * ```javascript
32
+ * const machine = new PageMachine({
33
+ * initialState: STATE_ANIMATE,
34
+ * routeMap: {
35
+ * [STATE_ANIMATE]: '/game/animate',
36
+ * [STATE_PLAY]: '/game/play'
37
+ * },
38
+ * onEnterHooks: {
39
+ * [STATE_ANIMATE]: (done) => {
40
+ * const animation = playAnimation(1000);
41
+ * animation.finished.then(() => done(STATE_PLAY));
42
+ *
43
+ * return {
44
+ * abort: () => animation.cancel(),
45
+ * complete: () => animation.finish()
46
+ * };
47
+ * }
48
+ * }
49
+ * });
50
+ *
51
+ * // Fast-forward animation
52
+ * machine.completeTransitions();
53
+ *
54
+ * // Cancel animation
55
+ * machine.abortTransitions();
56
+ * ```
57
+ */
58
+ export default class PageMachine {
59
+ /**
60
+ * Constructor
61
+ *
62
+ * @param {Object} config - Configuration object
63
+ * @param {string} config.initialState - Initial state name
64
+ * @param {Record<string, string>} [config.routeMap={}] - Map of states to route paths
65
+ * @param {Record<string, any>} [config.initialData={}] - Initial data properties (from server)
66
+ * @param {Record<string, Function>} [config.onEnterHooks={}] - Map of states to onEnter hook functions
67
+ *
68
+ * @example
69
+ * ```javascript
70
+ * const machine = new PageMachine({
71
+ * initialState: STATE_START,
72
+ * routeMap: {
73
+ * [STATE_START]: '/intro/start',
74
+ * [STATE_ANIMATE]: '/intro/animate'
75
+ * },
76
+ * initialData: {
77
+ * INTRO_COMPLETED: false
78
+ * },
79
+ * onEnterHooks: {
80
+ * [STATE_ANIMATE]: (done) => {
81
+ * setTimeout(() => done(STATE_START), 1000);
82
+ * return {
83
+ * abort: () => clearTimeout(...),
84
+ * complete: () => done(STATE_START)
85
+ * };
86
+ * }
87
+ * }
88
+ * });
89
+ * ```
90
+ */
91
+ constructor({ initialState, routeMap, initialData, onEnterHooks }: {
92
+ initialState: string;
93
+ routeMap?: Record<string, string> | undefined;
94
+ initialData?: Record<string, any> | undefined;
95
+ onEnterHooks?: Record<string, Function> | undefined;
96
+ });
97
+ /**
98
+ * Synchronize machine state with URL path
99
+ *
100
+ * Call this in a $effect that watches $page.url.pathname
101
+ * Automatically tracks visited states
102
+ *
103
+ * @param {string} currentPath - Current URL pathname
104
+ *
105
+ * @returns {boolean} True if state was changed
106
+ */
107
+ syncFromPath(currentPath: string): boolean;
108
+ /**
109
+ * Set the current state directly
110
+ * Handles onEnter hooks and auto-transitions
111
+ *
112
+ * @param {string} newState - Target state
113
+ */
114
+ setState(newState: string): Promise<void>;
115
+ /**
116
+ * Get route path for a given state
117
+ *
118
+ * @param {string} state - State name
119
+ *
120
+ * @returns {string|null} Route path or null if no mapping
121
+ */
122
+ getPathForState(state: string): string | null;
123
+ /**
124
+ * Get route path for current state
125
+ *
126
+ * @returns {string|null} Route path or null if no mapping
127
+ */
128
+ getCurrentPath(): string | null;
129
+ /**
130
+ * Get current state
131
+ *
132
+ * @returns {string} Current state name
133
+ */
134
+ get current(): string;
135
+ /**
136
+ * Get the route map
137
+ *
138
+ * @returns {Record<string, string>} Copy of route map
139
+ */
140
+ get routeMap(): Record<string, string>;
141
+ /**
142
+ * Set a data property value
143
+ *
144
+ * @param {string} key - Property key
145
+ * @param {any} value - Property value
146
+ *
147
+ * @example
148
+ * ```javascript
149
+ * machine.setData('HAS_STRONG_PROFILE', true);
150
+ * machine.setData('PROFILE_SCORE', 85);
151
+ * ```
152
+ */
153
+ setData(key: string, value: any): void;
154
+ /**
155
+ * Get a data property value
156
+ *
157
+ * @param {string} key - Property key
158
+ *
159
+ * @returns {any} Property value or undefined
160
+ *
161
+ * @example
162
+ * ```javascript
163
+ * const hasProfile = machine.getData('HAS_STRONG_PROFILE');
164
+ * const score = machine.getData('PROFILE_SCORE');
165
+ * ```
166
+ */
167
+ getData(key: string): any;
168
+ /**
169
+ * Get all data properties
170
+ *
171
+ * @returns {Record<string, any>} Copy of all data
172
+ *
173
+ * @example
174
+ * ```javascript
175
+ * const allData = machine.getAllData();
176
+ * await playerService.saveData(allData);
177
+ * ```
178
+ */
179
+ getAllData(): Record<string, any>;
180
+ /**
181
+ * Update multiple data properties at once
182
+ *
183
+ * @param {Record<string, any>} dataUpdates - Object with key-value pairs
184
+ *
185
+ * @example
186
+ * ```javascript
187
+ * machine.updateData({
188
+ * HAS_STRONG_PROFILE: true,
189
+ * PROFILE_SCORE: 85,
190
+ * MATCHED_SECTOR: 'technology'
191
+ * });
192
+ * ```
193
+ */
194
+ updateData(dataUpdates: Record<string, any>): void;
195
+ /**
196
+ * Check if a state has been visited
197
+ *
198
+ * @param {string} state - State name to check
199
+ *
200
+ * @returns {boolean} True if the state has been visited
201
+ *
202
+ * @example
203
+ * ```javascript
204
+ * if (machine.hasVisited(STATE_PROFILE)) {
205
+ * // User has seen profile page, skip intro
206
+ * }
207
+ * ```
208
+ */
209
+ hasVisited(state: string): boolean;
210
+ /**
211
+ * Get all visited states
212
+ *
213
+ * @returns {string[]} Array of visited state names
214
+ */
215
+ getVisitedStates(): string[];
216
+ /**
217
+ * Reset visited states tracking
218
+ * Useful for testing or resetting experience
219
+ */
220
+ resetVisitedStates(): void;
221
+ /**
222
+ * Abort current state's transitions
223
+ * Cancels animations/operations immediately (incomplete state)
224
+ *
225
+ * @example
226
+ * ```javascript
227
+ * // User clicks "Cancel" button
228
+ * machine.abortTransitions();
229
+ * ```
230
+ */
231
+ abortTransitions(): void;
232
+ /**
233
+ * Complete current state's transitions immediately
234
+ * Fast-forwards animations/operations to completion (complete state)
235
+ *
236
+ * @example
237
+ * ```javascript
238
+ * // User clicks "Skip" or "Next" button
239
+ * machine.completeTransitions();
240
+ * ```
241
+ */
242
+ completeTransitions(): void;
243
+ /**
244
+ * Check if current state has transitions that can be completed
245
+ *
246
+ * @returns {boolean} True if completeTransitions() can be called
247
+ *
248
+ * @example
249
+ * ```svelte
250
+ * {#if machine.canCompleteTransitions}
251
+ * <button onclick={() => machine.completeTransitions()}>Skip</button>
252
+ * {/if}
253
+ * ```
254
+ */
255
+ get canCompleteTransitions(): boolean;
256
+ /**
257
+ * Check if current state has transitions that can be aborted
258
+ *
259
+ * @returns {boolean} True if abortTransitions() can be called
260
+ *
261
+ * @example
262
+ * ```svelte
263
+ * {#if machine.canAbortTransitions}
264
+ * <button onclick={() => machine.abortTransitions()}>Cancel</button>
265
+ * {/if}
266
+ * ```
267
+ */
268
+ get canAbortTransitions(): boolean;
269
+ #private;
270
+ }