aiplang 2.10.1 → 2.10.3

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.1'
8
+ const VERSION = '2.10.3'
9
9
  const RUNTIME_DIR = path.join(__dirname, '..', 'runtime')
10
10
  const cmd = process.argv[2]
11
11
  const args = process.argv.slice(3)
@@ -768,6 +768,42 @@ function parseBlock(line) {
768
768
  return{kind:'if',cond:line.slice(3,bi).trim(),inner:line.slice(bi+1,line.lastIndexOf('}')).trim(),extraClass,animate}
769
769
  }
770
770
 
771
+ // ── chart{} — data visualization ───────────────────────────────
772
+ if(line.startsWith('chart{') || line.startsWith('chart ')) {
773
+ const bi=line.indexOf('{'); if(bi===-1) return null
774
+ const body=line.slice(bi+1,line.lastIndexOf('}')).trim()
775
+ const parts=body.split('|').map(x=>x.trim())
776
+ const type=parts.find(p=>['bar','line','pie','area','donut'].includes(p))||'bar'
777
+ const binding=parts.find(p=>p.startsWith('@'))||''
778
+ const labels=parts.find(p=>p.startsWith('x:'))?.slice(2)||'label'
779
+ const values=parts.find(p=>p.startsWith('y:'))?.slice(2)||'value'
780
+ const title=parts.find(p=>!p.startsWith('@')&&!['bar','line','pie','area','donut'].includes(p)&&!p.startsWith('x:')&&!p.startsWith('y:'))||''
781
+ return{kind:'chart',type,binding,labels,values,title,extraClass,animate,variant,style}
782
+ }
783
+
784
+ // ── kanban{} — drag-and-drop board ───────────────────────────────
785
+ if(line.startsWith('kanban{') || line.startsWith('kanban ')) {
786
+ const bi=line.indexOf('{'); if(bi===-1) return null
787
+ const body=line.slice(bi+1,line.lastIndexOf('}')).trim()
788
+ const parts=body.split('|').map(x=>x.trim())
789
+ const binding=parts.find(p=>p.startsWith('@'))||''
790
+ const cols=parts.filter(p=>!p.startsWith('@')&&!p.startsWith('status:'))
791
+ const statusField=parts.find(p=>p.startsWith('status:'))?.slice(7)||'status'
792
+ const updatePath=parts.find(p=>p.startsWith('PUT ')||p.startsWith('PATCH '))||''
793
+ return{kind:'kanban',binding,cols,statusField,updatePath,extraClass,animate,style}
794
+ }
795
+
796
+ // ── editor{} — rich text editor ──────────────────────────────────
797
+ if(line.startsWith('editor{') || line.startsWith('editor ')) {
798
+ const bi=line.indexOf('{'); if(bi===-1) return null
799
+ const body=line.slice(bi+1,line.lastIndexOf('}')).trim()
800
+ const parts=body.split('|').map(x=>x.trim())
801
+ const name=parts[0]||'content'
802
+ const placeholder=parts[1]||'Start writing...'
803
+ const submitPath=parts.find(p=>p.startsWith('POST ')||p.startsWith('PUT '))||''
804
+ return{kind:'editor',name,placeholder,submitPath,extraClass,animate,style}
805
+ }
806
+
771
807
  // ── each @list { template } — loop como React .map() ────────
