@bjro/spriggan 1.0.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/LICENSE +201 -0
- package/README.md +232 -0
- package/package.json +56 -0
- package/src/spriggan.d.ts +251 -0
- package/src/spriggan.js +728 -0
package/src/spriggan.js
ADDED
|
@@ -0,0 +1,728 @@
|
|
|
1
|
+
/* @ts-self-types="./spriggan.d.ts" */
|
|
2
|
+
/**
|
|
3
|
+
* Spriggan - A minimal TEA-inspired framework
|
|
4
|
+
* No build tools, pure functions, LLM-friendly
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// @ts-check
|
|
8
|
+
|
|
9
|
+
/** @typedef {{type: string, [key: string]: unknown}} Message */
|
|
10
|
+
/** @typedef {(msg: Message) => void} Dispatch */
|
|
11
|
+
/** @typedef {{type: string, [key: string]: unknown}} Effect */
|
|
12
|
+
/** @typedef {(effect: Effect, dispatch: Dispatch) => void} EffectHandler */
|
|
13
|
+
/** @typedef {(effect: Effect, dispatch: Dispatch, handlers: Record<string, EffectHandler>) => void} EffectRunner */
|
|
14
|
+
/** @typedef {(dispatch: Dispatch) => (() => void) | (() => void)[] | void} SubscriptionFn */
|
|
15
|
+
|
|
16
|
+
/** @type {Window & {Idiomorph?: {morph: (el: Element, content: string, opts: object) => void}}} */
|
|
17
|
+
const win = /** @type {*} */ (typeof window !== "undefined" ? window : {});
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Tagged template literal for HTML
|
|
21
|
+
* @param {TemplateStringsArray} strings - Template string parts
|
|
22
|
+
* @param {...*} values - Interpolated values
|
|
23
|
+
* @returns {string} HTML string
|
|
24
|
+
*/
|
|
25
|
+
export function html(strings, ...values) {
|
|
26
|
+
let result = strings[0] ?? "";
|
|
27
|
+
|
|
28
|
+
for (let i = 0; i < values.length; i++) {
|
|
29
|
+
const value = values[i] ?? "";
|
|
30
|
+
|
|
31
|
+
if (Array.isArray(value)) {
|
|
32
|
+
// special case for embedding "components"
|
|
33
|
+
// if (typeof value[value.length-1] === 'function')
|
|
34
|
+
result += value.join("");
|
|
35
|
+
} else if (value == null || value === false) {
|
|
36
|
+
result += "";
|
|
37
|
+
} else if (value === true) {
|
|
38
|
+
result += "";
|
|
39
|
+
} else if (
|
|
40
|
+
typeof value === "object" &&
|
|
41
|
+
"type" in value &&
|
|
42
|
+
typeof value.type === "string"
|
|
43
|
+
) {
|
|
44
|
+
// It looks like a Message, so wrap it in single quotes and stringify it as JSON
|
|
45
|
+
result += `'${JSON.stringify(value)}'`;
|
|
46
|
+
} else {
|
|
47
|
+
result += value;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
result += strings[i + 1];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return result;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Create a new Spriggan instance
|
|
58
|
+
* @returns {{app: Function, html: typeof html}}
|
|
59
|
+
*/
|
|
60
|
+
export default function createSpriggan() {
|
|
61
|
+
/** @type {unknown} */
|
|
62
|
+
let currentState = null;
|
|
63
|
+
/** @type {unknown} */
|
|
64
|
+
let _currentView = null;
|
|
65
|
+
/** @type {HTMLElement | null} */
|
|
66
|
+
let rootElement = null;
|
|
67
|
+
/** @type {((state: unknown, msg: Message) => unknown) | null} */
|
|
68
|
+
let updateFn = null;
|
|
69
|
+
/** @type {((state: unknown, dispatch: Dispatch) => string | Node) | null} */
|
|
70
|
+
let viewFn = null;
|
|
71
|
+
/** @type {Record<string, EffectHandler>} */
|
|
72
|
+
let effectHandlers = {};
|
|
73
|
+
/** @type {EffectRunner | null} */
|
|
74
|
+
let runEffectFn = null;
|
|
75
|
+
let isDebugMode = false;
|
|
76
|
+
let eventListenersAttached = false;
|
|
77
|
+
/** @type {Record<string, (e: Event) => void> | null} */
|
|
78
|
+
let boundEventHandlers = null;
|
|
79
|
+
/** @type {Array<{msg: Message, state: unknown, timestamp: number}>} */
|
|
80
|
+
let debugHistory = [];
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Initialize a Spriggan application
|
|
84
|
+
* @param {string | HTMLElement} selector - CSS selector for root element
|
|
85
|
+
* @param {{
|
|
86
|
+
* init: unknown | (() => unknown),
|
|
87
|
+
* update: (state: unknown, msg: Message) => unknown,
|
|
88
|
+
* view: (state: unknown, dispatch: Dispatch) => string | Node,
|
|
89
|
+
* effects?: Record<string, EffectHandler>,
|
|
90
|
+
* effectRunner?: EffectRunner,
|
|
91
|
+
* subscriptions?: SubscriptionFn,
|
|
92
|
+
* debug?: boolean
|
|
93
|
+
* }} config - Application configuration
|
|
94
|
+
* @returns {{dispatch: Dispatch, getState: () => unknown, destroy: () => void}}
|
|
95
|
+
*/
|
|
96
|
+
function app(selector, config) {
|
|
97
|
+
const {
|
|
98
|
+
init,
|
|
99
|
+
update,
|
|
100
|
+
view,
|
|
101
|
+
effects = {},
|
|
102
|
+
effectRunner = defaultEffectRunner,
|
|
103
|
+
subscriptions = null,
|
|
104
|
+
debug = false,
|
|
105
|
+
} = config;
|
|
106
|
+
|
|
107
|
+
if (!init || !update || !view) {
|
|
108
|
+
throw new Error("Spriggan: init, update, and view are required");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const el =
|
|
112
|
+
selector instanceof HTMLElement
|
|
113
|
+
? selector
|
|
114
|
+
: document.querySelector(selector);
|
|
115
|
+
if (!el) {
|
|
116
|
+
throw new Error(`Spriggan: element "${selector}" not found`);
|
|
117
|
+
}
|
|
118
|
+
rootElement = /** @type {HTMLElement} */ (el);
|
|
119
|
+
|
|
120
|
+
isDebugMode = debug;
|
|
121
|
+
updateFn = isDebugMode ? debugUpdate(update) : update;
|
|
122
|
+
viewFn = view;
|
|
123
|
+
effectHandlers = { ...defaultEffects, ...effects };
|
|
124
|
+
runEffectFn = isDebugMode ? debugEffectRunner(effectRunner) : effectRunner;
|
|
125
|
+
|
|
126
|
+
currentState =
|
|
127
|
+
typeof init === "function" ? /** @type {() => unknown} */ (init)() : init;
|
|
128
|
+
|
|
129
|
+
if (isDebugMode) {
|
|
130
|
+
console.log("[Spriggan] Initialized with state:", currentState);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** @type {Array<() => void>} */
|
|
134
|
+
let cleanupFns = [];
|
|
135
|
+
if (subscriptions) {
|
|
136
|
+
const cleanup = subscriptions(dispatch);
|
|
137
|
+
if (cleanup) {
|
|
138
|
+
cleanupFns = Array.isArray(cleanup) ? cleanup : [cleanup];
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
render();
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
dispatch,
|
|
146
|
+
getState: () => currentState,
|
|
147
|
+
destroy: () => {
|
|
148
|
+
cleanupFns.forEach((fn) => void fn());
|
|
149
|
+
|
|
150
|
+
if (rootElement) {
|
|
151
|
+
detachEventListeners(rootElement);
|
|
152
|
+
rootElement.innerHTML = "";
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
currentState = null;
|
|
156
|
+
_currentView = null;
|
|
157
|
+
rootElement = null;
|
|
158
|
+
updateFn = null;
|
|
159
|
+
viewFn = null;
|
|
160
|
+
effectHandlers = {};
|
|
161
|
+
runEffectFn = null;
|
|
162
|
+
isDebugMode = false;
|
|
163
|
+
debugHistory = [];
|
|
164
|
+
},
|
|
165
|
+
...(isDebugMode && {
|
|
166
|
+
debug: {
|
|
167
|
+
history: debugHistory,
|
|
168
|
+
timeTravel: /** @param {number} index */ (index) => {
|
|
169
|
+
if (debugHistory[index]) {
|
|
170
|
+
currentState = debugHistory[index].state;
|
|
171
|
+
render();
|
|
172
|
+
console.log("[Spriggan] Time traveled to state:", currentState);
|
|
173
|
+
} else {
|
|
174
|
+
console.warn(`[Spriggan] No history entry at index ${index}`);
|
|
175
|
+
}
|
|
176
|
+
},
|
|
177
|
+
clearHistory: () => {
|
|
178
|
+
debugHistory.length = 0;
|
|
179
|
+
console.log("[Spriggan] History cleared");
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
}),
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** @param {Message} msg */
|
|
187
|
+
function dispatch(msg) {
|
|
188
|
+
if (!msg || !msg.type) {
|
|
189
|
+
console.warn("Spriggan: dispatch called with invalid message", msg);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (!updateFn) return;
|
|
194
|
+
const result = updateFn(currentState, msg);
|
|
195
|
+
|
|
196
|
+
if (Array.isArray(result)) {
|
|
197
|
+
const [newState, ...effects] = result;
|
|
198
|
+
currentState = newState;
|
|
199
|
+
|
|
200
|
+
effects.forEach((eff) => {
|
|
201
|
+
if (eff && runEffectFn) {
|
|
202
|
+
runEffectFn(eff, dispatch, effectHandlers);
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
} else {
|
|
206
|
+
currentState = result;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
render();
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function render() {
|
|
213
|
+
if (!rootElement || !viewFn) return;
|
|
214
|
+
|
|
215
|
+
const startTime = isDebugMode ? performance.now() : 0;
|
|
216
|
+
|
|
217
|
+
const newView = viewFn(currentState, dispatch);
|
|
218
|
+
const newContent = typeof newView === "string" ? newView : newView;
|
|
219
|
+
|
|
220
|
+
if (newContent == null || newContent === "") {
|
|
221
|
+
rootElement.innerHTML = "";
|
|
222
|
+
} else if (typeof newContent === "string") {
|
|
223
|
+
if (typeof win.Idiomorph !== "undefined") {
|
|
224
|
+
win.Idiomorph.morph(rootElement, `<div>${newContent}</div>`, {
|
|
225
|
+
morphStyle: "innerHTML",
|
|
226
|
+
callbacks: {
|
|
227
|
+
beforeNodeMorphed: (
|
|
228
|
+
/** @type {HTMLElement} */ fromNode,
|
|
229
|
+
/** @type {HTMLElement} */ toNode,
|
|
230
|
+
) => {
|
|
231
|
+
if (fromNode.id && toNode.id) {
|
|
232
|
+
return fromNode.id === toNode.id;
|
|
233
|
+
}
|
|
234
|
+
return true;
|
|
235
|
+
},
|
|
236
|
+
},
|
|
237
|
+
});
|
|
238
|
+
} else {
|
|
239
|
+
rootElement.innerHTML = newContent;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
attachEventListeners(rootElement);
|
|
243
|
+
} else {
|
|
244
|
+
rootElement.innerHTML = "";
|
|
245
|
+
rootElement.appendChild(newContent);
|
|
246
|
+
attachEventListeners(rootElement);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
_currentView = newView;
|
|
250
|
+
|
|
251
|
+
if (isDebugMode) {
|
|
252
|
+
const endTime = performance.now();
|
|
253
|
+
console.log(
|
|
254
|
+
`[Spriggan] Render took ${(endTime - startTime).toFixed(2)}ms`,
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** @param {HTMLElement} root */
|
|
260
|
+
function attachEventListeners(root) {
|
|
261
|
+
if (eventListenersAttached) return;
|
|
262
|
+
|
|
263
|
+
/** @type {Record<string, (e: Event) => void>} */
|
|
264
|
+
const handlers = {
|
|
265
|
+
click: /** @param {Event} e */ (e) => {
|
|
266
|
+
const target = /** @type {HTMLElement | null} */ (
|
|
267
|
+
/** @type {Element} */ (e.target).closest("[data-msg]")
|
|
268
|
+
);
|
|
269
|
+
if (target?.dataset.msg) {
|
|
270
|
+
try {
|
|
271
|
+
const msg = JSON.parse(target.dataset.msg);
|
|
272
|
+
dispatch(msg);
|
|
273
|
+
} catch (err) {
|
|
274
|
+
console.error("Spriggan: failed to parse data-msg", err);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
},
|
|
278
|
+
|
|
279
|
+
input: /** @param {Event} e */ (e) => {
|
|
280
|
+
const el = /** @type {HTMLInputElement} */ (e.target);
|
|
281
|
+
if (el?.dataset.model) {
|
|
282
|
+
dispatch({
|
|
283
|
+
type: "FieldChanged",
|
|
284
|
+
field: el.dataset.model,
|
|
285
|
+
value: el.value,
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
},
|
|
289
|
+
|
|
290
|
+
change: /** @param {Event} e */ (e) => {
|
|
291
|
+
const el = /** @type {HTMLInputElement} */ (e.target);
|
|
292
|
+
if (el?.dataset.model) {
|
|
293
|
+
const value = el.type === "checkbox" ? el.checked : el.value;
|
|
294
|
+
|
|
295
|
+
dispatch({
|
|
296
|
+
type: "FieldChanged",
|
|
297
|
+
field: el.dataset.model,
|
|
298
|
+
value: value,
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
},
|
|
302
|
+
|
|
303
|
+
submit: /** @param {Event} e */ (e) => {
|
|
304
|
+
const target = /** @type {HTMLElement | null} */ (
|
|
305
|
+
/** @type {Element} */ (e.target).closest("[data-msg]")
|
|
306
|
+
);
|
|
307
|
+
if (target?.dataset.msg) {
|
|
308
|
+
e.preventDefault();
|
|
309
|
+
try {
|
|
310
|
+
const msg = JSON.parse(target.dataset.msg);
|
|
311
|
+
dispatch(msg);
|
|
312
|
+
} catch (err) {
|
|
313
|
+
console.error("Spriggan: failed to parse data-msg", err);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
},
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
boundEventHandlers = handlers;
|
|
320
|
+
|
|
321
|
+
root.addEventListener(
|
|
322
|
+
"click",
|
|
323
|
+
/** @type {EventListener} */ (handlers.click),
|
|
324
|
+
);
|
|
325
|
+
root.addEventListener(
|
|
326
|
+
"input",
|
|
327
|
+
/** @type {EventListener} */ (handlers.input),
|
|
328
|
+
);
|
|
329
|
+
root.addEventListener(
|
|
330
|
+
"change",
|
|
331
|
+
/** @type {EventListener} */ (handlers.change),
|
|
332
|
+
);
|
|
333
|
+
root.addEventListener(
|
|
334
|
+
"submit",
|
|
335
|
+
/** @type {EventListener} */ (handlers.submit),
|
|
336
|
+
);
|
|
337
|
+
|
|
338
|
+
eventListenersAttached = true;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** @param {HTMLElement} root */
|
|
342
|
+
function detachEventListeners(root) {
|
|
343
|
+
if (!boundEventHandlers) return;
|
|
344
|
+
|
|
345
|
+
root.removeEventListener(
|
|
346
|
+
"click",
|
|
347
|
+
/** @type {EventListener} */ (boundEventHandlers.click),
|
|
348
|
+
);
|
|
349
|
+
root.removeEventListener(
|
|
350
|
+
"input",
|
|
351
|
+
/** @type {EventListener} */ (boundEventHandlers.input),
|
|
352
|
+
);
|
|
353
|
+
root.removeEventListener(
|
|
354
|
+
"change",
|
|
355
|
+
/** @type {EventListener} */ (boundEventHandlers.change),
|
|
356
|
+
);
|
|
357
|
+
root.removeEventListener(
|
|
358
|
+
"submit",
|
|
359
|
+
/** @type {EventListener} */ (boundEventHandlers.submit),
|
|
360
|
+
);
|
|
361
|
+
|
|
362
|
+
boundEventHandlers = null;
|
|
363
|
+
eventListenersAttached = false;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* @param {Effect} effect
|
|
368
|
+
* @param {Dispatch} dispatch
|
|
369
|
+
* @param {Record<string, EffectHandler>} handlers
|
|
370
|
+
*/
|
|
371
|
+
function defaultEffectRunner(effect, dispatch, handlers) {
|
|
372
|
+
isDebugMode &&
|
|
373
|
+
console.log(`[Spriggan] Running DOM effect: ${effect?.type}"`);
|
|
374
|
+
|
|
375
|
+
if (!effect || !effect.type) return;
|
|
376
|
+
|
|
377
|
+
const handler = handlers[effect.type];
|
|
378
|
+
|
|
379
|
+
if (!handler) {
|
|
380
|
+
console.warn(`Spriggan: unknown effect type "${effect.type}"`);
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
try {
|
|
385
|
+
handler(effect, dispatch);
|
|
386
|
+
} catch (err) {
|
|
387
|
+
console.error(
|
|
388
|
+
`Spriggan: effect handler "${effect.type}" threw an error`,
|
|
389
|
+
err,
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** @type {Record<string, EffectHandler>} */
|
|
395
|
+
const defaultEffects = {
|
|
396
|
+
http: (effect, dispatch) => {
|
|
397
|
+
const {
|
|
398
|
+
url,
|
|
399
|
+
method = "GET",
|
|
400
|
+
body,
|
|
401
|
+
headers = {},
|
|
402
|
+
onSuccess,
|
|
403
|
+
onError,
|
|
404
|
+
} = /** @type {{url: string, method?: string, body?: unknown, headers?: Record<string, string>, onSuccess?: string, onError?: string}} */ (
|
|
405
|
+
/** @type {unknown} */ (effect)
|
|
406
|
+
);
|
|
407
|
+
|
|
408
|
+
/** @type {RequestInit} */
|
|
409
|
+
const fetchOptions = {
|
|
410
|
+
method,
|
|
411
|
+
headers: {
|
|
412
|
+
"Content-Type": "application/json",
|
|
413
|
+
...headers,
|
|
414
|
+
},
|
|
415
|
+
};
|
|
416
|
+
|
|
417
|
+
if (body) {
|
|
418
|
+
fetchOptions.body = JSON.stringify(body);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
fetch(url, fetchOptions)
|
|
422
|
+
.then((response) => {
|
|
423
|
+
if (!response.ok) {
|
|
424
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
425
|
+
}
|
|
426
|
+
if (
|
|
427
|
+
response.headers.get("Content-Type")?.includes("application/json")
|
|
428
|
+
) {
|
|
429
|
+
return response.json();
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
return response.text();
|
|
433
|
+
})
|
|
434
|
+
.then((data) => {
|
|
435
|
+
if (onSuccess) {
|
|
436
|
+
dispatch({ type: onSuccess, data });
|
|
437
|
+
}
|
|
438
|
+
})
|
|
439
|
+
.catch((err) => {
|
|
440
|
+
if (onError) {
|
|
441
|
+
dispatch({
|
|
442
|
+
type: onError,
|
|
443
|
+
error: err,
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
});
|
|
447
|
+
},
|
|
448
|
+
|
|
449
|
+
delay: /** @param {Effect} effect */ /** @param {Dispatch} dispatch */ (
|
|
450
|
+
effect,
|
|
451
|
+
dispatch,
|
|
452
|
+
) => {
|
|
453
|
+
const { ms, msg } = effect;
|
|
454
|
+
|
|
455
|
+
if (!msg) {
|
|
456
|
+
console.warn("Spriggan: delay effect requires msg property");
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
setTimeout(
|
|
461
|
+
() => {
|
|
462
|
+
dispatch(/** @type {Message} */ (msg));
|
|
463
|
+
},
|
|
464
|
+
/** @type {number} */ (ms),
|
|
465
|
+
);
|
|
466
|
+
},
|
|
467
|
+
|
|
468
|
+
storage: /** @param {Effect} effect */ /** @param {Dispatch} dispatch */ (
|
|
469
|
+
effect,
|
|
470
|
+
dispatch,
|
|
471
|
+
) => {
|
|
472
|
+
const { action, key, value, onSuccess } = effect;
|
|
473
|
+
|
|
474
|
+
try {
|
|
475
|
+
if (action === "set") {
|
|
476
|
+
localStorage.setItem(
|
|
477
|
+
/** @type {string} */ (key),
|
|
478
|
+
JSON.stringify(value),
|
|
479
|
+
);
|
|
480
|
+
if (onSuccess) {
|
|
481
|
+
dispatch({ type: /** @type {string} */ (onSuccess) });
|
|
482
|
+
}
|
|
483
|
+
} else if (action === "get") {
|
|
484
|
+
const data = JSON.parse(
|
|
485
|
+
localStorage.getItem(/** @type {string} */ (key)) || "null",
|
|
486
|
+
);
|
|
487
|
+
if (onSuccess) {
|
|
488
|
+
dispatch({ type: /** @type {string} */ (onSuccess), data });
|
|
489
|
+
}
|
|
490
|
+
} else if (action === "remove") {
|
|
491
|
+
localStorage.removeItem(/** @type {string} */ (key));
|
|
492
|
+
if (onSuccess) {
|
|
493
|
+
dispatch({ type: /** @type {string} */ (onSuccess) });
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
} catch (err) {
|
|
497
|
+
console.error("Spriggan: storage effect failed", err);
|
|
498
|
+
}
|
|
499
|
+
},
|
|
500
|
+
|
|
501
|
+
fn: /** @param {Effect} effect */ /** @param {Dispatch} dispatch */ (
|
|
502
|
+
effect,
|
|
503
|
+
dispatch,
|
|
504
|
+
) => {
|
|
505
|
+
const { run, onComplete } = effect;
|
|
506
|
+
|
|
507
|
+
if (typeof run !== "function") {
|
|
508
|
+
console.warn(
|
|
509
|
+
"Spriggan: fn effect requires run property to be a function",
|
|
510
|
+
);
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
try {
|
|
515
|
+
const result = run();
|
|
516
|
+
if (onComplete) {
|
|
517
|
+
dispatch({ type: /** @type {string} */ (onComplete), result });
|
|
518
|
+
}
|
|
519
|
+
} catch (err) {
|
|
520
|
+
console.error("Spriggan: fn effect failed", err);
|
|
521
|
+
}
|
|
522
|
+
},
|
|
523
|
+
|
|
524
|
+
dom: /** @param {Effect} effect */ /** @param {Dispatch} _dispatch */ (
|
|
525
|
+
effect,
|
|
526
|
+
_dispatch,
|
|
527
|
+
) => {
|
|
528
|
+
const { action, selector, name, value, delay = 0 } = effect;
|
|
529
|
+
const runDomAction = () => {
|
|
530
|
+
const element = selector
|
|
531
|
+
? document.querySelector(/** @type {string} */ (selector))
|
|
532
|
+
: null;
|
|
533
|
+
|
|
534
|
+
if (!element && selector) {
|
|
535
|
+
console.warn(
|
|
536
|
+
`Spriggan: dom effect - element not found: "${selector}"`,
|
|
537
|
+
);
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
try {
|
|
542
|
+
switch (action) {
|
|
543
|
+
case "focus":
|
|
544
|
+
/** @type {HTMLElement} */ (element)?.focus();
|
|
545
|
+
break;
|
|
546
|
+
|
|
547
|
+
case "blur":
|
|
548
|
+
/** @type {HTMLElement} */ (element)?.blur();
|
|
549
|
+
break;
|
|
550
|
+
|
|
551
|
+
case "scrollIntoView":
|
|
552
|
+
/** @type {HTMLElement} */ (element)?.scrollIntoView(
|
|
553
|
+
typeof effect.options === "object"
|
|
554
|
+
? /** @type {ScrollIntoViewOptions} */ (effect.options)
|
|
555
|
+
: {},
|
|
556
|
+
);
|
|
557
|
+
break;
|
|
558
|
+
|
|
559
|
+
case "setAttribute":
|
|
560
|
+
if (element && name) {
|
|
561
|
+
element.setAttribute(
|
|
562
|
+
/** @type {string} */ (name),
|
|
563
|
+
String(value),
|
|
564
|
+
);
|
|
565
|
+
}
|
|
566
|
+
break;
|
|
567
|
+
|
|
568
|
+
case "removeAttribute":
|
|
569
|
+
if (element && name) {
|
|
570
|
+
element.removeAttribute(/** @type {string} */ (name));
|
|
571
|
+
}
|
|
572
|
+
break;
|
|
573
|
+
|
|
574
|
+
case "addClass":
|
|
575
|
+
if (element && value) {
|
|
576
|
+
element.classList.add(String(value));
|
|
577
|
+
}
|
|
578
|
+
break;
|
|
579
|
+
|
|
580
|
+
case "removeClass":
|
|
581
|
+
if (element && value) {
|
|
582
|
+
element.classList.remove(String(value));
|
|
583
|
+
}
|
|
584
|
+
break;
|
|
585
|
+
|
|
586
|
+
case "toggleClass":
|
|
587
|
+
if (element && value) {
|
|
588
|
+
element.classList.toggle(String(value));
|
|
589
|
+
}
|
|
590
|
+
break;
|
|
591
|
+
|
|
592
|
+
case "setProperty":
|
|
593
|
+
if (element && name) {
|
|
594
|
+
/** @type {Record<string, unknown>} */ (
|
|
595
|
+
/** @type {*} */ (element)
|
|
596
|
+
)[/** @type {string} */ (name)] = value;
|
|
597
|
+
}
|
|
598
|
+
break;
|
|
599
|
+
|
|
600
|
+
default:
|
|
601
|
+
console.warn(`Spriggan: dom effect - unknown action "${action}"`);
|
|
602
|
+
}
|
|
603
|
+
} catch (err) {
|
|
604
|
+
console.error("Spriggan: dom effect failed", err);
|
|
605
|
+
}
|
|
606
|
+
};
|
|
607
|
+
|
|
608
|
+
if (/** @type {number} */ (delay) > 0) {
|
|
609
|
+
setTimeout(runDomAction, /** @type {number} */ (delay));
|
|
610
|
+
} else {
|
|
611
|
+
requestAnimationFrame(runDomAction);
|
|
612
|
+
}
|
|
613
|
+
},
|
|
614
|
+
};
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* @param {(state: unknown, msg: Message) => unknown} updateFn
|
|
618
|
+
* @returns {(state: unknown, msg: Message) => unknown}
|
|
619
|
+
*/
|
|
620
|
+
function debugUpdate(updateFn) {
|
|
621
|
+
return (state, msg) => {
|
|
622
|
+
const startTime = performance.now();
|
|
623
|
+
|
|
624
|
+
console.group(`[Spriggan] Dispatch: ${msg.type}`);
|
|
625
|
+
console.log("Message:", msg);
|
|
626
|
+
console.log("Previous state:", state);
|
|
627
|
+
|
|
628
|
+
const result = updateFn(state, msg);
|
|
629
|
+
const newState = Array.isArray(result) ? result[0] : result;
|
|
630
|
+
const effects = Array.isArray(result) ? result.slice(1) : [];
|
|
631
|
+
|
|
632
|
+
if (result === undefined) {
|
|
633
|
+
console.warn(
|
|
634
|
+
"[Spriggan] update() returned undefined - this may be unintentional",
|
|
635
|
+
);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
console.log("New state:", newState);
|
|
639
|
+
|
|
640
|
+
const changes = stateDiff(state, newState);
|
|
641
|
+
if (changes.length > 0) {
|
|
642
|
+
console.log("Changes:", changes);
|
|
643
|
+
} else {
|
|
644
|
+
console.log("No state changes");
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
if (effects.length > 0) {
|
|
648
|
+
console.log("Effects:", effects);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
const endTime = performance.now();
|
|
652
|
+
console.log(`Update took ${(endTime - startTime).toFixed(2)}ms`);
|
|
653
|
+
console.groupEnd();
|
|
654
|
+
|
|
655
|
+
debugHistory.push({
|
|
656
|
+
msg,
|
|
657
|
+
state: newState,
|
|
658
|
+
timestamp: Date.now(),
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
return result;
|
|
662
|
+
};
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* @param {EffectRunner} runner
|
|
667
|
+
* @returns {EffectRunner}
|
|
668
|
+
*/
|
|
669
|
+
function debugEffectRunner(runner) {
|
|
670
|
+
return (effect, dispatch, handlers) => {
|
|
671
|
+
console.log("[Spriggan Effect]", effect);
|
|
672
|
+
return runner(effect, dispatch, handlers);
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* @param {unknown} oldState
|
|
678
|
+
* @param {unknown} newState
|
|
679
|
+
* @returns {Array<{key: string, from: unknown, to: unknown}>}
|
|
680
|
+
*/
|
|
681
|
+
function stateDiff(oldState, newState) {
|
|
682
|
+
/** @type {Array<{key: string, from: unknown, to: unknown}>} */
|
|
683
|
+
const changes = [];
|
|
684
|
+
|
|
685
|
+
if (
|
|
686
|
+
typeof oldState !== "object" ||
|
|
687
|
+
oldState === null ||
|
|
688
|
+
typeof newState !== "object" ||
|
|
689
|
+
newState === null
|
|
690
|
+
) {
|
|
691
|
+
if (oldState !== newState) {
|
|
692
|
+
changes.push({
|
|
693
|
+
key: "(value)",
|
|
694
|
+
from: oldState,
|
|
695
|
+
to: newState,
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
return changes;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
const oldObj = /** @type {Record<string, unknown>} */ (oldState);
|
|
702
|
+
const newObj = /** @type {Record<string, unknown>} */ (newState);
|
|
703
|
+
|
|
704
|
+
for (const key in newObj) {
|
|
705
|
+
if (oldObj[key] !== newObj[key]) {
|
|
706
|
+
changes.push({
|
|
707
|
+
key,
|
|
708
|
+
from: oldObj[key],
|
|
709
|
+
to: newObj[key],
|
|
710
|
+
});
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
for (const key in oldObj) {
|
|
715
|
+
if (!(key in newObj)) {
|
|
716
|
+
changes.push({
|
|
717
|
+
key,
|
|
718
|
+
from: oldObj[key],
|
|
719
|
+
to: undefined,
|
|
720
|
+
});
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
return changes;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
return { app, html };
|
|
728
|
+
}
|