aiplang 2.9.4 → 2.10.1

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.9.4'
8
+ const VERSION = '2.10.1'
9
9
  const RUNTIME_DIR = path.join(__dirname, '..', 'runtime')
10
10
  const cmd = process.argv[2]
11
11
  const args = process.argv.slice(3)
@@ -648,9 +648,15 @@ function parseBlock(line) {
648
648
  const binding=line.slice(start,idx).trim().replace(/^@/,'@')
649
649
  const content=line.slice(idx+1,line.lastIndexOf('}')).trim()
650
650
  const em=content.match(/edit\s+(PUT|PATCH)\s+(\S+)/), dm=content.match(/delete\s+(?:DELETE\s+)?(\S+)/)
651
- const clean=content.replace(/edit\s+(PUT|PATCH)\s+\S+/g,'').replace(/delete\s+(?:DELETE\s+)?\S+/g,'')
651
+ const fallbackM=content.match(/fallback\s*:\s*([^|]+)/)
652
+ const retryM=content.match(/retry\s*:\s*(\S+)/)
653
+ const clean=content
654
+ .replace(/edit\s+(PUT|PATCH)\s+\S+/g,'')
655
+ .replace(/delete\s+(?:DELETE\s+)?\S+/g,'')
656
+ .replace(/fallback\s*:[^|]+/g,'')
657
+ .replace(/retry\s*:\s*\S+/g,'')
652
658
  const cols=parseCols(clean)
653
- return{kind:'table',binding,cols:Array.isArray(cols)?cols:[],empty:parseEmpty(clean),editPath:em?.[2]||null,editMethod:em?.[1]||'PUT',deletePath:dm?.[1]||null,deleteKey:'id',extraClass,animate,variant,style,bg}
659
+ return{kind:'table',binding,cols:Array.isArray(cols)?cols:[],empty:parseEmpty(clean),editPath:em?.[2]||null,editMethod:em?.[1]||'PUT',deletePath:dm?.[1]||null,deleteKey:'id',fallback:fallbackM?.[1]?.trim()||null,retry:retryM?.[1]||null,extraClass,animate,variant,style,bg}
654
660
  }
655
661
 
656
662
  // ── form ────────────────────────────────────────────────────
@@ -658,12 +664,17 @@ function parseBlock(line) {
658
664
  const bi=line.indexOf('{');if(bi===-1) return null
659
665
  let head=line.slice(line.startsWith('form{')?4:5,bi).trim()
660
666
  const content=line.slice(bi+1,line.lastIndexOf('}')).trim()
661
- let action=''; const ai=head.indexOf('=>')
662
- if(ai!==-1){action=head.slice(ai+2).trim();head=head.slice(0,ai).trim()}
667
+ let action='', optimistic=false; const ai=head.indexOf('=>')
668
+ if(ai!==-1){
669
+ action=head.slice(ai+2).trim()
670
+ // Optimistic: => @list.optimistic($result)
671
+ if(action.includes('.optimistic(')){optimistic=true;action=action.replace('.optimistic','')}
672
+ head=head.slice(0,ai).trim()
673
+ }
663
674
  const parts=head.trim().split(/\s+/)
664
675
  const method=parts[0]&&['GET','POST','PUT','PATCH','DELETE'].includes(parts[0].toUpperCase())?parts[0].toUpperCase():'POST'
665
676
  const bpath=parts[method===parts[0].toUpperCase()?1:0]||''
666
- return{kind:'form',method,bpath,action,fields:parseFields(content)||[],extraClass,animate,variant,style,bg}
677
+ return{kind:'form',method,bpath,action,optimistic,fields:parseFields(content)||[],extraClass,animate,variant,style,bg}
667
678
  }
668
679
 
669
680
  // ── pricing ─────────────────────────────────────────────────
