aiplang 2.10.0 → 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.10.0'
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)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aiplang",
3
- "version": "2.10.0",
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.10.0',
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,