aiplang 2.10.1 → 2.10.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/aiplang.js +1 -1
- package/package.json +1 -1
- package/runtime/aiplang-hydrate.js +113 -68
- package/server/server.js +39 -8
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.
|
|
8
|
+
const VERSION = '2.10.2'
|
|
9
9
|
const RUNTIME_DIR = path.join(__dirname, '..', 'runtime')
|
|
10
10
|
const cmd = process.argv[2]
|
|
11
11
|
const args = process.argv.slice(3)
|
package/package.json
CHANGED
|
@@ -6,7 +6,6 @@
|
|
|
6
6
|
const cfg = window.__AIPLANG_PAGE__
|
|
7
7
|
if (!cfg) return
|
|
8
8
|
|
|
9
|
-
// ── State ────────────────────────────────────────────────────────
|
|
10
9
|
const _state = {}
|
|
11
10
|
const _watchers = {}
|
|
12
11
|
|
|
@@ -28,7 +27,6 @@ function watch(key, cb) {
|
|
|
28
27
|
return () => { _watchers[key] = _watchers[key].filter(f => f !== cb) }
|
|
29
28
|
}
|
|
30
29
|
|
|
31
|
-
// Batched notify — queues all pending updates and flushes in rAF (like React's batching)
|
|
32
30
|
const _pending = new Set()
|
|
33
31
|
let _batchScheduled = false
|
|
34
32
|
|
|
@@ -48,7 +46,6 @@ function notify(key) {
|
|
|
48
46
|
}
|
|
49
47
|
}
|
|
50
48
|
|
|
51
|
-
// Force immediate flush (for critical updates like form submit)
|
|
52
49
|
function notifySync(key) {
|
|
53
50
|
;(_watchers[key] || []).forEach(cb => cb(_state[key]))
|
|
54
51
|
}
|
|
@@ -63,12 +60,10 @@ function resolve(str) {
|
|
|
63
60
|
})
|
|
64
61
|
}
|
|
65
62
|
|
|
66
|
-
// Resolve path with row data: /api/users/{id} + {id:1} → /api/users/1
|
|
67
63
|
function resolvePath(tmpl, row) {
|
|
68
64
|
return tmpl.replace(/\{([^}]+)\}/g, (_, k) => row?.[k] ?? get(k) ?? '')
|
|
69
65
|
}
|
|
70
66
|
|
|
71
|
-
// ── Query Engine ─────────────────────────────────────────────────
|
|
72
67
|
const _intervals = []
|
|
73
68
|
|
|
74
69
|
async function runQuery(q) {
|
|
@@ -96,11 +91,11 @@ function applyAction(data, target, action) {
|
|
|
96
91
|
if (pm) { set(pm[1], [...(get(pm[1]) || []), data]); return }
|
|
97
92
|
const fm = action.match(/^@([a-zA-Z_]+)\.filter\((.+)\)$/)
|
|
98
93
|
if (fm) {
|
|
99
|
-
|
|
94
|
+
|
|
100
95
|
try {
|
|
101
96
|
const expr = fm[2].trim()
|
|
102
97
|
const filtered = (get(fm[1]) || []).filter(item => {
|
|
103
|
-
|
|
98
|
+
|
|
104
99
|
const eq = expr.match(/^([a-zA-Z_.]+)\s*(!?=)\s*(.+)$/)
|
|
105
100
|
if (eq) {
|
|
106
101
|
const [, field, op, val] = eq
|
|
@@ -132,7 +127,6 @@ function mountQueries() {
|
|
|
132
127
|
}
|
|
133
128
|
}
|
|
134
129
|
|
|
135
|
-
// ── HTTP helper ──────────────────────────────────────────────────
|
|
136
130
|
async function http(method, path, body) {
|
|
137
131
|
const opts = { method, headers: { 'Content-Type': 'application/json' } }
|
|
138
132
|
if (body) opts.body = JSON.stringify(body)
|
|
@@ -141,7 +135,6 @@ async function http(method, path, body) {
|
|
|
141
135
|
return { ok: res.ok, status: res.status, data }
|
|
142
136
|
}
|
|
143
137
|
|
|
144
|
-
// ── Toast notifications ──────────────────────────────────────────
|
|
145
138
|
function toast(msg, type) {
|
|
146
139
|
const t = document.createElement('div')
|
|
147
140
|
t.textContent = msg
|
|
@@ -158,7 +151,6 @@ function toast(msg, type) {
|
|
|
158
151
|
setTimeout(() => { t.style.opacity = '0'; setTimeout(() => t.remove(), 300) }, 2500)
|
|
159
152
|
}
|
|
160
153
|
|
|
161
|
-
// ── Confirm modal ────────────────────────────────────────────────
|
|
162
154
|
function confirm(msg) {
|
|
163
155
|
return new Promise(resolve => {
|
|
164
156
|
const overlay = document.createElement('div')
|
|
@@ -179,7 +171,6 @@ function confirm(msg) {
|
|
|
179
171
|
})
|
|
180
172
|
}
|
|
181
173
|
|
|
182
|
-
// ── Edit modal ───────────────────────────────────────────────────
|
|
183
174
|
function editModal(row, cols, path, method, stateKey) {
|
|
184
175
|
return new Promise(resolve => {
|
|
185
176
|
const overlay = document.createElement('div')
|
|
@@ -228,21 +219,19 @@ function editModal(row, cols, path, method, stateKey) {
|
|
|
228
219
|
})
|
|
229
220
|
}
|
|
230
221
|
|
|
231
|
-
// ── Hydrate tables with CRUD ─────────────────────────────────────
|
|
232
222
|
function hydrateTables() {
|
|
233
223
|
document.querySelectorAll('[data-fx-table]').forEach(tbl => {
|
|
234
224
|
const binding = tbl.getAttribute('data-fx-table')
|
|
235
225
|
const colsJSON = tbl.getAttribute('data-fx-cols')
|
|
236
|
-
const editPath = tbl.getAttribute('data-fx-edit')
|
|
226
|
+
const editPath = tbl.getAttribute('data-fx-edit')
|
|
237
227
|
const editMethod= tbl.getAttribute('data-fx-edit-method') || 'PUT'
|
|
238
|
-
const delPath = tbl.getAttribute('data-fx-delete')
|
|
228
|
+
const delPath = tbl.getAttribute('data-fx-delete')
|
|
239
229
|
const delKey = tbl.getAttribute('data-fx-delete-key') || 'id'
|
|
240
230
|
|
|
241
231
|
const cols = colsJSON ? JSON.parse(colsJSON) : []
|
|
242
232
|
const tbody = tbl.querySelector('tbody')
|
|
243
233
|
if (!tbody) return
|
|
244
234
|
|
|
245
|
-
// Add action column headers if needed
|
|
246
235
|
if ((editPath || delPath) && tbl.querySelector('thead tr')) {
|
|
247
236
|
const thead = tbl.querySelector('thead tr')
|
|
248
237
|
if (!thead.querySelector('.fx-th-actions')) {
|
|
@@ -269,37 +258,71 @@ function hydrateTables() {
|
|
|
269
258
|
return
|
|
270
259
|
}
|
|
271
260
|
|
|
272
|
-
|
|
273
|
-
const
|
|
274
|
-
const
|
|
261
|
+
const VIRTUAL_THRESHOLD = 80
|
|
262
|
+
const OVERSCAN = 8
|
|
263
|
+
const colSpanTotal = cols.length + (editPath || delPath ? 1 : 0)
|
|
275
264
|
const useVirtual = rows.length >= VIRTUAL_THRESHOLD
|
|
265
|
+
let rowHeights = null, totalHeight = 0, scrollListener = null
|
|
276
266
|
|
|
277
267
|
if (useVirtual) {
|
|
278
|
-
const wrapDiv =
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
const
|
|
282
|
-
const
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
268
|
+
const wrapDiv = tbl.closest('.fx-table-wrap') || tbl.parentElement
|
|
269
|
+
wrapDiv.style.cssText += ';max-height:520px;overflow-y:auto;position:relative'
|
|
270
|
+
|
|
271
|
+
const measureRow = rows[0]
|
|
272
|
+
const tempTr = document.createElement('tr')
|
|
273
|
+
tempTr.style.visibility = 'hidden'
|
|
274
|
+
cols.forEach(col => {
|
|
275
|
+
const td = document.createElement('td'); td.className = 'fx-td'
|
|
276
|
+
td.textContent = measureRow[col.key] || ''; tempTr.appendChild(td)
|
|
277
|
+
})
|
|
278
|
+
tbody.appendChild(tempTr)
|
|
279
|
+
const rowH = Math.max(tempTr.getBoundingClientRect().height, 40) || 44
|
|
280
|
+
tbody.removeChild(tempTr)
|
|
281
|
+
|
|
282
|
+
const viewH = wrapDiv.clientHeight || 480
|
|
283
|
+
const visibleCount = Math.ceil(viewH / rowH) + OVERSCAN * 2
|
|
284
|
+
|
|
285
|
+
const renderVirtual = () => {
|
|
286
|
+
const scrollTop = wrapDiv.scrollTop
|
|
287
|
+
const startRaw = Math.floor(scrollTop / rowH)
|
|
288
|
+
const start = Math.max(0, startRaw - OVERSCAN)
|
|
289
|
+
const end = Math.min(rows.length - 1, start + visibleCount)
|
|
290
|
+
const paddingTop = start * rowH
|
|
291
|
+
const paddingBot = Math.max(0, (rows.length - end - 1) * rowH)
|
|
292
|
+
|
|
293
|
+
tbody.innerHTML = ''
|
|
294
|
+
|
|
295
|
+
if (paddingTop > 0) {
|
|
296
|
+
const tr = document.createElement('tr')
|
|
297
|
+
const td = document.createElement('td')
|
|
298
|
+
td.colSpan = colSpanTotal; td.style.cssText = 'height:'+paddingTop+'px;padding:0;border:none'
|
|
299
|
+
tr.appendChild(td); tbody.appendChild(tr)
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
for (let i = start; i <= end; i++) renderRow(rows[i], i)
|
|
303
|
+
|
|
304
|
+
if (paddingBot > 0) {
|
|
305
|
+
const tr = document.createElement('tr')
|
|
306
|
+
const td = document.createElement('td')
|
|
307
|
+
td.colSpan = colSpanTotal; td.style.cssText = 'height:'+paddingBot+'px;padding:0;border:none'
|
|
308
|
+
tr.appendChild(td); tbody.appendChild(tr)
|
|
309
|
+
}
|
|
293
310
|
}
|
|
294
|
-
|
|
311
|
+
|
|
312
|
+
let rafPending = false
|
|
313
|
+
scrollListener = () => {
|
|
314
|
+
if (rafPending) return; rafPending = true
|
|
315
|
+
requestAnimationFrame(() => { rafPending = false; renderVirtual() })
|
|
316
|
+
}
|
|
317
|
+
wrapDiv.addEventListener('scroll', scrollListener, { passive: true })
|
|
318
|
+
renderVirtual()
|
|
319
|
+
return
|
|
295
320
|
}
|
|
296
321
|
|
|
297
|
-
|
|
322
|
+
function renderRow(row, idx) {
|
|
298
323
|
const tr = document.createElement('tr')
|
|
299
324
|
tr.className = 'fx-tr'
|
|
300
|
-
if (useVirtual) tr.style.height = ROW_HEIGHT + 'px'
|
|
301
325
|
|
|
302
|
-
// Data cells
|
|
303
326
|
for (const col of cols) {
|
|
304
327
|
const td = document.createElement('td')
|
|
305
328
|
td.className = 'fx-td'
|
|
@@ -307,7 +330,6 @@ function hydrateTables() {
|
|
|
307
330
|
tr.appendChild(td)
|
|
308
331
|
}
|
|
309
332
|
|
|
310
|
-
// Action cell
|
|
311
333
|
if (editPath || delPath) {
|
|
312
334
|
const td = document.createElement('td')
|
|
313
335
|
td.className = 'fx-td fx-td-actions'
|
|
@@ -351,7 +373,9 @@ function hydrateTables() {
|
|
|
351
373
|
tr.appendChild(td)
|
|
352
374
|
}
|
|
353
375
|
tbody.appendChild(tr)
|
|
354
|
-
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
rows.forEach((row, idx) => renderRow(row, idx))
|
|
355
379
|
}
|
|
356
380
|
|
|
357
381
|
const stateKey = binding.startsWith('@') ? binding.slice(1) : binding
|
|
@@ -360,7 +384,6 @@ function hydrateTables() {
|
|
|
360
384
|
})
|
|
361
385
|
}
|
|
362
386
|
|
|
363
|
-
// ── Hydrate lists ────────────────────────────────────────────────
|
|
364
387
|
function hydrateLists() {
|
|
365
388
|
document.querySelectorAll('[data-fx-list]').forEach(wrap => {
|
|
366
389
|
const binding = wrap.getAttribute('data-fx-list')
|
|
@@ -389,7 +412,6 @@ function hydrateLists() {
|
|
|
389
412
|
})
|
|
390
413
|
}
|
|
391
414
|
|
|
392
|
-
// ── Hydrate forms ─────────────────────────────────────────────────
|
|
393
415
|
function hydrateForms() {
|
|
394
416
|
document.querySelectorAll('[data-fx-form]').forEach(form => {
|
|
395
417
|
const path = form.getAttribute('data-fx-form')
|
|
@@ -424,8 +446,6 @@ function hydrateForms() {
|
|
|
424
446
|
})
|
|
425
447
|
}
|
|
426
448
|
|
|
427
|
-
// ── Hydrate btns ──────────────────────────────────────────────────
|
|
428
|
-
// <button data-fx-btn="/api/path" data-fx-method="POST" data-fx-action="...">
|
|
429
449
|
function hydrateBtns() {
|
|
430
450
|
document.querySelectorAll('[data-fx-btn]').forEach(btn => {
|
|
431
451
|
const path = btn.getAttribute('data-fx-btn')
|
|
@@ -452,8 +472,6 @@ function hydrateBtns() {
|
|
|
452
472
|
})
|
|
453
473
|
}
|
|
454
474
|
|
|
455
|
-
// ── Hydrate select dropdowns ──────────────────────────────────────
|
|
456
|
-
// <select data-fx-model="@filter"> sets @filter on change
|
|
457
475
|
function hydrateSelects() {
|
|
458
476
|
document.querySelectorAll('[data-fx-model]').forEach(sel => {
|
|
459
477
|
const binding = sel.getAttribute('data-fx-model')
|
|
@@ -464,7 +482,6 @@ function hydrateSelects() {
|
|
|
464
482
|
})
|
|
465
483
|
}
|
|
466
484
|
|
|
467
|
-
// ── Hydrate text bindings ─────────────────────────────────────────
|
|
468
485
|
function hydrateBindings() {
|
|
469
486
|
document.querySelectorAll('[data-fx-bind]').forEach(el => {
|
|
470
487
|
const expr = el.getAttribute('data-fx-bind')
|
|
@@ -475,7 +492,6 @@ function hydrateBindings() {
|
|
|
475
492
|
})
|
|
476
493
|
}
|
|
477
494
|
|
|
478
|
-
// ── Hydrate conditionals ──────────────────────────────────────────
|
|
479
495
|
function hydrateIfs() {
|
|
480
496
|
document.querySelectorAll('[data-fx-if]').forEach(wrap => {
|
|
481
497
|
const cond = wrap.getAttribute('data-fx-if')
|
|
@@ -496,9 +512,8 @@ function hydrateIfs() {
|
|
|
496
512
|
})
|
|
497
513
|
}
|
|
498
514
|
|
|
499
|
-
// ── Advanced Animations (scroll-triggered + stagger) ─────────────
|
|
500
515
|
function initAnimations() {
|
|
501
|
-
|
|
516
|
+
|
|
502
517
|
const style = document.createElement('style')
|
|
503
518
|
style.textContent = `
|
|
504
519
|
@keyframes fx-blur-in { from{opacity:0;filter:blur(8px);transform:translateY(8px)} to{opacity:1;filter:blur(0);transform:none} }
|
|
@@ -532,7 +547,6 @@ function initAnimations() {
|
|
|
532
547
|
`
|
|
533
548
|
document.head.appendChild(style)
|
|
534
549
|
|
|
535
|
-
// Intersection Observer — trigger when element scrolls into view (like Framer whileInView)
|
|
536
550
|
const observer = new IntersectionObserver((entries) => {
|
|
537
551
|
entries.forEach(entry => {
|
|
538
552
|
if (entry.isIntersecting) {
|
|
@@ -543,14 +557,57 @@ function initAnimations() {
|
|
|
543
557
|
}, { threshold: 0.12, rootMargin: '0px 0px -30px 0px' })
|
|
544
558
|
|
|
545
559
|
document.querySelectorAll('[class*="fx-anim-"]').forEach(el => {
|
|
546
|
-
|
|
560
|
+
|
|
547
561
|
if (el.classList.contains('fx-anim-bounce') || el.classList.contains('fx-anim-pulse')) {
|
|
548
562
|
el.classList.add('fx-visible'); return
|
|
549
563
|
}
|
|
550
564
|
observer.observe(el)
|
|
551
565
|
})
|
|
552
566
|
|
|
553
|
-
|
|
567
|
+
window.aiplang = window.aiplang || {}
|
|
568
|
+
window.aiplang.spring = function(el, prop, from, to, opts = {}) {
|
|
569
|
+
const k = opts.stiffness || 180
|
|
570
|
+
const b = opts.damping || 22
|
|
571
|
+
const m = opts.mass || 1
|
|
572
|
+
let pos = from, vel = 0
|
|
573
|
+
const dt = 1/60
|
|
574
|
+
let raf
|
|
575
|
+
|
|
576
|
+
const tick = () => {
|
|
577
|
+
const F = -k * (pos - to) - b * vel
|
|
578
|
+
vel += (F / m) * dt
|
|
579
|
+
pos += vel * dt
|
|
580
|
+
if (Math.abs(pos - to) < 0.01 && Math.abs(vel) < 0.01) {
|
|
581
|
+
pos = to
|
|
582
|
+
el.style[prop] = pos + (opts.unit || 'px')
|
|
583
|
+
return
|
|
584
|
+
}
|
|
585
|
+
el.style[prop] = pos + (opts.unit || 'px')
|
|
586
|
+
raf = requestAnimationFrame(tick)
|
|
587
|
+
}
|
|
588
|
+
cancelAnimationFrame(raf)
|
|
589
|
+
requestAnimationFrame(tick)
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
const springObs = new IntersectionObserver(entries => {
|
|
593
|
+
entries.forEach(entry => {
|
|
594
|
+
if (!entry.isIntersecting) return
|
|
595
|
+
const el = entry.target
|
|
596
|
+
if (el.classList.contains('fx-anim-spring')) {
|
|
597
|
+
el.style.opacity = '1'
|
|
598
|
+
el.style.transform = 'translateY(0px)'
|
|
599
|
+
window.aiplang.spring(el, '--spring-y', 24, 0, { stiffness: 200, damping: 20, unit: 'px' })
|
|
600
|
+
springObs.unobserve(el)
|
|
601
|
+
}
|
|
602
|
+
})
|
|
603
|
+
}, { threshold: 0.1 })
|
|
604
|
+
|
|
605
|
+
document.querySelectorAll('.fx-anim-spring').forEach(el => {
|
|
606
|
+
el.style.opacity = '0'
|
|
607
|
+
el.style.transform = 'translateY(24px)'
|
|
608
|
+
springObs.observe(el)
|
|
609
|
+
})
|
|
610
|
+
|
|
554
611
|
document.querySelectorAll('.fx-stat-val').forEach(el => {
|
|
555
612
|
const target = parseFloat(el.textContent)
|
|
556
613
|
if (isNaN(target) || target === 0) return
|
|
@@ -576,7 +633,6 @@ function initAnimations() {
|
|
|
576
633
|
})
|
|
577
634
|
}
|
|
578
635
|
|
|
579
|
-
// ── Inject action column CSS ──────────────────────────────────────
|
|
580
636
|
function injectActionCSS() {
|
|
581
637
|
const style = document.createElement('style')
|
|
582
638
|
style.textContent = `
|
|
@@ -595,9 +651,6 @@ function injectActionCSS() {
|
|
|
595
651
|
document.head.appendChild(style)
|
|
596
652
|
}
|
|
597
653
|
|
|
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
654
|
function loadSSRData() {
|
|
602
655
|
const ssr = window.__SSR_DATA__
|
|
603
656
|
if (!ssr) return
|
|
@@ -606,8 +659,6 @@ function loadSSRData() {
|
|
|
606
659
|
}
|
|
607
660
|
}
|
|
608
661
|
|
|
609
|
-
// ── Optimistic UI ─────────────────────────────────────────────────
|
|
610
|
-
// form data-fx-optimistic="true": updates state instantly, rolls back on error
|
|
611
662
|
function hydrateOptimistic() {
|
|
612
663
|
document.querySelectorAll('[data-fx-optimistic]').forEach(form => {
|
|
613
664
|
const action = form.getAttribute('data-fx-action') || ''
|
|
@@ -616,7 +667,7 @@ function hydrateOptimistic() {
|
|
|
616
667
|
const key = pm[1]
|
|
617
668
|
|
|
618
669
|
form.addEventListener('submit', (e) => {
|
|
619
|
-
|
|
670
|
+
|
|
620
671
|
const body = {}
|
|
621
672
|
form.querySelectorAll('input,select,textarea').forEach(inp => {
|
|
622
673
|
if (inp.name) body[inp.name] = inp.value
|
|
@@ -626,26 +677,23 @@ function hydrateOptimistic() {
|
|
|
626
677
|
const current = [...(get(key) || [])]
|
|
627
678
|
set(key, [...current, optimisticItem])
|
|
628
679
|
|
|
629
|
-
// After actual submit (handled by hydrateForms), remove temp if error
|
|
630
680
|
const origAction = form.getAttribute('data-fx-action')
|
|
631
681
|
form.setAttribute('data-fx-action-orig', origAction)
|
|
632
682
|
form.setAttribute('data-fx-action', `@${key}._rollback_${tempId}`)
|
|
633
683
|
|
|
634
|
-
// Restore action after tick
|
|
635
684
|
setTimeout(() => {
|
|
636
685
|
form.setAttribute('data-fx-action', origAction)
|
|
637
|
-
|
|
686
|
+
|
|
638
687
|
setTimeout(() => {
|
|
639
688
|
const arr = get(key) || []
|
|
640
689
|
const hasReal = arr.some(i => !i._optimistic)
|
|
641
690
|
if (hasReal) set(key, arr.filter(i => !i._optimistic || i.id !== tempId))
|
|
642
691
|
}, 500)
|
|
643
692
|
}, 50)
|
|
644
|
-
}, true)
|
|
693
|
+
}, true)
|
|
645
694
|
})
|
|
646
695
|
}
|
|
647
696
|
|
|
648
|
-
// ── Error recovery — fallback + retry ────────────────────────────
|
|
649
697
|
function hydrateTableErrors() {
|
|
650
698
|
document.querySelectorAll('[data-fx-fallback]').forEach(tbl => {
|
|
651
699
|
const fallback = tbl.getAttribute('data-fx-fallback')
|
|
@@ -656,7 +704,6 @@ function hydrateTableErrors() {
|
|
|
656
704
|
const tbody = tbl.querySelector('tbody')
|
|
657
705
|
const originalEmpty = tbl.getAttribute('data-fx-empty') || 'No data.'
|
|
658
706
|
|
|
659
|
-
// Override runQuery to detect errors for this table's binding
|
|
660
707
|
const key = binding?.replace(/^@/, '') || ''
|
|
661
708
|
if (key) {
|
|
662
709
|
const cleanup = watch(key, (val) => {
|
|
@@ -680,7 +727,6 @@ function hydrateTableErrors() {
|
|
|
680
727
|
}
|
|
681
728
|
}
|
|
682
729
|
|
|
683
|
-
// ── Boot ──────────────────────────────────────────────────────────
|
|
684
730
|
function boot() {
|
|
685
731
|
loadSSRData()
|
|
686
732
|
injectActionCSS()
|
|
@@ -698,7 +744,6 @@ function boot() {
|
|
|
698
744
|
mountQueries()
|
|
699
745
|
}
|
|
700
746
|
|
|
701
|
-
// ── Hydrate each @list { template } ──────────────────────────────
|
|
702
747
|
function hydrateEach() {
|
|
703
748
|
document.querySelectorAll('[data-fx-each]').forEach(wrap => {
|
|
704
749
|
const binding = wrap.getAttribute('data-fx-each')
|
|
@@ -721,7 +766,7 @@ function hydrateEach() {
|
|
|
721
766
|
items.forEach(item => {
|
|
722
767
|
const div = document.createElement('div')
|
|
723
768
|
div.className = 'fx-each-item'
|
|
724
|
-
|
|
769
|
+
|
|
725
770
|
const html = tpl.replace(/\{item\.([^}]+)\}/g, (_, field) => {
|
|
726
771
|
const parts = field.split('.')
|
|
727
772
|
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 !== '{')
|
|
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
|
|
625
|
-
|
|
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+/);
|
|
666
|
+
function parseDBLine(s) { const p=s.split(/\s+/); const d=p[0]||'sqlite'; return{driver:d==='pg'||d==='psql'?'postgres':d,dsn:p[1]||'./app.db'} }
|
|
652
667
|
function parseAuthLine(s) { const p=s.split(/\s+/); const a={provider:'jwt',secret:p[1]||'$JWT_SECRET',expire:'7d',refresh:'30d'}; for(const x of p){if(x.startsWith('expire='))a.expire=x.slice(7);if(x.startsWith('refresh='))a.refresh=x.slice(8);if(x==='google')a.oauth=['google'];if(x==='github')a.oauth=[...(a.oauth||[]),'google']}; return a }
|
|
653
668
|
function parseMailLine(s) { const parts=s.split(/\s+/); const m={driver:parts[0]||'smtp'}; for(const x of parts.slice(1)){const[k,v]=x.split('='); m[k]=v}; return m }
|
|
654
669
|
function parseStripeLine(s) {
|
|
@@ -708,7 +723,6 @@ function parseEventLine(s) { const m=s.match(/^(\S+)\s*=>\s*(.+)$/); return{even
|
|
|
708
723
|
function parseField(line) {
|
|
709
724
|
const p=line.split(':').map(s=>s.trim())
|
|
710
725
|
const f={name:p[0],type:p[1]||'text',modifiers:[],enumVals:[],default:null}
|
|
711
|
-
// If type is enum, p[2] contains comma-separated values directly
|
|
712
726
|
if (f.type === 'enum' && p[2] && !p[2].startsWith('default=') && !['required','unique','hashed','pk','auto','index'].includes(p[2])) {
|
|
713
727
|
f.enumVals = p[2].split(',').map(v=>v.trim()).filter(Boolean)
|
|
714
728
|
for(let j=3;j<p.length;j++){const x=p[j];if(x.startsWith('default='))f.default=x.slice(8);else if(x)f.modifiers.push(x)}
|
|
@@ -717,6 +731,19 @@ function parseField(line) {
|
|
|
717
731
|
}
|
|
718
732
|
return f
|
|
719
733
|
}
|
|
734
|
+
|
|
735
|
+
// Compact model field: "email:text:unique:required" single-line
|
|
736
|
+
function parseFieldCompact(def) {
|
|
737
|
+
const parts = def.trim().split(':').map(s=>s.trim()).filter(Boolean)
|
|
738
|
+
const f = {name:parts[0], type:parts[1]||'text', modifiers:[], enumVals:[], default:null}
|
|
739
|
+
for (let i=2; i<parts.length; i++) {
|
|
740
|
+
const x = parts[i]
|
|
741
|
+
if (x.startsWith('default=')) f.default = x.slice(8)
|
|
742
|
+
else if (/^[a-z]+,[a-z]/.test(x)) f.enumVals = x.split(',').map(v=>v.trim())
|
|
743
|
+
else f.modifiers.push(x)
|
|
744
|
+
}
|
|
745
|
+
return f
|
|
746
|
+
}
|
|
720
747
|
function parseAPILine(line, route) {
|
|
721
748
|
if(line.startsWith('~guard ')) route.guards=line.slice(7).split('|').map(s=>s.trim())
|
|
722
749
|
else if(line.startsWith('~validate ')) line.slice(10).split('|').forEach(v=>{const p=v.trim().split(/\s+/);if(p[0])route.validate.push({field:p[0],rules:p.slice(1)})})
|
|
@@ -727,10 +754,14 @@ function parseFrontPage(src) {
|
|
|
727
754
|
const lines=src.split('\n').map(l=>l.trim()).filter(l=>l&&!l.startsWith('#'))
|
|
728
755
|
const p={id:'page',theme:'dark',route:'/',themeVars:null,state:{},queries:[],blocks:[]}
|
|
729
756
|
for(const line of lines){
|
|
730
|
-
if(line.startsWith('%')){const pts=line.slice(1).trim().split(/\s+/);p.id=pts[0]||'page';p.route=pts[2]||'/';const rt=pts[1]||'dark';if(rt.includes('#')){const c=rt.split(',');p.theme='custom';p.customTheme={bg:c[0],text:c[1]||'#f1f5f9',accent:c[2]||'#2563eb'}}else p.theme=rt}
|
|
757
|
+
if(line.startsWith('%')){const pts=line.slice(1).trim().split(/\s+/);p.id=pts[0]||'page';p.route=pts[2]||'/';const cachePt=pts.find(x=>x.startsWith('cache='));if(cachePt)p.cacheTTL=parseInt(cachePt.slice(6));const rt=pts[1]||'dark';if(rt.includes('#')){const c=rt.split(',');p.theme='custom';p.customTheme={bg:c[0],text:c[1]||'#f1f5f9',accent:c[2]||'#2563eb'}}else p.theme=rt}
|
|
731
758
|
else if(line.startsWith('~theme ')){p.themeVars=p.themeVars||{};line.slice(7).trim().split(/\s+/).forEach(pair=>{const eq=pair.indexOf('=');if(eq!==-1)p.themeVars[pair.slice(0,eq)]=pair.slice(eq+1)})}
|
|
732
759
|
else if(line.startsWith('@')&&line.includes('=')){const eq=line.indexOf('=');p.state[line.slice(1,eq).trim()]=line.slice(eq+1).trim()}
|
|
733
|
-
else if(line.startsWith('~')){const pts=line.slice(1).trim().split(/\s+/);const ai=pts.indexOf('=>');if(pts[0]==='mount')
|
|
760
|
+
else if(line.startsWith('~')){const pts=line.slice(1).trim().split(/\s+/);const ai=pts.indexOf('=>');if(pts[0]==='mount'){
|
|
761
|
+
// Auto-detect target from path if not specified: ~mount GET /api/users → @users
|
|
762
|
+
const autoTarget = pts[3] || ('@' + (pts[2]?.split('/').filter(Boolean).pop()?.split('?')[0]||'data'))
|
|
763
|
+
p.queries.push({trigger:'mount',method:pts[1],path:pts[2],target:ai===-1?autoTarget:null,action:ai!==-1?pts.slice(ai+1).join(' '):null})
|
|
764
|
+
} else if(pts[0]==='interval')p.queries.push({trigger:'interval',interval:parseInt(pts[1]),method:pts[2],path:pts[3],target:ai===-1?pts[4]:null,action:ai!==-1?pts.slice(ai+1).join(' '):null})}
|
|
734
765
|
else p.blocks.push({kind:blockKind(line),rawLine:line})
|
|
735
766
|
}
|
|
736
767
|
return p
|
|
@@ -1797,7 +1828,7 @@ async function startServer(aipFile, port = 3000) {
|
|
|
1797
1828
|
|
|
1798
1829
|
// Health
|
|
1799
1830
|
srv.addRoute('GET', '/health', (req, res) => res.json(200, {
|
|
1800
|
-
status:'ok', version:'2.10.
|
|
1831
|
+
status:'ok', version:'2.10.2',
|
|
1801
1832
|
models: app.models.map(m=>m.name),
|
|
1802
1833
|
routes: app.apis.length, pages: app.pages.length,
|
|
1803
1834
|
admin: app.admin?.prefix || null,
|