@@ -858,11 +869,33 @@ function renderBlock(b, page) {
858
869
  case 'testimonial': return rTestimonial(b)
859
870
  case 'gallery': return rGallery(b)
860
871
  case 'raw': return (b.html||'')+'\n'
872
+ case 'html': return `<div class="fx-html">${b.content||''}</div>\n`
873
+ case 'spacer': return `<div class="fx-spacer" style="height:${esc(b.height||'2rem')}"></div>\n`
874
+ case 'divider': return b.label?`<div class="fx-divider"><span class="fx-divider-label">${esc(b.label)}</span></div>\n`:`<hr class="fx-hr">\n`
875
+ case 'badge': return `<div class="fx-badge-row"><span class="fx-badge-tag">${esc(b.content||'')}</span></div>\n`
876
+ case 'card': return rCardBlock(b)
877
+ case 'cols': return rColsBlock(b)
878
+ case 'each': return `<div class="fx-each fx-each-${b.variant||'list'}" data-fx-each="${esc(b.binding||'')}" data-fx-tpl="${esc(b.tpl||'')}"${b.style?` style="${b.style.replace(/,/g,';')}"`:''}>\n<div class="fx-each-empty fx-td-empty">Loading...</div></div>\n`
861
879
  case 'if': return `<div class="fx-if-wrap" data-fx-if="${esc(b.cond)}" style="display:none"></div>\n`
862
880
  default: return ''
863
881
  }
864
882
  }
865
883
 