772
808
  if(line.startsWith('each ')) {
773
809
  const bi=line.indexOf('{');if(bi===-1) return null
@@ -824,7 +860,7 @@ function applyMods(html, b) {
824
860
  function renderPage(page, allPages) {
825
861
  const needsJS=page.queries.length>0||page.blocks.some(b=>['table','list','form','if','btn','select','faq'].includes(b.kind))
826
862
  const body=page.blocks.map(b=>{try{return applyMods(renderBlock(b,page),b)}catch(e){console.error('[aiplang] Block render error:',b.kind,e.message);return ''}}).join('')
827
- const config=needsJS?JSON.stringify({id:page.id,theme:page.theme,routes:allPages.map(p=>p.route),state:page.state,queries:page.queries}):''
863
+ const config=needsJS?JSON.stringify({id:page.id,theme:page.theme,routes:allPages.map(p=>p.route),state:page.state,queries:page.queries,stores:page.stores||[],computed:page.computed||{}}):''
828
864
  const hydrate=needsJS?`\n<script>window.__AIPLANG_PAGE__=${config};</script>\n<script src="./aiplang-hydrate.js" defer></script>`:''
829
865
  const customVars=page.customTheme?genCustomThemeVars(page.customTheme):''
830
866
  const themeVarCSS=page.themeVars?genThemeVarCSS(page.themeVars):''
@@ -875,12 +911,55 @@ function renderBlock(b, page) {
875
911
  case 'badge': return `<div class="fx-badge-row"><span class="fx-badge-tag">${esc(b.content||'')}</span></div>\n`
876
912
  case 'card': return rCardBlock(b)
877
913
  case 'cols': return rColsBlock(b)
914
+ case 'chart': return rChart(b)
915
+ case 'kanban': return rKanban(b)
916
+ case 'editor': return rEditor(b)
878
917
  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`
879
918
  case 'if': return `<div class="fx-if-wrap" data-fx-if="${esc(b.cond)}" style="display:none"></div>\n`
880
919
  default: return ''
881
920
  }
882
921
  }
883
922
 
923
+ // ── Chart — lazy-loads Chart.js from CDN ────────────────────────
924
+ function rChart(b) {
925
+ const id = 'chart_' + Math.random().toString(36).slice(2,8)
926
+ const binding = b.binding || ''
927
+ const style = b.style ? ` style="${b.style.replace(/,/g,';')}"` : ''
928
+ return `<div class="fx-chart-wrap"${style}>
929
+ ${b.title ? `<div class="fx-chart-title">${esc(b.title)}</div>` : ''}
930
+ <canvas id="${id}" class="fx-chart" data-fx-chart="${esc(binding)}" data-chart-type="${esc(b.type||'bar')}" data-chart-labels="${esc(b.labels||'label')}" data-chart-values="${esc(b.values||'value')}"></canvas>
931
+ </div>\n`
932
+ }
933
+
934
+ // ── Kanban — drag-and-drop board ─────────────────────────────────
935
+ function rKanban(b) {
936
+ const cols = (b.cols||['Todo','In Progress','Done'])
937
+ const colsHtml = cols.map(col => `
938
+ <div class="fx-kanban-col" data-col="${esc(col)}">
939
+ <div class="fx-kanban-col-title">${esc(col)}</div>
940
+ <div class="fx-kanban-cards" data-status="${esc(col)}"></div>
941
+ </div>`).join('')
942
+ const style = b.style ? ` style="${b.style.replace(/,/g,';')}"` : ''
943
+ return `<div class="fx-kanban" data-fx-kanban="${esc(b.binding||'')}" data-status-field="${esc(b.statusField||'status')}" data-update-path="${esc(b.updatePath||'')}"${style}>${colsHtml}</div>\n`
944
+ }
945
+
946
+ // ── Rich text editor ──────────────────────────────────────────────
947
+ function rEditor(b) {
948
+ const style = b.style ? ` style="${b.style.replace(/,/g,';')}"` : ''
949
+ return `<div class="fx-editor-wrap"${style}>
950
+ <div class="fx-editor-toolbar">
951
+ <button type="button" onclick="document.execCommand('bold')" class="fx-editor-btn" title="Bold"><b>B</b></button>
952
+ <button type="button" onclick="document.execCommand('italic')" class="fx-editor-btn" title="Italic"><i>I</i></button>
953
+ <button type="button" onclick="document.execCommand('underline')" class="fx-editor-btn" title="Underline"><u>U</u></button>
954
+ <button type="button" onclick="document.execCommand('insertUnorderedList')" class="fx-editor-btn" title="List">≡</button>
955
+ <button type="button" onclick="document.execCommand('createLink',false,prompt('URL:'))" class="fx-editor-btn" title="Link">🔗</button>
956
+ ${b.submitPath ? `<button type="button" class="fx-editor-save fx-btn" data-editor-save="${esc(b.submitPath)}" data-editor-field="${esc(b.name||'content')}">Save</button>` : ''}
957
+ </div>
958
+ <div class="fx-editor" contenteditable="true" data-fx-editor="${esc(b.name||'content')}" placeholder="${esc(b.placeholder||'Start writing...')}"></div>
959
+ <input type="hidden" name="${esc(b.name||'content')}" class="fx-editor-hidden">
960
+ </div>\n`
961
+ }
962
+
884
963
  function rCardBlock(b) {
885
964
  const img=b.img?`<img src="${esc(b.img)}" class="fx-card-img" alt="${esc(b.title||'')}" loading="lazy">`:'';
886
965
  const badge=b.badge?`<span class="fx-card-badge">${esc(b.badge)}</span>`:'';
@@ -1129,6 +1208,9 @@ function css(theme) {
1129
1208
  .fx-pricing-compact{border-radius:.875rem;padding:1.25rem;display:flex;align-items:center;gap:1rem;border:1px solid rgba(255,255,255,.08)}
1130
1209
  .fx-pricing-price-sm{font-size:1.5rem;font-weight:800;letter-spacing:-.04em}
1131
1210
  .fx-grid-numbered>.fx-card{counter-increment:card-counter}
1211
+ .fx-chart-wrap{padding:1rem 2.5rem;position:relative}.fx-chart-title{font-family:monospace;font-size:.65rem;letter-spacing:.1em;text-transform:uppercase;color:#475569;margin-bottom:.75rem}.fx-chart{max-height:320px}
1212
+ .fx-kanban{display:flex;gap:1rem;padding:1rem 2.5rem;overflow-x:auto;align-items:flex-start}.fx-kanban-col{flex:0 0 280px;background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.08);border-radius:.875rem;padding:1rem}.fx-kanban-col-title{font-family:monospace;font-size:.65rem;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:#64748b;margin-bottom:.875rem}.fx-kanban-cards{min-height:80px;display:flex;flex-direction:column;gap:.5rem}.fx-kanban-card{background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.1);border-radius:.5rem;padding:.75rem;cursor:grab;font-size:.8125rem;line-height:1.5;transition:transform .15s,box-shadow .15s}.fx-kanban-card:hover{transform:translateY(-1px);box-shadow:0 4px 12px rgba(0,0,0,.3)}.fx-kanban-card.dragging{opacity:.5;cursor:grabbing}
1213
+ .fx-editor-wrap{padding:.75rem 2.5rem}.fx-editor-toolbar{display:flex;gap:.25rem;margin-bottom:.5rem;flex-wrap:wrap}.fx-editor-btn{background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.1);color:#e2e8f0;border-radius:.375rem;padding:.25rem .625rem;cursor:pointer;font-size:.8125rem;font-family:inherit;transition:background .1s}.fx-editor-btn:hover{background:rgba(255,255,255,.12)}.fx-editor{min-height:160px;padding:1rem;background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.1);border-radius:.625rem;color:#e2e8f0;font-size:.875rem;line-height:1.7;outline:none}.fx-editor:empty::before{content:attr(placeholder);color:#475569;pointer-events:none}.fx-editor-save{margin-left:auto}
1132
1214
  .fx-grid-numbered>.fx-card::before{content:counter(card-counter,decimal-leading-zero);font-size:2rem;font-weight:900;opacity:.15;font-family:monospace;line-height:1}
1133
1215
  .fx-grid-bordered>.fx-card{border:1px solid rgba(255,255,255,.08)}@keyframes fx-fade-up{from{opacity:0;transform:translateY(20px)}to{opacity:1;transform:none}}@keyframes fx-fade-in{from{opacity:0}to{opacity:1}}@keyframes fx-slide-left{from{opacity:0;transform:translateX(30px)}to{opacity:1;transform:none}}@keyframes fx-slide-right{from{opacity:0;transform:translateX(-30px)}to{opacity:1;transform:none}}@keyframes fx-zoom-in{from{opacity:0;transform:scale(.95)}to{opacity:1;transform:scale(1)}}@keyframes fx-blur-in{from{opacity:0;filter:blur(8px)}to{opacity:1;filter:blur(0)}}.fx-anim-fade-up{animation:fx-fade-up .6s cubic-bezier(.4,0,.2,1) both}.fx-anim-fade-in{animation:fx-fade-in .6s ease both}.fx-anim-slide-left{animation:fx-slide-left .6s cubic-bezier(.4,0,.2,1) both}.fx-anim-slide-right{animation:fx-slide-right .6s cubic-bezier(.4,0,.2,1) both}.fx-anim-zoom-in{animation:fx-zoom-in .5s cubic-bezier(.4,0,.2,1) both}.fx-anim-blur-in{animation:fx-blur-in .7s ease both}.fx-anim-stagger>.fx-card:nth-child(1){animation:fx-fade-up .5s 0s both}.fx-anim-stagger>.fx-card:nth-child(2){animation:fx-fade-up .5s .1s both}.fx-anim-stagger>.fx-card:nth-child(3){animation:fx-fade-up .5s .2s both}.fx-anim-stagger>.fx-card:nth-child(4){animation:fx-fade-up .5s .3s both}.fx-anim-stagger>.fx-card:nth-child(5){animation:fx-fade-up .5s .4s both}.fx-anim-stagger>.fx-card:nth-child(6){animation:fx-fade-up .5s .5s both}`
1134
1216
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aiplang",
3
- "version": "2.10.1",
3
+ "version": "2.10.3",
4
4
  "description": "AI-first web language. One .aip file = complete app. Frontend + backend + database + auth.",
5
5
  "keywords": [
6
6
  "aiplang",
@@ -6,29 +6,51 @@
6
6
  const cfg = window.__AIPLANG_PAGE__
7
7
  if (!cfg) return
8
8
 
9
- // ── State ────────────────────────────────────────────────────────
9
+ // ── Global Store — cross-page state (like React Context / Zustand) ─
10
+ const _STORE_KEY = 'aiplang_store_v1'
11
+ const _globalStore = (() => {
12
+ try { return JSON.parse(sessionStorage.getItem(_STORE_KEY) || '{}') } catch { return {} }
13
+ })()
14
+ function syncStore(key, value) {
15
+ _globalStore[key] = value
16
+ try { sessionStorage.setItem(_STORE_KEY, JSON.stringify(_globalStore)) } catch {}
17
+ try { new BroadcastChannel(_STORE_KEY).postMessage({ key, value }) } catch {}
18
+ }
19
+
20
+ // ── Page-level State ─────────────────────────────────────────────
10
21
  const _state = {}
11
22
  const _watchers = {}
23
+ const _storeKeys = new Set((cfg.stores || []).map(s => s.key))
12
24
 
13
- for (const [k, v] of Object.entries(cfg.state || {})) {
14
- try { _state[k] = JSON.parse(v) } catch { _state[k] = v }
25
+ // Bootstrap state: SSR data > global store > page state declarations
26
+ const _boot = { ...(window.__SSR_DATA__ || {}), ..._globalStore }
27
+ for (const [k, v] of Object.entries({ ...(cfg.state || {}), ..._boot })) {
28
+ try { _state[k] = typeof v === 'string' && (v.startsWith('[') || v.startsWith('{') || v === 'true' || v === 'false' || !isNaN(v)) ? JSON.parse(v) : v } catch { _state[k] = v }
15
29
  }
16
30
 
17
31
  function get(key) { return _state[key] }
18
32
 
19
- function set(key, value) {
33
+ function set(key, value, _persist) {
20
34
  if (JSON.stringify(_state[key]) === JSON.stringify(value)) return
21
35
  _state[key] = value
36
+ if (_storeKeys.has(key) || _persist) syncStore(key, value)
22
37
  notify(key)
23
38
  }
24
39
 
40
+ // Cross-tab store sync (other pages update when store changes)
41
+ try {
42
+ const _bc = new BroadcastChannel(_STORE_KEY)
43
+ _bc.onmessage = ({ data: { key, value } }) => {
44
+ _state[key] = value; notify(key)
45
+ }
46
+ } catch {}
47
+
25
48
  function watch(key, cb) {
26
49
  if (!_watchers[key]) _watchers[key] = []
27
50
  _watchers[key].push(cb)
28
51
  return () => { _watchers[key] = _watchers[key].filter(f => f !== cb) }
29
52
  }
30
53
 
31
- // Batched notify — queues all pending updates and flushes in rAF (like React's batching)
32
54
  const _pending = new Set()
33
55
  let _batchScheduled = false
34
56
 
@@ -48,7 +70,6 @@ function notify(key) {
48
70
  }
49
71
  }
50
72
 
51
- // Force immediate flush (for critical updates like form submit)
52
73
  function notifySync(key) {
53
74
  ;(_watchers[key] || []).forEach(cb => cb(_state[key]))
54
75
  }
@@ -63,12 +84,10 @@ function resolve(str) {
63
84
  })
64
85
  }
65
86
 
66
- // Resolve path with row data: /api/users/{id} + {id:1} → /api/users/1
67
87
  function resolvePath(tmpl, row) {
68
88
  return tmpl.replace(/\{([^}]+)\}/g, (_, k) => row?.[k] ?? get(k) ?? '')
69
89
  }
70
90
 
71
- // ── Query Engine ─────────────────────────────────────────────────
72
91
  const _intervals = []
73
92
 
74
93
  async function runQuery(q) {
@@ -96,11 +115,11 @@ function applyAction(data, target, action) {
96
115
  if (pm) { set(pm[1], [...(get(pm[1]) || []), data]); return }
97
116
  const fm = action.match(/^@([a-zA-Z_]+)\.filter\((.+)\)$/)
98
117
  if (fm) {
99
- // Safe filter: @list.filter(item.status=active) style — no eval/new Function
118
+
100
119
  try {
101
120
  const expr = fm[2].trim()
102
121
  const filtered = (get(fm[1]) || []).filter(item => {
103
- // Support simple: field=value or field!=value
122
+
104
123
  const eq = expr.match(/^([a-zA-Z_.]+)\s*(!?=)\s*(.+)$/)
105
124
  if (eq) {
106
125
  const [, field, op, val] = eq
@@ -132,7 +151,6 @@ function mountQueries() {
132
151
  }
133
152
  }
134
153
 
135
- // ── HTTP helper ──────────────────────────────────────────────────
136
154
  async function http(method, path, body) {
137
155
  const opts = { method, headers: { 'Content-Type': 'application/json' } }
138
156
  if (body) opts.body = JSON.stringify(body)
@@ -141,7 +159,6 @@ async function http(method, path, body) {
141
159
  return { ok: res.ok, status: res.status, data }
142
160
  }
143
161
 
144
- // ── Toast notifications ──────────────────────────────────────────
145
162
  function toast(msg, type) {
146
163
  const t = document.createElement('div')
147
164
  t.textContent = msg
@@ -158,7 +175,6 @@ function toast(msg, type) {
158
175
  setTimeout(() => { t.style.opacity = '0'; setTimeout(() => t.remove(), 300) }, 2500)
159
176
  }
160
177
 
161
- // ── Confirm modal ────────────────────────────────────────────────
162
178
  function confirm(msg) {
163
179
  return new Promise(resolve => {
164
180
  const overlay = document.createElement('div')
@@ -179,7 +195,6 @@ function confirm(msg) {
179
195
  })
180
196
  }
181
197
 
182
- // ── Edit modal ───────────────────────────────────────────────────
183
198
  function editModal(row, cols, path, method, stateKey) {
184
199
  return new Promise(resolve => {
185
200
  const overlay = document.createElement('div')
@@ -228,21 +243,19 @@ function editModal(row, cols, path, method, stateKey) {
228
243
  })
229
244
  }
230
245
 
231
- // ── Hydrate tables with CRUD ─────────────────────────────────────
232
246
  function hydrateTables() {
233
247
  document.querySelectorAll('[data-fx-table]').forEach(tbl => {
234
248
  const binding = tbl.getAttribute('data-fx-table')
235
249
  const colsJSON = tbl.getAttribute('data-fx-cols')
236
- const editPath = tbl.getAttribute('data-fx-edit') // e.g. /api/users/{id}
250
+ const editPath = tbl.getAttribute('data-fx-edit')
237
251
  const editMethod= tbl.getAttribute('data-fx-edit-method') || 'PUT'
238
- const delPath = tbl.getAttribute('data-fx-delete') // e.g. /api/users/{id}
252
+ const delPath = tbl.getAttribute('data-fx-delete')
239
253
  const delKey = tbl.getAttribute('data-fx-delete-key') || 'id'
240
254
 
241
255
  const cols = colsJSON ? JSON.parse(colsJSON) : []
242
256
  const tbody = tbl.querySelector('tbody')
243
257
  if (!tbody) return
244
258
 
245
- // Add action column headers if needed
246
259
  if ((editPath || delPath) && tbl.querySelector('thead tr')) {
247
260
  const thead = tbl.querySelector('thead tr')
248
261
  if (!thead.querySelector('.fx-th-actions')) {
@@ -269,37 +282,71 @@ function hydrateTables() {
269
282
  return
270
283
  }
271
284
 
272
- // Virtual rendering: only render visible rows for large datasets
273
- const VIRTUAL_THRESHOLD = 100
274
- const ROW_HEIGHT = 44 // px
285
+ const VIRTUAL_THRESHOLD = 80
286
+ const OVERSCAN = 8
287
+ const colSpanTotal = cols.length + (editPath || delPath ? 1 : 0)
275
288
  const useVirtual = rows.length >= VIRTUAL_THRESHOLD
289
+ let rowHeights = null, totalHeight = 0, scrollListener = null
276
290
 
277
291
  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)
292
+ const wrapDiv = tbl.closest('.fx-table-wrap') || tbl.parentElement
293
+ wrapDiv.style.cssText += ';max-height:520px;overflow-y:auto;position:relative'
294
+
295
+ const measureRow = rows[0]
296
+ const tempTr = document.createElement('tr')
297
+ tempTr.style.visibility = 'hidden'
298
+ cols.forEach(col => {
299
+ const td = document.createElement('td'); td.className = 'fx-td'
300
+ td.textContent = measureRow[col.key] || ''; tempTr.appendChild(td)
301
+ })
302
+ tbody.appendChild(tempTr)
303
+ const rowH = Math.max(tempTr.getBoundingClientRect().height, 40) || 44
304
+ tbody.removeChild(tempTr)
305
+
306
+ const viewH = wrapDiv.clientHeight || 480
307
+ const visibleCount = Math.ceil(viewH / rowH) + OVERSCAN * 2
308
+
309
+ const renderVirtual = () => {
310
+ const scrollTop = wrapDiv.scrollTop
311
+ const startRaw = Math.floor(scrollTop / rowH)
312
+ const start = Math.max(0, startRaw - OVERSCAN)
313
+ const end = Math.min(rows.length - 1, start + visibleCount)
314
+ const paddingTop = start * rowH
315
+ const paddingBot = Math.max(0, (rows.length - end - 1) * rowH)
316
+
317
+ tbody.innerHTML = ''
318
+
319
+ if (paddingTop > 0) {
320
+ const tr = document.createElement('tr')
321
+ const td = document.createElement('td')
322
+ td.colSpan = colSpanTotal; td.style.cssText = 'height:'+paddingTop+'px;padding:0;border:none'
323
+ tr.appendChild(td); tbody.appendChild(tr)
324
+ }
325
+
326
+ for (let i = start; i <= end; i++) renderRow(rows[i], i)
327
+
328
+ if (paddingBot > 0) {
329
+ const tr = document.createElement('tr')
330
+ const td = document.createElement('td')
331
+ td.colSpan = colSpanTotal; td.style.cssText = 'height:'+paddingBot+'px;padding:0;border:none'
332
+ tr.appendChild(td); tbody.appendChild(tr)
333
+ }
334
+ }
335
+
336
+ let rafPending = false
337
+ scrollListener = () => {
338
+ if (rafPending) return; rafPending = true
339
+ requestAnimationFrame(() => { rafPending = false; renderVirtual() })
293
340
  }
294
- rows = rows.slice(startIdx, endIdx + 1)
341
+ wrapDiv.addEventListener('scroll', scrollListener, { passive: true })
342
+ renderVirtual()
343
+ return
295
344
  }
296
345
 
297
- rows.forEach((row, idx) => {
346
+ function renderRow(row, idx) {
298
347
  const tr = document.createElement('tr')
299
348
  tr.className = 'fx-tr'
300
- if (useVirtual) tr.style.height = ROW_HEIGHT + 'px'
301
349
 
302
- // Data cells
303
350
  for (const col of cols) {
304
351
  const td = document.createElement('td')
305
352
  td.className = 'fx-td'
@@ -307,7 +354,6 @@ function hydrateTables() {
307
354
  tr.appendChild(td)
308
355
  }
309
356
 
310
- // Action cell
311
357
  if (editPath || delPath) {
312
358
  const td = document.createElement('td')
313
359
  td.className = 'fx-td fx-td-actions'
@@ -351,7 +397,9 @@ function hydrateTables() {
351
397
  tr.appendChild(td)
352
398
  }
353
399
  tbody.appendChild(tr)
354
- })
400
+ }
401
+
402
+ rows.forEach((row, idx) => renderRow(row, idx))
355
403
  }
356
404
 
357
405
  const stateKey = binding.startsWith('@') ? binding.slice(1) : binding
@@ -360,7 +408,6 @@ function hydrateTables() {
360
408
  })
361
409
  }
362
410
 
363
- // ── Hydrate lists ────────────────────────────────────────────────
364
411
  function hydrateLists() {
365
412
  document.querySelectorAll('[data-fx-list]').forEach(wrap => {
366
413
  const binding = wrap.getAttribute('data-fx-list')
@@ -389,7 +436,6 @@ function hydrateLists() {
389
436
  })
390
437
  }
391
438
 
392
- // ── Hydrate forms ─────────────────────────────────────────────────
393
439
  function hydrateForms() {
394
440
  document.querySelectorAll('[data-fx-form]').forEach(form => {
395
441
  const path = form.getAttribute('data-fx-form')
@@ -424,8 +470,6 @@ function hydrateForms() {
424
470
  })
425
471
  }
426
472
 
427
- // ── Hydrate btns ──────────────────────────────────────────────────
428
- // <button data-fx-btn="/api/path" data-fx-method="POST" data-fx-action="...">
429
473
  function hydrateBtns() {
430
474
  document.querySelectorAll('[data-fx-btn]').forEach(btn => {
431
475
  const path = btn.getAttribute('data-fx-btn')
@@ -452,8 +496,6 @@ function hydrateBtns() {
452
496
  })
453
497
  }
454
498
 
455
- // ── Hydrate select dropdowns ──────────────────────────────────────
456
- // <select data-fx-model="@filter"> sets @filter on change
457
499
  function hydrateSelects() {
458
500
  document.querySelectorAll('[data-fx-model]').forEach(sel => {
459
501
  const binding = sel.getAttribute('data-fx-model')
@@ -464,7 +506,6 @@ function hydrateSelects() {
464
506
  })
465
507
  }
466
508
 
467
- // ── Hydrate text bindings ─────────────────────────────────────────
468
509
  function hydrateBindings() {
469
510
  document.querySelectorAll('[data-fx-bind]').forEach(el => {
470
511
  const expr = el.getAttribute('data-fx-bind')
@@ -475,7 +516,6 @@ function hydrateBindings() {
475
516
  })
476
517
  }
477
518
 
478
- // ── Hydrate conditionals ──────────────────────────────────────────
479
519
  function hydrateIfs() {
480
520
  document.querySelectorAll('[data-fx-if]').forEach(wrap => {
481
521
  const cond = wrap.getAttribute('data-fx-if')
@@ -496,9 +536,8 @@ function hydrateIfs() {
496
536
  })
497
537
  }
498
538
 
499
- // ── Advanced Animations (scroll-triggered + stagger) ─────────────
500
539
  function initAnimations() {
501
- // Extended animation presets — beyond what React ships by default
540
+
502
541
  const style = document.createElement('style')
503
542
  style.textContent = `
504
543
  @keyframes fx-blur-in { from{opacity:0;filter:blur(8px);transform:translateY(8px)} to{opacity:1;filter:blur(0);transform:none} }
@@ -532,7 +571,6 @@ function initAnimations() {
532
571
  `
533
572
  document.head.appendChild(style)
534
573
 
535
- // Intersection Observer — trigger when element scrolls into view (like Framer whileInView)
536
574
  const observer = new IntersectionObserver((entries) => {
537
575
  entries.forEach(entry => {
538
576
  if (entry.isIntersecting) {
@@ -543,14 +581,57 @@ function initAnimations() {
543
581
  }, { threshold: 0.12, rootMargin: '0px 0px -30px 0px' })
544
582
 
545
583
  document.querySelectorAll('[class*="fx-anim-"]').forEach(el => {
546
- // bounce and pulse run immediately
584
+
547
585
  if (el.classList.contains('fx-anim-bounce') || el.classList.contains('fx-anim-pulse')) {
548
586
  el.classList.add('fx-visible'); return
549
587
  }
550
588
  observer.observe(el)
551
589
  })
552
590
 
553
- // Counter animation numbers count up on scroll-in
591
+ window.aiplang = window.aiplang || {}
592
+ window.aiplang.spring = function(el, prop, from, to, opts = {}) {
593
+ const k = opts.stiffness || 180
594
+ const b = opts.damping || 22
595
+ const m = opts.mass || 1
596
+ let pos = from, vel = 0
597
+ const dt = 1/60
598
+ let raf
599
+
600
+ const tick = () => {
601
+ const F = -k * (pos - to) - b * vel
602
+ vel += (F / m) * dt
603
+ pos += vel * dt
604
+ if (Math.abs(pos - to) < 0.01 && Math.abs(vel) < 0.01) {
605
+ pos = to
606
+ el.style[prop] = pos + (opts.unit || 'px')
607
+ return
608
+ }
609
+ el.style[prop] = pos + (opts.unit || 'px')
610
+ raf = requestAnimationFrame(tick)
611
+ }
612
+ cancelAnimationFrame(raf)
613
+ requestAnimationFrame(tick)
614
+ }
615
+
616
+ const springObs = new IntersectionObserver(entries => {
617
+ entries.forEach(entry => {
618
+ if (!entry.isIntersecting) return
619
+ const el = entry.target
620
+ if (el.classList.contains('fx-anim-spring')) {
621
+ el.style.opacity = '1'
622
+ el.style.transform = 'translateY(0px)'
623
+ window.aiplang.spring(el, '--spring-y', 24, 0, { stiffness: 200, damping: 20, unit: 'px' })
624
+ springObs.unobserve(el)
625
+ }
626
+ })
627
+ }, { threshold: 0.1 })
628
+
629
+ document.querySelectorAll('.fx-anim-spring').forEach(el => {
630
+ el.style.opacity = '0'
631
+ el.style.transform = 'translateY(24px)'
632
+ springObs.observe(el)
633
+ })
634
+
554
635
  document.querySelectorAll('.fx-stat-val').forEach(el => {
555
636
  const target = parseFloat(el.textContent)
556
637
  if (isNaN(target) || target === 0) return
@@ -576,7 +657,6 @@ function initAnimations() {
576
657
  })
577
658
  }
578
659
 
579
- // ── Inject action column CSS ──────────────────────────────────────
580
660
  function injectActionCSS() {
581
661
  const style = document.createElement('style')
582
662
  style.textContent = `
@@ -595,9 +675,6 @@ function injectActionCSS() {
595
675
  document.head.appendChild(style)
596
676
  }
597
677
 
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
678
  function loadSSRData() {
602
679
  const ssr = window.__SSR_DATA__
603
680
  if (!ssr) return
@@ -606,8 +683,6 @@ function loadSSRData() {
606
683
  }
607
684
  }
608
685
 
609
- // ── Optimistic UI ─────────────────────────────────────────────────
610
- // form data-fx-optimistic="true": updates state instantly, rolls back on error
611
686
  function hydrateOptimistic() {
612
687
  document.querySelectorAll('[data-fx-optimistic]').forEach(form => {
613
688
  const action = form.getAttribute('data-fx-action') || ''
@@ -616,7 +691,7 @@ function hydrateOptimistic() {
616
691
  const key = pm[1]
617
692
 
618
693
  form.addEventListener('submit', (e) => {
619
- // Inject a temp item optimistically before submit fires
694
+
620
695
  const body = {}
621
696
  form.querySelectorAll('input,select,textarea').forEach(inp => {
622
697
  if (inp.name) body[inp.name] = inp.value
@@ -626,26 +701,23 @@ function hydrateOptimistic() {
626
701
  const current = [...(get(key) || [])]
627
702
  set(key, [...current, optimisticItem])
628
703
 
629
- // After actual submit (handled by hydrateForms), remove temp if error
630
704
  const origAction = form.getAttribute('data-fx-action')
631
705
  form.setAttribute('data-fx-action-orig', origAction)
632
706
  form.setAttribute('data-fx-action', `@${key}._rollback_${tempId}`)
633
707
 
634
- // Restore action after tick
635
708
  setTimeout(() => {
636
709
  form.setAttribute('data-fx-action', origAction)
637
- // Clean up optimistic item if real item arrived
710
+
638
711
  setTimeout(() => {
639
712
  const arr = get(key) || []
640
713
  const hasReal = arr.some(i => !i._optimistic)
641
714
  if (hasReal) set(key, arr.filter(i => !i._optimistic || i.id !== tempId))
642
715
  }, 500)
643
716
  }, 50)
644
- }, true) // capture phase — before hydrateForms submit handler
717
+ }, true)
645
718
  })
646
719
  }
647
720
 
648
- // ── Error recovery — fallback + retry ────────────────────────────
649
721
  function hydrateTableErrors() {
650
722
  document.querySelectorAll('[data-fx-fallback]').forEach(tbl => {
651
723
  const fallback = tbl.getAttribute('data-fx-fallback')
@@ -656,7 +728,6 @@ function hydrateTableErrors() {
656
728
  const tbody = tbl.querySelector('tbody')
657
729
  const originalEmpty = tbl.getAttribute('data-fx-empty') || 'No data.'
658
730
 
659
- // Override runQuery to detect errors for this table's binding
660
731
  const key = binding?.replace(/^@/, '') || ''
661
732
  if (key) {
662
733
  const cleanup = watch(key, (val) => {
@@ -680,7 +751,6 @@ function hydrateTableErrors() {
680
751
  }
681
752
  }
682
753
 
683
- // ── Boot ──────────────────────────────────────────────────────────
684
754
  function boot() {
685
755
  loadSSRData()
686
756
  injectActionCSS()
@@ -695,10 +765,12 @@ function boot() {
695
765
  hydrateSelects()
696
766
  hydrateIfs()
697
767
  hydrateEach()
768
+ hydrateCharts()
769
+ hydrateKanban()
770
+ hydrateEditors()
698
771
  mountQueries()
699
772
  }
700
773
 
701
- // ── Hydrate each @list { template } ──────────────────────────────
702
774
  function hydrateEach() {
703
775
  document.querySelectorAll('[data-fx-each]').forEach(wrap => {
704
776
  const binding = wrap.getAttribute('data-fx-each')
@@ -721,7 +793,7 @@ function hydrateEach() {
721
793
  items.forEach(item => {
722
794
  const div = document.createElement('div')
723
795
  div.className = 'fx-each-item'
724
- // Interpolate {item.field} syntax in template
796
+
725
797
  const html = tpl.replace(/\{item\.([^}]+)\}/g, (_, field) => {
726
798
  const parts = field.split('.')
727
799
  let val = item
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,25 @@ 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') {
765
+ 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})
766
+ } else if(pts[0]==='ssr') {
767
+ const target = (ai!==-1 ? pts[ai+1] : pts[3] || ('@'+(pts[2]?.split('/').filter(Boolean).pop()||'data'))).replace(/^@/,'')
768
+ p.ssr = p.ssr || []
769
+ p.ssr.push({ method: pts[1]||'GET', path: pts[2], target })
770
+ } else if(pts[0]==='store') {
771
+ const sk = (pts[1]||'').replace(/^@/,'')
772
+ p.stores = p.stores || []
773
+ p.stores.push({ key: sk, method: pts[2], path: pts[3], persist: pts.find(x=>x.startsWith('persist='))?.slice(8)||'session' })
774
+ p.queries.push({ trigger:'mount', method:pts[2], path:pts[3], target:'@'+sk })
775
+ }}
734
776
  else p.blocks.push({kind:blockKind(line),rawLine:line})
735
777
  }
736
778
  return p
@@ -1797,7 +1839,7 @@ async function startServer(aipFile, port = 3000) {
1797
1839
 
1798
1840
  // Health
1799
1841
  srv.addRoute('GET', '/health', (req, res) => res.json(200, {
1800
- status:'ok', version:'2.10.1',
1842
+ status:'ok', version:'2.10.3',
1801
1843
  models: app.models.map(m=>m.name),
1802
1844
  routes: app.apis.length, pages: app.pages.length,
1803
1845
  admin: app.admin?.prefix || null,