aiplang 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,1100 @@
1
+ /**
2
+ * flux-runtime.js — FLUX Runtime v1.0
3
+ * Reactive state + SPA routing + DOM engine + query engine
4
+ * Zero dependencies. ~28KB unminified.
5
+ */
6
+
7
+ const FLUX = (() => {
8
+
9
+ // ─────────────────────────────────────────────────────────────
10
+ // ICONS
11
+ // ─────────────────────────────────────────────────────────────
12
+
13
+ const ICONS = {
14
+ bolt:'⚡',leaf:'🌱',map:'🗺',chart:'📊',lock:'🔒',star:'⭐',
15
+ heart:'❤',check:'✓',alert:'⚠',user:'👤',car:'🚗',money:'💰',
16
+ phone:'📱',shield:'🛡',fire:'🔥',rocket:'🚀',clock:'🕐',
17
+ globe:'🌐',gear:'⚙',pin:'📍',flash:'⚡',eye:'◉',tag:'◈',
18
+ plus:'+',minus:'−',edit:'✎',trash:'🗑',search:'⌕',bell:'🔔',
19
+ home:'⌂',mail:'✉',download:'↓',upload:'↑',link:'⛓',
20
+ }
21
+
22
+ // ─────────────────────────────────────────────────────────────
23
+ // REACTIVE STATE
24
+ // ─────────────────────────────────────────────────────────────
25
+
26
+ class State {
27
+ constructor() {
28
+ this._data = {}
29
+ this._computed = {}
30
+ this._watchers = {} // key → Set of callbacks
31
+ this._batching = false
32
+ this._dirty = new Set()
33
+ }
34
+
35
+ set(key, value) {
36
+ const old = this._data[key]
37
+ if (JSON.stringify(old) === JSON.stringify(value)) return
38
+ this._data[key] = value
39
+ if (this._batching) {
40
+ this._dirty.add(key)
41
+ } else {
42
+ this._notify(key)
43
+ this._recompute()
44
+ }
45
+ }
46
+
47
+ get(key) {
48
+ return this._data[key]
49
+ }
50
+
51
+ // Evaluate expression against current state
52
+ // Supports: @var, @var.field, @var.length, simple JS
53
+ eval(expr) {
54
+ if (!expr) return undefined
55
+ expr = expr.trim()
56
+
57
+ // Simple @var lookup
58
+ if (expr.startsWith('@')) {
59
+ const path = expr.slice(1).split('.')
60
+ let val = this._data[path[0]]
61
+ for (let i = 1; i < path.length; i++) {
62
+ if (val == null) return undefined
63
+ val = val[path[i]]
64
+ }
65
+ return val
66
+ }
67
+
68
+ // $computed
69
+ if (expr.startsWith('$')) {
70
+ return this._computed[expr.slice(1)]
71
+ }
72
+
73
+ // Template string with @bindings: "Hello {@user.name}"
74
+ if (expr.includes('{@') || expr.includes('{$')) {
75
+ return expr.replace(/\{[@$][^}]+\}/g, m => {
76
+ const inner = m.slice(1, -1)
77
+ const v = this.eval(inner)
78
+ return v == null ? '' : v
79
+ })
80
+ }
81
+
82
+ return expr
83
+ }
84
+
85
+ // Resolve bindings in a string: "Hello @user.name" or plain text
86
+ resolve(str) {
87
+ if (!str) return ''
88
+ if (!str.includes('@') && !str.includes('$')) return str
89
+ return str.replace(/[@$][a-zA-Z_][a-zA-Z0-9_.[\]]*/g, m => {
90
+ const v = this.eval(m)
91
+ return v == null ? '' : String(v)
92
+ })
93
+ }
94
+
95
+ defineComputed(name, expr) {
96
+ this._computed[name] = null
97
+ this._computedExprs = this._computedExprs || {}
98
+ this._computedExprs[name] = expr
99
+ this._recompute()
100
+ }
101
+
102
+ _recompute() {
103
+ if (!this._computedExprs) return
104
+ for (const [name, expr] of Object.entries(this._computedExprs)) {
105
+ try {
106
+ const fn = new Function(
107
+ ...Object.keys(this._data).map(k => '_'+k),
108
+ `try { return (${expr}) } catch(e) { return null }`
109
+ )
110
+ const val = fn(...Object.keys(this._data).map(k => this._data[k]))
111
+ if (JSON.stringify(this._computed[name]) !== JSON.stringify(val)) {
112
+ this._computed[name] = val
113
+ this._notify('$'+name)
114
+ }
115
+ } catch(e) {}
116
+ }
117
+ }
118
+
119
+ watch(key, cb) {
120
+ if (!this._watchers[key]) this._watchers[key] = new Set()
121
+ this._watchers[key].add(cb)
122
+ return () => this._watchers[key].delete(cb)
123
+ }
124
+
125
+ _notify(key) {
126
+ if (this._watchers[key]) {
127
+ for (const cb of this._watchers[key]) cb(this._data[key])
128
+ }
129
+ // Wildcard watchers
130
+ if (this._watchers['*']) {
131
+ for (const cb of this._watchers['*']) cb(key, this._data[key])
132
+ }
133
+ }
134
+
135
+ batch(fn) {
136
+ this._batching = true
137
+ fn()
138
+ this._batching = false
139
+ for (const key of this._dirty) this._notify(key)
140
+ this._dirty.clear()
141
+ this._recompute()
142
+ }
143
+ }
144
+
145
+ // ─────────────────────────────────────────────────────────────
146
+ // QUERY ENGINE
147
+ // ─────────────────────────────────────────────────────────────
148
+
149
+ class QueryEngine {
150
+ constructor(state) {
151
+ this.state = state
152
+ this.intervals = []
153
+ }
154
+
155
+ async run(q) {
156
+ // q = { method, path, target, action, body }
157
+ const path = this.state.resolve(q.path)
158
+ const opts = { method: q.method, headers: { 'Content-Type': 'application/json' } }
159
+ if (q.body) opts.body = JSON.stringify(q.body)
160
+
161
+ try {
162
+ const res = await fetch(path, opts)
163
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
164
+ const data = await res.json()
165
+ this._applyResult(data, q.target, q.action)
166
+ return data
167
+ } catch (e) {
168
+ console.warn('[FLUX] query failed:', q.method, path, e.message)
169
+ return null
170
+ }
171
+ }
172
+
173
+ _applyResult(data, target, action) {
174
+ if (!target && !action) return
175
+ const state = this.state
176
+
177
+ if (action) {
178
+ // action: "redirect /path" | "@var = $result" | "@list.push($result)"
179
+ if (action.startsWith('redirect ')) {
180
+ Router.push(action.slice(9).trim())
181
+ return
182
+ }
183
+ if (action.startsWith('reload')) {
184
+ window.location.reload()
185
+ return
186
+ }
187
+ // @list.push($result)
188
+ const pushMatch = action.match(/^@([a-zA-Z_]+)\.push\(\$result\)$/)
189
+ if (pushMatch) {
190
+ const arr = state.get(pushMatch[1]) || []
191
+ state.set(pushMatch[1], [...arr, data])
192
+ return
193
+ }
194
+ // @list.filter(...)
195
+ const filterMatch = action.match(/^@([a-zA-Z_]+)\s*=\s*@\1\.filter\((.+)\)$/)
196
+ if (filterMatch) {
197
+ const arr = state.get(filterMatch[1]) || []
198
+ try {
199
+ const fn = new Function('item', `return (${filterMatch[2]})(item)`)
200
+ state.set(filterMatch[1], arr.filter(fn))
201
+ } catch(e) {}
202
+ return
203
+ }
204
+ // @var = $result
205
+ const assignMatch = action.match(/^@([a-zA-Z_]+)\s*=\s*\$result$/)
206
+ if (assignMatch) {
207
+ state.set(assignMatch[1], data)
208
+ return
209
+ }
210
+ }
211
+
212
+ if (target) {
213
+ // target: "@varname"
214
+ if (target.startsWith('@')) {
215
+ state.set(target.slice(1), data)
216
+ }
217
+ }
218
+ }
219
+
220
+ mountAll(queries) {
221
+ for (const q of queries) {
222
+ if (q.trigger === 'mount') {
223
+ this.run(q)
224
+ } else if (q.trigger === 'interval') {
225
+ this.run(q)
226
+ const id = setInterval(() => this.run(q), q.interval)
227
+ this.intervals.push(id)
228
+ }
229
+ }
230
+ }
231
+
232
+ destroy() {
233
+ for (const id of this.intervals) clearInterval(id)
234
+ this.intervals = []
235
+ }
236
+ }
237
+
238
+ // ─────────────────────────────────────────────────────────────
239
+ // PARSER
240
+ // ─────────────────────────────────────────────────────────────
241
+
242
+ function parseFlux(src) {
243
+ // Split into pages by --- separator
244
+ const pageSections = src.split(/^---$/m)
245
+ return pageSections.map(section => parsePage(section.trim())).filter(p => p)
246
+ }
247
+
248
+ function parsePage(src) {
249
+ const lines = src.split('\n').map(l => l.trim()).filter(l => l && !l.startsWith('#'))
250
+ if (!lines.length) return null
251
+
252
+ const page = {
253
+ id: 'page',
254
+ theme: 'dark',
255
+ route: '/',
256
+ state: {}, // @var = value
257
+ computed: {}, // $var = expr
258
+ queries: [], // ~mount / ~interval
259
+ blocks: [], // nav, hero, etc.
260
+ }
261
+
262
+ for (const line of lines) {
263
+ // Meta: %id theme /route
264
+ if (line.startsWith('%')) {
265
+ const parts = line.slice(1).trim().split(/\s+/)
266
+ page.id = parts[0] || 'page'
267
+ page.theme = parts[1] || 'dark'
268
+ page.route = parts[2] || '/'
269
+ continue
270
+ }
271
+
272
+ // State: @var = value
273
+ if (line.startsWith('@')) {
274
+ const eq = line.indexOf('=')
275
+ if (eq !== -1) {
276
+ const key = line.slice(1, eq).trim()
277
+ const val = line.slice(eq+1).trim()
278
+ try { page.state[key] = JSON.parse(val) }
279
+ catch { page.state[key] = val }
280
+ }
281
+ continue
282
+ }
283
+
284
+ // Computed: $var = expr
285
+ if (line.startsWith('$')) {
286
+ const eq = line.indexOf('=')
287
+ if (eq !== -1) {
288
+ const key = line.slice(1, eq).trim()
289
+ const expr = line.slice(eq+1).trim()
290
+ page.computed[key] = expr
291
+ }
292
+ continue
293
+ }
294
+
295
+ // Lifecycle: ~mount GET /path => @var OR ~mount GET /path @var
296
+ if (line.startsWith('~')) {
297
+ const q = parseQuery(line.slice(1).trim())
298
+ if (q) page.queries.push(q)
299
+ continue
300
+ }
301
+
302
+ // Block with state binding: table @var { ... }
303
+ const tableMatch = line.match(/^table\s+(@[a-zA-Z_$][a-zA-Z0-9_.]*)\s*\{(.*)/)
304
+ if (tableMatch) {
305
+ const content = tableMatch[2].endsWith('}')
306
+ ? tableMatch[2].slice(0, -1)
307
+ : tableMatch[2]
308
+ page.blocks.push({
309
+ kind: 'table',
310
+ binding: tableMatch[1],
311
+ cols: parseTableCols(content),
312
+ empty: parseTableEmpty(content),
313
+ })
314
+ continue
315
+ }
316
+
317
+ // list @var { ... }
318
+ const listMatch = line.match(/^list\s+(@[a-zA-Z_$][a-zA-Z0-9_.]*)\s*\{(.*)/)
319
+ if (listMatch) {
320
+ const content = listMatch[2].endsWith('}') ? listMatch[2].slice(0,-1) : listMatch[2]
321
+ page.blocks.push({
322
+ kind: 'list',
323
+ binding: listMatch[1],
324
+ fields: parseTableCols(content),
325
+ })
326
+ continue
327
+ }
328
+
329
+ // form METHOD /path => action { ... }
330
+ const formMatch = line.match(/^form\s+(GET|POST|PUT|PATCH|DELETE)\s+(\S+)(?:\s*=>\s*([^{]+))?\s*\{(.*)/)
331
+ if (formMatch) {
332
+ const content = formMatch[4].endsWith('}') ? formMatch[4].slice(0,-1) : formMatch[4]
333
+ page.blocks.push({
334
+ kind: 'form',
335
+ method: formMatch[1],
336
+ path: formMatch[2],
337
+ action: (formMatch[3]||'').trim(),
338
+ fields: parseFormFields(content),
339
+ })
340
+ continue
341
+ }
342
+
343
+ // if @condition { block }
344
+ const ifMatch = line.match(/^if\s+(!?[@$][a-zA-Z_0-9.]+)\s*\{(.*)/)
345
+ if (ifMatch) {
346
+ const content = ifMatch[2].endsWith('}') ? ifMatch[2].slice(0,-1) : ifMatch[2]
347
+ page.blocks.push({
348
+ kind: 'if',
349
+ condition: ifMatch[1],
350
+ inner: content.trim(),
351
+ })
352
+ continue
353
+ }
354
+
355
+ // Regular block: nav{...} hero{...} etc
356
+ const bi = line.indexOf('{')
357
+ if (bi !== -1) {
358
+ const head = line.slice(0, bi).trim()
359
+ const body = line.slice(bi+1, line.lastIndexOf('}')).trim()
360
+ const m = head.match(/^([a-z]+)(\d+)?$/)
361
+ const kind = m ? m[1] : head
362
+ const cols = m && m[2] ? parseInt(m[2]) : 3
363
+ const items = parseItems(body)
364
+ page.blocks.push({ kind, cols, items })
365
+ }
366
+ }
367
+
368
+ return page
369
+ }
370
+
371
+ function parseQuery(s) {
372
+ // "mount GET /api/users => @users"
373
+ // "mount GET /api/users @users"
374
+ // "interval 5000 GET /api/stats => @stats"
375
+ const parts = s.split(/\s+/)
376
+ if (parts[0] === 'mount') {
377
+ const arrowIdx = parts.indexOf('=>')
378
+ if (arrowIdx !== -1) {
379
+ return {
380
+ trigger: 'mount',
381
+ method: parts[1],
382
+ path: parts[2],
383
+ target: null,
384
+ action: parts.slice(arrowIdx+1).join(' ').trim(),
385
+ }
386
+ }
387
+ return { trigger:'mount', method:parts[1], path:parts[2], target:parts[3], action:null }
388
+ }
389
+ if (parts[0] === 'interval') {
390
+ const arrowIdx = parts.indexOf('=>')
391
+ if (arrowIdx !== -1) {
392
+ return {
393
+ trigger:'interval', interval:parseInt(parts[1]),
394
+ method:parts[2], path:parts[3],
395
+ target:null, action:parts.slice(arrowIdx+1).join(' ').trim(),
396
+ }
397
+ }
398
+ return { trigger:'interval', interval:parseInt(parts[1]), method:parts[2], path:parts[3], target:parts[4], action:null }
399
+ }
400
+ return null
401
+ }
402
+
403
+ function parseItems(body) {
404
+ return body.split('|').map(item =>
405
+ item.trim().split('>').map(f => {
406
+ f = f.trim()
407
+ if (f.startsWith('/')) {
408
+ const [path, label] = f.split(':')
409
+ return { isLink:true, path:path.trim(), label:(label||'').trim() }
410
+ }
411
+ return { isLink:false, text:f }
412
+ })
413
+ ).filter(i => i.length > 0 && (i[0].text || i[0].isLink))
414
+ }
415
+
416
+ function parseTableCols(s) {
417
+ // "Name:name | Email:email | Status:status"
418
+ return s.split('|')
419
+ .map(c => {
420
+ c = c.trim()
421
+ if (c.startsWith('empty:')) return null
422
+ const [label, key] = c.split(':').map(x => x.trim())
423
+ return key ? { label, key } : null
424
+ })
425
+ .filter(Boolean)
426
+ }
427
+
428
+ function parseTableEmpty(s) {
429
+ const m = s.match(/empty:\s*([^|]+)/)
430
+ return m ? m[1].trim() : 'No data.'
431
+ }
432
+
433
+ function parseFormFields(s) {
434
+ // "Name : text : placeholder | Email : email : hint"
435
+ return s.split('|').map(f => {
436
+ const parts = f.split(':').map(p => p.trim())
437
+ return {
438
+ label: parts[0],
439
+ type: parts[1] || 'text',
440
+ placeholder: parts[2] || '',
441
+ name: (parts[0]||'').toLowerCase().replace(/\s+/g,'_'),
442
+ }
443
+ }).filter(f => f.label)
444
+ }
445
+
446
+ // ─────────────────────────────────────────────────────────────
447
+ // DOM RENDERER
448
+ // ─────────────────────────────────────────────────────────────
449
+
450
+ class Renderer {
451
+ constructor(state, container) {
452
+ this.state = state
453
+ this.container = container
454
+ this._cleanups = []
455
+ }
456
+
457
+ render(page) {
458
+ this.container.innerHTML = ''
459
+ this.container.className = `flux-root flux-theme-${page.theme}`
460
+ for (const block of page.blocks) {
461
+ const el = this.renderBlock(block)
462
+ if (el) this.container.appendChild(el)
463
+ }
464
+ }
465
+
466
+ renderBlock(block) {
467
+ switch(block.kind) {
468
+ case 'nav': return this.renderNav(block)
469
+ case 'hero': return this.renderHero(block)
470
+ case 'stats': return this.renderStats(block)
471
+ case 'row': return this.renderRow(block)
472
+ case 'sect': return this.renderSect(block)
473
+ case 'foot': return this.renderFoot(block)
474
+ case 'table': return this.renderTable(block)
475
+ case 'list': return this.renderList(block)
476
+ case 'form': return this.renderForm(block)
477
+ case 'if': return this.renderIf(block)
478
+ case 'alert': return this.renderAlert(block)
479
+ default: return null
480
+ }
481
+ }
482
+
483
+ // Resolve bindings in text
484
+ t(str) { return this.state.resolve(str) }
485
+
486
+ // Create element helper
487
+ el(tag, cls, inner) {
488
+ const e = document.createElement(tag)
489
+ if (cls) e.className = cls
490
+ if (inner) e.innerHTML = inner
491
+ return e
492
+ }
493
+
494
+ renderNav(block) {
495
+ const nav = this.el('nav', 'fx-nav')
496
+ if (!block.items?.[0]) return nav
497
+ const item = block.items[0]
498
+ let ls = 0
499
+ if (!item[0]?.isLink) {
500
+ const brand = this.el('span', 'fx-brand')
501
+ brand.textContent = this.t(item[0].text)
502
+ nav.appendChild(brand)
503
+ ls = 1
504
+ }
505
+ const links = this.el('div', 'fx-nav-links')
506
+ for (const f of item.slice(ls)) {
507
+ if (f.isLink) {
508
+ const a = document.createElement('a')
509
+ a.className = 'fx-nav-link'
510
+ a.href = f.path
511
+ a.textContent = f.label
512
+ a.addEventListener('click', e => { e.preventDefault(); Router.push(f.path) })
513
+ links.appendChild(a)
514
+ }
515
+ }
516
+ nav.appendChild(links)
517
+ return nav
518
+ }
519
+
520
+ renderHero(block) {
521
+ const sec = this.el('section', 'fx-hero')
522
+ const inner = this.el('div', 'fx-hero-inner')
523
+ sec.appendChild(inner)
524
+ let h1 = false
525
+ for (const item of block.items) {
526
+ for (const f of item) {
527
+ if (f.isLink) {
528
+ const a = this.el('a', 'fx-cta')
529
+ a.href = f.path
530
+ a.textContent = f.label
531
+ a.addEventListener('click', e => { e.preventDefault(); Router.push(f.path) })
532
+ inner.appendChild(a)
533
+ } else if (!h1) {
534
+ const el = this.el('h1', 'fx-title')
535
+ el.textContent = this.t(f.text)
536
+ inner.appendChild(el)
537
+ h1 = true
538
+ // Reactive bind
539
+ if (f.text.includes('@') || f.text.includes('$')) {
540
+ const orig = f.text
541
+ const stop = this.state.watch('*', () => { el.textContent = this.t(orig) })
542
+ this._cleanups.push(stop)
543
+ }
544
+ } else {
545
+ const el = this.el('p', 'fx-sub')
546
+ el.textContent = this.t(f.text)
547
+ inner.appendChild(el)
548
+ }
549
+ }
550
+ }
551
+ return sec
552
+ }
553
+
554
+ renderStats(block) {
555
+ const wrap = this.el('div', 'fx-stats')
556
+ for (const item of block.items) {
557
+ const raw = item[0]?.text || ''
558
+ const [val, lbl] = raw.split(':')
559
+ const cell = this.el('div', 'fx-stat')
560
+ const valEl = this.el('div', 'fx-stat-val')
561
+ const lblEl = this.el('div', 'fx-stat-lbl')
562
+ valEl.textContent = this.t(val?.trim())
563
+ lblEl.textContent = this.t(lbl?.trim())
564
+ cell.appendChild(valEl)
565
+ cell.appendChild(lblEl)
566
+ wrap.appendChild(cell)
567
+ // Reactive
568
+ if (raw.includes('@') || raw.includes('$')) {
569
+ const origVal = val?.trim(), origLbl = lbl?.trim()
570
+ const stop = this.state.watch('*', () => {
571
+ valEl.textContent = this.t(origVal)
572
+ lblEl.textContent = this.t(origLbl)
573
+ })
574
+ this._cleanups.push(stop)
575
+ }
576
+ }
577
+ return wrap
578
+ }
579
+
580
+ renderRow(block) {
581
+ const grid = this.el('div', `fx-grid fx-grid-${block.cols || 3}`)
582
+ for (const item of block.items) {
583
+ const card = this.el('div', 'fx-card')
584
+ item.forEach((f, fi) => {
585
+ if (f.isLink) {
586
+ const a = this.el('a', 'fx-card-link')
587
+ a.href = f.path
588
+ a.textContent = `${f.label} →`
589
+ a.addEventListener('click', e => { e.preventDefault(); Router.push(f.path) })
590
+ card.appendChild(a)
591
+ } else if (fi === 0) {
592
+ const ico = this.el('div', 'fx-icon')
593
+ ico.textContent = ICONS[f.text] || f.text
594
+ card.appendChild(ico)
595
+ } else if (fi === 1) {
596
+ const h = this.el('h3', 'fx-card-title')
597
+ h.textContent = this.t(f.text)
598
+ card.appendChild(h)
599
+ } else {
600
+ const p = this.el('p', 'fx-card-body')
601
+ p.textContent = this.t(f.text)
602
+ card.appendChild(p)
603
+ }
604
+ })
605
+ grid.appendChild(card)
606
+ }
607
+ return grid
608
+ }
609
+
610
+ renderSect(block) {
611
+ const sec = this.el('section', 'fx-sect')
612
+ block.items.forEach((item, ii) => {
613
+ for (const f of item) {
614
+ if (f.isLink) {
615
+ const a = this.el('a', 'fx-sect-link')
616
+ a.href = f.path; a.textContent = f.label
617
+ a.addEventListener('click', e => { e.preventDefault(); Router.push(f.path) })
618
+ sec.appendChild(a)
619
+ } else if (ii === 0) {
620
+ const h = this.el('h2', 'fx-sect-title')
621
+ h.textContent = this.t(f.text)
622
+ sec.appendChild(h)
623
+ } else {
624
+ const p = this.el('p', 'fx-sect-body')
625
+ p.textContent = this.t(f.text)
626
+ sec.appendChild(p)
627
+ }
628
+ }
629
+ })
630
+ return sec
631
+ }
632
+
633
+ renderFoot(block) {
634
+ const foot = this.el('footer', 'fx-footer')
635
+ for (const item of block.items) {
636
+ for (const f of item) {
637
+ if (f.isLink) {
638
+ const a = this.el('a', 'fx-footer-link')
639
+ a.href = f.path; a.textContent = f.label
640
+ foot.appendChild(a)
641
+ } else {
642
+ const p = this.el('p', 'fx-footer-text')
643
+ p.textContent = this.t(f.text)
644
+ foot.appendChild(p)
645
+ }
646
+ }
647
+ }
648
+ return foot
649
+ }
650
+
651
+ renderTable(block) {
652
+ const wrap = this.el('div', 'fx-table-wrap')
653
+ const table = document.createElement('table')
654
+ table.className = 'fx-table'
655
+ const thead = document.createElement('thead')
656
+ const tr = document.createElement('tr')
657
+ tr.className = 'fx-thead-row'
658
+ for (const col of block.cols) {
659
+ const th = document.createElement('th')
660
+ th.className = 'fx-th'
661
+ th.textContent = col.label
662
+ tr.appendChild(th)
663
+ }
664
+ thead.appendChild(tr)
665
+ table.appendChild(thead)
666
+ const tbody = document.createElement('tbody')
667
+ tbody.className = 'fx-tbody'
668
+ table.appendChild(tbody)
669
+ wrap.appendChild(table)
670
+
671
+ const render = () => {
672
+ let rows = this.state.eval(block.binding)
673
+ if (!Array.isArray(rows)) rows = []
674
+ tbody.innerHTML = ''
675
+ if (rows.length === 0) {
676
+ const tr = document.createElement('tr')
677
+ const td = document.createElement('td')
678
+ td.colSpan = block.cols.length
679
+ td.className = 'fx-td-empty'
680
+ td.textContent = block.empty || 'No data.'
681
+ tr.appendChild(td)
682
+ tbody.appendChild(tr)
683
+ return
684
+ }
685
+ for (const row of rows) {
686
+ const tr = document.createElement('tr')
687
+ tr.className = 'fx-tr'
688
+ for (const col of block.cols) {
689
+ const td = document.createElement('td')
690
+ td.className = 'fx-td'
691
+ td.textContent = row[col.key] != null ? row[col.key] : ''
692
+ tr.appendChild(td)
693
+ }
694
+ tbody.appendChild(tr)
695
+ }
696
+ }
697
+
698
+ render()
699
+ const key = block.binding.slice(1)
700
+ const stop = this.state.watch(key, render)
701
+ this._cleanups.push(stop)
702
+ // Also watch computed
703
+ if (block.binding.startsWith('$')) {
704
+ const stop2 = this.state.watch(block.binding, render)
705
+ this._cleanups.push(stop2)
706
+ }
707
+
708
+ return wrap
709
+ }
710
+
711
+ renderList(block) {
712
+ const wrap = this.el('div', 'fx-list-wrap')
713
+
714
+ const render = () => {
715
+ let items = this.state.eval(block.binding)
716
+ if (!Array.isArray(items)) items = []
717
+ wrap.innerHTML = ''
718
+ for (const item of items) {
719
+ const card = this.el('div', 'fx-list-item')
720
+ for (const f of block.fields) {
721
+ if (f.isLink) {
722
+ const href = f.path.replace(/\{([^}]+)\}/g, (_, k) => item[k] || '')
723
+ const a = this.el('a', 'fx-list-link')
724
+ a.href = href; a.textContent = f.label
725
+ a.addEventListener('click', e => { e.preventDefault(); Router.push(href) })
726
+ card.appendChild(a)
727
+ } else {
728
+ const p = this.el('p', 'fx-list-field')
729
+ p.textContent = item[f.key] || ''
730
+ card.appendChild(p)
731
+ }
732
+ }
733
+ wrap.appendChild(card)
734
+ }
735
+ }
736
+
737
+ render()
738
+ const key = block.binding.slice(1)
739
+ const stop = this.state.watch(key, render)
740
+ this._cleanups.push(stop)
741
+ return wrap
742
+ }
743
+
744
+ renderForm(block) {
745
+ const wrap = this.el('div', 'fx-form-wrap')
746
+ const form = document.createElement('form')
747
+ form.className = 'fx-form'
748
+
749
+ for (const f of block.fields) {
750
+ const fieldWrap = this.el('div', 'fx-field')
751
+ const label = this.el('label', 'fx-label')
752
+ label.textContent = f.label
753
+ fieldWrap.appendChild(label)
754
+
755
+ if (f.type === 'select' && f.placeholder) {
756
+ const sel = document.createElement('select')
757
+ sel.className = 'fx-input'
758
+ sel.name = f.name
759
+ for (const opt of f.placeholder.split(',')) {
760
+ const o = document.createElement('option')
761
+ o.value = opt.trim(); o.textContent = opt.trim()
762
+ sel.appendChild(o)
763
+ }
764
+ fieldWrap.appendChild(sel)
765
+ } else {
766
+ const inp = document.createElement('input')
767
+ inp.className = 'fx-input'
768
+ inp.type = f.type
769
+ inp.name = f.name
770
+ inp.placeholder = f.placeholder
771
+ if (f.type !== 'password') inp.autocomplete = 'on'
772
+ fieldWrap.appendChild(inp)
773
+ }
774
+ form.appendChild(fieldWrap)
775
+ }
776
+
777
+ // Error/success message area
778
+ const msg = this.el('div', 'fx-form-msg')
779
+ form.appendChild(msg)
780
+
781
+ const btn = document.createElement('button')
782
+ btn.type = 'submit'
783
+ btn.className = 'fx-btn'
784
+ btn.textContent = 'Submit'
785
+ form.appendChild(btn)
786
+
787
+ form.addEventListener('submit', async e => {
788
+ e.preventDefault()
789
+ btn.disabled = true
790
+ btn.textContent = 'Loading...'
791
+ msg.textContent = ''
792
+
793
+ const data = {}
794
+ for (const inp of form.querySelectorAll('input,select')) {
795
+ data[inp.name] = inp.value
796
+ }
797
+
798
+ const path = this.state.resolve(block.path)
799
+ try {
800
+ const res = await fetch(path, {
801
+ method: block.method,
802
+ headers: { 'Content-Type': 'application/json' },
803
+ body: JSON.stringify(data),
804
+ })
805
+ const result = await res.json()
806
+ if (!res.ok) {
807
+ msg.className = 'fx-form-msg fx-form-err'
808
+ msg.textContent = result.message || result.error || 'Error. Try again.'
809
+ btn.disabled = false
810
+ btn.textContent = 'Submit'
811
+ return
812
+ }
813
+ if (block.action) {
814
+ const qe = new QueryEngine(this.state)
815
+ qe._applyResult(result, null, block.action)
816
+ }
817
+ msg.className = 'fx-form-msg fx-form-ok'
818
+ msg.textContent = 'Done!'
819
+ } catch(err) {
820
+ msg.className = 'fx-form-msg fx-form-err'
821
+ msg.textContent = 'Network error. Try again.'
822
+ }
823
+ btn.disabled = false
824
+ btn.textContent = 'Submit'
825
+ })
826
+
827
+ wrap.appendChild(form)
828
+ return wrap
829
+ }
830
+
831
+ renderIf(block) {
832
+ const wrap = this.el('div', 'fx-if-wrap')
833
+
834
+ const evalCond = () => {
835
+ const cond = block.condition
836
+ const neg = cond.startsWith('!')
837
+ const expr = neg ? cond.slice(1) : cond
838
+ const val = this.state.eval(expr)
839
+ const truthy = Array.isArray(val) ? val.length > 0 : !!val
840
+ return neg ? !truthy : truthy
841
+ }
842
+
843
+ const render = () => {
844
+ wrap.innerHTML = ''
845
+ if (evalCond()) {
846
+ // Parse and render inner block
847
+ const innerLine = block.inner
848
+ const bi = innerLine.indexOf('{')
849
+ if (bi !== -1) {
850
+ const head = innerLine.slice(0,bi).trim()
851
+ const body = innerLine.slice(bi+1, innerLine.lastIndexOf('}')).trim()
852
+ const m = head.match(/^([a-z]+)(\d+)?$/)
853
+ const innerBlock = {
854
+ kind: m ? m[1] : head,
855
+ cols: m && m[2] ? parseInt(m[2]) : 3,
856
+ items: parseItems(body),
857
+ }
858
+ const el = this.renderBlock(innerBlock)
859
+ if (el) wrap.appendChild(el)
860
+ }
861
+ }
862
+ }
863
+
864
+ render()
865
+
866
+ // Watch all @vars in condition
867
+ const matches = block.condition.match(/[@$][a-zA-Z_][a-zA-Z0-9_.]*/g) || []
868
+ for (const m of matches) {
869
+ const key = m.startsWith('@') || m.startsWith('$') ? m.slice(1) : m
870
+ const stop = this.state.watch(key, render)
871
+ this._cleanups.push(stop)
872
+ }
873
+
874
+ return wrap
875
+ }
876
+
877
+ renderAlert(block) {
878
+ const div = this.el('div', 'fx-alert')
879
+ if (block.items?.[0]?.[0]) {
880
+ div.textContent = this.t(block.items[0][0].text)
881
+ }
882
+ return div
883
+ }
884
+
885
+ destroy() {
886
+ for (const fn of this._cleanups) fn()
887
+ this._cleanups = []
888
+ }
889
+ }
890
+
891
+ // ─────────────────────────────────────────────────────────────
892
+ // ROUTER
893
+ // ─────────────────────────────────────────────────────────────
894
+
895
+ const Router = {
896
+ pages: [],
897
+ container: null,
898
+ currentRenderer: null,
899
+ currentQE: null,
900
+
901
+ init(pages, container) {
902
+ this.pages = pages
903
+ this.container = container
904
+ window.addEventListener('popstate', () => this._render(location.pathname))
905
+ this._render(location.pathname)
906
+ },
907
+
908
+ push(path) {
909
+ if (path === location.pathname) return
910
+ history.pushState({}, '', path)
911
+ this._render(path)
912
+ },
913
+
914
+ _render(path) {
915
+ // Match route
916
+ let page = this.pages.find(p => p.route === path)
917
+ if (!page) {
918
+ // Try prefix match
919
+ page = this.pages.find(p => path.startsWith(p.route) && p.route !== '/')
920
+ }
921
+ if (!page) page = this.pages.find(p => p.route === '/')
922
+ if (!page) return
923
+
924
+ // Destroy previous
925
+ if (this.currentRenderer) this.currentRenderer.destroy()
926
+ if (this.currentQE) this.currentQE.destroy()
927
+
928
+ // New state for this page
929
+ const state = new State()
930
+ for (const [k, v] of Object.entries(page.state || {})) state.set(k, v)
931
+ for (const [k, expr] of Object.entries(page.computed || {})) {
932
+ state.defineComputed(k, expr.replace(/@([a-zA-Z_]+)/g, '_$1').replace(/\$([a-zA-Z_]+)/g, 'computed.$1'))
933
+ }
934
+
935
+ // Render
936
+ const renderer = new Renderer(state, this.container)
937
+ renderer.render(page)
938
+ this.currentRenderer = renderer
939
+
940
+ // Queries
941
+ const qe = new QueryEngine(state)
942
+ qe.mountAll(page.queries)
943
+ this.currentQE = qe
944
+
945
+ // Update page title
946
+ document.title = page.id.charAt(0).toUpperCase() + page.id.slice(1)
947
+ }
948
+ }
949
+
950
+ // ─────────────────────────────────────────────────────────────
951
+ // CSS
952
+ // ─────────────────────────────────────────────────────────────
953
+
954
+ const CSS = `
955
+ *,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
956
+ html{scroll-behavior:smooth}
957
+ body{font-family:-apple-system,'Segoe UI',system-ui,sans-serif;-webkit-font-smoothing:antialiased}
958
+ a{text-decoration:none;color:inherit}
959
+ input,button,select{font-family:inherit}
960
+ .flux-root{min-height:100vh}
961
+ .flux-theme-dark{background:#030712;color:#f1f5f9}
962
+ .flux-theme-light{background:#fff;color:#0f172a}
963
+ .flux-theme-acid{background:#000;color:#a3e635}
964
+ .fx-nav{display:flex;align-items:center;justify-content:space-between;padding:1rem 2.5rem;position:sticky;top:0;z-index:50;backdrop-filter:blur(12px)}
965
+ .flux-theme-dark .fx-nav{border-bottom:1px solid #1e293b;background:rgba(3,7,18,.85)}
966
+ .flux-theme-light .fx-nav{border-bottom:1px solid #e2e8f0;background:rgba(255,255,255,.85)}
967
+ .flux-theme-acid .fx-nav{border-bottom:1px solid #1a2e05;background:rgba(0,0,0,.9)}
968
+ .fx-brand{font-size:1.25rem;font-weight:800;letter-spacing:-.03em}
969
+ .fx-nav-links{display:flex;align-items:center;gap:1.75rem}
970
+ .fx-nav-link{font-size:.875rem;font-weight:500;opacity:.65;transition:opacity .15s;cursor:pointer}
971
+ .fx-nav-link:hover{opacity:1}
972
+ .flux-theme-dark .fx-nav-link{color:#cbd5e1}
973
+ .flux-theme-light .fx-nav-link{color:#475569}
974
+ .flux-theme-acid .fx-nav-link{color:#86efac}
975
+ .fx-hero{display:flex;align-items:center;justify-content:center;min-height:92vh;padding:4rem 1.5rem}
976
+ .fx-hero-inner{max-width:56rem;text-align:center;display:flex;flex-direction:column;align-items:center;gap:1.5rem}
977
+ .fx-title{font-size:clamp(2.5rem,8vw,5.5rem);font-weight:900;letter-spacing:-.04em;line-height:1}
978
+ .fx-sub{font-size:clamp(1rem,2vw,1.25rem);line-height:1.75;max-width:40rem}
979
+ .flux-theme-dark .fx-sub{color:#94a3b8}
980
+ .flux-theme-light .fx-sub{color:#475569}
981
+ .fx-cta{display:inline-flex;align-items:center;padding:.875rem 2.5rem;border-radius:.75rem;font-weight:700;font-size:1rem;letter-spacing:-.01em;transition:transform .15s,box-shadow .15s;margin:.25rem;cursor:pointer}
982
+ .fx-cta:hover{transform:translateY(-1px)}
983
+ .flux-theme-dark .fx-cta{background:#2563eb;color:#fff;box-shadow:0 8px 24px rgba(37,99,235,.35)}
984
+ .flux-theme-light .fx-cta{background:#2563eb;color:#fff}
985
+ .flux-theme-acid .fx-cta{background:#a3e635;color:#000;font-weight:800}
986
+ .fx-stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:3rem;padding:5rem 2.5rem;text-align:center}
987
+ .fx-stat-val{font-size:clamp(2.5rem,5vw,4rem);font-weight:900;letter-spacing:-.04em;line-height:1}
988
+ .fx-stat-lbl{font-size:.75rem;font-weight:600;text-transform:uppercase;letter-spacing:.1em;margin-top:.5rem}
989
+ .flux-theme-dark .fx-stat-lbl{color:#64748b}
990
+ .flux-theme-light .fx-stat-lbl{color:#94a3b8}
991
+ .fx-grid{display:grid;gap:1.25rem;padding:1rem 2.5rem 5rem}
992
+ .fx-grid-2{grid-template-columns:repeat(auto-fit,minmax(280px,1fr))}
993
+ .fx-grid-3{grid-template-columns:repeat(auto-fit,minmax(240px,1fr))}
994
+ .fx-grid-4{grid-template-columns:repeat(auto-fit,minmax(200px,1fr))}
995
+ .fx-card{border-radius:1rem;padding:1.75rem;transition:transform .2s,box-shadow .2s}
996
+ .fx-card:hover{transform:translateY(-2px)}
997
+ .flux-theme-dark .fx-card{background:#0f172a;border:1px solid #1e293b}
998
+ .flux-theme-light .fx-card{background:#f8fafc;border:1px solid #e2e8f0}
999
+ .flux-theme-acid .fx-card{background:#0a0f00;border:1px solid #1a2e05}
1000
+ .flux-theme-dark .fx-card:hover{box-shadow:0 20px 40px rgba(0,0,0,.5)}
1001
+ .flux-theme-light .fx-card:hover{box-shadow:0 20px 40px rgba(0,0,0,.08)}
1002
+ .fx-icon{font-size:2rem;margin-bottom:1rem}
1003
+ .fx-card-title{font-size:1.0625rem;font-weight:700;letter-spacing:-.02em;margin-bottom:.5rem}
1004
+ .fx-card-body{font-size:.875rem;line-height:1.65}
1005
+ .flux-theme-dark .fx-card-body{color:#64748b}
1006
+ .flux-theme-light .fx-card-body{color:#475569}
1007
+ .fx-card-link{font-size:.8125rem;font-weight:600;display:inline-block;margin-top:1rem;opacity:.6;transition:opacity .15s}
1008
+ .fx-card-link:hover{opacity:1}
1009
+ .fx-sect{padding:5rem 2.5rem}
1010
+ .fx-sect-title{font-size:clamp(1.75rem,4vw,3rem);font-weight:800;letter-spacing:-.04em;margin-bottom:1.5rem;text-align:center}
1011
+ .fx-sect-body{font-size:1rem;line-height:1.75;text-align:center;max-width:48rem;margin:0 auto}
1012
+ .flux-theme-dark .fx-sect-body{color:#64748b}
1013
+ .fx-form-wrap{padding:3rem 2.5rem;display:flex;justify-content:center}
1014
+ .fx-form{width:100%;max-width:28rem;border-radius:1.25rem;padding:2.5rem}
1015
+ .flux-theme-dark .fx-form{background:#0f172a;border:1px solid #1e293b}
1016
+ .flux-theme-light .fx-form{background:#f8fafc;border:1px solid #e2e8f0}
1017
+ .fx-field{margin-bottom:1.25rem}
1018
+ .fx-label{display:block;font-size:.8125rem;font-weight:600;margin-bottom:.5rem}
1019
+ .flux-theme-dark .fx-label{color:#94a3b8}
1020
+ .flux-theme-light .fx-label{color:#475569}
1021
+ .fx-input{width:100%;padding:.75rem 1rem;border-radius:.625rem;font-size:.9375rem;outline:none;transition:box-shadow .15s;background:transparent}
1022
+ .fx-input:focus{box-shadow:0 0 0 3px rgba(37,99,235,.35)}
1023
+ .flux-theme-dark .fx-input{background:#020617;border:1px solid #1e293b;color:#f1f5f9}
1024
+ .flux-theme-dark .fx-input::placeholder{color:#334155}
1025
+ .flux-theme-light .fx-input{background:#fff;border:1px solid #cbd5e1;color:#0f172a}
1026
+ .flux-theme-acid .fx-input{background:#000;border:1px solid #1a2e05;color:#a3e635}
1027
+ .fx-btn{width:100%;padding:.875rem 1.5rem;border:none;border-radius:.625rem;font-size:.9375rem;font-weight:700;cursor:pointer;margin-top:.5rem;transition:transform .15s,opacity .15s;letter-spacing:-.01em}
1028
+ .fx-btn:hover{transform:translateY(-1px)}
1029
+ .fx-btn:disabled{opacity:.5;cursor:not-allowed;transform:none}
1030
+ .flux-theme-dark .fx-btn{background:#2563eb;color:#fff;box-shadow:0 4px 14px rgba(37,99,235,.4)}
1031
+ .flux-theme-light .fx-btn{background:#2563eb;color:#fff}
1032
+ .flux-theme-acid .fx-btn{background:#a3e635;color:#000;font-weight:800}
1033
+ .fx-form-msg{font-size:.8125rem;padding:.5rem 0;min-height:1.5rem;text-align:center}
1034
+ .fx-form-err{color:#f87171}
1035
+ .fx-form-ok{color:#4ade80}
1036
+ .fx-table-wrap{overflow-x:auto;padding:0 2.5rem 4rem}
1037
+ .fx-table{width:100%;border-collapse:collapse;font-size:.875rem}
1038
+ .fx-th{text-align:left;padding:.875rem 1.25rem;font-size:.75rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em}
1039
+ .flux-theme-dark .fx-th{color:#475569;border-bottom:1px solid #1e293b}
1040
+ .flux-theme-light .fx-th{color:#94a3b8;border-bottom:1px solid #e2e8f0}
1041
+ .fx-tr{transition:background .1s}
1042
+ .fx-td{padding:.875rem 1.25rem}
1043
+ .flux-theme-dark .fx-tr:hover{background:#0f172a}
1044
+ .flux-theme-light .fx-tr:hover{background:#f8fafc}
1045
+ .flux-theme-dark .fx-td{border-bottom:1px solid #0f172a}
1046
+ .flux-theme-light .fx-td{border-bottom:1px solid #f1f5f9}
1047
+ .fx-td-empty{padding:2rem 1.25rem;text-align:center;opacity:.4}
1048
+ .fx-list-wrap{padding:1rem 2.5rem 4rem;display:flex;flex-direction:column;gap:.75rem}
1049
+ .fx-list-item{border-radius:.75rem;padding:1.25rem 1.5rem}
1050
+ .flux-theme-dark .fx-list-item{background:#0f172a;border:1px solid #1e293b}
1051
+ .fx-list-field{font-size:.9375rem;line-height:1.5}
1052
+ .fx-list-link{font-size:.8125rem;font-weight:600;opacity:.6;transition:opacity .15s}
1053
+ .fx-list-link:hover{opacity:1}
1054
+ .fx-alert{padding:1rem 2.5rem;font-size:.9375rem;font-weight:500;border-radius:.75rem;margin:1rem 2.5rem}
1055
+ .flux-theme-dark .fx-alert{background:rgba(239,68,68,.1);border:1px solid rgba(239,68,68,.3);color:#fca5a5}
1056
+ .fx-if-wrap{display:contents}
1057
+ .fx-footer{padding:3rem 2.5rem;text-align:center}
1058
+ .flux-theme-dark .fx-footer{border-top:1px solid #1e293b}
1059
+ .flux-theme-light .fx-footer{border-top:1px solid #e2e8f0}
1060
+ .fx-footer-text{font-size:.8125rem}
1061
+ .flux-theme-dark .fx-footer-text{color:#334155}
1062
+ .fx-footer-link{font-size:.8125rem;margin:0 .75rem;opacity:.5;transition:opacity .15s}
1063
+ .fx-footer-link:hover{opacity:1}
1064
+ `
1065
+
1066
+ // ─────────────────────────────────────────────────────────────
1067
+ // BOOTSTRAP
1068
+ // ─────────────────────────────────────────────────────────────
1069
+
1070
+ function boot(src, container) {
1071
+ // Inject CSS once
1072
+ if (!document.getElementById('flux-css')) {
1073
+ const style = document.createElement('style')
1074
+ style.id = 'flux-css'
1075
+ style.textContent = CSS
1076
+ document.head.appendChild(style)
1077
+ }
1078
+
1079
+ const pages = parseFlux(src)
1080
+ if (!pages.length) {
1081
+ container.textContent = '[FLUX] no pages found'
1082
+ return
1083
+ }
1084
+
1085
+ Router.init(pages, container)
1086
+ }
1087
+
1088
+ return { boot, parseFlux, State, Renderer, Router, QueryEngine }
1089
+
1090
+ })()
1091
+
1092
+ // Auto-boot from <script type="text/flux">
1093
+ document.addEventListener('DOMContentLoaded', () => {
1094
+ const script = document.querySelector('script[type="text/flux"]')
1095
+ if (script) {
1096
+ const targetSel = script.getAttribute('target') || '#app'
1097
+ const container = document.querySelector(targetSel)
1098
+ if (container) FLUX.boot(script.textContent, container)
1099
+ }
1100
+ })