884
+ function rCardBlock(b) {
885
+ const img=b.img?`<img src="${esc(b.img)}" class="fx-card-img" alt="${esc(b.title||'')}" loading="lazy">`:'';
886
+ const badge=b.badge?`<span class="fx-card-badge">${esc(b.badge)}</span>`:'';
887
+ const title=b.title?`<h3 class="fx-card-title">${esc(b.title)}</h3>`:'';
888
+ const sub=b.subtitle?`<p class="fx-card-body">${esc(b.subtitle)}</p>`:'';
889
+ const link=b.link?`<a href="${esc(b.link.split(':')[0])}" class="fx-card-link">${esc(b.link.split(':')[1]||'View')} →</a>`:'';
890
+ const bg=b.bg?` style="background:${b.bg}"`:b.style?` style="${b.style.replace(/,/g,';')}"`:''
891
+ return`<div class="fx-card"${bg}>${img}${badge}${title}${sub}${link}</div>\n`
892
+ }
893
+ function rColsBlock(b) {
894
+ const cols=(b.items||[]).map(col=>`<div class="fx-col">${col}</div>`).join('')
895
+ const style=b.style?` style="${b.style.replace(/,/g,';')}"`:''
896
+ return`<div class="fx-cols fx-cols-${b.n||2}"${style}>${cols}</div>\n`
897
+ }
898
+
866
899
  function rNav(b) {
867
900
  if(!b.items?.[0]) return ''
868
901
  const it=b.items[0]
@@ -973,7 +1006,9 @@ function rTable(b) {
973
1006
  const da=b.deletePath?` data-fx-delete="${esc(b.deletePath)}"`:''
974
1007
  const at=(b.editPath||b.deletePath)?'<th class="fx-th fx-th-actions">Actions</th>':''
975
1008
  const span=cols.length+((b.editPath||b.deletePath)?1:0)
976
- return `<div class="fx-table-wrap"><table class="fx-table" data-fx-table="${esc(b.binding)}" data-fx-cols='${keys}' data-fx-col-map='${cm}'${ea}${da}><thead><tr>${ths}${at}</tr></thead><tbody class="fx-tbody"><tr><td colspan="${span}" class="fx-td-empty">${esc(b.empty)}</td></tr></tbody></table></div>\n`
1009
+ const fallbackAttr=b.fallback?` data-fx-fallback="${esc(b.fallback)}"`:''
1010
+ const retryAttr=b.retry?` data-fx-retry="${esc(b.retry)}"`:''
1011
+ return `<div class="fx-table-wrap"><table class="fx-table" data-fx-table="${esc(b.binding)}" data-fx-cols='${keys}' data-fx-col-map='${cm}'${ea}${da}${fallbackAttr}${retryAttr}><thead><tr>${ths}${at}</tr></thead><tbody class="fx-tbody"><tr><td colspan="${span}" class="fx-td-empty">${esc(b.empty)}</td></tr></tbody></table></div>\n`
977
1012
  }
978
1013
 
979
1014
  function rForm(b) {
@@ -993,7 +1028,8 @@ function rForm(b) {
993
1028
  if(v==='minimal') {
994
1029
  return `<div class="fx-form-minimal"><form data-fx-form="${esc(b.bpath)}" data-fx-method="${esc(b.method)}" data-fx-action="${esc(b.action)}">${fields}<div class="fx-form-msg"></div><button type="submit" class="fx-btn">${esc(label)}</button></form></div>\n`
995
1030
  }
996
- return `<div class="fx-form-wrap"><form class="fx-form"${bgStyle} data-fx-form="${esc(b.bpath)}" data-fx-method="${esc(b.method)}" data-fx-action="${esc(b.action)}">${fields}<div class="fx-form-msg"></div><button type="submit" class="fx-btn">${esc(label)}</button></form></div>\n`
1031
+ const optAttr=b.optimistic?' data-fx-optimistic="true"':''
1032
+ return `<div class="fx-form-wrap"><form class="fx-form"${bgStyle}${optAttr} data-fx-form="${esc(b.bpath)}" data-fx-method="${esc(b.method)}" data-fx-action="${esc(b.action)}">${fields}<div class="fx-form-msg"></div><button type="submit" class="fx-btn">${esc(label)}</button></form></div>\n`
997
1033
  }
998
1034
 
999
1035
  function rBtn(b) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aiplang",
3
- "version": "2.9.4",
3
+ "version": "2.10.1",
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'
@@ -31,7 +28,28 @@ function watch(key, cb) {
31
28
  return () => { _watchers[key] = _watchers[key].filter(f => f !== cb) }
32
29
  }
33
30
 
31
+ // Batched notify — queues all pending updates and flushes in rAF (like React's batching)
32
+ const _pending = new Set()
33
+ let _batchScheduled = false
34
+
35
+ function flushBatch() {
36
+ _batchScheduled = false
37
+ for (const key of _pending) {
38
+ ;(_watchers[key] || []).forEach(cb => cb(_state[key]))
39
+ }
40
+ _pending.clear()
41
+ }
42
+
34
43
  function notify(key) {
44
+ _pending.add(key)
45
+ if (!_batchScheduled) {
46
+ _batchScheduled = true
47
+ requestAnimationFrame(flushBatch)
48
+ }
49
+ }
50
+
51
+ // Force immediate flush (for critical updates like form submit)
52
+ function notifySync(key) {
35
53
  ;(_watchers[key] || []).forEach(cb => cb(_state[key]))
36
54
  }
37
55
 
@@ -251,9 +269,35 @@ function hydrateTables() {
251
269
  return
252
270
  }
253
271
 
272
+ // Virtual rendering: only render visible rows for large datasets
273
+ const VIRTUAL_THRESHOLD = 100
274
+ const ROW_HEIGHT = 44 // px
275
+ const useVirtual = rows.length >= VIRTUAL_THRESHOLD
276
+
277
+ if (useVirtual) {
278
+ const wrapDiv = tbody.closest('.fx-table-wrap') || tbody.parentElement
279
+ const visible = Math.ceil((wrapDiv.clientHeight || 400) / ROW_HEIGHT) + 10
280
+ const scrollTop = wrapDiv.scrollTop || 0
281
+ const startIdx = Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - 5)
282
+ const endIdx = Math.min(rows.length - 1, startIdx + visible)
283
+
284
+ // Spacer before
285
+ if (startIdx > 0) {
286
+ const spacerTr = document.createElement('tr')
287
+ const spacerTd = document.createElement('td')
288
+ spacerTd.colSpan = cols.length + (editPath || delPath ? 1 : 0)
289
+ spacerTd.style.height = (startIdx * ROW_HEIGHT) + 'px'
290
+ spacerTd.style.padding = '0'
291
+ spacerTr.appendChild(spacerTd)
292
+ tbody.appendChild(spacerTr)
293
+ }
294
+ rows = rows.slice(startIdx, endIdx + 1)
295
+ }
296
+
254
297
  rows.forEach((row, idx) => {
255
298
  const tr = document.createElement('tr')
256
299
  tr.className = 'fx-tr'
300
+ if (useVirtual) tr.style.height = ROW_HEIGHT + 'px'
257
301
 
258
302
  // Data cells
259
303
  for (const col of cols) {
@@ -452,6 +496,86 @@ function hydrateIfs() {
452
496
  })
453
497
  }
454
498
 
499
+ // ── Advanced Animations (scroll-triggered + stagger) ─────────────
500
+ function initAnimations() {
501
+ // Extended animation presets — beyond what React ships by default
502
+ const style = document.createElement('style')
503
+ style.textContent = `
504
+ @keyframes fx-blur-in { from{opacity:0;filter:blur(8px);transform:translateY(8px)} to{opacity:1;filter:blur(0);transform:none} }
505
+ @keyframes fx-fade-up { from{opacity:0;transform:translateY(20px)} to{opacity:1;transform:none} }
506
+ @keyframes fx-fade-in { from{opacity:0} to{opacity:1} }
507
+ @keyframes fx-slide-up { from{opacity:0;transform:translateY(40px)} to{opacity:1;transform:none} }
508
+ @keyframes fx-slide-left{ from{opacity:0;transform:translateX(30px)} to{opacity:1;transform:none} }
509
+ @keyframes fx-scale-in { from{opacity:0;transform:scale(.92)} to{opacity:1;transform:scale(1)} }
510
+ @keyframes fx-bounce { 0%,100%{transform:translateY(0)} 50%{transform:translateY(-8px)} }
511
+ @keyframes fx-shake { 0%,100%{transform:translateX(0)} 25%{transform:translateX(-6px)} 75%{transform:translateX(6px)} }
512
+ @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} }
513
+ @keyframes fx-count { from{opacity:0;transform:translateY(4px)} to{opacity:1;transform:none} }
514
+
515
+ [class*="fx-anim-"] { opacity: 0 }
516
+ [class*="fx-anim-"].fx-visible { animation-fill-mode: both; animation-timing-function: cubic-bezier(.4,0,.2,1) }
517
+ .fx-visible.fx-anim-blur-in { animation: fx-blur-in .7s both }
518
+ .fx-visible.fx-anim-fade-up { animation: fx-fade-up .6s both }
519
+ .fx-visible.fx-anim-fade-in { animation: fx-fade-in .5s both }
520
+ .fx-visible.fx-anim-slide-up { animation: fx-slide-up .65s both }
521
+ .fx-visible.fx-anim-slide-left{ animation: fx-slide-left .6s both }
522
+ .fx-visible.fx-anim-scale-in { animation: fx-scale-in .5s both }
523
+ .fx-visible.fx-anim-stagger > * { animation: fx-fade-up .5s both }
524
+ .fx-visible.fx-anim-stagger > *:nth-child(1) { animation-delay: 0s }
525
+ .fx-visible.fx-anim-stagger > *:nth-child(2) { animation-delay: .1s }
526
+ .fx-visible.fx-anim-stagger > *:nth-child(3) { animation-delay: .2s }
527
+ .fx-visible.fx-anim-stagger > *:nth-child(4) { animation-delay: .3s }
528
+ .fx-visible.fx-anim-stagger > *:nth-child(5) { animation-delay: .4s }
529
+ .fx-visible.fx-anim-stagger > *:nth-child(6) { animation-delay: .5s }
530
+ .fx-anim-bounce { animation: fx-bounce 1.5s ease-in-out infinite !important; opacity: 1 !important }
531
+ .fx-anim-pulse { animation: fx-pulse-ring 2s ease infinite !important; opacity: 1 !important }
532
+ `
533
+ document.head.appendChild(style)
534
+
535
+ // Intersection Observer — trigger when element scrolls into view (like Framer whileInView)
536
+ const observer = new IntersectionObserver((entries) => {
537
+ entries.forEach(entry => {
538
+ if (entry.isIntersecting) {
539
+ entry.target.classList.add('fx-visible')
540
+ observer.unobserve(entry.target)
541
+ }
542
+ })
543
+ }, { threshold: 0.12, rootMargin: '0px 0px -30px 0px' })
544
+
545
+ document.querySelectorAll('[class*="fx-anim-"]').forEach(el => {
546
+ // bounce and pulse run immediately
547
+ if (el.classList.contains('fx-anim-bounce') || el.classList.contains('fx-anim-pulse')) {
548
+ el.classList.add('fx-visible'); return
549
+ }
550
+ observer.observe(el)
551
+ })
552
+
553
+ // Counter animation — numbers count up on scroll-in
554
+ document.querySelectorAll('.fx-stat-val').forEach(el => {
555
+ const target = parseFloat(el.textContent)
556
+ if (isNaN(target) || target === 0) return
557
+ const isFloat = el.textContent.includes('.')
558
+ let hasAnimated = false
559
+ const obs = new IntersectionObserver(([entry]) => {
560
+ if (!entry.isIntersecting || hasAnimated) return
561
+ hasAnimated = true
562
+ obs.unobserve(el)
563
+ const dur = Math.min(1200, Math.max(600, target * 2))
564
+ const start = Date.now()
565
+ const tick = () => {
566
+ const elapsed = Date.now() - start
567
+ const progress = Math.min(elapsed / dur, 1)
568
+ const eased = 1 - Math.pow(1 - progress, 3)
569
+ const current = target * eased
570
+ el.textContent = isFloat ? current.toFixed(1) : Math.round(current).toLocaleString()
571
+ if (progress < 1) requestAnimationFrame(tick)
572
+ }
573
+ requestAnimationFrame(tick)
574
+ }, { threshold: 0.5 })
575
+ obs.observe(el)
576
+ })
577
+ }
578
+
455
579
  // ── Inject action column CSS ──────────────────────────────────────
456
580
  function injectActionCSS() {
457
581
  const style = document.createElement('style')
@@ -471,19 +595,149 @@ function injectActionCSS() {
471
595
  document.head.appendChild(style)
472
596
  }
473
597
 
598
+ // ── SSR Data Injection — pre-populate state from server data ────────
599
+ // Server can inject window.__SSR_DATA__ = {users: [...], stats: {...}}
600
+ // to avoid loading flash (like Next.js getServerSideProps)
601
+ function loadSSRData() {
602
+ const ssr = window.__SSR_DATA__
603
+ if (!ssr) return
604
+ for (const [key, value] of Object.entries(ssr)) {
605
+ _state[key] = value
606
+ }
607
+ }
608
+
609
+ // ── Optimistic UI ─────────────────────────────────────────────────
610
+ // form data-fx-optimistic="true": updates state instantly, rolls back on error
611
+ function hydrateOptimistic() {
612
+ document.querySelectorAll('[data-fx-optimistic]').forEach(form => {
613
+ const action = form.getAttribute('data-fx-action') || ''
614
+ const pm = action.match(/^@([a-zA-Z_]+)\.push\(\$result\)$/)
615
+ if (!pm) return
616
+ const key = pm[1]
617
+
618
+ form.addEventListener('submit', (e) => {
619
+ // Inject a temp item optimistically before submit fires
620
+ const body = {}
621
+ form.querySelectorAll('input,select,textarea').forEach(inp => {
622
+ if (inp.name) body[inp.name] = inp.value
623
+ })
624
+ const tempId = '__temp_' + Date.now()
625
+ const optimisticItem = { ...body, id: tempId, _optimistic: true }
626
+ const current = [...(get(key) || [])]
627
+ set(key, [...current, optimisticItem])
628
+
629
+ // After actual submit (handled by hydrateForms), remove temp if error
630
+ const origAction = form.getAttribute('data-fx-action')
631
+ form.setAttribute('data-fx-action-orig', origAction)
632
+ form.setAttribute('data-fx-action', `@${key}._rollback_${tempId}`)
633
+
634
+ // Restore action after tick
635
+ setTimeout(() => {
636
+ form.setAttribute('data-fx-action', origAction)
637
+ // Clean up optimistic item if real item arrived
638
+ setTimeout(() => {
639
+ const arr = get(key) || []
640
+ const hasReal = arr.some(i => !i._optimistic)
641
+ if (hasReal) set(key, arr.filter(i => !i._optimistic || i.id !== tempId))
642
+ }, 500)
643
+ }, 50)
644
+ }, true) // capture phase — before hydrateForms submit handler
645
+ })
646
+ }
647
+
648
+ // ── Error recovery — fallback + retry ────────────────────────────
649
+ function hydrateTableErrors() {
650
+ document.querySelectorAll('[data-fx-fallback]').forEach(tbl => {
651
+ const fallback = tbl.getAttribute('data-fx-fallback')
652
+ const retryPath = tbl.getAttribute('data-fx-retry')
653
+ const binding = tbl.getAttribute('data-fx-table')
654
+ if (!fallback) return
655
+
656
+ const tbody = tbl.querySelector('tbody')
657
+ const originalEmpty = tbl.getAttribute('data-fx-empty') || 'No data.'
658
+
659
+ // Override runQuery to detect errors for this table's binding
660
+ const key = binding?.replace(/^@/, '') || ''
661
+ if (key) {
662
+ const cleanup = watch(key, (val) => {
663
+ if (val === '__error__') {
664
+ if (tbody) {
665
+ const cols = JSON.parse(tbl.getAttribute('data-fx-cols') || '[]')
666
+ tbody.innerHTML = `<tr><td colspan="${cols.length + 2}" class="fx-td-empty" style="color:#f87171">
667
+ ${fallback}
668
+ ${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>` : ''}
669
+ </td></tr>`
670
+ }
671
+ }
672
+ })
673
+ }
674
+ })
675
+
676
+ window.__aiplang_retry = (binding, path) => {
677
+ const key = binding.replace(/^@/, '')
678
+ set(key, [])
679
+ runQuery({ method: 'GET', path, target: binding })
680
+ }
681
+ }
682
+
474
683
  // ── Boot ──────────────────────────────────────────────────────────
475
684
  function boot() {
685
+ loadSSRData()
476
686
  injectActionCSS()
687
+ initAnimations()
477
688
  hydrateBindings()
478
689
  hydrateTables()
690
+ hydrateTableErrors()
479
691
  hydrateLists()
480
692
  hydrateForms()
693
+ hydrateOptimistic()
481
694
  hydrateBtns()
482
695
  hydrateSelects()
483
696
  hydrateIfs()
697
+ hydrateEach()
484
698
  mountQueries()
485
699
  }
486
700
 
701
+ // ── Hydrate each @list { template } ──────────────────────────────
702
+ function hydrateEach() {
703
+ document.querySelectorAll('[data-fx-each]').forEach(wrap => {
704
+ const binding = wrap.getAttribute('data-fx-each')
705
+ const tpl = wrap.getAttribute('data-fx-tpl') || ''
706
+ const key = binding.startsWith('@') ? binding.slice(1) : binding
707
+
708
+ const render = () => {
709
+ let items = get(key)
710
+ if (!Array.isArray(items)) items = []
711
+ wrap.innerHTML = ''
712
+
713
+ if (!items.length) {
714
+ const empty = document.createElement('div')
715
+ empty.className = 'fx-each-empty fx-td-empty'
716
+ empty.textContent = wrap.getAttribute('data-fx-empty') || 'No items.'
717
+ wrap.appendChild(empty)
718
+ return
719
+ }
720
+
721
+ items.forEach(item => {
722
+ const div = document.createElement('div')
723
+ div.className = 'fx-each-item'
724
+ // Interpolate {item.field} syntax in template
725
+ const html = tpl.replace(/\{item\.([^}]+)\}/g, (_, field) => {
726
+ const parts = field.split('.')
727
+ let val = item
728
+ for (const p of parts) val = val?.[p]
729
+ return val != null ? String(val) : ''
730
+ })
731
+ div.textContent = html || (item.name || item.title || item.label || JSON.stringify(item))
732
+ wrap.appendChild(div)
733
+ })
734
+ }
735
+
736
+ watch(key, render)
737
+ render()
738
+ })
739
+ }
740
+
487
741
  if (document.readyState === 'loading') {
488
742
  document.addEventListener('DOMContentLoaded', boot)
489
743
  } else {
package/server/server.js CHANGED
@@ -1797,7 +1797,7 @@ async function startServer(aipFile, port = 3000) {
1797
1797
 
1798
1798
  // Health
1799
1799
  srv.addRoute('GET', '/health', (req, res) => res.json(200, {
1800
- status:'ok', version:'2.9.4',
1800
+ status:'ok', version:'2.10.1',
1801
1801
  models: app.models.map(m=>m.name),
1802
1802
  routes: app.apis.length, pages: app.pages.length,
1803
1803
  admin: app.admin?.prefix || null,