aiplang 2.10.0 → 2.10.2

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/bin/aiplang.js CHANGED
@@ -5,7 +5,7 @@ const fs = require('fs')
5
5
  const path = require('path')
6
6
  const http = require('http')
7
7
 
8
- const VERSION = '2.10.0'
8
+ const VERSION = '2.10.2'
9
9
  const RUNTIME_DIR = path.join(__dirname, '..', 'runtime')
10
10
  const cmd = process.argv[2]
11
11
  const args = process.argv.slice(3)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aiplang",
3
- "version": "2.10.0",
3
+ "version": "2.10.2",
4
4
  "description": "AI-first web language. One .aip file = complete app. Frontend + backend + database + auth.",
5
5
  "keywords": [
6
6
  "aiplang",
@@ -1,7 +1,4 @@
1
- /**
2
- * aiplang-hydrate.js — aiplang Hydration Runtime v2.1
3
- * Handles: state, queries, table, list, form, if, edit, delete, btn, select
4
- */
1
+
5
2
 
6
3
  (function () {
7
4
  'use strict'
@@ -9,7 +6,6 @@
9
6
  const cfg = window.__AIPLANG_PAGE__
10
7
  if (!cfg) return
11
8
 
12
- // ── State ────────────────────────────────────────────────────────
13
9
  const _state = {}
14
10
  const _watchers = {}
15
11
 
@@ -31,7 +27,26 @@ function watch(key, cb) {
31
27
  return () => { _watchers[key] = _watchers[key].filter(f => f !== cb) }
32
28
  }
33
29
 
30
+ const _pending = new Set()
31
+ let _batchScheduled = false
32
+
33
+ function flushBatch() {
34
+ _batchScheduled = false
35
+ for (const key of _pending) {
36
+ ;(_watchers[key] || []).forEach(cb => cb(_state[key]))
37
+ }
38
+ _pending.clear()
39
+ }
40
+
34
41
  function notify(key) {
42
+ _pending.add(key)
43
+ if (!_batchScheduled) {
44
+ _batchScheduled = true
45
+ requestAnimationFrame(flushBatch)
46
+ }
47
+ }
48
+
49
+ function notifySync(key) {
35
50
  ;(_watchers[key] || []).forEach(cb => cb(_state[key]))
36
51
  }
37
52
 
@@ -45,12 +60,10 @@ function resolve(str) {
45
60
  })
46
61
  }
47
62
 
48
- // Resolve path with row data: /api/users/{id} + {id:1} → /api/users/1
49
63
  function resolvePath(tmpl, row) {
50
64
  return tmpl.replace(/\{([^}]+)\}/g, (_, k) => row?.[k] ?? get(k) ?? '')
51
65
  }
52
66
 
53
- // ── Query Engine ─────────────────────────────────────────────────
54
67
  const _intervals = []
55
68
 
