@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.
@@ -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
+ }