aiplang 2.10.2 → 2.10.4

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.2'
8
+ const VERSION = '2.10.4'
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.2",
3
+ "version": "2.10.4",
4
4
  "description": "AI-first web language. One .aip file = complete app. Frontend + backend + database + auth.",
5
5
  "keywords": [
6
6
  "aiplang",
@@ -6,21 +6,54 @@
6
6
  const cfg = window.__AIPLANG_PAGE__
7
7
  if (!cfg) return
8
8
 
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 ─────────────────────────────────────────────
9
21
  const _state = {}
10
22
  const _watchers = {}
23
+ const _storeKeys = new Set((cfg.stores || []).map(s => s.key))
11
24
 
12
- for (const [k, v] of Object.entries(cfg.state || {})) {
13
- 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 }
14
29
  }
15
30
 
16
31
  function get(key) { return _state[key] }
17
32
 
18
- function set(key, value) {
19
- if (JSON.stringify(_state[key]) === JSON.stringify(value)) return
33
+ function set(key, value, _persist) {
34
+ // Fast equality check: primitives first (avoid JSON.stringify for numbers/strings)
35
+ const old = _state[key]
36
+ if (old === value) return
37
+ if (typeof value !== 'object' && old === value) return
38
+ if (typeof value === 'object' && value !== null && typeof old === 'object' && old !== null) {
39
+ // Only deep check for objects/arrays — skip if different length (fast exit)
40
+ if (Array.isArray(value) && Array.isArray(old) && value.length !== old.length) {
41
+ // Different length — definitely changed
42
+ } else if (JSON.stringify(old) === JSON.stringify(value)) return
43
+ }
20
44
  _state[key] = value
45
+ if (_storeKeys.has(key) || _persist) syncStore(key, value)
21
46
  notify(key)
22
47
  }
23
48
 
49
+ // Cross-tab store sync (other pages update when store changes)
50
+ try {
51
+ const _bc = new BroadcastChannel(_STORE_KEY)
52
+ _bc.onmessage = ({ data: { key, value } }) => {
53
+ _state[key] = value; notify(key)
54
+ }
55
+ } catch {}
56
+
24
57
  function watch(key, cb) {
25
58
  if (!_watchers[key]) _watchers[key] = []
26
59
  _watchers[key].push(cb)
@@ -29,20 +62,26 @@ function watch(key, cb) {
29
62
 
30
63
  const _pending = new Set()
31
64
  let _batchScheduled = false
65
+ let _batchMode = 'raf' // 'raf' for animations, 'micro' for data updates
32
66
 
33
67
  function flushBatch() {
34
68
  _batchScheduled = false
35
- for (const key of _pending) {
69
+ const keys = [..._pending]
70
+ _pending.clear()
71
+ for (const key of keys) {
36
72
  ;(_watchers[key] || []).forEach(cb => cb(_state[key]))
37
73
  }
38
- _pending.clear()
39
74
  }
40
75
 
41
76
  function notify(key) {
42
77
  _pending.add(key)
43
78
  if (!_batchScheduled) {
44
79
  _batchScheduled = true
45
- requestAnimationFrame(flushBatch)
80
+ // Use microtask (Promise.resolve) for data fetches — fires faster than rAF
81
+ // Use rAF for user interaction (avoids mid-frame layout thrash)
82
+ Promise.resolve().then(() => {
83
+ if (_batchScheduled) requestAnimationFrame(flushBatch)
84
+ })
46
85
  }
47
86
  }
48
87
 
@@ -242,13 +281,27 @@ function hydrateTables() {
242
281
  }
243
282
  }
244
283
 
284
+ // ── Row cache for surgical DOM updates ──────────────────────────
285
+ // First render: full DocumentFragment build (fast)
286
+ // Re-renders: only update cells that actually changed
287
+ const _rowCache = new Map() // id → {score, status, ..., tr element}
288
+ const _colKeys = cols.map(c => c.key)
289
+ let _initialized = false
290
+
291
+ const renderRow = (row, idx) => {
292
+ // (defined above, used by virtual scroll too)
293
+ }
294
+
245
295
  const render = () => {
246
296
  const key = binding.startsWith('@') ? binding.slice(1) : binding
247
297
  let rows = get(key)
248
298
  if (!Array.isArray(rows)) rows = []
249
- tbody.innerHTML = ''
250
299
 
300
+ // Empty state
251
301
  if (!rows.length) {
302
+ tbody.innerHTML = ''
303
+ _rowCache.clear()
304
+ _initialized = false
252
305
  const tr = document.createElement('tr')
253
306
  const td = document.createElement('td')
254
307
  td.colSpan = cols.length + (editPath || delPath ? 1 : 0)
@@ -375,7 +428,128 @@ function hydrateTables() {
375
428
  tbody.appendChild(tr)
376
429
  }
377
430
 
378
- rows.forEach((row, idx) => renderRow(row, idx))
431
+ // ── Surgical update vs full initial build ────────────────────
432
+ if (!_initialized) {
433
+ // INITIAL: DocumentFragment for single layout pass
434
+ _initialized = true
435
+ tbody.innerHTML = ''
436
+ const frag = document.createDocumentFragment()
437
+ for (let i = 0; i < rows.length; i++) {
438
+ const row = rows[i]
439
+ const tr = document.createElement('tr')
440
+ tr.className = 'fx-tr'
441
+ tr.dataset.id = row.id || i
442
+
443
+ for (const col of cols) {
444
+ const td = document.createElement('td')
445
+ td.className = 'fx-td'
446
+ td.textContent = row[col.key] != null ? row[col.key] : ''
447
+ tr.appendChild(td)
448
+ }
449
+ // Action cells (edit + delete)
450
+ if (editPath || delPath) {
451
+ const actTd = document.createElement('td')
452
+ actTd.className = 'fx-td fx-td-actions'
453
+ actTd.style.cssText = 'white-space:nowrap'
454
+ if (editPath) {
455
+ const eb = document.createElement('button')
456
+ eb.className = 'fx-action-btn fx-edit-btn'; eb.textContent = '✎ Edit'
457
+ const _row = row, _i = i
458
+ eb.onclick = async () => {
459
+ const upd = await editModal(_row, cols, editPath, editMethod, key)
460
+ if (!upd) return
461
+ const arr = [...(get(key)||[])]; arr[_i]={..._row,...upd}; set(key, arr)
462
+ }
463
+ actTd.appendChild(eb)
464
+ }
465
+ if (delPath) {
466
+ const db = document.createElement('button')
467
+ db.className = 'fx-action-btn fx-delete-btn'; db.textContent = '✕ Delete'
468
+ const _row = row, _i = i
469
+ db.onclick = async () => {
470
+ if (!await confirm('Delete this record? This cannot be undone.')) return
471
+ const {ok,data} = await http('DELETE', resolvePath(delPath,_row), null)
472
+ if (ok) { set(key,(get(key)||[]).filter((_,j)=>j!==_i)); toast('Deleted','ok') }
473
+ else toast(data.message||'Error deleting','err')
474
+ }
475
+ actTd.appendChild(db)
476
+ }
477
+ tr.appendChild(actTd)
478
+ }
479
+
480
+ _rowCache.set(row.id != null ? row.id : i, {
481
+ vals: _colKeys.map(k => row[k]),
482
+ tr
483
+ })
484
+ frag.appendChild(tr)
485
+ }
486
+ tbody.appendChild(frag)
487
+ } else {
488
+ // UPDATE: surgical patches — only touch changed cells
489
+ const existingIds = new Set()
490
+ const tbodyRows = Array.from(tbody.querySelectorAll('tr.fx-tr'))
491
+
492
+ for (let i = 0; i < rows.length; i++) {
493
+ const row = rows[i]
494
+ const rowId = row.id != null ? row.id : i
495
+ existingIds.add(rowId)
496
+ const cached = _rowCache.get(rowId)
497
+
498
+ if (cached) {
499
+ // Row exists — only patch changed cells
500
+ const cells = cached.tr.querySelectorAll('.fx-td')
501
+ for (let c = 0; c < _colKeys.length; c++) {
502
+ const newVal = row[_colKeys[c]] != null ? String(row[_colKeys[c]]) : ''
503
+ if (String(cached.vals[c] != null ? cached.vals[c] : '') !== newVal) {
504
+ cells[c].textContent = newVal
505
+ cached.vals[c] = row[_colKeys[c]]
506
+ }
507
+ }
508
+ // Ensure tr is in correct position
509
+ if (tbodyRows[i] !== cached.tr) tbody.insertBefore(cached.tr, tbodyRows[i] || null)
510
+ } else {
511
+ // New row — create and insert
512
+ const tr = document.createElement('tr')
513
+ tr.className = 'fx-tr'
514
+ tr.dataset.id = rowId
515
+ for (const col of cols) {
516
+ const td = document.createElement('td')
517
+ td.className = 'fx-td'
518
+ td.textContent = row[col.key] != null ? row[col.key] : ''
519
+ tr.appendChild(td)
520
+ }
521
+ if (editPath || delPath) {
522
+ const actTd = document.createElement('td')
523
+ actTd.className = 'fx-td fx-td-actions'; actTd.style.cssText = 'white-space:nowrap'
524
+ if (editPath) {
525
+ const eb = document.createElement('button')
526
+ eb.className = 'fx-action-btn fx-edit-btn'; eb.textContent = '✎ Edit'
527
+ const _row = row, _i = i
528
+ eb.onclick = async () => { const upd = await editModal(_row,cols,editPath,editMethod,key); if(!upd) return; const arr=[...(get(key)||[])]; arr[_i]={..._row,...upd}; set(key,arr) }
529
+ actTd.appendChild(eb)
530
+ }
531
+ if (delPath) {
532
+ const db = document.createElement('button')
533
+ db.className = 'fx-action-btn fx-delete-btn'; db.textContent = '✕ Delete'
534
+ const _row = row, _i = i
535
+ db.onclick = async () => { if(!await confirm('Delete this record?')) return; const {ok,data}=await http('DELETE',resolvePath(delPath,_row),null); if(ok){set(key,(get(key)||[]).filter((_,j)=>j!==_i));toast('Deleted','ok')}else toast(data.message||'Error','err') }
536
+ actTd.appendChild(db)
537
+ }
538
+ tr.appendChild(actTd)
539
+ }
540
+ _rowCache.set(rowId, { vals: _colKeys.map(k => row[k]), tr })
541
+ tbody.insertBefore(tr, tbody.querySelectorAll('tr.fx-tr')[i] || null)
542
+ }
543
+ }
544
+
545
+ // Remove deleted rows
546
+ for (const [id, cached] of _rowCache.entries()) {
547
+ if (!existingIds.has(id)) {
548
+ cached.tr.remove()
549
+ _rowCache.delete(id)
550
+ }
551
+ }
552
+ }
379
553
  }
380
554
 
381
555
  const stateKey = binding.startsWith('@') ? binding.slice(1) : binding
@@ -486,9 +660,22 @@ function hydrateBindings() {
486
660
  document.querySelectorAll('[data-fx-bind]').forEach(el => {
487
661
  const expr = el.getAttribute('data-fx-bind')
488
662
  const keys = (expr.match(/[@$][a-zA-Z_][a-zA-Z0-9_.]*/g) || []).map(m => m.slice(1).split('.')[0])
489
- const update = () => { el.textContent = resolve(expr) }
490
- for (const key of keys) watch(key, update)
491
- update()
663
+ // Fast path: single key with simple path direct textContent assignment
664
+ const simpleM = expr.match(/^[@$]([a-zA-Z_][a-zA-Z0-9_.]*)$/)
665
+ if (simpleM) {
666
+ const path = simpleM[1].split('.')
667
+ const update = () => {
668
+ let v = get(path[0])
669
+ for (let i=1;i<path.length;i++) v = v?.[path[i]]
670
+ el.textContent = v != null ? v : ''
671
+ }
672
+ for (const key of keys) watch(key, update)
673
+ update()
674
+ } else {
675
+ const update = () => { el.textContent = resolve(expr) }
676
+ for (const key of keys) watch(key, update)
677
+ update()
678
+ }
492
679
  })
493
680
  }
494
681
 
@@ -741,6 +928,9 @@ function boot() {
741
928
  hydrateSelects()
742
929
  hydrateIfs()
743
930
  hydrateEach()
931
+ hydrateCharts()
932
+ hydrateKanban()
933
+ hydrateEditors()
744
934
  mountQueries()
745
935
  }
746
936
 
package/server/server.js CHANGED
@@ -761,7 +761,18 @@ function parseFrontPage(src) {
761
761
  // Auto-detect target from path if not specified: ~mount GET /api/users → @users
762
762
  const autoTarget = pts[3] || ('@' + (pts[2]?.split('/').filter(Boolean).pop()?.split('?')[0]||'data'))
763
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})}
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
+ }}
765
776
  else p.blocks.push({kind:blockKind(line),rawLine:line})
766
777
  }
767
778
  return p
@@ -1828,7 +1839,7 @@ async function startServer(aipFile, port = 3000) {
1828
1839
 
1829
1840
  // Health
1830
1841
  srv.addRoute('GET', '/health', (req, res) => res.json(200, {
1831
- status:'ok', version:'2.10.2',
1842
+ status:'ok', version:'2.10.4',
1832
1843
  models: app.models.map(m=>m.name),
1833
1844
  routes: app.apis.length, pages: app.pages.length,
1834
1845
  admin: app.admin?.prefix || null,