56
69
  async function runQuery(q) {
@@ -78,11 +91,11 @@ function applyAction(data, target, action) {
78
91
  if (pm) { set(pm[1], [...(get(pm[1]) || []), data]); return }
79
92
  const fm = action.match(/^@([a-zA-Z_]+)\.filter\((.+)\)$/)
80
93
  if (fm) {
81
- // Safe filter: @list.filter(item.status=active) style — no eval/new Function
94
+
82
95
  try {
83
96
  const expr = fm[2].trim()
84
97
  const filtered = (get(fm[1]) || []).filter(item => {
85
- // Support simple: field=value or field!=value
98
+
86
99
  const eq = expr.match(/^([a-zA-Z_.]+)\s*(!?=)\s*(.+)$/)
87
100
  if (eq) {
88
101
  const [, field, op, val] = eq
@@ -114,7 +127,6 @@ function mountQueries() {
114
127
  }
115
128
  }
116
129
 
117
- // ── HTTP helper ──────────────────────────────────────────────────
118
130
  async function http(method, path, body) {
119
131
  const opts = { method, headers: { 'Content-Type': 'application/json' } }
120
132
  if (body) opts.body = JSON.stringify(body)
@@ -123,7 +135,6 @@ async function http(method, path, body) {
123
135
  return { ok: res.ok, status: res.status, data }
124
136
  }
125
137
 
126
- // ── Toast notifications ──────────────────────────────────────────
127
138
  function toast(msg, type) {
128
139
  const t = document.createElement('div')
129
140
  t.textContent = msg
@@ -140,7 +151,6 @@ function toast(msg, type) {
140
151
  setTimeout(() => { t.style.opacity = '0'; setTimeout(() => t.remove(), 300) }, 2500)
141
152
  }
142
153
 
143
- // ── Confirm modal ────────────────────────────────────────────────
144
154
  function confirm(msg) {
145
155
  return new Promise(resolve => {
146
156
  const overlay = document.createElement('div')
@@ -161,7 +171,6 @@ function confirm(msg) {
161
171
  })
162
172
  }
163
173
 
164
- // ── Edit modal ───────────────────────────────────────────────────
165
174
  function editModal(row, cols, path, method, stateKey) {
166
175
  return new Promise(resolve => {
167
176
  const overlay = document.createElement('div')
@@ -210,21 +219,19 @@ function editModal(row, cols, path, method, stateKey) {
210
219
  })
211
220
  }
212
221
 
213
- // ── Hydrate tables with CRUD ─────────────────────────────────────
214
222
  function hydrateTables() {
215
223
  document.querySelectorAll('[data-fx-table]').forEach(tbl => {
216
224
  const binding = tbl.getAttribute('data-fx-table')
217
225
  const colsJSON = tbl.getAttribute('data-fx-cols')
218
- const editPath = tbl.getAttribute('data-fx-edit') // e.g. /api/users/{id}
226
+ const editPath = tbl.getAttribute('data-fx-edit')
219
227
  const editMethod= tbl.getAttribute('data-fx-edit-method') || 'PUT'
220
- const delPath = tbl.getAttribute('data-fx-delete') // e.g. /api/users/{id}
228
+ const delPath = tbl.getAttribute('data-fx-delete')
221
229
  const delKey = tbl.getAttribute('data-fx-delete-key') || 'id'
222
230
 
223
231
  const cols = colsJSON ? JSON.parse(colsJSON) : []
224
232
  const tbody = tbl.querySelector('tbody')
225
233
  if (!tbody) return
226
234
 
227
- // Add action column headers if needed
228
235
  if ((editPath || delPath) && tbl.querySelector('thead tr')) {
229
236
  const thead = tbl.querySelector('thead tr')
230
237
  if (!thead.querySelector('.fx-th-actions')) {
@@ -251,11 +258,71 @@ function hydrateTables() {
251
258
  return
252
259
  }
253
260
 
254
- rows.forEach((row, idx) => {
261
+ const VIRTUAL_THRESHOLD = 80
262
+ const OVERSCAN = 8
263
+ const colSpanTotal = cols.length + (editPath || delPath ? 1 : 0)
264
+ const useVirtual = rows.length >= VIRTUAL_THRESHOLD
265
+ let rowHeights = null, totalHeight = 0, scrollListener = null
266
+
267
+ if (useVirtual) {
268
+ const wrapDiv = tbl.closest('.fx-table-wrap') || tbl.parentElement
269
+ wrapDiv.style.cssText += ';max-height:520px;overflow-y:auto;position:relative'
270
+
271
+ const measureRow = rows[0]
272
+ const tempTr = document.createElement('tr')
273
+ tempTr.style.visibility = 'hidden'
274
+ cols.forEach(col => {
275
+ const td = document.createElement('td'); td.className = 'fx-td'
276
+ td.textContent = measureRow[col.key] || ''; tempTr.appendChild(td)
277
+ })
278
+ tbody.appendChild(tempTr)
279
+ const rowH = Math.max(tempTr.getBoundingClientRect().height, 40) || 44
280
+ tbody.removeChild(tempTr)
281
+
282
+ const viewH = wrapDiv.clientHeight || 480
283
+ const visibleCount = Math.ceil(viewH / rowH) + OVERSCAN * 2
284
+
285
+ const renderVirtual = () => {
286
+ const scrollTop = wrapDiv.scrollTop
287
+ const startRaw = Math.floor(scrollTop / rowH)
288
+ const start = Math.max(0, startRaw - OVERSCAN)
289
+ const end = Math.min(rows.length - 1, start + visibleCount)
290
+ const paddingTop = start * rowH
291
+ const paddingBot = Math.max(0, (rows.length - end - 1) * rowH)
292
+
293
+ tbody.innerHTML = ''
294
+
295
+ if (paddingTop > 0) {
296
+ const tr = document.createElement('tr')
297
+ const td = document.createElement('td')
298
+ td.colSpan = colSpanTotal; td.style.cssText = 'height:'+paddingTop+'px;padding:0;border:none'
299
+ tr.appendChild(td); tbody.appendChild(tr)
300
+ }
301
+
302
+ for (let i = start; i <= end; i++) renderRow(rows[i], i)
303
+
304
+ if (paddingBot > 0) {
305
+ const tr = document.createElement('tr')
306
+ const td = document.createElement('td')
307
+ td.colSpan = colSpanTotal; td.style.cssText = 'height:'+paddingBot+'px;padding:0;border:none'
308
+ tr.appendChild(td); tbody.appendChild(tr)
309
+ }
310
+ }
311
+
312
+ let rafPending = false
313
+ scrollListener = () => {
314
+ if (rafPending) return; rafPending = true
315
+ requestAnimationFrame(() => { rafPending = false; renderVirtual() })
316
+ }
317
+ wrapDiv.addEventListener('scroll', scrollListener, { passive: true })
318
+ renderVirtual()
319
+ return
320
+ }
321
+
322
+ function renderRow(row, idx) {
255
323
  const tr = document.createElement('tr')
256
324
  tr.className = 'fx-tr'
257
325
 
258
- // Data cells
259
326
  for (const col of cols) {
260
327
  const td = document.createElement('td')
261
328
  td.className = 'fx-td'
@@ -263,7 +330,6 @@ function hydrateTables() {
263
330
  tr.appendChild(td)
264
331
  }
265
332
 
266
- // Action cell
267
333
  if (editPath || delPath) {
268
334
  const td = document.createElement('td')
269
335
  td.className = 'fx-td fx-td-actions'
@@ -307,7 +373,9 @@ function hydrateTables() {
307
373
  tr.appendChild(td)
308
374
  }
309
375
  tbody.appendChild(tr)
310
- })
376
+ }
377
+
378
+ rows.forEach((row, idx) => renderRow(row, idx))
311
379
  }
312
380
 
313
381
  const stateKey = binding.startsWith('@') ? binding.slice(1) : binding
@@ -316,7 +384,6 @@ function hydrateTables() {
316
384
  })
317
385
  }
318
386
 
319
- // ── Hydrate lists ────────────────────────────────────────────────
320
387
  function hydrateLists() {
321
388
  document.querySelectorAll('[data-fx-list]').forEach(wrap => {
322
389
  const binding = wrap.getAttribute('data-fx-list')
@@ -345,7 +412,6 @@ function hydrateLists() {
345
412
  })
346
413
  }
347
414
 
348
- // ── Hydrate forms ─────────────────────────────────────────────────
349
415
  function hydrateForms() {
350
416
  document.querySelectorAll('[data-fx-form]').forEach(form => {
351
417
  const path = form.getAttribute('data-fx-form')
@@ -380,8 +446,6 @@ function hydrateForms() {
380
446
  })
381
447
  }
382
448
 
383
- // ── Hydrate btns ──────────────────────────────────────────────────
384
- // <button data-fx-btn="/api/path" data-fx-method="POST" data-fx-action="...">
385
449
  function hydrateBtns() {
386
450
  document.querySelectorAll('[data-fx-btn]').forEach(btn => {
387
451
  const path = btn.getAttribute('data-fx-btn')
@@ -408,8 +472,6 @@ function hydrateBtns() {
408
472
  })
409
473
  }
410
474
 
411
- // ── Hydrate select dropdowns ──────────────────────────────────────
412
- // <select data-fx-model="@filter"> sets @filter on change
413
475
  function hydrateSelects() {
414
476
  document.querySelectorAll('[data-fx-model]').forEach(sel => {
415
477
  const binding = sel.getAttribute('data-fx-model')
@@ -420,7 +482,6 @@ function hydrateSelects() {
420
482
  })
421
483
  }
422
484
 
423
- // ── Hydrate text bindings ─────────────────────────────────────────
424
485
  function hydrateBindings() {
425
486
  document.querySelectorAll('[data-fx-bind]').forEach(el => {
426
487
  const expr = el.getAttribute('data-fx-bind')
@@ -431,7 +492,6 @@ function hydrateBindings() {
431
492
  })
432
493
  }
433
494
 
434
- // ── Hydrate conditionals ──────────────────────────────────────────
435
495
  function hydrateIfs() {
436
496
  document.querySelectorAll('[data-fx-if]').forEach(wrap => {
437
497
  const cond = wrap.getAttribute('data-fx-if')
@@ -452,7 +512,127 @@ function hydrateIfs() {
452
512
  })
453
513
  }
454
514
 
455
- // ── Inject action column CSS ──────────────────────────────────────
515
+ function initAnimations() {
516
+
517
+ const style = document.createElement('style')
518
+ style.textContent = `
519
+ @keyframes fx-blur-in { from{opacity:0;filter:blur(8px);transform:translateY(8px)} to{opacity:1;filter:blur(0);transform:none} }
520
+ @keyframes fx-fade-up { from{opacity:0;transform:translateY(20px)} to{opacity:1;transform:none} }
521
+ @keyframes fx-fade-in { from{opacity:0} to{opacity:1} }
522
+ @keyframes fx-slide-up { from{opacity:0;transform:translateY(40px)} to{opacity:1;transform:none} }
523
+ @keyframes fx-slide-left{ from{opacity:0;transform:translateX(30px)} to{opacity:1;transform:none} }
524
+ @keyframes fx-scale-in { from{opacity:0;transform:scale(.92)} to{opacity:1;transform:scale(1)} }
525
+ @keyframes fx-bounce { 0%,100%{transform:translateY(0)} 50%{transform:translateY(-8px)} }
526
+ @keyframes fx-shake { 0%,100%{transform:translateX(0)} 25%{transform:translateX(-6px)} 75%{transform:translateX(6px)} }
527
+ @keyframes fx-pulse-ring{ 0%{box-shadow:0 0 0 0 rgba(99,102,241,.4)} 70%{box-shadow:0 0 0 12px transparent} 100%{box-shadow:0 0 0 0 transparent} }
528
+ @keyframes fx-count { from{opacity:0;transform:translateY(4px)} to{opacity:1;transform:none} }
529
+
530
+ [class*="fx-anim-"] { opacity: 0 }
531
+ [class*="fx-anim-"].fx-visible { animation-fill-mode: both; animation-timing-function: cubic-bezier(.4,0,.2,1) }
532
+ .fx-visible.fx-anim-blur-in { animation: fx-blur-in .7s both }
533
+ .fx-visible.fx-anim-fade-up { animation: fx-fade-up .6s both }
534
+ .fx-visible.fx-anim-fade-in { animation: fx-fade-in .5s both }
535
+ .fx-visible.fx-anim-slide-up { animation: fx-slide-up .65s both }
536
+ .fx-visible.fx-anim-slide-left{ animation: fx-slide-left .6s both }
537
+ .fx-visible.fx-anim-scale-in { animation: fx-scale-in .5s both }
538
+ .fx-visible.fx-anim-stagger > * { animation: fx-fade-up .5s both }
539
+ .fx-visible.fx-anim-stagger > *:nth-child(1) { animation-delay: 0s }
540
+ .fx-visible.fx-anim-stagger > *:nth-child(2) { animation-delay: .1s }
541
+ .fx-visible.fx-anim-stagger > *:nth-child(3) { animation-delay: .2s }
542
+ .fx-visible.fx-anim-stagger > *:nth-child(4) { animation-delay: .3s }
543
+ .fx-visible.fx-anim-stagger > *:nth-child(5) { animation-delay: .4s }
544
+ .fx-visible.fx-anim-stagger > *:nth-child(6) { animation-delay: .5s }
545
+ .fx-anim-bounce { animation: fx-bounce 1.5s ease-in-out infinite !important; opacity: 1 !important }
546
+ .fx-anim-pulse { animation: fx-pulse-ring 2s ease infinite !important; opacity: 1 !important }
547
+ `
548
+ document.head.appendChild(style)
549
+
550
+ const observer = new IntersectionObserver((entries) => {
551
+ entries.forEach(entry => {
552
+ if (entry.isIntersecting) {
553
+ entry.target.classList.add('fx-visible')
554
+ observer.unobserve(entry.target)
555
+ }
556
+ })
557
+ }, { threshold: 0.12, rootMargin: '0px 0px -30px 0px' })
558
+
559
+ document.querySelectorAll('[class*="fx-anim-"]').forEach(el => {
560
+
561
+ if (el.classList.contains('fx-anim-bounce') || el.classList.contains('fx-anim-pulse')) {
562
+ el.classList.add('fx-visible'); return
563
+ }
564
+ observer.observe(el)
565
+ })
566
+
567
+ window.aiplang = window.aiplang || {}
568
+ window.aiplang.spring = function(el, prop, from, to, opts = {}) {
569
+ const k = opts.stiffness || 180
570
+ const b = opts.damping || 22
571
+ const m = opts.mass || 1
572
+ let pos = from, vel = 0
573
+ const dt = 1/60
574
+ let raf
575
+
576
+ const tick = () => {
577
+ const F = -k * (pos - to) - b * vel
578
+ vel += (F / m) * dt
579
+ pos += vel * dt
580
+ if (Math.abs(pos - to) < 0.01 && Math.abs(vel) < 0.01) {
581
+ pos = to
582
+ el.style[prop] = pos + (opts.unit || 'px')
583
+ return
584
+ }
585
+ el.style[prop] = pos + (opts.unit || 'px')
586
+ raf = requestAnimationFrame(tick)
587
+ }
588
+ cancelAnimationFrame(raf)
589
+ requestAnimationFrame(tick)
590
+ }
591
+
592
+ const springObs = new IntersectionObserver(entries => {
593
+ entries.forEach(entry => {
594
+ if (!entry.isIntersecting) return
595
+ const el = entry.target
596
+ if (el.classList.contains('fx-anim-spring')) {
597
+ el.style.opacity = '1'
598
+ el.style.transform = 'translateY(0px)'
599
+ window.aiplang.spring(el, '--spring-y', 24, 0, { stiffness: 200, damping: 20, unit: 'px' })
600
+ springObs.unobserve(el)
601
+ }
602
+ })
603
+ }, { threshold: 0.1 })
604
+
605
+ document.querySelectorAll('.fx-anim-spring').forEach(el => {
606
+ el.style.opacity = '0'
607
+ el.style.transform = 'translateY(24px)'
608
+ springObs.observe(el)
609
+ })
610
+
611
+ document.querySelectorAll('.fx-stat-val').forEach(el => {
612
+ const target = parseFloat(el.textContent)
613
+ if (isNaN(target) || target === 0) return
614
+ const isFloat = el.textContent.includes('.')
615
+ let hasAnimated = false
616
+ const obs = new IntersectionObserver(([entry]) => {
617
+ if (!entry.isIntersecting || hasAnimated) return
618
+ hasAnimated = true
619
+ obs.unobserve(el)
620
+ const dur = Math.min(1200, Math.max(600, target * 2))
621
+ const start = Date.now()
622
+ const tick = () => {
623
+ const elapsed = Date.now() - start
624
+ const progress = Math.min(elapsed / dur, 1)
625
+ const eased = 1 - Math.pow(1 - progress, 3)
626
+ const current = target * eased
627
+ el.textContent = isFloat ? current.toFixed(1) : Math.round(current).toLocaleString()
628
+ if (progress < 1) requestAnimationFrame(tick)
629
+ }
630
+ requestAnimationFrame(tick)
631
+ }, { threshold: 0.5 })
632
+ obs.observe(el)
633
+ })
634
+ }
635
+
456
636
  function injectActionCSS() {
457
637
  const style = document.createElement('style')
458
638
  style.textContent = `
@@ -471,19 +651,138 @@ function injectActionCSS() {
471
651
  document.head.appendChild(style)
472
652
  }
473
653
 
474
- // ── Boot ──────────────────────────────────────────────────────────
654
+ function loadSSRData() {
655
+ const ssr = window.__SSR_DATA__
656
+ if (!ssr) return
657
+ for (const [key, value] of Object.entries(ssr)) {
658
+ _state[key] = value
659
+ }
660
+ }
661
+
662
+ function hydrateOptimistic() {
663
+ document.querySelectorAll('[data-fx-optimistic]').forEach(form => {
664
+ const action = form.getAttribute('data-fx-action') || ''
665
+ const pm = action.match(/^@([a-zA-Z_]+)\.push\(\$result\)$/)
666
+ if (!pm) return
667
+ const key = pm[1]
668
+
669
+ form.addEventListener('submit', (e) => {
670
+
671
+ const body = {}
672
+ form.querySelectorAll('input,select,textarea').forEach(inp => {
673
+ if (inp.name) body[inp.name] = inp.value
674
+ })
675
+ const tempId = '__temp_' + Date.now()
676
+ const optimisticItem = { ...body, id: tempId, _optimistic: true }
677
+ const current = [...(get(key) || [])]
678
+ set(key, [...current, optimisticItem])
679
+
680
+ const origAction = form.getAttribute('data-fx-action')
681
+ form.setAttribute('data-fx-action-orig', origAction)
682
+ form.setAttribute('data-fx-action', `@${key}._rollback_${tempId}`)
683
+
684
+ setTimeout(() => {
685
+ form.setAttribute('data-fx-action', origAction)
686
+
687
+ setTimeout(() => {
688
+ const arr = get(key) || []
689
+ const hasReal = arr.some(i => !i._optimistic)
690
+ if (hasReal) set(key, arr.filter(i => !i._optimistic || i.id !== tempId))
691
+ }, 500)
692
+ }, 50)
693
+ }, true)
694
+ })
695
+ }
696
+
697
+ function hydrateTableErrors() {
698
+ document.querySelectorAll('[data-fx-fallback]').forEach(tbl => {
699
+ const fallback = tbl.getAttribute('data-fx-fallback')
700
+ const retryPath = tbl.getAttribute('data-fx-retry')
701
+ const binding = tbl.getAttribute('data-fx-table')
702
+ if (!fallback) return
703
+
704
+ const tbody = tbl.querySelector('tbody')
705
+ const originalEmpty = tbl.getAttribute('data-fx-empty') || 'No data.'
706
+
707
+ const key = binding?.replace(/^@/, '') || ''
708
+ if (key) {
709
+ const cleanup = watch(key, (val) => {
710
+ if (val === '__error__') {
711
+ if (tbody) {
712
+ const cols = JSON.parse(tbl.getAttribute('data-fx-cols') || '[]')
713
+ tbody.innerHTML = `<tr><td colspan="${cols.length + 2}" class="fx-td-empty" style="color:#f87171">
714
+ ${fallback}
715
+ ${retryPath ? `<button onclick="window.__aiplang_retry('${binding}','${retryPath}')" style="margin-left:.75rem;padding:.3rem .75rem;background:rgba(248,113,113,.1);border:1px solid rgba(248,113,113,.3);color:#f87171;border-radius:.375rem;cursor:pointer;font-size:.75rem">↻ Retry</button>` : ''}
716
+ </td></tr>`
717
+ }
718
+ }
719
+ })
720
+ }
721
+ })
722
+
723
+ window.__aiplang_retry = (binding, path) => {
724
+ const key = binding.replace(/^@/, '')
725
+ set(key, [])
726
+ runQuery({ method: 'GET', path, target: binding })
727
+ }
728
+ }
729
+
475
730
  function boot() {
731
+ loadSSRData()
476
732
  injectActionCSS()
733
+ initAnimations()
477
734
  hydrateBindings()
478
735
  hydrateTables()
736
+ hydrateTableErrors()
479
737
  hydrateLists()
480
738
  hydrateForms()
739
+ hydrateOptimistic()
481
740
  hydrateBtns()
482
741
  hydrateSelects()
483
742
  hydrateIfs()
743
+ hydrateEach()
484
744
  mountQueries()
485
745
  }
486
746
 
747
+ function hydrateEach() {
748
+ document.querySelectorAll('[data-fx-each]').forEach(wrap => {
749
+ const binding = wrap.getAttribute('data-fx-each')
750
+ const tpl = wrap.getAttribute('data-fx-tpl') || ''
751
+ const key = binding.startsWith('@') ? binding.slice(1) : binding
752
+
753
+ const render = () => {
754
+ let items = get(key)
755
+ if (!Array.isArray(items)) items = []
756
+ wrap.innerHTML = ''
757
+
758
+ if (!items.length) {
759
+ const empty = document.createElement('div')
760
+ empty.className = 'fx-each-empty fx-td-empty'
761
+ empty.textContent = wrap.getAttribute('data-fx-empty') || 'No items.'
762
+ wrap.appendChild(empty)
763
+ return
764
+ }
765
+
766
+ items.forEach(item => {
767
+ const div = document.createElement('div')
768
+ div.className = 'fx-each-item'
769
+
770
+ const html = tpl.replace(/\{item\.([^}]+)\}/g, (_, field) => {
771
+ const parts = field.split('.')
772
+ let val = item
773
+ for (const p of parts) val = val?.[p]
774
+ return val != null ? String(val) : ''
775
+ })
776
+ div.textContent = html || (item.name || item.title || item.label || JSON.stringify(item))
777
+ wrap.appendChild(div)
778
+ })
779
+ }
780
+
781
+ watch(key, render)
782
+ render()
783
+ })
784
+ }
785
+
487
786
  if (document.readyState === 'loading') {
488
787
  document.addEventListener('DOMContentLoaded', boot)
489
788
  } else {
package/server/server.js CHANGED
@@ -613,7 +613,18 @@ function parseApp(src) {
613
613
  else if (line.startsWith('~belongs '))curModel.relationships.push({ type:'belongsTo', model:line.slice(9).trim() })
614
614
  else if (line.startsWith('~hook ')) curModel.hooks.push(line.slice(6).trim())
615
615
  else if (line === '~soft-delete') curModel.softDelete = true
616
- else if (line && line !== '{') curModel.fields.push(parseField(line))
616
+ else if (line && line !== '{') {
617
+ // Support both multi-line and compact single-line field defs
618
+ if (line.startsWith('~')) {
619
+ if (line === '~soft-delete') curModel.softDelete = true
620
+ else if (line.startsWith('~belongs ')) curModel.relationships.push({type:'belongsTo',model:line.slice(9).trim()})
621
+ } else if (!line.includes(' ') && line.includes(':')) {
622
+ // Compact: "email:text:unique:required"
623
+ curModel.fields.push(parseFieldCompact(line))
624
+ } else {
625
+ curModel.fields.push(parseField(line))
626
+ }
627
+ }
617
628
  i++; continue
618
629
  }
619
630
 
@@ -621,8 +632,12 @@ function parseApp(src) {
621
632
  if (inAPI && curAPI) app.apis.push(curAPI)
622
633
  const braceIdx = line.indexOf('{')
623
634
  const closeBraceIdx = line.lastIndexOf('}')
624
- const pts = line.slice(4, braceIdx).trim().split(/\s+/)
625
- curAPI = { method:pts[0], path:pts[1], guards:[], validate:[], query:[], body:[], return:null }
635
+ const rawHead = line.slice(4, braceIdx).trim()
636
+ // Shorthand: api GET /path => auth,admin { arrow guard syntax
637
+ const arrowM = rawHead.match(/^(\S+)\s+(\S+)\s*=>\s*([\w,]+)\s*$/)
638
+ const pts = (arrowM ? rawHead.slice(0, rawHead.indexOf('=>')).trim() : rawHead).split(/\s+/)
639
+ const inlineGuards = arrowM ? arrowM[3].split(',').map(g=>g.trim()) : []
640
+ curAPI = { method:pts[0], path:pts[1], guards:[...inlineGuards], validate:[], query:[], body:[], return:null }
626
641
  // Inline api: "api GET /path { ops }" — entire api on one line
627
642
  if (braceIdx !== -1 && closeBraceIdx > braceIdx) {
628
643
  const inlineBody = line.slice(braceIdx+1, closeBraceIdx).trim()
@@ -648,7 +663,7 @@ function parseApp(src) {
648
663
  }
649
664
 
650
665
  function parseEnvLine(s) { const p=s.split(/\s+/); const ev={name:'',required:false,default:null}; for(const x of p){if(x==='required')ev.required=true;else if(x.includes('=')){const[k,v]=x.split('=');ev.name=k;ev.default=v}else ev.name=x}; return ev }
651
- function parseDBLine(s) { const p=s.split(/\s+/); return{driver:p[0]||'sqlite',dsn:p[1]||'./app.db'} }
666
+ function parseDBLine(s) { const p=s.split(/\s+/); const d=p[0]||'sqlite'; return{driver:d==='pg'||d==='psql'?'postgres':d,dsn:p[1]||'./app.db'} }
652
667
  function parseAuthLine(s) { const p=s.split(/\s+/); const a={provider:'jwt',secret:p[1]||'$JWT_SECRET',expire:'7d',refresh:'30d'}; for(const x of p){if(x.startsWith('expire='))a.expire=x.slice(7);if(x.startsWith('refresh='))a.refresh=x.slice(8);if(x==='google')a.oauth=['google'];if(x==='github')a.oauth=[...(a.oauth||[]),'google']}; return a }
653
668
  function parseMailLine(s) { const parts=s.split(/\s+/); const m={driver:parts[0]||'smtp'}; for(const x of parts.slice(1)){const[k,v]=x.split('='); m[k]=v}; return m }
654
669
  function parseStripeLine(s) {
@@ -708,7 +723,6 @@ function parseEventLine(s) { const m=s.match(/^(\S+)\s*=>\s*(.+)$/); return{even
708
723
  function parseField(line) {
709
724
  const p=line.split(':').map(s=>s.trim())
710
725
  const f={name:p[0],type:p[1]||'text',modifiers:[],enumVals:[],default:null}
711
- // If type is enum, p[2] contains comma-separated values directly
712
726
  if (f.type === 'enum' && p[2] && !p[2].startsWith('default=') && !['required','unique','hashed','pk','auto','index'].includes(p[2])) {
713
727
  f.enumVals = p[2].split(',').map(v=>v.trim()).filter(Boolean)
714
728
  for(let j=3;j<p.length;j++){const x=p[j];if(x.startsWith('default='))f.default=x.slice(8);else if(x)f.modifiers.push(x)}
@@ -717,6 +731,19 @@ function parseField(line) {
717
731
  }
718
732
  return f
719
733
  }
734
+
735
+ // Compact model field: "email:text:unique:required" single-line
736
+ function parseFieldCompact(def) {
737
+ const parts = def.trim().split(':').map(s=>s.trim()).filter(Boolean)
738
+ const f = {name:parts[0], type:parts[1]||'text', modifiers:[], enumVals:[], default:null}
739
+ for (let i=2; i<parts.length; i++) {
740
+ const x = parts[i]
741
+ if (x.startsWith('default=')) f.default = x.slice(8)
742
+ else if (/^[a-z]+,[a-z]/.test(x)) f.enumVals = x.split(',').map(v=>v.trim())
743
+ else f.modifiers.push(x)
744
+ }
745
+ return f
746
+ }
720
747
  function parseAPILine(line, route) {
721
748
  if(line.startsWith('~guard ')) route.guards=line.slice(7).split('|').map(s=>s.trim())
722
749
  else if(line.startsWith('~validate ')) line.slice(10).split('|').forEach(v=>{const p=v.trim().split(/\s+/);if(p[0])route.validate.push({field:p[0],rules:p.slice(1)})})
@@ -727,10 +754,14 @@ function parseFrontPage(src) {
727
754
  const lines=src.split('\n').map(l=>l.trim()).filter(l=>l&&!l.startsWith('#'))
728
755
  const p={id:'page',theme:'dark',route:'/',themeVars:null,state:{},queries:[],blocks:[]}
729
756
  for(const line of lines){
730
- if(line.startsWith('%')){const pts=line.slice(1).trim().split(/\s+/);p.id=pts[0]||'page';p.route=pts[2]||'/';const rt=pts[1]||'dark';if(rt.includes('#')){const c=rt.split(',');p.theme='custom';p.customTheme={bg:c[0],text:c[1]||'#f1f5f9',accent:c[2]||'#2563eb'}}else p.theme=rt}
757
+ if(line.startsWith('%')){const pts=line.slice(1).trim().split(/\s+/);p.id=pts[0]||'page';p.route=pts[2]||'/';const cachePt=pts.find(x=>x.startsWith('cache='));if(cachePt)p.cacheTTL=parseInt(cachePt.slice(6));const rt=pts[1]||'dark';if(rt.includes('#')){const c=rt.split(',');p.theme='custom';p.customTheme={bg:c[0],text:c[1]||'#f1f5f9',accent:c[2]||'#2563eb'}}else p.theme=rt}
731
758
  else if(line.startsWith('~theme ')){p.themeVars=p.themeVars||{};line.slice(7).trim().split(/\s+/).forEach(pair=>{const eq=pair.indexOf('=');if(eq!==-1)p.themeVars[pair.slice(0,eq)]=pair.slice(eq+1)})}
732
759
  else if(line.startsWith('@')&&line.includes('=')){const eq=line.indexOf('=');p.state[line.slice(1,eq).trim()]=line.slice(eq+1).trim()}
733
- else if(line.startsWith('~')){const pts=line.slice(1).trim().split(/\s+/);const ai=pts.indexOf('=>');if(pts[0]==='mount')p.queries.push({trigger:'mount',method:pts[1],path:pts[2],target:ai===-1?pts[3]:null,action:ai!==-1?pts.slice(ai+1).join(' '):null});else if(pts[0]==='interval')p.queries.push({trigger:'interval',interval:parseInt(pts[1]),method:pts[2],path:pts[3],target:ai===-1?pts[4]:null,action:ai!==-1?pts.slice(ai+1).join(' '):null})}
760
+ else if(line.startsWith('~')){const pts=line.slice(1).trim().split(/\s+/);const ai=pts.indexOf('=>');if(pts[0]==='mount'){
761
+ // Auto-detect target from path if not specified: ~mount GET /api/users → @users
762
+ const autoTarget = pts[3] || ('@' + (pts[2]?.split('/').filter(Boolean).pop()?.split('?')[0]||'data'))
763
+ p.queries.push({trigger:'mount',method:pts[1],path:pts[2],target:ai===-1?autoTarget:null,action:ai!==-1?pts.slice(ai+1).join(' '):null})
764
+ } else if(pts[0]==='interval')p.queries.push({trigger:'interval',interval:parseInt(pts[1]),method:pts[2],path:pts[3],target:ai===-1?pts[4]:null,action:ai!==-1?pts.slice(ai+1).join(' '):null})}
734
765
  else p.blocks.push({kind:blockKind(line),rawLine:line})
735
766
  }
736
767
  return p
@@ -1797,7 +1828,7 @@ async function startServer(aipFile, port = 3000) {
1797
1828
 
1798
1829
  // Health
1799
1830
  srv.addRoute('GET', '/health', (req, res) => res.json(200, {
1800
- status:'ok', version:'2.10.0',
1831
+ status:'ok', version:'2.10.2',
1801
1832
  models: app.models.map(m=>m.name),
1802
1833
  routes: app.apis.length, pages: app.pages.length,
1803
1834
  admin: app.admin?.prefix || null,