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 +84 -2
- package/package.json +1 -1
- package/runtime/aiplang-hydrate.js +143 -71
- package/server/server.js +50 -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.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
|
@@ -6,29 +6,51 @@
|
|
|
6
6
|
const cfg = window.__AIPLANG_PAGE__
|
|
7
7
|
if (!cfg) return
|
|
8
8
|
|
|
9
|
-
// ──
|
|
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
|
-
|
|
14
|
-
|
|
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
|
-
|
|
118
|
+
|
|
100
119
|
try {
|
|
101
120
|
const expr = fm[2].trim()
|
|
102
121
|
const filtered = (get(fm[1]) || []).filter(item => {
|
|
103
|
-
|
|
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')
|
|
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')
|
|
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
|
-
|
|
273
|
-
const
|
|
274
|
-
const
|
|
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 =
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
const
|
|
282
|
-
const
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
-
|
|
341
|
+
wrapDiv.addEventListener('scroll', scrollListener, { passive: true })
|
|
342
|
+
renderVirtual()
|
|
343
|
+
return
|
|
295
344
|
}
|
|
296
345
|
|
|
297
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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 !== '{')
|
|
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,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')
|
|
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.
|
|
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,
|