weifuwu 0.27.12 → 0.27.13

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,976 @@
1
+ /**
2
+ * weifuwu-ui.js — Zero-dependency frontend runtime for weifuwu SSR.
3
+ *
4
+ * One <script> covers: AJAX, state binding, SSE streaming, WebSocket,
5
+ * theme switching, i18n, flash messages, and UI components.
6
+ *
7
+ * Usage:
8
+ * <script src="/__wfw/js/weifuwu-ui.js"></script>
9
+ * <link rel="stylesheet" href="/__wfw/css/weifuwu-ui.css">
10
+ *
11
+ * @license MIT
12
+ * @version 0.1.0
13
+ */
14
+ (function () {
15
+ 'use strict'
16
+
17
+ // ═══════════════════════════════════════════════════════════════════
18
+ // Internal state
19
+ // ═══════════════════════════════════════════════════════════════════
20
+
21
+ /** WeakMap<Element, Proxy> — wu-data reactive state per element */
22
+ const states = new WeakMap()
23
+
24
+ /** Guard to prevent duplicate initialization */
25
+ let _wuInitialized = false
26
+
27
+ /** Active SSE/WS connections for abort */
28
+ let activeStream = null
29
+
30
+ /** WebSocket connections tracked by element */
31
+ const wsConnections = new WeakMap()
32
+
33
+ // ═══════════════════════════════════════════════════════════════════
34
+ // Public API
35
+ // ═══════════════════════════════════════════════════════════════════
36
+
37
+ const wu = {
38
+ /** Initialize/reinitialize all wu-* behaviors under a root element */
39
+ init,
40
+ /** Get the reactive state for an element (from its nearest [wu-data] ancestor) */
41
+ getState,
42
+ /** Access the DOM (public ref for extensions) */
43
+ dom: { getState, findState },
44
+ }
45
+ // These are assigned later via property assignment
46
+ // (they cannot be in the object literal because they aren't hoisted).
47
+ wu.abort = function () {}
48
+ wu.send = function () {}
49
+ wu.stream = function () {}
50
+ wu.toast = function () {}
51
+
52
+ // ═══════════════════════════════════════════════════════════════════
53
+ // 1. Initialization
54
+ // ═══════════════════════════════════════════════════════════════════
55
+
56
+ function init(root) {
57
+ if (root === document || !root) {
58
+ if (_wuInitialized) return
59
+ _wuInitialized = true
60
+ }
61
+ root = root || document
62
+ if (root === document) {
63
+ initTheme()
64
+ initFlash()
65
+ initI18n()
66
+ }
67
+ initStates(root)
68
+ initBindings(root)
69
+ initActions(root)
70
+ initTriggers(root)
71
+ initSSE(root)
72
+ initWS(root)
73
+ initComponents(root)
74
+ }
75
+
76
+ // Auto-init on DOMContentLoaded
77
+ if (document.readyState === 'loading') {
78
+ document.addEventListener('DOMContentLoaded', () => init())
79
+ } else {
80
+ init()
81
+ }
82
+
83
+ // Re-init after AJAX content replacement
84
+ const origPushState = history.pushState
85
+ history.pushState = function () {
86
+ origPushState.apply(this, arguments)
87
+ init(document.body)
88
+ }
89
+ window.addEventListener('popstate', () => init(document.body))
90
+
91
+ // ═══════════════════════════════════════════════════════════════════
92
+ // 2. Reactive state (wu-data)
93
+ // ═══════════════════════════════════════════════════════════════════
94
+
95
+ function initStates(root) {
96
+ root.querySelectorAll('[wu-data]').forEach((el) => {
97
+ if (states.has(el)) return // already initialized
98
+ try {
99
+ const raw = el.getAttribute('wu-data')
100
+ const initial = JSON.parse(raw)
101
+ const state = createReactiveState(el, initial)
102
+ states.set(el, state)
103
+ } catch (e) {
104
+ // eslint-disable-next-line no-console
105
+ console.warn('[wu] Invalid wu-data:', el.getAttribute('wu-data'), e)
106
+ }
107
+ })
108
+ }
109
+
110
+ function createReactiveState(el, initial) {
111
+ const target = { ...initial }
112
+ const proxy = new Proxy(target, {
113
+ set(obj, key, value) {
114
+ const old = obj[key]
115
+ if (old === value) return true
116
+ obj[key] = value
117
+ // Update all bindings under this element
118
+ queueBindingUpdate(el, key, value)
119
+ return true
120
+ },
121
+ })
122
+ return proxy
123
+ }
124
+
125
+ /** Find the nearest [wu-data] ancestor and return its state */
126
+ function findState(el) {
127
+ const parent = el.closest('[wu-data]')
128
+ return parent ? states.get(parent) : null
129
+ }
130
+
131
+ function getState(el) {
132
+ if (states.has(el)) return states.get(el)
133
+ return findState(el)
134
+ }
135
+
136
+ // Apply binding updates synchronously
137
+ function queueBindingUpdate(root, key, value) {
138
+ applyBindings(root, key, value)
139
+ }
140
+
141
+ function applyBindings(root, key, value) {
142
+ const els = root.querySelectorAll('[wu-text="' + CSS.escape(key) + '"]')
143
+ for (const el of els) {
144
+ el.textContent = value == null ? '' : String(value)
145
+ }
146
+ const showEls = root.querySelectorAll('[wu-show="' + CSS.escape(key) + '"]')
147
+ for (const el of showEls) {
148
+ el.style.display = value ? '' : 'none'
149
+ }
150
+ const hideEls = root.querySelectorAll('[wu-hide="' + CSS.escape(key) + '"]')
151
+ for (const el of hideEls) {
152
+ el.style.display = value ? 'none' : ''
153
+ }
154
+ const classEls = root.querySelectorAll('[wu-class]')
155
+ for (const el of classEls) {
156
+ const expr = el.getAttribute('wu-class')
157
+ const state = findState(el)
158
+ if (state) el.className = evaluateExpr(expr, state) || ''
159
+ }
160
+ const htmlEls = root.querySelectorAll('[wu-html="' + CSS.escape(key) + '"]')
161
+ for (const el of htmlEls) {
162
+ el.innerHTML = value == null ? '' : String(value)
163
+ }
164
+ // Re-render wu-each (exact match + prefix match for nested paths)
165
+ const eachEls = root.querySelectorAll('[wu-each]')
166
+ for (const el of eachEls) {
167
+ const eachPath = el.getAttribute('wu-each')
168
+ if (eachPath === key || eachPath.startsWith(key + '.')) {
169
+ renderEach(el)
170
+ }
171
+ }
172
+ }
173
+
174
+ // ═══════════════════════════════════════════════════════════════════
175
+ // 3. Binding initialization (static render)
176
+ // ═══════════════════════════════════════════════════════════════════
177
+
178
+ function initBindings(root) {
179
+ // wu-text: set initial text from state
180
+ root.querySelectorAll('[wu-text]').forEach((el) => {
181
+ const key = el.getAttribute('wu-text')
182
+ const state = findState(el)
183
+ if (state) {
184
+ const val = getNestedValue(state, key)
185
+ el.textContent = val == null ? '' : String(val)
186
+ }
187
+ })
188
+ // wu-show / wu-hide: initial visibility
189
+ root.querySelectorAll('[wu-show], [wu-hide]').forEach((el) => {
190
+ const key = el.getAttribute('wu-show') || el.getAttribute('wu-hide')
191
+ const state = findState(el)
192
+ if (state) {
193
+ const val = getNestedValue(state, key)
194
+ const isShow = el.hasAttribute('wu-show')
195
+ el.style.display = val ? (isShow ? '' : 'none') : isShow ? 'none' : ''
196
+ }
197
+ })
198
+ // wu-class: initial class
199
+ root.querySelectorAll('[wu-class]').forEach((el) => {
200
+ const expr = el.getAttribute('wu-class')
201
+ const state = findState(el)
202
+ if (state) {
203
+ el.className = evaluateExpr(expr, state) || ''
204
+ }
205
+ })
206
+ // wu-model: initial value
207
+ root.querySelectorAll('[wu-model]').forEach((el) => {
208
+ const key = el.getAttribute('wu-model')
209
+ const state = findState(el)
210
+ if (state) {
211
+ const val = getNestedValue(state, key)
212
+ el.value = val == null ? '' : String(val)
213
+ }
214
+ })
215
+ // wu-each: initial render
216
+ root.querySelectorAll('[wu-each]').forEach(renderEach)
217
+ }
218
+
219
+ // ═══════════════════════════════════════════════════════════════════
220
+ // 4. wu-each: list rendering
221
+ // ═══════════════════════════════════════════════════════════════════
222
+
223
+ /** Get nested property value by dot-separated path */
224
+ function getNestedValue(obj, path) {
225
+ return path.split('.').reduce((o, k) => (o != null ? o[k] : undefined), obj)
226
+ }
227
+
228
+ function renderEach(el) {
229
+ const path = el.getAttribute('wu-each')
230
+ const state = findState(el)
231
+ if (!state) return
232
+ const items = getNestedValue(state, path)
233
+ if (!Array.isArray(items)) return
234
+ // Save the template from first render
235
+ let template = el._wu_template
236
+ if (!template) {
237
+ template = el.innerHTML
238
+ el._wu_template = template
239
+ }
240
+ el.innerHTML = items
241
+ .map((item, index) => {
242
+ const rendered = template
243
+ .replace(/\$\{index\}/g, String(index))
244
+ .replace(/\$\{this\}/g, String(item == null ? '' : item))
245
+ // Support nested path access: ${item.name}, ${item.user.email}
246
+ return rendered.replace(/\$\{item\.([^}]+)\}/g, (_, prop) => {
247
+ return String(getNestedValue(item, prop) ?? '')
248
+ })
249
+ })
250
+ .join('')
251
+ }
252
+
253
+ // ═══════════════════════════════════════════════════════════════════
254
+ // 5. wu-on: event binding
255
+ // ═══════════════════════════════════════════════════════════════════
256
+
257
+ function initActions(root) {
258
+ // Event delegation for wu-on
259
+ root.addEventListener('click', (e) => {
260
+ const el = e.target.closest('[wu-on]')
261
+ if (!el) return
262
+ const expr = el.getAttribute('wu-on')
263
+ handleAction(expr, el, e)
264
+ })
265
+ root.addEventListener('keyup', (e) => {
266
+ const el = e.target.closest('[wu-on]')
267
+ if (!el) return
268
+ const expr = el.getAttribute('wu-on')
269
+ if (!expr.includes('keyup')) return
270
+ handleAction(expr, el, e)
271
+ })
272
+ }
273
+
274
+ function handleAction(expr, el, event) {
275
+ const state = findState(el)
276
+ if (!state) return
277
+
278
+ // Parse "eventType: expression" or just "expression"
279
+ let actionExpr = expr
280
+ const colonIdx = expr.indexOf(':')
281
+ if (colonIdx !== -1) {
282
+ const eventType = expr.slice(0, colonIdx).trim()
283
+ // Filter by event type
284
+ if (eventType === 'click' && event.type !== 'click') return
285
+ if (eventType === 'keyup' && event.type !== 'keyup') return
286
+ actionExpr = expr.slice(colonIdx + 1).trim()
287
+ }
288
+
289
+ evaluateExpr(actionExpr, state)
290
+ }
291
+
292
+ // ═══════════════════════════════════════════════════════════════════
293
+ // 6. Expression evaluator
294
+ // ═══════════════════════════════════════════════════════════════════
295
+
296
+ function evaluateExpr(expr, state) {
297
+ // Execute expression in the Proxy's scope via with().
298
+ // The Proxy's set trap catches mutations and triggers binding updates.
299
+ // Returns the expression result (used by wu-class).
300
+ try {
301
+ const fn = new Function('$s', `with($s) { return (${expr}) }`)
302
+ return fn(state)
303
+ } catch (e) {
304
+ // eslint-disable-next-line no-console
305
+ console.warn('[wu] Expression error:', expr, e)
306
+ return ''
307
+ }
308
+ }
309
+
310
+ // ═══════════════════════════════════════════════════════════════════
311
+ // 7. wu-model: two-way binding
312
+ // ═══════════════════════════════════════════════════════════════════
313
+
314
+ function initModels(root) {
315
+ root.querySelectorAll('[wu-model]').forEach((el) => {
316
+ const key = el.getAttribute('wu-model')
317
+ const state = findState(el)
318
+ if (!state) return
319
+
320
+ el.addEventListener('input', () => {
321
+ state[key] = el.value
322
+ })
323
+ el.addEventListener('change', () => {
324
+ state[key] = el.value
325
+ })
326
+ })
327
+ }
328
+
329
+ // ═══════════════════════════════════════════════════════════════════
330
+ // 8. wu-get / wu-post / wu-put / wu-patch / wu-delete (AJAX)
331
+ // ═══════════════════════════════════════════════════════════════════
332
+
333
+ function initTriggers(root) {
334
+ // Click triggers: [wu-get], [wu-post], [wu-put], [wu-patch], [wu-delete]
335
+ root.addEventListener('click', (e) => {
336
+ const el = e.target.closest('[wu-get],[wu-post],[wu-put],[wu-patch],[wu-delete]')
337
+ if (!el) return
338
+ const method = el.hasAttribute('wu-get')
339
+ ? 'GET'
340
+ : el.hasAttribute('wu-post')
341
+ ? 'POST'
342
+ : el.hasAttribute('wu-put')
343
+ ? 'PUT'
344
+ : el.hasAttribute('wu-patch')
345
+ ? 'PATCH'
346
+ : 'DELETE'
347
+ const url =
348
+ el.getAttribute('wu-get') ||
349
+ el.getAttribute('wu-post') ||
350
+ el.getAttribute('wu-put') ||
351
+ el.getAttribute('wu-patch') ||
352
+ el.getAttribute('wu-delete')
353
+ triggerRequest(el, method, url, e)
354
+ })
355
+
356
+ // Load triggers: [wu-get wu-trigger="load"]
357
+ root.querySelectorAll('[wu-trigger="load"]').forEach((el) => {
358
+ const method = el.hasAttribute('wu-get') ? 'GET' : 'POST'
359
+ const url = el.getAttribute('wu-get') || el.getAttribute('wu-post')
360
+ if (url) triggerRequest(el, method, url, null)
361
+ })
362
+
363
+ // Every triggers: [wu-trigger="every:5s"]
364
+ root.querySelectorAll('[wu-trigger]').forEach((el) => {
365
+ const trigger = el.getAttribute('wu-trigger')
366
+ const m = trigger && trigger.match(/^every:(\d+)$/)
367
+ if (!m) return
368
+ const interval = parseInt(m[1], 10) * 1000
369
+ const method = el.hasAttribute('wu-get') ? 'GET' : 'POST'
370
+ const url = el.getAttribute('wu-get') || el.getAttribute('wu-post')
371
+ if (url) {
372
+ setInterval(() => triggerRequest(el, method, url, null), interval)
373
+ }
374
+ })
375
+
376
+ // visible triggers: [wu-trigger="visible"]
377
+ if ('IntersectionObserver' in window) {
378
+ root.querySelectorAll('[wu-trigger="visible"]').forEach((el) => {
379
+ const method = el.hasAttribute('wu-get') ? 'GET' : 'POST'
380
+ const url = el.getAttribute('wu-get') || el.getAttribute('wu-post')
381
+ if (!url) return
382
+ const observer = new IntersectionObserver(
383
+ (entries) => {
384
+ for (const entry of entries) {
385
+ if (entry.isIntersecting) {
386
+ triggerRequest(el, method, url, null)
387
+ observer.disconnect()
388
+ }
389
+ }
390
+ },
391
+ { rootMargin: '100px' },
392
+ )
393
+ observer.observe(el)
394
+ })
395
+ }
396
+
397
+ // Form submit triggers
398
+ root.addEventListener('submit', (e) => {
399
+ const form = e.target.closest('[wu-post],[wu-put]')
400
+ if (!form) return
401
+ e.preventDefault()
402
+ const method = form.hasAttribute('wu-post') ? 'POST' : 'PUT'
403
+ const url = form.getAttribute('wu-post') || form.getAttribute('wu-put')
404
+ triggerRequest(form, method, url, e)
405
+ })
406
+ }
407
+
408
+ function triggerRequest(el, method, url, _event) {
409
+ // Confirmation
410
+ const confirmMsg = el.getAttribute('wu-confirm')
411
+ if (confirmMsg && !confirm(confirmMsg)) return
412
+
413
+ // Build options
414
+ const opts = { method, headers: {} }
415
+ const isForm = el.tagName === 'FORM'
416
+
417
+ // Body: from form or wu-data
418
+ if (method === 'POST' || method === 'PUT' || method === 'PATCH') {
419
+ if (isForm) {
420
+ opts.body = new FormData(el)
421
+ } else {
422
+ const state = findState(el)
423
+ if (state && el.getAttribute('wu-data')) {
424
+ opts.headers['Content-Type'] = 'application/json'
425
+ opts.body = JSON.stringify(state)
426
+ }
427
+ }
428
+ }
429
+
430
+ // Show loading indicator
431
+ const loadingId = el.getAttribute('wu-loading')
432
+ const loadingEl = loadingId ? document.querySelector(loadingId) : null
433
+ if (loadingEl) loadingEl.classList.remove('wu-hidden')
434
+
435
+ // wu-stream check
436
+ const isStream = el.hasAttribute('wu-stream')
437
+ const target = el.getAttribute('wu-target')
438
+
439
+ if (isStream) {
440
+ // Abort previous stream
441
+ if (activeStream) activeStream.abort()
442
+ const controller = new AbortController()
443
+ activeStream = controller
444
+ opts.signal = controller.signal
445
+ }
446
+
447
+ fetch(url, opts)
448
+ .then(async (res) => {
449
+ if (loadingEl) loadingEl.classList.add('wu-hidden')
450
+
451
+ if (isStream) {
452
+ return handleStreamResponse(res, el)
453
+ }
454
+
455
+ if (!res.ok) {
456
+ // Try to parse error JSON
457
+ try {
458
+ const errData = await res.json()
459
+ if (errData.errors) {
460
+ handleErrors(el, errData.errors)
461
+ }
462
+ } catch {}
463
+ return
464
+ }
465
+
466
+ // Handle redirect
467
+ const redirect = res.headers.get('X-WFU-Redirect')
468
+ if (redirect) {
469
+ return (window.location.href = redirect)
470
+ }
471
+
472
+ const html = await res.text()
473
+ if (!target) {
474
+ document.open()
475
+ document.write(html)
476
+ document.close()
477
+ init()
478
+ return
479
+ }
480
+
481
+ const targetEl = document.querySelector(target)
482
+ if (!targetEl) return
483
+
484
+ const swap = el.getAttribute('wu-swap') || 'innerHTML'
485
+ applySwap(targetEl, html, swap)
486
+
487
+ // Re-init inside the replaced content
488
+ init(targetEl)
489
+ })
490
+ .catch((err) => {
491
+ if (err.name === 'AbortError') return
492
+ if (loadingEl) loadingEl.classList.add('wu-hidden')
493
+ // eslint-disable-next-line no-console
494
+ console.warn('[wu] Fetch error:', err)
495
+ })
496
+ }
497
+
498
+ function applySwap(target, html, swap) {
499
+ switch (swap) {
500
+ case 'outerHTML':
501
+ target.outerHTML = html
502
+ break
503
+ case 'before':
504
+ target.insertAdjacentHTML('beforebegin', html)
505
+ break
506
+ case 'after':
507
+ target.insertAdjacentHTML('afterend', html)
508
+ break
509
+ case 'prepend':
510
+ target.insertAdjacentHTML('afterbegin', html)
511
+ break
512
+ case 'append':
513
+ target.insertAdjacentHTML('beforeend', html)
514
+ break
515
+ default:
516
+ target.innerHTML = html
517
+ }
518
+ }
519
+
520
+ function handleErrors(el, errors) {
521
+ const root = el.closest('[wu-data]') || el
522
+ for (const [field, message] of Object.entries(errors)) {
523
+ const errorEl = root.querySelector('[wu-error="' + field + '"]')
524
+ if (errorEl) errorEl.textContent = message
525
+ }
526
+ }
527
+
528
+ // ═══════════════════════════════════════════════════════════════════
529
+ // 9. SSE streaming (wu-stream, wu-sse)
530
+ // ═══════════════════════════════════════════════════════════════════
531
+
532
+ function initSSE(root) {
533
+ // Auto-connect SSE on [wu-sse] elements
534
+ root.querySelectorAll('[wu-sse]').forEach((el) => {
535
+ if (el._wu_sse) return
536
+ const url = el.getAttribute('wu-sse')
537
+ connectSSE(el, url)
538
+ })
539
+ }
540
+
541
+ function connectSSE(el, url) {
542
+ el._wu_sse = true
543
+ const es = new EventSource(url)
544
+ el._wu_es = es
545
+
546
+ es.addEventListener('message', (e) => {
547
+ try {
548
+ const handler = el.getAttribute('wu-on-sse-message')
549
+ if (handler) {
550
+ const state = findState(el)
551
+ if (state) {
552
+ const _data = JSON.parse(e.data)
553
+ const fn = new Function('$s', 'data', `with($s) { ${handler} }`)
554
+ fn(state, _data)
555
+ }
556
+ }
557
+ } catch {}
558
+ })
559
+
560
+ // Custom event handlers: wu-on-sse-{eventName}
561
+ const handlerAttr = Array.from(el.attributes)
562
+ .filter((a) => a.name.startsWith('wu-on-sse-'))
563
+ for (const attr of handlerAttr) {
564
+ const eventName = attr.name.slice('wu-on-sse-'.length)
565
+ const handler = attr.value
566
+ es.addEventListener(eventName, (e) => {
567
+ try {
568
+ const data = JSON.parse(e.data)
569
+ const state = findState(el)
570
+ if (state) {
571
+ const fn = new Function('$s', 'data', `with($s) { ${handler} }`)
572
+ fn(state, data)
573
+ }
574
+ } catch (err) {
575
+ // eslint-disable-next-line no-console
576
+ console.warn('[wu] SSE handler error:', err)
577
+ }
578
+ })
579
+ }
580
+ }
581
+
582
+ /** Programmatic SSE stream (used with wu-post/wu-get wu-stream) */
583
+ function handleStreamResponse(res, el) {
584
+ const reader = res.body.getReader()
585
+ const decoder = new TextDecoder()
586
+ let buffer = ''
587
+
588
+ async function read() {
589
+ while (true) {
590
+ const { done, value } = await reader.read()
591
+ if (done) break
592
+ buffer += decoder.decode(value, { stream: true })
593
+ const lines = buffer.split('\n')
594
+ buffer = lines.pop() || ''
595
+ for (const line of lines) {
596
+ if (!line.startsWith('data: ')) continue
597
+ try {
598
+ const data = JSON.parse(line.slice(6))
599
+ emitSSEEvent(el, data)
600
+ } catch {}
601
+ }
602
+ }
603
+ }
604
+
605
+ read().catch(() => {
606
+ activeStream = null
607
+ })
608
+ }
609
+
610
+ function emitSSEEvent(el, data) {
611
+ if (!data.type) return
612
+ const handlerAttr = 'wu-on-sse-' + data.type
613
+ const state = findState(el)
614
+ if (!state) return
615
+
616
+ // Find handler in el or ancestors
617
+ const handler = el.getAttribute(handlerAttr)
618
+ if (handler) {
619
+ const fn = new Function('$s', 'data', `with($s) { ${handler} }`)
620
+ fn(state, data)
621
+ }
622
+ }
623
+
624
+ wu.stream = function (method, url, opts) {
625
+ if (activeStream) activeStream.abort()
626
+ const controller = new AbortController()
627
+ activeStream = controller
628
+
629
+ const fetchOpts = {
630
+ method,
631
+ headers: { 'Content-Type': 'application/json' },
632
+ signal: controller.signal,
633
+ ...(opts.body ? { body: opts.body } : {}),
634
+ }
635
+
636
+ fetch(url, fetchOpts)
637
+ .then(async (res) => {
638
+ const reader = res.body.getReader()
639
+ const decoder = new TextDecoder()
640
+ let buffer = ''
641
+
642
+ while (true) {
643
+ const { done, value } = await reader.read()
644
+ if (done) break
645
+ buffer += decoder.decode(value, { stream: true })
646
+ const lines = buffer.split('\n')
647
+ buffer = lines.pop() || ''
648
+ for (const line of lines) {
649
+ if (!line.startsWith('data: ')) continue
650
+ try {
651
+ const data = JSON.parse(line.slice(6))
652
+ if (opts.onEvent && opts.onEvent[data.type]) {
653
+ opts.onEvent[data.type](data)
654
+ }
655
+ } catch {}
656
+ }
657
+ }
658
+ })
659
+ .catch(() => {})
660
+ .finally(() => {
661
+ if (opts.onDone) opts.onDone()
662
+ })
663
+ }
664
+
665
+ wu.abort = function () {
666
+ if (activeStream) {
667
+ activeStream.abort()
668
+ activeStream = null
669
+ }
670
+ }
671
+
672
+ // ═══════════════════════════════════════════════════════════════════
673
+ // 10. WebSocket (wu-ws)
674
+ // ═══════════════════════════════════════════════════════════════════
675
+
676
+ function initWS(root) {
677
+ root.querySelectorAll('[wu-ws]').forEach((el) => {
678
+ if (wsConnections.has(el)) return
679
+ const url = el.getAttribute('wu-ws')
680
+ const ws = new WebSocket(url)
681
+ wsConnections.set(el, ws)
682
+
683
+ ws.onopen = () => {
684
+ const handler = el.getAttribute('wu-on-ws-open')
685
+ if (handler) {
686
+ const state = findState(el)
687
+ if (state) evaluateExpr(handler, state)
688
+ }
689
+ }
690
+
691
+ ws.onclose = () => {
692
+ const handler = el.getAttribute('wu-on-ws-close')
693
+ if (handler) {
694
+ const state = findState(el)
695
+ if (state) evaluateExpr(handler, state)
696
+ }
697
+ }
698
+
699
+ ws.onmessage = (e) => {
700
+ const handler = el.getAttribute('wu-on-ws-message')
701
+ if (handler) {
702
+ const state = findState(el)
703
+ if (state) {
704
+ const fn = new Function('$s', 'data', `with($s) { ${handler} }`)
705
+ fn(state, e.data)
706
+ }
707
+ }
708
+ }
709
+ })
710
+ }
711
+
712
+ wu.send = function (data) {
713
+ for (const [, ws] of wsConnections) {
714
+ if (ws.readyState === WebSocket.OPEN) {
715
+ ws.send(typeof data === 'string' ? data : JSON.stringify(data))
716
+ }
717
+ }
718
+ }
719
+
720
+ // ═══════════════════════════════════════════════════════════════════
721
+ // 11. Theme (wu-theme)
722
+ // ═══════════════════════════════════════════════════════════════════
723
+
724
+ function initTheme() {
725
+ // Set initial theme from cookie
726
+ const cookie = document.cookie.match(/theme=([^;]+)/)?.[1] || 'system'
727
+ applyTheme(cookie)
728
+
729
+ // Listen for system preference changes
730
+ if (window.matchMedia) {
731
+ window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
732
+ const current = document.cookie.match(/theme=([^;]+)/)?.[1] || 'system'
733
+ if (current === 'system') applyTheme('system')
734
+ })
735
+ }
736
+ }
737
+
738
+ function applyTheme(value) {
739
+ const theme =
740
+ value === 'system'
741
+ ? window.matchMedia('(prefers-color-scheme: dark)').matches
742
+ ? 'dark'
743
+ : 'light'
744
+ : value
745
+ document.documentElement.setAttribute('data-theme', theme)
746
+ }
747
+
748
+ // Theme switching (delegated click)
749
+ document.addEventListener('click', (e) => {
750
+ const btn = e.target.closest('[wu-theme]')
751
+ if (!btn) return
752
+ const value = btn.getAttribute('wu-theme')
753
+ applyTheme(value)
754
+ document.cookie = 'theme=' + value + '; Path=/; SameSite=Lax; Max-Age=31536000'
755
+ // Sync with server (no redirect, JSON request)
756
+ fetch('/__theme/' + value, { headers: { Accept: 'application/json' } }).catch(() => {})
757
+ })
758
+
759
+ // ═══════════════════════════════════════════════════════════════════
760
+ // 12. i18n (wu-lang, wu-text-key)
761
+ // ═══════════════════════════════════════════════════════════════════
762
+
763
+ let i18nMessages = {}
764
+
765
+ function initI18n() {
766
+ const script = document.getElementById('__wfw-i18n')
767
+ if (script) {
768
+ try {
769
+ i18nMessages = JSON.parse(script.textContent)
770
+ } catch {}
771
+ }
772
+ }
773
+
774
+ // Language switching (delegated click)
775
+ document.addEventListener('click', (e) => {
776
+ const btn = e.target.closest('[wu-lang]')
777
+ if (!btn) return
778
+ const locale = btn.getAttribute('wu-lang')
779
+ switchLocale(locale)
780
+ })
781
+
782
+ async function switchLocale(locale) {
783
+ try {
784
+ const res = await fetch('/__lang/' + locale, {
785
+ headers: { Accept: 'application/json' },
786
+ })
787
+ const data = await res.json()
788
+ // locale updated server-side via data-locale attribute
789
+ i18nMessages = data.messages || {}
790
+ document.cookie = 'locale=' + locale + '; Path=/; SameSite=Lax; Max-Age=31536000'
791
+ document.body.setAttribute('data-locale', locale)
792
+ // Update all wu-text-key elements
793
+ document.querySelectorAll('[wu-text-key]').forEach((el) => {
794
+ const key = el.getAttribute('wu-text-key')
795
+ el.textContent = translate(key)
796
+ })
797
+ } catch (err) {
798
+ // eslint-disable-next-line no-console
799
+ console.warn('[wu] i18n switch failed:', err)
800
+ }
801
+ }
802
+
803
+ function translate(key) {
804
+ const msg = key.split('.').reduce((o, k) => (o && typeof o === 'object' ? o[k] : undefined), i18nMessages)
805
+ return msg != null ? String(msg) : key
806
+ }
807
+
808
+ wu.t = translate
809
+
810
+ // ═══════════════════════════════════════════════════════════════════
811
+ // 13. Flash (wu-flash)
812
+ // ═══════════════════════════════════════════════════════════════════
813
+
814
+ function initFlash() {
815
+ const script = document.getElementById('__wfw-flash')
816
+ if (!script) return
817
+ try {
818
+ const data = JSON.parse(script.textContent)
819
+ const container = document.querySelector('[wu-flash]')
820
+ if (!container) return
821
+ showFlash(container, data)
822
+ } catch {}
823
+ }
824
+
825
+ function showFlash(container, data) {
826
+ const type = data.type || 'info'
827
+ const message = data.message || data.text || String(data)
828
+ const el = document.createElement('div')
829
+ el.className = 'wu-flash-msg wu-flash-' + type
830
+ el.textContent = message
831
+ container.appendChild(el)
832
+ setTimeout(() => {
833
+ el.classList.add('wu-flash-leaving')
834
+ setTimeout(() => el.remove(), 200)
835
+ }, 3000)
836
+ }
837
+
838
+ // ═══════════════════════════════════════════════════════════════════
839
+ // 14. UI Components
840
+ // ═══════════════════════════════════════════════════════════════════
841
+
842
+ function initComponents(root) {
843
+ initModal(root)
844
+ initCollapse(root)
845
+ initTabs(root)
846
+ initDropdown(root)
847
+ initToast(root)
848
+ initModels(root)
849
+ }
850
+
851
+ // ── Modal ──
852
+
853
+ function initModal(root) {
854
+ // Toggle buttons
855
+ root.addEventListener('click', (e) => {
856
+ const btn = e.target.closest('[wu-toggle]')
857
+ if (!btn) return
858
+ const target = btn.getAttribute('wu-target')
859
+ if (!target) return
860
+ const modal = root.querySelector(target)
861
+ if (modal && modal.hasAttribute('wu-modal')) {
862
+ modal.classList.toggle('wu-open')
863
+ }
864
+ })
865
+
866
+ // Close buttons
867
+ root.addEventListener('click', (e) => {
868
+ const btn = e.target.closest('[wu-close]')
869
+ if (!btn) return
870
+ const modal = btn.closest('[wu-modal]')
871
+ if (modal) modal.classList.remove('wu-open')
872
+ })
873
+
874
+ // Click outside to close
875
+ root.addEventListener('click', (e) => {
876
+ const modal = e.target.closest('[wu-modal].wu-open')
877
+ if (!modal) return
878
+ if (e.target === modal) modal.classList.remove('wu-open')
879
+ })
880
+
881
+ // ESC to close
882
+ root.addEventListener('keydown', (e) => {
883
+ if (e.key === 'Escape') {
884
+ root.querySelectorAll('[wu-modal].wu-open').forEach((m) => m.classList.remove('wu-open'))
885
+ }
886
+ })
887
+ }
888
+
889
+ // ── Collapse ──
890
+
891
+ function initCollapse(root) {
892
+ root.addEventListener('click', (e) => {
893
+ const toggle = e.target.closest('[wu-collapse] > [wu-toggle]')
894
+ if (!toggle) return
895
+ toggle.parentElement.classList.toggle('wu-open')
896
+ })
897
+ }
898
+
899
+ // ── Tabs ──
900
+
901
+ function initTabs(root) {
902
+ root.addEventListener('click', (e) => {
903
+ const tab = e.target.closest('[wu-tabs] [wu-tab]')
904
+ if (!tab) return
905
+ const tabs = tab.closest('[wu-tabs]')
906
+ const tabName = tab.getAttribute('wu-tab')
907
+ if (!tabs || !tabName) return
908
+
909
+ tabs.querySelectorAll('[wu-tab]').forEach((t) => t.classList.remove('wu-active'))
910
+ tab.classList.add('wu-active')
911
+
912
+ tabs.querySelectorAll('[wu-panel]').forEach((p) => p.classList.remove('wu-active'))
913
+ const panel = tabs.querySelector('[wu-panel="' + tabName + '"]')
914
+ if (panel) panel.classList.add('wu-active')
915
+ })
916
+ }
917
+
918
+ // ── Dropdown ──
919
+
920
+ function initDropdown(root) {
921
+ root.addEventListener('click', (e) => {
922
+ const dd = e.target.closest('[wu-dropdown]')
923
+ if (!dd) return
924
+ const toggle = e.target.closest('[wu-toggle]')
925
+ if (toggle && dd.contains(toggle)) {
926
+ dd.classList.toggle('wu-open')
927
+ e.stopPropagation()
928
+ return
929
+ }
930
+ })
931
+
932
+ // Close dropdowns on outside click
933
+ document.addEventListener('click', (e) => {
934
+ document.querySelectorAll('[wu-dropdown].wu-open').forEach((dd) => {
935
+ if (!dd.contains(e.target)) dd.classList.remove('wu-open')
936
+ })
937
+ })
938
+ }
939
+
940
+ // ── Toast ──
941
+
942
+ let toastContainer = null
943
+
944
+ function initToast() {
945
+ toastContainer = document.querySelector('.wu-toast-container')
946
+ if (!toastContainer) {
947
+ toastContainer = document.createElement('div')
948
+ toastContainer.className = 'wu-toast-container'
949
+ document.body.appendChild(toastContainer)
950
+ }
951
+ }
952
+
953
+ wu.toast = function (message, type) {
954
+ type = type || 'info'
955
+ const el = document.createElement('div')
956
+ el.className = 'wu-toast wu-toast-' + type
957
+ el.textContent = message
958
+ toastContainer.appendChild(el)
959
+ setTimeout(() => {
960
+ el.classList.add('wu-toast-leaving')
961
+ setTimeout(() => el.remove(), 200)
962
+ }, 3000)
963
+ }
964
+
965
+ // ═══════════════════════════════════════════════════════════════════
966
+ // Expose
967
+ // ═══════════════════════════════════════════════════════════════════
968
+
969
+ window.wu = wu
970
+ window.wu_ = wu // shorthand for inline scripts
971
+
972
+ // Convenience: re-init when new content is loaded
973
+ document.addEventListener('wu:content-loaded', (e) => {
974
+ init(e.detail || document.body)
975
+ })
976
+ })()