aiplang 2.0.0 → 2.1.0

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.
Files changed (53) hide show
  1. package/bin/aiplang.js +7 -7
  2. package/package.json +7 -5
  3. package/server/node_modules/.package-lock.json +9 -0
  4. package/server/node_modules/nodemailer/.gitattributes +6 -0
  5. package/server/node_modules/nodemailer/.ncurc.js +9 -0
  6. package/server/node_modules/nodemailer/.prettierignore +8 -0
  7. package/server/node_modules/nodemailer/.prettierrc +12 -0
  8. package/server/node_modules/nodemailer/.prettierrc.js +10 -0
  9. package/server/node_modules/nodemailer/.release-please-config.json +9 -0
  10. package/server/node_modules/nodemailer/CHANGELOG.md +976 -0
  11. package/server/node_modules/nodemailer/CODE_OF_CONDUCT.md +76 -0
  12. package/server/node_modules/nodemailer/LICENSE +16 -0
  13. package/server/node_modules/nodemailer/README.md +86 -0
  14. package/server/node_modules/nodemailer/SECURITY.txt +22 -0
  15. package/server/node_modules/nodemailer/eslint.config.js +88 -0
  16. package/server/node_modules/nodemailer/lib/addressparser/index.js +382 -0
  17. package/server/node_modules/nodemailer/lib/base64/index.js +140 -0
  18. package/server/node_modules/nodemailer/lib/dkim/index.js +245 -0
  19. package/server/node_modules/nodemailer/lib/dkim/message-parser.js +154 -0
  20. package/server/node_modules/nodemailer/lib/dkim/relaxed-body.js +154 -0
  21. package/server/node_modules/nodemailer/lib/dkim/sign.js +116 -0
  22. package/server/node_modules/nodemailer/lib/errors.js +58 -0
  23. package/server/node_modules/nodemailer/lib/fetch/cookies.js +276 -0
  24. package/server/node_modules/nodemailer/lib/fetch/index.js +278 -0
  25. package/server/node_modules/nodemailer/lib/json-transport/index.js +82 -0
  26. package/server/node_modules/nodemailer/lib/mail-composer/index.js +599 -0
  27. package/server/node_modules/nodemailer/lib/mailer/index.js +446 -0
  28. package/server/node_modules/nodemailer/lib/mailer/mail-message.js +312 -0
  29. package/server/node_modules/nodemailer/lib/mime-funcs/index.js +610 -0
  30. package/server/node_modules/nodemailer/lib/mime-funcs/mime-types.js +2109 -0
  31. package/server/node_modules/nodemailer/lib/mime-node/index.js +1334 -0
  32. package/server/node_modules/nodemailer/lib/mime-node/last-newline.js +33 -0
  33. package/server/node_modules/nodemailer/lib/mime-node/le-unix.js +40 -0
  34. package/server/node_modules/nodemailer/lib/mime-node/le-windows.js +49 -0
  35. package/server/node_modules/nodemailer/lib/nodemailer.js +151 -0
  36. package/server/node_modules/nodemailer/lib/punycode/index.js +460 -0
  37. package/server/node_modules/nodemailer/lib/qp/index.js +230 -0
  38. package/server/node_modules/nodemailer/lib/sendmail-transport/index.js +205 -0
  39. package/server/node_modules/nodemailer/lib/ses-transport/index.js +223 -0
  40. package/server/node_modules/nodemailer/lib/shared/index.js +698 -0
  41. package/server/node_modules/nodemailer/lib/smtp-connection/data-stream.js +105 -0
  42. package/server/node_modules/nodemailer/lib/smtp-connection/http-proxy-client.js +144 -0
  43. package/server/node_modules/nodemailer/lib/smtp-connection/index.js +1903 -0
  44. package/server/node_modules/nodemailer/lib/smtp-pool/index.js +641 -0
  45. package/server/node_modules/nodemailer/lib/smtp-pool/pool-resource.js +256 -0
  46. package/server/node_modules/nodemailer/lib/smtp-transport/index.js +402 -0
  47. package/server/node_modules/nodemailer/lib/stream-transport/index.js +135 -0
  48. package/server/node_modules/nodemailer/lib/well-known/index.js +47 -0
  49. package/server/node_modules/nodemailer/lib/well-known/services.json +619 -0
  50. package/server/node_modules/nodemailer/lib/xoauth2/index.js +436 -0
  51. package/server/node_modules/nodemailer/package.json +48 -0
  52. package/server/server.js +686 -865
  53. /package/{FLUX-PROJECT-KNOWLEDGE.md → aiplang-knowledge.md} +0 -0
package/server/server.js CHANGED
@@ -1,1082 +1,903 @@
1
1
  'use strict'
2
- // aiplang Full-Stack Server
3
- // Competes with Laravel: ORM, relationships, migrations, auth, middleware, validation, queues, email
4
-
5
- const http = require('http')
6
- const fs = require('fs')
7
- const path = require('path')
8
- const url = require('url')
9
- const crypto = require('crypto')
10
- const bcrypt = require('bcryptjs')
11
- const jwt = require('jsonwebtoken')
12
-
13
- // ── SQL.js DB (pure JS SQLite, no native deps) ─────────────────
14
- let SQL, DB_FILE
15
- let _db = null
16
-
2
+ // aiplang Full-Stack Server v2 — Laravel-competitive
3
+ // Features: ORM+relations, email, jobs/queues, admin panel, OAuth, soft deletes, events
4
+
5
+ const http = require('http')
6
+ const fs = require('fs')
7
+ const path = require('path')
8
+ const url = require('url')
9
+ const crypto = require('crypto')
10
+ const bcrypt = require('bcryptjs')
11
+ const jwt = require('jsonwebtoken')
12
+ const nodemailer = require('nodemailer').createTransport ? require('nodemailer') : null
13
+
14
+ // ── SQL.js (pure JS SQLite) ───────────────────────────────────────
15
+ let SQL, DB_FILE, _db = null
17
16
  async function getDB(dbFile = ':memory:') {
18
17
  if (_db) return _db
19
18
  const initSqlJs = require('sql.js')
20
19
  SQL = await initSqlJs()
21
20
  if (dbFile !== ':memory:' && fs.existsSync(dbFile)) {
22
- const fileBuffer = fs.readFileSync(dbFile)
23
- _db = new SQL.Database(fileBuffer)
21
+ _db = new SQL.Database(fs.readFileSync(dbFile))
24
22
  } else {
25
23
  _db = new SQL.Database()
26
24
  }
27
25
  DB_FILE = dbFile
28
26
  return _db
29
27
  }
30
-
31
28
  function persistDB() {
32
29
  if (!_db || !DB_FILE || DB_FILE === ':memory:') return
33
- const data = _db.export()
34
- fs.writeFileSync(DB_FILE, Buffer.from(data))
35
- }
36
-
37
- function dbRun(sql, params = []) {
38
- _db.run(sql, params)
39
- persistDB()
30
+ try { fs.writeFileSync(DB_FILE, Buffer.from(_db.export())) } catch {}
40
31
  }
41
-
32
+ function dbRun(sql, params = []) { _db.run(sql, params); persistDB() }
42
33
  function dbAll(sql, params = []) {
43
- const stmt = _db.prepare(sql)
44
- stmt.bind(params)
45
- const rows = []
46
- while (stmt.step()) {
47
- rows.push(stmt.getAsObject())
48
- }
49
- stmt.free()
34
+ const stmt = _db.prepare(sql); stmt.bind(params)
35
+ const rows = []; while (stmt.step()) rows.push(stmt.getAsObject()); stmt.free()
50
36
  return rows
51
37
  }
52
-
53
- function dbGet(sql, params = []) {
54
- const rows = dbAll(sql, params)
55
- return rows[0] || null
56
- }
57
-
58
- // ── UUID ────────────────────────────────────────────────────────
59
- const uuid = () => crypto.randomUUID()
60
-
61
- // ── JWT ─────────────────────────────────────────────────────────
62
- let JWT_SECRET = process.env.JWT_SECRET || 'aiplang-secret-change-in-production'
63
- let JWT_EXPIRE = '7d'
64
-
65
- function generateJWT(user) {
66
- const payload = { id: user.id, email: user.email, role: user.role || 'user' }
67
- return jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRE })
68
- }
69
-
70
- function verifyJWT(token) {
71
- try { return jwt.verify(token, JWT_SECRET) }
72
- catch { return null }
73
- }
74
-
75
- // ═══════════════════════════════════════════════════════════════
76
- // PARSER extends aiplang v2 syntax
77
- // ═══════════════════════════════════════════════════════════════
78
-
79
- function parseApp(src) {
80
- const app = {
81
- env: [], db: null, auth: null, cache: null,
82
- middleware: [], models: [], apis: [], pages: [],
83
- seeds: [], jobs: [], events: []
84
- }
85
-
86
- const lines = src.split('\n').map(l => l.trim()).filter(l => l && !l.startsWith('#'))
87
- let i = 0
88
- let inModel = false, inAPI = false, currentModel = null, currentAPI = null
89
- let pageLines = [], inPage = false
90
-
91
- while (i < lines.length) {
92
- const line = lines[i]
93
-
94
- // Page separator
95
- if (line === '---') {
96
- if (inPage && pageLines.length) app.pages.push(parsePage(pageLines.join('\n')))
97
- pageLines = []; inPage = false; inModel = false; inAPI = false
98
- currentModel = null; currentAPI = null
99
- i++; continue
100
- }
101
-
102
- // Page start
103
- if (line.startsWith('%')) {
104
- inPage = true; inModel = false; inAPI = false
105
- currentModel = null; currentAPI = null
106
- pageLines.push(line); i++; continue
107
- }
108
- if (inPage) { pageLines.push(line); i++; continue }
109
-
110
- // Config directives
111
- if (line.startsWith('~env ')) { app.env.push(parseEnvLine(line.slice(5))); i++; continue }
112
- if (line.startsWith('~db ')) { app.db = parseDBLine(line.slice(4)); i++; continue }
113
- if (line.startsWith('~auth ')) { app.auth = parseAuthLine(line.slice(6)); i++; continue }
114
- if (line.startsWith('~middleware ')) { app.middleware = line.slice(12).split('|').map(s=>s.trim()); i++; continue }
115
- if (line.startsWith('~cache ')) { app.cache = parseCacheLine(line.slice(7)); i++; continue }
116
-
117
- // Model block
118
- if (line.startsWith('model ')) {
119
- if (inModel && currentModel) app.models.push(currentModel)
120
- const mName = line.slice(6).replace('{','').trim()
121
- currentModel = { name: mName, fields: [], relationships: [], hooks: [] }
122
- inModel = true; inAPI = false; i++; continue
123
- }
124
- if (inModel && line === '}') { if (currentModel) app.models.push(currentModel); currentModel = null; inModel = false; i++; continue }
125
- if (inModel && currentModel) {
126
- if (line.startsWith('~has-many ')) currentModel.relationships.push({ type: 'hasMany', model: line.slice(10).trim() })
127
- else if (line.startsWith('~belongs ')) currentModel.relationships.push({ type: 'belongsTo', model: line.slice(9).trim() })
128
- else if (line.startsWith('~hook ')) currentModel.hooks.push(line.slice(6).trim())
129
- else if (line && line !== '{') currentModel.fields.push(parseModelField(line))
130
- i++; continue
131
- }
132
-
133
- // API block
134
- if (line.startsWith('api ')) {
135
- if (inAPI && currentAPI) app.apis.push(currentAPI)
136
- const parts = line.slice(4).replace('{','').trim().split(/\s+/)
137
- currentAPI = { method: parts[0], path: parts[1], guards: [], validate: [], query: [], body: [], return: null }
138
- inAPI = true; i++; continue
38
+ function dbGet(sql, params = []) { return dbAll(sql, params)[0] || null }
39
+
40
+ // ── Helpers ───────────────────────────────────────────────────────
41
+ const uuid = () => crypto.randomUUID()
42
+ const now = () => new Date().toISOString()
43
+ const esc = s => s == null ? '' : String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;')
44
+ const ic = n => ({bolt:'⚡',rocket:'🚀',shield:'🛡',chart:'📊',star:'⭐',check:'✓',globe:'🌐',lock:'🔒',user:'👤',gear:'⚙',fire:'🔥',money:'💰',bell:'🔔',mail:'✉',heart:'❤',eye:'👁',tag:'🏷',search:'🔍',home:'🏠',plus:'+',edit:'✏',trash:'🗑',info:'ℹ'}[n] || n)
45
+
46
+ // ── JWT ───────────────────────────────────────────────────────────
47
+ let JWT_SECRET = process.env.JWT_SECRET || 'aiplang-secret-dev'
48
+ let JWT_EXPIRE = '7d'
49
+ const generateJWT = (user) => jwt.sign({ id: user.id, email: user.email, role: user.role || 'user' }, JWT_SECRET, { expiresIn: JWT_EXPIRE })
50
+ const verifyJWT = (token) => { try { return jwt.verify(token, JWT_SECRET) } catch { return null } }
51
+
52
+ // ── Queue system ──────────────────────────────────────────────────
53
+ const QUEUE = []
54
+ const WORKERS = {}
55
+ let QUEUE_RUNNING = false
56
+ function dispatch(jobName, payload) {
57
+ QUEUE.push({ job: jobName, payload, id: uuid(), created: now(), attempts: 0 })
58
+ if (!QUEUE_RUNNING) processQueue()
59
+ }
60
+ async function processQueue() {
61
+ QUEUE_RUNNING = true
62
+ while (QUEUE.length > 0) {
63
+ const item = QUEUE.shift()
64
+ const worker = WORKERS[item.job]
65
+ if (worker) {
66
+ try { await worker(item.payload) }
67
+ catch (e) {
68
+ item.attempts++
69
+ if (item.attempts < 3) QUEUE.push(item)
70
+ else console.error(`[aiplang:queue] Job ${item.job} failed after 3 attempts:`, e.message)
71
+ }
139
72
  }
140
- if (inAPI && line === '}') { if (currentAPI) app.apis.push(currentAPI); currentAPI = null; inAPI = false; i++; continue }
141
- if (inAPI && currentAPI) { parseAPILine(line, currentAPI); i++; continue }
142
-
143
- i++
144
- }
145
-
146
- if (inPage && pageLines.length) app.pages.push(parsePage(pageLines.join('\n')))
147
- if (inModel && currentModel) app.models.push(currentModel)
148
- if (inAPI && currentAPI) app.apis.push(currentAPI)
149
-
150
- return app
151
- }
152
-
153
- function parseEnvLine(s) {
154
- const parts = s.split(/\s+/)
155
- const ev = { name: '', required: false, default: null }
156
- for (const p of parts) {
157
- if (p === 'required') ev.required = true
158
- else if (p.includes('=')) { const [k,v] = p.split('='); ev.name = k; ev.default = v }
159
- else ev.name = p
160
- }
161
- return ev
162
- }
163
-
164
- function parseDBLine(s) {
165
- const parts = s.split(/\s+/)
166
- return { driver: parts[0] || 'sqlite', dsn: parts[1] || './app.db' }
167
- }
168
-
169
- function parseAuthLine(s) {
170
- const parts = s.split(/\s+/)
171
- const auth = { provider: parts[0] || 'jwt', secret: parts[1] || '$JWT_SECRET', expire: '7d' }
172
- for (const p of parts) { if (p.startsWith('expire=')) auth.expire = p.slice(7) }
173
- return auth
174
- }
175
-
176
- function parseCacheLine(s) {
177
- const parts = s.split(/\s+/)
178
- return { driver: parts[0] || 'memory', url: parts[1] || '', ttl: 300 }
179
- }
180
-
181
- function parseModelField(line) {
182
- const parts = line.split(':').map(s => s.trim())
183
- const f = { name: parts[0], type: parts[1] || 'text', modifiers: [], enumVals: [], default: null, ref: null }
184
- for (let j = 2; j < parts.length; j++) {
185
- const p = parts[j]
186
- if (p.startsWith('default=')) f.default = p.slice(8)
187
- else if (p.startsWith('ref ')) f.ref = p.slice(4)
188
- else if (p.startsWith('enum:')) f.enumVals = p.slice(5).split(',')
189
- else if (p !== '') f.modifiers.push(p)
190
73
  }
191
- return f
74
+ QUEUE_RUNNING = false
192
75
  }
193
76
 
194
- function parseAPILine(line, route) {
195
- if (line.startsWith('~guard ')) route.guards = line.slice(7).split('|').map(s=>s.trim())
196
- else if (line.startsWith('~validate ')) {
197
- line.slice(10).split('|').forEach(v => {
198
- const parts = v.trim().split(/\s+/)
199
- if (parts[0]) route.validate.push({ field: parts[0], rules: parts.slice(1) })
200
- })
201
- }
202
- else if (line.startsWith('~query ')) {
203
- line.slice(7).split('|').forEach(q => {
204
- q = q.trim(); const eq = q.indexOf('=')
205
- route.query.push(eq !== -1 ? { name: q.slice(0,eq), default: q.slice(eq+1) } : { name: q, default: null })
77
+ // ── Email ─────────────────────────────────────────────────────────
78
+ let MAIL_CONFIG = null
79
+ let MAIL_TRANSPORTER = null
80
+ function setupMail(config) {
81
+ MAIL_CONFIG = config
82
+ if (!nodemailer) return
83
+ try {
84
+ MAIL_TRANSPORTER = nodemailer.createTransport({
85
+ host: config.host || 'smtp.gmail.com',
86
+ port: parseInt(config.port || '587'),
87
+ secure: config.port === '465',
88
+ auth: { user: resolveEnv(config.user), pass: resolveEnv(config.pass) }
206
89
  })
207
- }
208
- else route.body.push(line)
90
+ } catch {}
209
91
  }
210
-
211
- function parsePage(src) {
212
- // Reuse existing frontend parser logic
213
- const lines = src.split('\n').map(l => l.trim()).filter(l => l && !l.startsWith('#'))
214
- const p = { id: 'page', theme: 'dark', route: '/', themeVars: null, state: {}, queries: [], blocks: [] }
215
- for (const line of lines) {
216
- if (line.startsWith('%')) {
217
- const pts = line.slice(1).trim().split(/\s+/)
218
- p.id = pts[0]||'page'; p.route = pts[2]||'/'
219
- const rt = pts[1]||'dark'
220
- if (rt.includes('#')) { const c=rt.split(','); p.theme='custom'; p.customTheme={bg:c[0],text:c[1]||'#f1f5f9',accent:c[2]||'#2563eb'} }
221
- else p.theme = rt
222
- } else if (line.startsWith('~theme ')) {
223
- p.themeVars = p.themeVars || {}
224
- 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) })
225
- } else if (line.startsWith('@') && line.includes('=')) {
226
- const eq = line.indexOf('='); p.state[line.slice(1,eq).trim()] = line.slice(eq+1).trim()
227
- } else if (line.startsWith('~')) {
228
- const pts = line.slice(1).trim().split(/\s+/)
229
- const ai = pts.indexOf('=>')
230
- if (pts[0]==='mount') p.queries.push({ trigger:'mount', method:pts[1], path:pts[2], target:ai===-1?pts[3]:null, action:ai!==-1?pts.slice(ai+1).join(' '):null })
231
- 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 })
232
- } else {
233
- p.blocks.push({ kind: blockKind(line), rawLine: line })
234
- }
92
+ async function sendMail(opts) {
93
+ if (!MAIL_TRANSPORTER) {
94
+ console.log(`[aiplang:mail] MOCK To: ${opts.to} | Subject: ${opts.subject}`)
95
+ return { messageId: 'mock-' + uuid() }
235
96
  }
236
- return p
237
- }
238
-
239
- function blockKind(line) {
240
- const bi = line.indexOf('{'); if (bi === -1) return 'unknown'
241
- const head = line.slice(0, bi).trim()
242
- const m = head.match(/^([a-z]+)\d+$/)
243
- return m ? m[1] : head
97
+ return MAIL_TRANSPORTER.sendMail({
98
+ from: MAIL_CONFIG?.from || 'noreply@aiplang.app',
99
+ ...opts
100
+ })
244
101
  }
245
102
 
246
- // ═══════════════════════════════════════════════════════════════
247
- // AUTO MIGRATION
248
- // ═══════════════════════════════════════════════════════════════
249
-
250
- function toTableName(model) {
251
- return model.toLowerCase().replace(/([A-Z])/g, '_$1').replace(/^_/, '') + 's'
103
+ // ── Events system (simple pub/sub) ───────────────────────────────
104
+ const EVENT_LISTENERS = {}
105
+ function emit(event, data) {
106
+ const listeners = EVENT_LISTENERS[event] || []
107
+ listeners.forEach(fn => { try { fn(data) } catch {} })
252
108
  }
253
-
254
- function toColumnName(field) {
255
- return field.replace(/([A-Z])/g, '_$1').toLowerCase()
109
+ function on(event, fn) {
110
+ EVENT_LISTENERS[event] = EVENT_LISTENERS[event] || []
111
+ EVENT_LISTENERS[event].push(fn)
256
112
  }
257
113
 
258
- function migrateModels(models) {
259
- for (const model of models) {
260
- const table = toTableName(model.name)
261
- let cols = []
262
-
263
- for (const f of model.fields) {
264
- const col = toColumnName(f.name)
265
- let sqlType = 'TEXT'
266
- switch (f.type) {
267
- case 'uuid': sqlType = 'TEXT'; break
268
- case 'int': sqlType = 'INTEGER'; break
269
- case 'float': sqlType = 'REAL'; break
270
- case 'bool': sqlType = 'INTEGER'; break
271
- case 'timestamp': sqlType = 'TEXT'; break
272
- case 'json': sqlType = 'TEXT'; break
273
- case 'enum': sqlType = 'TEXT'; break
274
- default: sqlType = 'TEXT'
275
- }
276
-
277
- let def = `${col} ${sqlType}`
278
- if (f.modifiers.includes('pk')) def += ' PRIMARY KEY'
279
- if (f.modifiers.includes('required')) def += ' NOT NULL'
280
- if (f.modifiers.includes('unique')) def += ' UNIQUE'
281
- if (f.default !== null) def += ` DEFAULT '${f.default}'`
114
+ // ═══════════════════════════════════════════════════════════════════
115
+ // ORM enhanced Model
116
+ // ═══════════════════════════════════════════════════════════════════
117
+ const MODEL_DEFS = {}
282
118
 
283
- cols.push(def)
284
- }
285
-
286
- // Add relationship foreign keys
287
- for (const rel of model.relationships || []) {
288
- if (rel.type === 'belongsTo') {
289
- const fkCol = rel.model.toLowerCase() + '_id'
290
- cols.push(`${fkCol} TEXT`)
291
- }
292
- }
293
-
294
- // Always include timestamp columns
295
- const hasCreatedAt = cols.some(c => c.startsWith('created_at'))
296
- const hasUpdatedAt = cols.some(c => c.startsWith('updated_at'))
297
- if (!hasCreatedAt) cols.push('created_at TEXT')
298
- if (!hasUpdatedAt) cols.push('updated_at TEXT')
299
-
300
- const sql = `CREATE TABLE IF NOT EXISTS ${table} (${cols.join(', ')})`
301
- try { dbRun(sql) } catch (e) { /* table might already exist - try ALTER */ }
302
-
303
- console.log(`[aiplang] ✓ ${table} (${cols.length} columns)`)
304
- }
305
- }
306
-
307
- // ═══════════════════════════════════════════════════════════════
308
- // ORM — Model operations
309
- // ═══════════════════════════════════════════════════════════════
119
+ function toTable(name) { return name.toLowerCase().replace(/([A-Z])/g,'_$1').replace(/^_/,'') + 's' }
120
+ function toCol(field) { return field.replace(/([A-Z])/g,'_$1').toLowerCase() }
310
121
 
311
122
  class Model {
312
- constructor(name) {
313
- this.tableName = toTableName(name)
314
- this.modelName = name
123
+ constructor(name, def = null) {
124
+ this.modelName = name
125
+ this.tableName = toTable(name)
126
+ this.def = def || MODEL_DEFS[name] || {}
127
+ this.softDelete = this.def.softDelete || false
128
+ this.timestamps = this.def.timestamps !== false
315
129
  }
316
130
 
131
+ // ── Core queries ────────────────────────────────────────────────
317
132
  all(opts = {}) {
318
133
  let sql = `SELECT * FROM ${this.tableName}`
319
- const params = []
320
- if (opts.where) { sql += ` WHERE ${opts.where}`; if (opts.whereParams) params.push(...opts.whereParams) }
321
- if (opts.order) sql += ` ORDER BY ${opts.order}`
134
+ const params = [], conditions = []
135
+ if (this.softDelete) conditions.push('deleted_at IS NULL')
136
+ if (opts.where) { conditions.push(opts.where); if (opts.whereParams) params.push(...opts.whereParams) }
137
+ if (conditions.length) sql += ` WHERE ${conditions.join(' AND ')}`
138
+ if (opts.order) sql += ` ORDER BY ${opts.order}`
322
139
  if (opts.limit) sql += ` LIMIT ${opts.limit}`
323
140
  if (opts.offset) sql += ` OFFSET ${opts.offset}`
324
141
  return dbAll(sql, params)
325
142
  }
326
143
 
327
- find(id) { return dbGet(`SELECT * FROM ${this.tableName} WHERE id = ?`, [id]) }
144
+ find(id) {
145
+ let sql = `SELECT * FROM ${this.tableName} WHERE id = ?`
146
+ if (this.softDelete) sql += ' AND deleted_at IS NULL'
147
+ return dbGet(sql, [id])
148
+ }
328
149
 
329
- findBy(field, value) { return dbGet(`SELECT * FROM ${this.tableName} WHERE ${field} = ? LIMIT 1`, [value]) }
150
+ findBy(field, value) {
151
+ let sql = `SELECT * FROM ${this.tableName} WHERE ${field} = ? LIMIT 1`
152
+ if (this.softDelete) sql = `SELECT * FROM ${this.tableName} WHERE ${field} = ? AND deleted_at IS NULL LIMIT 1`
153
+ return dbGet(sql, [value])
154
+ }
330
155
 
331
156
  where(field, op, value) {
332
- return dbAll(`SELECT * FROM ${this.tableName} WHERE ${field} ${op} ?`, [value])
157
+ let sql = `SELECT * FROM ${this.tableName} WHERE ${field} ${op} ?`
158
+ if (this.softDelete) sql += ' AND deleted_at IS NULL'
159
+ return dbAll(sql, [value])
160
+ }
161
+
162
+ scope(name) {
163
+ const scopeDef = this.def.scopes?.[name]
164
+ if (!scopeDef) return this.all()
165
+ return this.all({ where: scopeDef.where, order: scopeDef.order })
333
166
  }
334
167
 
335
168
  paginate(page = 1, perPage = 15, opts = {}) {
336
169
  const offset = (page - 1) * perPage
337
- const total = dbGet(`SELECT COUNT(*) as count FROM ${this.tableName}`)?.count || 0
170
+ let countSql = `SELECT COUNT(*) as count FROM ${this.tableName}`
171
+ if (this.softDelete) countSql += ' WHERE deleted_at IS NULL'
172
+ const total = dbGet(countSql)?.count || 0
338
173
  const data = this.all({ ...opts, limit: perPage, offset })
339
- return {
340
- data,
341
- meta: { total, page, per_page: perPage, last_page: Math.ceil(total / perPage) }
342
- }
174
+ return { data, meta: { total, page, per_page: perPage, last_page: Math.ceil(total / perPage), from: offset + 1, to: Math.min(offset + perPage, total) } }
343
175
  }
344
176
 
345
177
  create(data) {
346
178
  const row = { ...data }
347
179
  if (!row.id) row.id = uuid()
348
- if (!row.created_at) row.created_at = new Date().toISOString()
349
- if (!row.updated_at) row.updated_at = new Date().toISOString()
350
-
351
- const keys = Object.keys(row)
352
- const vals = Object.values(row)
353
- const sql = `INSERT INTO ${this.tableName} (${keys.join(',')}) VALUES (${keys.map(()=>'?').join(',')})`
354
- dbRun(sql, vals)
180
+ if (this.timestamps) {
181
+ if (!row.created_at) row.created_at = now()
182
+ if (!row.updated_at) row.updated_at = now()
183
+ }
184
+ const keys = Object.keys(row), vals = Object.values(row)
185
+ dbRun(`INSERT INTO ${this.tableName} (${keys.join(',')}) VALUES (${keys.map(()=>'?').join(',')})`, vals)
186
+ emit(`${this.modelName}.created`, row)
355
187
  return row
356
188
  }
357
189
 
358
190
  update(id, data) {
359
- data.updated_at = new Date().toISOString()
360
- delete data.id; delete data.created_at; delete data.password
361
- const sets = Object.keys(data).map(k => `${k} = ?`).join(', ')
362
- dbRun(`UPDATE ${this.tableName} SET ${sets} WHERE id = ?`, [...Object.values(data), id])
363
- return this.find(id)
191
+ const row = { ...data }
192
+ delete row.id; delete row.created_at; delete row.password
193
+ if (this.timestamps) row.updated_at = now()
194
+ const sets = Object.keys(row).map(k => `${k} = ?`).join(', ')
195
+ dbRun(`UPDATE ${this.tableName} SET ${sets} WHERE id = ?`, [...Object.values(row), id])
196
+ const updated = this.find(id)
197
+ emit(`${this.modelName}.updated`, updated)
198
+ return updated
364
199
  }
365
200
 
366
201
  delete(id) {
367
- dbRun(`DELETE FROM ${this.tableName} WHERE id = ?`, [id])
202
+ if (this.softDelete) {
203
+ dbRun(`UPDATE ${this.tableName} SET deleted_at = ?, updated_at = ? WHERE id = ?`, [now(), now(), id])
204
+ emit(`${this.modelName}.softDeleted`, { id })
205
+ } else {
206
+ dbRun(`DELETE FROM ${this.tableName} WHERE id = ?`, [id])
207
+ emit(`${this.modelName}.deleted`, { id })
208
+ }
209
+ }
210
+
211
+ restore(id) {
212
+ if (this.softDelete) {
213
+ dbRun(`UPDATE ${this.tableName} SET deleted_at = NULL WHERE id = ?`, [id])
214
+ }
368
215
  }
369
216
 
370
217
  count(opts = {}) {
371
218
  let sql = `SELECT COUNT(*) as count FROM ${this.tableName}`
372
- if (opts.where) sql += ` WHERE ${opts.where}`
219
+ const conditions = []
220
+ if (this.softDelete) conditions.push('deleted_at IS NULL')
221
+ if (opts.where) conditions.push(opts.where)
222
+ if (conditions.length) sql += ` WHERE ${conditions.join(' AND ')}`
373
223
  return dbGet(sql)?.count || 0
374
224
  }
375
225
 
376
- // Relationships
377
- hasMany(relModel, foreignKey) {
378
- return (parentId) => {
379
- const m = new Model(relModel)
380
- const fk = foreignKey || this.modelName.toLowerCase() + '_id'
381
- return dbAll(`SELECT * FROM ${m.tableName} WHERE ${fk} = ?`, [parentId])
382
- }
226
+ sum(field, opts = {}) {
227
+ let sql = `SELECT SUM(${field}) as total FROM ${this.tableName}`
228
+ if (this.softDelete) sql += ' WHERE deleted_at IS NULL'
229
+ return dbGet(sql)?.total || 0
383
230
  }
384
231
 
385
- belongsTo(relModel, foreignKey) {
386
- return (childRow) => {
387
- const m = new Model(relModel)
388
- const fk = foreignKey || relModel.toLowerCase() + '_id'
389
- return m.find(childRow[fk])
390
- }
232
+ avg(field) {
233
+ let sql = `SELECT AVG(${field}) as avg FROM ${this.tableName}`
234
+ if (this.softDelete) sql += ' WHERE deleted_at IS NULL'
235
+ return parseFloat(dbGet(sql)?.avg || 0).toFixed(2)
391
236
  }
392
- }
393
-
394
- // ═══════════════════════════════════════════════════════════════
395
- // HTTP SERVER — Express-like but stdlib
396
- // ═══════════════════════════════════════════════════════════════
397
237
 
398
- class AiplangServer {
399
- constructor() {
400
- this.routes = []
401
- this.globalMiddleware = []
402
- this.models = {}
403
- this.app = null
238
+ // ── Relationships ───────────────────────────────────────────────
239
+ hasMany(relModel, fk) {
240
+ const m = new Model(relModel)
241
+ return (parentId) => dbAll(`SELECT * FROM ${m.tableName} WHERE ${fk || this.modelName.toLowerCase() + '_id'} = ?`, [parentId])
404
242
  }
405
-
406
- use(fn) { this.globalMiddleware.push(fn) }
407
-
408
- addRoute(method, routePath, handler) {
409
- this.routes.push({ method: method.toUpperCase(), path: routePath, handler, params: parseRouteParams(routePath) })
243
+ belongsTo(relModel, fk) {
244
+ const m = new Model(relModel)
245
+ return (row) => m.find(row[fk || relModel.toLowerCase() + '_id'])
410
246
  }
411
-
412
- registerModel(name) {
413
- this.models[name] = new Model(name)
414
- return this.models[name]
247
+ hasOne(relModel, fk) {
248
+ const m = new Model(relModel)
249
+ return (parentId) => dbGet(`SELECT * FROM ${m.tableName} WHERE ${fk || this.modelName.toLowerCase() + '_id'} = ? LIMIT 1`, [parentId])
415
250
  }
416
251
 
417
- async handleRequest(req, res) {
418
- // Parse body
419
- if (req.method !== 'GET' && req.method !== 'DELETE') {
420
- req.body = await parseBody(req)
421
- } else {
422
- req.body = {}
423
- }
424
-
425
- // Parse query string
426
- const parsed = url.parse(req.url, true)
427
- req.query = parsed.query
428
- req.path = parsed.pathname
252
+ // ── Observers / hooks ───────────────────────────────────────────
253
+ observe(event, fn) { on(`${this.modelName}.${event}`, fn) }
254
+ }
429
255
 
430
- // Parse auth token
431
- const token = extractToken(req)
432
- req.user = token ? verifyJWT(token) : null
256
+ // ═══════════════════════════════════════════════════════════════════
257
+ // MIGRATION
258
+ // ═══════════════════════════════════════════════════════════════════
259
+ function migrateModels(models) {
260
+ for (const model of models) {
261
+ const table = toTable(model.name)
262
+ const cols = []
263
+ for (const f of model.fields) {
264
+ let sqlType = { uuid:'TEXT',int:'INTEGER',float:'REAL',bool:'INTEGER',timestamp:'TEXT',json:'TEXT',enum:'TEXT',text:'TEXT' }[f.type] || 'TEXT'
265
+ let def = `${toCol(f.name)} ${sqlType}`
266
+ if (f.modifiers.includes('pk')) def += ' PRIMARY KEY'
267
+ if (f.modifiers.includes('required')) def += ' NOT NULL'
268
+ if (f.modifiers.includes('unique')) def += ' UNIQUE'
269
+ if (f.default !== null) def += ` DEFAULT '${f.default}'`
270
+ cols.push(def)
271
+ }
272
+ for (const rel of model.relationships || []) {
273
+ if (rel.type === 'belongsTo') cols.push(`${rel.model.toLowerCase()}_id TEXT`)
274
+ }
275
+ if (!cols.some(c=>c.startsWith('created_at'))) cols.push('created_at TEXT')
276
+ if (!cols.some(c=>c.startsWith('updated_at'))) cols.push('updated_at TEXT')
277
+ if (model.softDelete) { if (!cols.some(c=>c.startsWith('deleted_at'))) cols.push('deleted_at TEXT') }
278
+ try { dbRun(`CREATE TABLE IF NOT EXISTS ${table} (${cols.join(', ')})`) } catch {}
279
+ console.log(`[aiplang] ✓ ${table} (${cols.length} cols${model.softDelete ? ', soft-delete' : ''})`)
280
+ MODEL_DEFS[model.name] = { softDelete: model.softDelete, timestamps: true }
281
+ }
282
+ }
433
283
 
434
- // CORS
435
- res.setHeader('Access-Control-Allow-Origin', '*')
436
- res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,PATCH,DELETE,OPTIONS')
437
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type,Authorization')
438
- if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return }
284
+ // ═══════════════════════════════════════════════════════════════════
285
+ // PARSER
286
+ // ═══════════════════════════════════════════════════════════════════
287
+ function parseApp(src) {
288
+ const app = { env:[], db:null, auth:null, mail:null, middleware:[], models:[], apis:[], pages:[], jobs:[], events:[], admin:null }
289
+ const lines = src.split('\n').map(l=>l.trim()).filter(l=>l&&!l.startsWith('#'))
290
+ let i=0, inModel=false, inAPI=false, curModel=null, curAPI=null, pageLines=[], inPage=false
439
291
 
440
- // Match route
441
- for (const route of this.routes) {
442
- if (route.method !== req.method) continue
443
- const match = matchRoute(route.path, req.path)
444
- if (!match) continue
445
- req.params = match
292
+ while (i < lines.length) {
293
+ const line = lines[i]
294
+ if (line === '---') {
295
+ if (inPage && pageLines.length) app.pages.push(parseFrontPage(pageLines.join('\n')))
296
+ pageLines=[]; inPage=false; inModel=false; inAPI=false; curModel=null; curAPI=null; i++; continue
297
+ }
298
+ if (line.startsWith('%')) { inPage=true; inModel=false; inAPI=false; curModel=null; curAPI=null; pageLines.push(line); i++; continue }
299
+ if (inPage) { pageLines.push(line); i++; continue }
446
300
 
447
- // Helper methods
448
- res.json = (status, data) => {
449
- if (typeof status === 'object') { data = status; status = 200 }
450
- res.writeHead(status, { 'Content-Type': 'application/json' })
451
- res.end(JSON.stringify(data))
452
- }
453
- res.error = (status, msg) => res.json(status, { error: msg })
454
- res.noContent = () => { res.writeHead(204); res.end() }
301
+ if (line.startsWith('~env ')) { app.env.push(parseEnvLine(line.slice(5))); i++; continue }
302
+ if (line.startsWith('~db ')) { app.db = parseDBLine(line.slice(4)); i++; continue }
303
+ if (line.startsWith('~auth ')) { app.auth = parseAuthLine(line.slice(6)); i++; continue }
304
+ if (line.startsWith('~mail ')) { app.mail = parseMailLine(line.slice(6)); i++; continue }
305
+ if (line.startsWith('~middleware ')) { app.middleware = line.slice(12).split('|').map(s=>s.trim()); i++; continue }
306
+ if (line.startsWith('~admin')) { app.admin = parseAdminLine(line); i++; continue }
307
+ if (line.startsWith('~job ')) { app.jobs.push(parseJobLine(line.slice(5))); i++; continue }
308
+ if (line.startsWith('~on ')) { app.events.push(parseEventLine(line.slice(4))); i++; continue }
455
309
 
456
- try { await route.handler(req, res) }
457
- catch (e) {
458
- console.error('[aiplang] Route error:', e.message)
459
- res.json(500, { error: 'Internal server error' })
460
- }
461
- return
310
+ if (line.startsWith('model ')) {
311
+ if (inModel && curModel) app.models.push(curModel)
312
+ curModel = { name: line.slice(6).replace('{','').trim(), fields:[], relationships:[], hooks:[], softDelete:false }
313
+ inModel=true; inAPI=false; i++; continue
314
+ }
315
+ if (inModel && line === '}') { if (curModel) app.models.push(curModel); curModel=null; inModel=false; i++; continue }
316
+ if (inModel && curModel) {
317
+ if (line.startsWith('~has-many ')) curModel.relationships.push({ type:'hasMany', model:line.slice(10).trim() })
318
+ else if (line.startsWith('~has-one '))curModel.relationships.push({ type:'hasOne', model:line.slice(9).trim() })
319
+ else if (line.startsWith('~belongs '))curModel.relationships.push({ type:'belongsTo', model:line.slice(9).trim() })
320
+ else if (line.startsWith('~hook ')) curModel.hooks.push(line.slice(6).trim())
321
+ else if (line === '~soft-delete') curModel.softDelete = true
322
+ else if (line && line !== '{') curModel.fields.push(parseField(line))
323
+ i++; continue
462
324
  }
463
325
 
464
- res.writeHead(404, { 'Content-Type': 'application/json' })
465
- res.end(JSON.stringify({ error: 'Not found' }))
326
+ if (line.startsWith('api ')) {
327
+ if (inAPI && curAPI) app.apis.push(curAPI)
328
+ const pts = line.slice(4).replace('{','').trim().split(/\s+/)
329
+ curAPI = { method:pts[0], path:pts[1], guards:[], validate:[], query:[], body:[], return:null }
330
+ inAPI=true; i++; continue
331
+ }
332
+ if (inAPI && line === '}') { if (curAPI) app.apis.push(curAPI); curAPI=null; inAPI=false; i++; continue }
333
+ if (inAPI && curAPI) { parseAPILine(line, curAPI); i++; continue }
334
+ i++
466
335
  }
336
+ if (inPage && pageLines.length) app.pages.push(parseFrontPage(pageLines.join('\n')))
337
+ if (inModel && curModel) app.models.push(curModel)
338
+ if (inAPI && curAPI) app.apis.push(curAPI)
339
+ return app
340
+ }
467
341
 
468
- listen(port) {
469
- const server = http.createServer((req, res) => this.handleRequest(req, res))
470
- server.listen(port, () => console.log(`[aiplang] Server running http://localhost:${port}`))
471
- return server
342
+ 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 }
343
+ function parseDBLine(s) { const p=s.split(/\s+/); return{driver:p[0]||'sqlite',dsn:p[1]||'./app.db'} }
344
+ function parseAuthLine(s) { const p=s.split(/\s+/); const a={provider:'jwt',secret:p[1]||'$JWT_SECRET',expire:'7d'}; for(const x of p){if(x.startsWith('expire='))a.expire=x.slice(7);if(x==='google')a.oauth=['google'];if(x==='github')a.oauth=[...(a.oauth||[]),'google']}; return a }
345
+ 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 }
346
+ function parseAdminLine(s) { const m=s.match(/~admin\s+(\S+)/); return{prefix:m?.[1]||'/admin',guard:'admin'} }
347
+ function parseJobLine(s) { const[name,...rest]=s.split(/\s+/); return{name,action:rest.join(' ')} }
348
+ function parseEventLine(s) { const m=s.match(/^(\S+)\s*=>\s*(.+)$/); return{event:m?.[1],action:m?.[2]} }
349
+ function parseField(line) {
350
+ const p=line.split(':').map(s=>s.trim())
351
+ const f={name:p[0],type:p[1]||'text',modifiers:[],enumVals:[],default:null}
352
+ for(let j=2;j<p.length;j++){const x=p[j];if(x.startsWith('default='))f.default=x.slice(8);else if(x.startsWith('enum:'))f.enumVals=x.slice(5).split(',');else if(x)f.modifiers.push(x)}
353
+ return f
354
+ }
355
+ function parseAPILine(line, route) {
356
+ if(line.startsWith('~guard ')) route.guards=line.slice(7).split('|').map(s=>s.trim())
357
+ 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)})})
358
+ else if(line.startsWith('~query ')) line.slice(7).split('|').forEach(q=>{q=q.trim();const eq=q.indexOf('=');route.query.push(eq!==-1?{name:q.slice(0,eq),default:q.slice(eq+1)}:{name:q,default:null})})
359
+ else route.body.push(line)
360
+ }
361
+ function parseFrontPage(src) {
362
+ const lines=src.split('\n').map(l=>l.trim()).filter(l=>l&&!l.startsWith('#'))
363
+ const p={id:'page',theme:'dark',route:'/',themeVars:null,state:{},queries:[],blocks:[]}
364
+ for(const line of lines){
365
+ 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}
366
+ 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)})}
367
+ else if(line.startsWith('@')&&line.includes('=')){const eq=line.indexOf('=');p.state[line.slice(1,eq).trim()]=line.slice(eq+1).trim()}
368
+ else if(line.startsWith('~')){const pts=line.slice(1).trim().split(/\s+/);const ai=pts.indexOf('=>');if(pts[0]==='mount')p.queries.push({trigger:'mount',method:pts[1],path:pts[2],target:ai===-1?pts[3]:null,action:ai!==-1?pts.slice(ai+1).join(' '):null});else if(pts[0]==='interval')p.queries.push({trigger:'interval',interval:parseInt(pts[1]),method:pts[2],path:pts[3],target:ai===-1?pts[4]:null,action:ai!==-1?pts.slice(ai+1).join(' '):null})}
369
+ else p.blocks.push({kind:blockKind(line),rawLine:line})
472
370
  }
371
+ return p
473
372
  }
373
+ function blockKind(line){const bi=line.indexOf('{');if(bi===-1)return'unknown';const h=line.slice(0,bi).trim();const m=h.match(/^([a-z]+)\d+$/);return m?m[1]:h}
474
374
 
475
- // ═══════════════════════════════════════════════════════════════
476
- // API ROUTE COMPILER
477
- // ═══════════════════════════════════════════════════════════════
478
-
479
- function compileAPIRoute(route, app, server) {
480
- const handler = async (req, res) => {
481
- const ctx = {
482
- req, res, params: req.params,
483
- body: req.body, query: req.query,
484
- user: req.user,
485
- vars: {},
486
- models: server.models,
487
- }
375
+ // ═══════════════════════════════════════════════════════════════════
376
+ // ROUTE COMPILER
377
+ // ═══════════════════════════════════════════════════════════════════
378
+ function compileRoute(route, server) {
379
+ server.addRoute(route.method, route.path, async (req, res) => {
380
+ const ctx = { req, res, params:req.params, body:req.body, query:req.query, user:req.user, vars:{}, models:server.models }
488
381
 
489
382
  // Guards
490
383
  for (const guard of route.guards) {
491
- if (guard === 'auth') {
492
- if (!req.user) { res.error(401, 'Unauthorized'); return }
493
- ctx.authUser = req.user
494
- }
495
- if (guard === 'admin') {
496
- if (!req.user || req.user.role !== 'admin') { res.error(403, 'Forbidden'); return }
497
- }
384
+ if (guard === 'auth' && !req.user) { res.error(401, 'Unauthorized'); return }
385
+ if (guard === 'admin' && req.user?.role !== 'admin') { res.error(403, 'Forbidden'); return }
498
386
  if (guard === 'owner') {
499
- // Check if record belongs to user — simple implementation
500
387
  if (!req.user) { res.error(401, 'Unauthorized'); return }
388
+ // owner check happens in ops
501
389
  }
502
390
  }
503
391
 
504
392
  // Query params
505
- for (const qp of route.query) {
506
- ctx.vars[qp.name] = req.query[qp.name] || qp.default
507
- }
393
+ for (const qp of route.query) ctx.vars[qp.name] = req.query[qp.name] ?? qp.default
508
394
 
509
395
  // Validation
510
396
  for (const v of route.validate) {
511
397
  const val = ctx.body[v.field]
512
398
  for (const rule of v.rules) {
513
- if (rule === 'required' && (!val || val === '')) {
514
- res.error(422, `${v.field} is required`); return
515
- }
516
- if (rule === 'email' && val && !val.includes('@')) {
517
- res.error(422, `${v.field} must be a valid email`); return
518
- }
519
- if (rule.startsWith('min=')) {
520
- const min = parseInt(rule.slice(4))
521
- if (!val || String(val).length < min) { res.error(422, `${v.field} must be at least ${min} characters`); return }
522
- }
523
- if (rule.startsWith('max=')) {
524
- const max = parseInt(rule.slice(4))
525
- if (val && String(val).length > max) { res.error(422, `${v.field} must be at most ${max} characters`); return }
526
- }
527
- if (rule.startsWith('unique:')) {
528
- const modelName = rule.slice(7)
529
- const m = server.models[modelName]
530
- if (m) {
531
- const existing = m.findBy(v.field, val)
532
- if (existing) { res.error(409, `${v.field} already exists`); return }
533
- }
534
- }
535
- if (rule.startsWith('exists:')) {
536
- const modelName = rule.slice(7)
537
- const m = server.models[modelName]
538
- if (m && !m.find(val)) { res.error(422, `${v.field} does not exist`); return }
539
- }
540
- if (rule.startsWith('in:')) {
541
- const allowed = rule.slice(3).split(',')
542
- if (val && !allowed.includes(val)) { res.error(422, `${v.field} must be one of: ${allowed.join(', ')}`); return }
543
- }
399
+ if (rule === 'required' && (!val && val !== 0)) { res.error(422, `${v.field} is required`); return }
400
+ if (rule === 'email' && val && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(val)) { res.error(422, `${v.field} must be a valid email`); return }
401
+ if (rule.startsWith('min=') && (!val || String(val).length < parseInt(rule.slice(4)))) { res.error(422, `${v.field} min length is ${rule.slice(4)}`); return }
402
+ if (rule.startsWith('max=') && val && String(val).length > parseInt(rule.slice(4))) { res.error(422, `${v.field} max length is ${rule.slice(4)}`); return }
403
+ if (rule === 'numeric' && val && isNaN(Number(val))) { res.error(422, `${v.field} must be numeric`); return }
404
+ if (rule.startsWith('in:') && val && !rule.slice(3).split(',').includes(val)) { res.error(422, `${v.field} must be one of: ${rule.slice(3)}`); return }
405
+ if (rule.startsWith('unique:')) { const m=server.models[rule.slice(7)]; if(m&&m.findBy(v.field,val)){ res.error(409,`${v.field} already exists`); return } }
406
+ if (rule.startsWith('exists:')) { const m=server.models[rule.slice(7)]; if(m&&!m.find(val)){ res.error(422,`${v.field} not found`); return } }
544
407
  }
545
408
  }
546
409
 
547
- // Execute body operations
410
+ // Execute ops
548
411
  for (const op of route.body) {
549
412
  const result = await execOp(op, ctx, server)
550
- if (result === '__RESPONDED__') return
551
- if (result !== null && result !== undefined) {
552
- ctx.lastResult = result
553
- }
413
+ if (result === '__DONE__') return
414
+ if (result !== null && result !== undefined) ctx.lastResult = result
554
415
  }
555
416
 
556
- // Default 200 if no explicit return
557
- if (!res.writableEnded) res.json(200, ctx.lastResult || {})
558
- }
559
-
560
- server.addRoute(route.method, route.path, handler)
417
+ if (!res.writableEnded) res.json(200, ctx.lastResult ?? {})
418
+ })
561
419
  }
562
420
 
563
421
  async function execOp(line, ctx, server) {
564
- line = line.trim()
565
- if (!line) return null
422
+ line = line.trim(); if (!line) return null
566
423
 
567
424
  // ~hash field
568
- if (line.startsWith('~hash ')) {
569
- const field = line.slice(6).trim()
570
- if (ctx.body[field]) ctx.body[field] = await bcrypt.hash(ctx.body[field], 12)
571
- return null
572
- }
425
+ if (line.startsWith('~hash ')) { const f=line.slice(6).trim(); if(ctx.body[f])ctx.body[f]=await bcrypt.hash(ctx.body[f],12); return null }
573
426
 
574
- // ~check password plain hashed | 401
427
+ // ~check password plain hashed | status
575
428
  if (line.startsWith('~check ')) {
576
- const parts = line.slice(7).trim().split(/\s+/)
577
- const plain = resolveVar(parts[1], ctx)
578
- const hashed = resolveVar(parts[2], ctx)
579
- const status = parseInt(parts[4]) || 401
580
- const ok = await bcrypt.compare(String(plain||''), String(hashed||''))
581
- if (!ok) { ctx.res.error(status, 'Invalid credentials'); return '__RESPONDED__' }
429
+ const p=line.slice(7).trim().split(/\s+/)
430
+ const plain=resolveVar(p[1],ctx), hashed=resolveVar(p[2],ctx), status=parseInt(p[4])||401
431
+ const ok=await bcrypt.compare(String(plain||''),String(hashed||''))
432
+ if (!ok) { ctx.res.error(status,'Invalid credentials'); return '__DONE__' }
582
433
  return null
583
434
  }
584
435
 
585
436
  // ~unique Model field value | status
586
437
  if (line.startsWith('~unique ')) {
587
- const parts = line.slice(8).trim().split(/\s+/)
588
- const modelName = parts[0], field = parts[1], value = resolveVar(parts[2], ctx)
589
- const status = parseInt(parts[4]) || 409
590
- const m = server.models[modelName]
591
- if (m) {
592
- const existing = m.findBy(field, value)
593
- if (existing) { ctx.res.error(status, `${field} already exists`); return '__RESPONDED__' }
594
- }
438
+ const p=line.slice(8).trim().split(/\s+/)
439
+ const m=server.models[p[0]]; if(m&&m.findBy(p[1],resolveVar(p[2],ctx))){ ctx.res.error(parseInt(p[4])||409,`${p[1]} already exists`); return '__DONE__' }
440
+ return null
441
+ }
442
+
443
+ // ~dispatch jobName payload
444
+ if (line.startsWith('~dispatch ')) {
445
+ const p=line.slice(10).trim().split(/\s+/)
446
+ dispatch(p[0], resolveVar(p.slice(1).join(' '), ctx))
447
+ return null
448
+ }
449
+
450
+ // ~mail to subject body
451
+ if (line.startsWith('~mail ')) {
452
+ const expr=line.slice(6).trim()
453
+ const m=expr.match(/^(\S+)\s+"([^"]+)"\s+"([^"]+)"/)
454
+ if (m) await sendMail({ to:resolveVar(m[1],ctx), subject:m[2], text:m[3] })
455
+ return null
456
+ }
457
+
458
+ // ~emit event data
459
+ if (line.startsWith('~emit ')) {
460
+ const p=line.slice(6).trim().split(/\s+/)
461
+ emit(p[0], resolveVar(p.slice(1).join(' '),ctx))
595
462
  return null
596
463
  }
597
464
 
598
465
  // $var = expr
599
466
  if (line.startsWith('$') && line.includes('=')) {
600
- const eq = line.indexOf('=')
601
- const varName = line.slice(1, eq).trim()
602
- const expr = line.slice(eq+1).trim()
603
- ctx.vars[varName] = evalExpr(expr, ctx, server)
467
+ const eq=line.indexOf('=')
468
+ const varName=line.slice(1,eq).trim()
469
+ ctx.vars[varName] = evalExpr(line.slice(eq+1).trim(), ctx, server)
604
470
  return null
605
471
  }
606
472
 
607
473
  // insert Model($body)
608
474
  if (line.startsWith('insert ')) {
609
- const modelName = line.match(/insert\s+(\w+)/)?.[1]
610
- const m = server.models[modelName]
611
- if (m) {
612
- const data = { ...ctx.body }
613
- ctx.vars['inserted'] = m.create(data)
614
- return ctx.vars['inserted']
615
- }
475
+ const modelName=line.match(/insert\s+(\w+)/)?.[1]; const m=server.models[modelName]
476
+ if (m) { ctx.vars['inserted']=m.create({...ctx.body}); return ctx.vars['inserted'] }
616
477
  return null
617
478
  }
618
479
 
619
480
  // update Model($id, $body)
620
481
  if (line.startsWith('update ')) {
621
- const modelName = line.match(/update\s+(\w+)/)?.[1]
622
- const m = server.models[modelName]
623
- if (m) {
624
- const id = ctx.params.id || ctx.vars['id']
625
- ctx.vars['updated'] = m.update(id, { ...ctx.body })
626
- return ctx.vars['updated']
627
- }
482
+ const modelName=line.match(/update\s+(\w+)/)?.[1]; const m=server.models[modelName]
483
+ if (m) { const id=ctx.params.id||ctx.vars['id']; ctx.vars['updated']=m.update(id,{...ctx.body}); return ctx.vars['updated'] }
628
484
  return null
629
485
  }
630
486
 
631
487
  // delete Model($id)
632
488
  if (line.startsWith('delete ')) {
633
- const modelName = line.match(/delete\s+(\w+)/)?.[1]
634
- const m = server.models[modelName]
635
- if (m) {
636
- const id = ctx.params.id || ctx.vars['id']
637
- m.delete(id)
638
- ctx.res.noContent(); return '__RESPONDED__'
639
- }
489
+ const modelName=line.match(/delete\s+(\w+)/)?.[1]; const m=server.models[modelName]
490
+ if (m) { m.delete(ctx.params.id||ctx.vars['id']); ctx.res.noContent(); return '__DONE__' }
640
491
  return null
641
492
  }
642
493
 
643
- // return expr statusCode
494
+ // restore Model($id) - soft delete restore
495
+ if (line.startsWith('restore ')) {
496
+ const modelName=line.match(/restore\s+(\w+)/)?.[1]; const m=server.models[modelName]
497
+ if (m) { m.restore(ctx.params.id); return m.find(ctx.params.id) }
498
+ return null
499
+ }
500
+
501
+ // return expr status
644
502
  if (line.startsWith('return ')) {
645
- const parts = line.slice(7).trim().split(/\s+/)
646
- const expr = parts[0]
647
- const status = parseInt(parts[1]) || 200
648
- let result = evalExpr(expr, ctx, server)
649
- if (result === null || result === undefined) result = ctx.vars['inserted'] || ctx.vars['updated'] || {}
650
- ctx.res.json(status, result)
651
- return '__RESPONDED__'
503
+ const p=line.slice(7).trim().split(/\s+/)
504
+ const status=parseInt(p[p.length-1])||200
505
+ const exprParts=isNaN(parseInt(p[p.length-1]))?p:p.slice(0,-1)
506
+ let result=evalExpr(exprParts.join(' '),ctx,server)
507
+ if(result===null||result===undefined)result=ctx.vars['inserted']||ctx.vars['updated']||{}
508
+ ctx.res.json(status,result); return '__DONE__'
652
509
  }
653
510
 
654
511
  return null
655
512
  }
656
513
 
657
514
  function evalExpr(expr, ctx, server) {
658
- expr = expr.trim()
659
-
660
- // jwt($var)
661
- if (expr.startsWith('jwt(')) {
662
- const varName = expr.match(/jwt\(\$([^)]+)\)/)?.[1]
663
- const user = varName ? ctx.vars[varName] : ctx.body
664
- return { token: generateJWT(user), user: sanitize(user) }
665
- }
666
-
667
- // Model.all(...)
668
- if (expr.includes('.all(')) {
669
- const modelName = expr.match(/^(\w+)\.all/)?.[1]
670
- const m = server.models[modelName]
671
- if (!m) return []
672
- const opts = {}
673
- const limitM = expr.match(/limit=(\$?[\w.]+)/)
674
- const offsetM = expr.match(/offset=([^,)]+)/)
675
- const orderM = expr.match(/order=([^,)]+)/)
676
- const whereM = expr.match(/where=([^,)]+)/)
677
- if (limitM) opts.limit = resolveVar(limitM[1], ctx)
678
- if (offsetM) opts.offset = evalMath(offsetM[1], ctx)
679
- if (orderM) opts.order = orderM[1]
680
- if (whereM) opts.where = whereM[1]
681
- return m.all(opts)
682
- }
683
-
684
- // Model.find($id)
685
- if (expr.includes('.find(')) {
686
- const modelName = expr.match(/^(\w+)\.find/)?.[1]
687
- const idExpr = expr.match(/\.find\(([^)]+)\)/)?.[1]
688
- const m = server.models[modelName]
689
- return m ? m.find(resolveVar(idExpr, ctx)) : null
690
- }
691
-
692
- // Model.findBy(field=value)
693
- if (expr.includes('.findBy(')) {
694
- const modelName = expr.match(/^(\w+)\.findBy/)?.[1]
695
- const args = expr.match(/\.findBy\(([^)]+)\)/)?.[1]
696
- const [field, valExpr] = (args || '').split('=')
697
- const m = server.models[modelName]
698
- return m ? m.findBy(field.trim(), resolveVar(valExpr?.trim(), ctx)) : null
699
- }
700
-
701
- // Model.paginate(page, perPage)
702
- if (expr.includes('.paginate(')) {
703
- const modelName = expr.match(/^(\w+)\.paginate/)?.[1]
704
- const args = expr.match(/\.paginate\(([^)]+)\)/)?.[1]?.split(',')
705
- const m = server.models[modelName]
706
- if (!m) return { data: [], meta: {} }
707
- const page = parseInt(resolveVar(args?.[0]?.trim(), ctx)) || 1
708
- const perPage = parseInt(resolveVar(args?.[1]?.trim(), ctx)) || 15
709
- return m.paginate(page, perPage)
710
- }
711
-
712
- // Model.count()
713
- if (expr.includes('.count(')) {
714
- const modelName = expr.match(/^(\w+)\.count/)?.[1]
715
- const m = server.models[modelName]
716
- return m ? m.count() : 0
717
- }
718
-
719
- // $var references
720
- if (expr === '$auth.user') return ctx.user
721
- if (expr.startsWith('$')) return resolveVar(expr, ctx)
722
-
723
- // @auth.user
724
- if (expr === '$auth.user' || expr === '$auth') return ctx.user
725
-
515
+ expr=expr.trim()
516
+ if (expr.startsWith('jwt(')) { const vn=expr.match(/jwt\(\$([^)]+)\)/)?.[1]; const u=vn?ctx.vars[vn]:ctx.body; return{token:generateJWT(u),user:sanitize(u)} }
517
+ if (expr==='$auth.user'||expr==='$auth') return ctx.user
518
+ if (expr.includes('.all(')) { return evalModelOp('all', expr, ctx, server) }
519
+ if (expr.includes('.find(')) { return evalModelOp('find', expr, ctx, server) }
520
+ if (expr.includes('.findBy(')) { return evalModelOp('findBy', expr, ctx, server) }
521
+ if (expr.includes('.paginate(')) { return evalModelOp('paginate', expr, ctx, server) }
522
+ if (expr.includes('.count(')) { return evalModelOp('count', expr, ctx, server) }
523
+ if (expr.includes('.sum(')) { return evalModelOp('sum', expr, ctx, server) }
524
+ if (expr.includes('.avg(')) { return evalModelOp('avg', expr, ctx, server) }
525
+ if (expr.includes('.where(')) { return evalModelOp('where', expr, ctx, server) }
526
+ if (expr.includes('.scope(')) { return evalModelOp('scope', expr, ctx, server) }
527
+ if (expr.startsWith('$')) { return resolveVar(expr, ctx) }
726
528
  return expr
727
529
  }
728
530
 
531
+ function evalModelOp(op, expr, ctx, server) {
532
+ const modelName=expr.match(/^(\w+)\./)?.[1]; const m=server.models[modelName]; if(!m)return op==='all'?[]:null
533
+ const inner=expr.match(/\.\w+\(([^)]*)\)/)?.[1]||''
534
+ const getArg=(key)=>{ const r=inner.match(new RegExp(key+'=([^,)]+)')); return r?resolveVar(r[1],ctx):null }
535
+ if(op==='all') return m.all({limit:getArg('limit'),offset:getArg('offset')||evalMath(getArg('_offset')||'0',ctx),order:getArg('order'),where:getArg('where')})
536
+ if(op==='find') { const id=inner.trim(); return m.find(resolveVar(id,ctx)||ctx.params.id) }
537
+ if(op==='findBy') { const[f,v]=inner.split('='); return m.findBy(f.trim(),resolveVar(v?.trim(),ctx)) }
538
+ if(op==='paginate') { const[pg,pp]=inner.split(','); return m.paginate(parseInt(resolveVar(pg?.trim(),ctx))||1,parseInt(resolveVar(pp?.trim(),ctx))||15) }
539
+ if(op==='count') return m.count()
540
+ if(op==='sum') return m.sum(inner.trim())
541
+ if(op==='avg') return m.avg(inner.trim())
542
+ if(op==='where') { const p=inner.split(','); return m.where(p[0]?.trim(),p[1]?.trim()||'=',resolveVar(p[2]?.trim(),ctx)) }
543
+ if(op==='scope') return m.scope(inner.trim())
544
+ return null
545
+ }
546
+
729
547
  function resolveVar(expr, ctx) {
730
- if (!expr) return undefined
731
- expr = expr.trim()
548
+ if (!expr) return undefined; expr=expr.trim()
732
549
  if (expr.startsWith('$body.')) return ctx.body[expr.slice(6)]
733
- if (expr.startsWith('$params.') || expr === '$id') {
734
- const key = expr.startsWith('$params.') ? expr.slice(8) : 'id'
735
- return ctx.params[key] || ctx.params['id']
736
- }
550
+ if (expr==='$id'||expr==='$params.id') return ctx.params.id
551
+ if (expr.startsWith('$params.'))return ctx.params[expr.slice(8)]
737
552
  if (expr.startsWith('$query.')) return ctx.query[expr.slice(7)]
738
553
  if (expr.startsWith('$auth.')) return ctx.user?.[expr.slice(6)]
739
- if (expr.startsWith('$')) {
740
- const path = expr.slice(1).split('.')
741
- let val = ctx.vars[path[0]]
742
- for (let i = 1; i < path.length; i++) val = val?.[path[i]]
743
- return val
744
- }
554
+ if (expr.startsWith('$')) { const path=expr.slice(1).split('.'); let v=ctx.vars[path[0]]; for(let i=1;i<path.length;i++)v=v?.[path[i]]; return v }
745
555
  return expr
746
556
  }
747
-
748
- function evalMath(expr, ctx) {
749
- // Handles simple expressions like ($page-1)*$limit
750
- try {
751
- const resolved = expr.replace(/\$[\w.]+/g, m => resolveVar(m, ctx) || 0)
752
- return Function(`"use strict"; return (${resolved})`)()
753
- } catch { return 0 }
754
- }
755
-
756
- // ═══════════════════════════════════════════════════════════════
757
- // FRONTEND RENDERER — same as v1 but served dynamically
758
- // ═══════════════════════════════════════════════════════════════
759
-
760
- function serveStaticFrontend(server, pages) {
761
- // Serve CSS + hydration runtime
762
- server.addRoute('GET', '/aiplang-hydrate.js', (req, res) => {
763
- const hydratePath = path.join(__dirname, 'node_modules', '..', 'flux-lang', 'runtime', 'aiplang-hydrate.js')
764
- if (fs.existsSync(hydratePath)) {
765
- res.writeHead(200, { 'Content-Type': 'application/javascript', 'Cache-Control': 'public, max-age=3600' })
766
- res.end(fs.readFileSync(hydratePath))
767
- } else {
768
- res.writeHead(404); res.end('// hydrate runtime not found')
557
+ function evalMath(expr,ctx){try{const r=expr.replace(/\$[\w.]+/g,m=>resolveVar(m,ctx)||0);return Function('"use strict";return('+r+')')()}catch{return 0}}
558
+ function sanitize(o){if(!o)return o;const s={...o};delete s.password;return s}
559
+ function resolveEnv(v){if(!v)return v;if(v.startsWith('$'))return process.env[v.slice(1)]||v;return v}
560
+
561
+ // ═══════════════════════════════════════════════════════════════════
562
+ // AUTO ADMIN PANEL
563
+ // ═══════════════════════════════════════════════════════════════════
564
+ function registerAdminPanel(server, adminConfig, models) {
565
+ const prefix = adminConfig.prefix || '/admin'
566
+ const guard = adminConfig.guard || 'admin'
567
+
568
+ // Admin dashboard
569
+ server.addRoute('GET', prefix, (req, res) => {
570
+ if (guard === 'admin' && req.user?.role !== 'admin') {
571
+ res.writeHead(302, { Location: prefix + '/login' }); res.end(); return
769
572
  }
573
+ const html = renderAdminDashboard(prefix, models, server.models)
574
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); res.end(html)
770
575
  })
771
576
 
772
- // Serve each page
773
- for (const page of pages) {
774
- const route = page.route || '/'
775
- server.addRoute('GET', route, (req, res) => {
776
- const html = renderPageHTML(page, pages)
777
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
778
- res.end(html)
779
- })
780
- }
781
- }
782
-
783
- function renderPageHTML(page, allPages) {
784
- const needsJS = page.queries.length > 0 || page.blocks.some(b => ['table','form','if','btn','select','faq'].includes(b.kind))
785
- const body = page.blocks.map(b => renderBlock(b)).join('')
786
- const config = needsJS ? JSON.stringify({
787
- id: page.id, theme: page.theme, state: page.state,
788
- routes: allPages.map(p=>p.route), queries: page.queries
789
- }) : ''
790
- const hydrate = needsJS ? `\n<script>window.__FLUX_PAGE__=${config};</script>\n<script src="/aiplang-hydrate.js" defer></script>` : ''
791
- const themeCSS = page.themeVars ? genThemeVarCSS(page.themeVars) : ''
792
- const customCSS = page.customTheme ? genCustomCSS(page.customTheme) : ''
793
- return `<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${page.id}</title><style>${getBaseCSS(page.theme)}${customCSS}${themeCSS}</style></head><body>${body}${hydrate}</body></html>`
794
- }
795
-
796
- function renderBlock(b) {
797
- const line = b.rawLine
798
- switch (b.kind) {
799
- case 'nav': return renderNav(line)
800
- case 'hero': return renderHero(line)
801
- case 'stats': return renderStats(line)
802
- case 'row': return renderRow(line)
803
- case 'sect': return renderSect(line)
804
- case 'foot': return renderFoot(line)
805
- case 'table': return renderTable(line)
806
- case 'form': return renderForm(line)
807
- case 'pricing': return renderPricing(line)
808
- case 'faq': return renderFaq(line)
809
- case 'raw': return extractBody(line) + '\n'
810
- case 'if': return `<div class="fx-if-wrap" data-fx-if="${extractCond(line)}" style="display:none"></div>\n`
811
- default: return ''
812
- }
813
- }
577
+ // Admin login page
578
+ server.addRoute('GET', prefix + '/login', (req, res) => {
579
+ const html = renderAdminLogin(prefix)
580
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); res.end(html)
581
+ })
814
582
 
815
- const esc = s => s==null?'':String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;')
816
- const ic = n => ({bolt:'',rocket:'🚀',shield:'🛡',chart:'📊',star:'⭐',check:'✓',globe:'🌐',lock:'🔒',user:'👤',gear:'⚙',fire:'🔥',money:'💰',bell:'🔔',mail:'✉'}[n] || n)
583
+ // Admin API: list model records
584
+ server.addRoute('GET', prefix + '/api/:model', (req, res) => {
585
+ if (guard === 'admin' && req.user?.role !== 'admin') { res.error(403, 'Forbidden'); return }
586
+ const modelName = req.params.model.charAt(0).toUpperCase() + req.params.model.slice(1).replace(/s$/, '')
587
+ const m = server.models[modelName]
588
+ if (!m) { res.error(404, 'Model not found'); return }
589
+ const page = parseInt(req.query.page) || 1
590
+ res.json(200, m.paginate(page, 20))
591
+ })
817
592
 
818
- function extractBody(line) {
819
- const bi=line.indexOf('{'),li=line.lastIndexOf('}')
820
- return bi!==-1&&li!==-1?line.slice(bi+1,li).trim():''
821
- }
822
- function extractCond(line) { return line.slice(3,line.indexOf('{')).trim() }
823
-
824
- function parseItems(body) {
825
- return body.split('|').map(raw=>{
826
- raw=raw.trim();if(!raw)return null
827
- return raw.split('>').map(f=>{
828
- f=f.trim()
829
- if(f.startsWith('img:')) return{isImg:true,src:f.slice(4)}
830
- if(f.startsWith('/')) {const[p,l]=f.split(':');return{isLink:true,path:p.trim(),label:(l||'').trim()}}
831
- return{isLink:false,text:f}
832
- })
833
- }).filter(Boolean)
834
- }
593
+ // Admin API: delete record
594
+ server.addRoute('DELETE', prefix + '/api/:model/:id', (req, res) => {
595
+ if (guard === 'admin' && req.user?.role !== 'admin') { res.error(403, 'Forbidden'); return }
596
+ const modelName = req.params.model.charAt(0).toUpperCase() + req.params.model.slice(1).replace(/s$/, '')
597
+ const m = server.models[modelName]
598
+ if (!m) { res.error(404, 'Model not found'); return }
599
+ m.delete(req.params.id)
600
+ res.noContent()
601
+ })
835
602
 
836
- function renderNav(line) {
837
- const items=parseItems(extractBody(line))
838
- if(!items[0]) return ''
839
- const it=items[0],brand=!it[0]?.isLink?`<span class="fx-brand">${esc(it[0].text)}</span>`:''
840
- const start=!it[0]?.isLink?1:0
841
- const links=it.slice(start).filter(f=>f.isLink).map(f=>`<a href="${esc(f.path)}" class="fx-nav-link">${esc(f.label)}</a>`).join('')
842
- return `<nav class="fx-nav">${brand}<button class="fx-hamburger" onclick="this.classList.toggle('open');document.querySelector('.fx-nav-links').classList.toggle('open')"><span></span><span></span><span></span></button><div class="fx-nav-links">${links}</div></nav>\n`
843
- }
603
+ // Admin API: update record
604
+ server.addRoute('PUT', prefix + '/api/:model/:id', (req, res) => {
605
+ if (guard === 'admin' && req.user?.role !== 'admin') { res.error(403, 'Forbidden'); return }
606
+ const modelName = req.params.model.charAt(0).toUpperCase() + req.params.model.slice(1).replace(/s$/, '')
607
+ const m = server.models[modelName]
608
+ if (!m) { res.error(404, 'Model not found'); return }
609
+ const updated = m.update(req.params.id, req.body)
610
+ res.json(200, updated)
611
+ })
844
612
 
845
- function renderHero(line) {
846
- const items=parseItems(extractBody(line))
847
- let h1='',sub='',img='',ctas=''
848
- for(const item of items) for(const f of item){
849
- if(f.isImg) img=`<img src="${esc(f.src)}" class="fx-hero-img" alt="hero" loading="eager">`
850
- else if(f.isLink) ctas+=`<a href="${esc(f.path)}" class="fx-cta">${esc(f.label)}</a>`
851
- else if(!h1) h1=`<h1 class="fx-title">${esc(f.text)}</h1>`
852
- else sub+=`<p class="fx-sub">${esc(f.text)}</p>`
853
- }
854
- return `<section class="fx-hero${img?' fx-hero-split':''}"><div class="fx-hero-inner">${h1}${sub}${ctas}</div>${img}</section>\n`
613
+ console.log(`[aiplang] Admin: ${prefix} (guard: ${guard})`)
855
614
  }
856
615
 
857
- function renderStats(line) {
858
- const cells=parseItems(extractBody(line)).map(item=>{
859
- const[val,lbl]=(item[0]?.text||'').split(':')
860
- const bind=(val?.includes('@')||val?.includes('$'))?` data-fx-bind="${esc(val?.trim())}"` :''
861
- return`<div class="fx-stat"><div class="fx-stat-val"${bind}>${esc(val?.trim())}</div><div class="fx-stat-lbl">${esc(lbl?.trim())}</div></div>`
616
+ function renderAdminDashboard(prefix, modelDefs, models) {
617
+ const modelNames = modelDefs.map(m => m.name)
618
+ const stats = modelNames.map(name => {
619
+ const m = models[name]; const count = m?.count() || 0
620
+ return `<div class="stat-card"><div class="stat-num">${count}</div><div class="stat-label">${name}s</div></div>`
862
621
  }).join('')
863
- return `<div class="fx-stats">${cells}</div>\n`
864
- }
865
-
866
- function renderRow(line) {
867
- const bi=line.indexOf('{'),head=line.slice(0,bi).trim()
868
- const m=head.match(/row(\d+)/),cols=m?parseInt(m[1]):3
869
- const cards=parseItems(extractBody(line)).map(item=>{
870
- const inner=item.map((f,fi)=>{
871
- if(f.isImg) return`<img src="${esc(f.src)}" class="fx-card-img" alt="" loading="lazy">`
872
- if(f.isLink) return`<a href="${esc(f.path)}" class="fx-card-link">${esc(f.label)} →</a>`
873
- if(fi===0) return`<div class="fx-icon">${ic(f.text)}</div>`
874
- if(fi===1) return`<h3 class="fx-card-title">${esc(f.text)}</h3>`
875
- return`<p class="fx-card-body">${esc(f.text)}</p>`
876
- }).join('')
877
- return`<div class="fx-card">${inner}</div>`
622
+ const nav = modelNames.map(name =>
623
+ `<a href="#" onclick="loadModel('${name}')" class="nav-link">${name}s</a>`
624
+ ).join('')
625
+
626
+ return `<!DOCTYPE html><html><head><meta charset="UTF-8"><title>aiplang Admin</title>
627
+ <style>
628
+ *{box-sizing:border-box;margin:0;padding:0}body{font-family:-apple-system,sans-serif;background:#030712;color:#f1f5f9;min-height:100vh}
629
+ .sidebar{position:fixed;top:0;left:0;width:240px;height:100vh;background:#0f172a;border-right:1px solid #1e293b;padding:1.5rem}
630
+ .sidebar .brand{font-size:1.25rem;font-weight:800;color:#2563eb;margin-bottom:2rem}
631
+ .sidebar .brand span{color:#64748b;font-weight:400;font-size:.875rem;display:block;margin-top:.25rem}
632
+ .nav-link{display:block;padding:.625rem 1rem;border-radius:.5rem;color:#94a3b8;font-size:.875rem;font-weight:500;cursor:pointer;text-decoration:none;margin-bottom:.25rem}
633
+ .nav-link:hover{background:#1e293b;color:#f1f5f9}.main{margin-left:240px;padding:2rem}
634
+ .header{margin-bottom:2rem}.header h1{font-size:1.75rem;font-weight:800;letter-spacing:-.03em}
635
+ .stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:1rem;margin-bottom:2rem}
636
+ .stat-card{background:#0f172a;border:1px solid #1e293b;border-radius:1rem;padding:1.5rem;text-align:center}
637
+ .stat-num{font-size:2.5rem;font-weight:900;color:#2563eb;letter-spacing:-.05em;line-height:1}
638
+ .stat-label{font-size:.75rem;color:#64748b;text-transform:uppercase;letter-spacing:.08em;margin-top:.5rem;font-weight:600}
639
+ .table-wrap{background:#0f172a;border:1px solid #1e293b;border-radius:1rem;overflow:hidden}
640
+ .table-header{padding:1.25rem 1.5rem;border-bottom:1px solid #1e293b;display:flex;align-items:center;justify-content:space-between}
641
+ .table-title{font-weight:700;font-size:1rem}
642
+ table{width:100%;border-collapse:collapse;font-size:.875rem}
643
+ th{padding:.875rem 1.25rem;text-align:left;font-size:.75rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#475569;border-bottom:1px solid #1e293b}
644
+ td{padding:.875rem 1.25rem;border-bottom:1px solid rgba(255,255,255,.04);color:#94a3b8;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
645
+ .btn-sm{border:none;cursor:pointer;font-size:.75rem;font-weight:600;padding:.3rem .75rem;border-radius:.375rem;font-family:inherit}
646
+ .btn-delete{background:#7f1d1d;color:#fca5a5}.btn-delete:hover{background:#991b1b}
647
+ .pagination{padding:1rem 1.5rem;display:flex;gap:.5rem;justify-content:flex-end}
648
+ .page-btn{padding:.375rem .75rem;border-radius:.375rem;border:1px solid #1e293b;background:transparent;color:#64748b;cursor:pointer;font-size:.8125rem}
649
+ .page-btn.active{background:#2563eb;color:#fff;border-color:#2563eb}
650
+ .empty{text-align:center;padding:3rem;color:#334155}
651
+ #content{min-height:200px}
652
+ </style></head><body>
653
+ <div class="sidebar">
654
+ <div class="brand">aiplang Admin<span>v2.0.1</span></div>
655
+ <a href="${prefix}" class="nav-link" style="color:#f1f5f9;background:#1e293b">📊 Dashboard</a>
656
+ ${nav}
657
+ </div>
658
+ <div class="main">
659
+ <div class="header"><h1>Dashboard</h1></div>
660
+ <div class="stats">${stats}</div>
661
+ <div id="content"><div class="table-wrap"><div class="empty">← Selecione um modelo na sidebar</div></div></div>
662
+ </div>
663
+ <script>
664
+ const prefix = '${prefix}'
665
+ const token = localStorage.getItem('admin_token') || ''
666
+ async function api(method, path, body) {
667
+ const r = await fetch(prefix + '/api' + path, {method, headers:{'Content-Type':'application/json','Authorization':'Bearer '+token},body:body?JSON.stringify(body):undefined})
668
+ return r.json()
669
+ }
670
+ async function loadModel(name, page=1) {
671
+ const table = name.toLowerCase() + 's'
672
+ const data = await api('GET', '/' + table + '?page=' + page)
673
+ const rows = data.data || []
674
+ const meta = data.meta || {}
675
+ const cols = rows.length ? Object.keys(rows[0]).filter(k => !['password','deleted_at'].includes(k)) : []
676
+ const ths = cols.map(c=>'<th>'+c+'</th>').join('') + '<th>Actions</th>'
677
+ const trs = rows.map(r=>{
678
+ const tds = cols.map(c=>'<td title="'+String(r[c]||'').replace(/"/g,'&quot;')+'">'+String(r[c]||'-').slice(0,40)+'</td>').join('')
679
+ return '<tr>'+tds+'<td><button class="btn-sm btn-delete" onclick="del(\\'' + table + '\\',\\''+r.id+'\\')">Delete</button></td></tr>'
878
680
  }).join('')
879
- return `<div class="fx-grid fx-grid-${cols}">${cards}</div>\n`
880
- }
681
+ const pages = Array.from({length:meta.last_page||1},(_,i)=>'<button class="page-btn'+(i+1===page?' active':'')+'" onclick="loadModel(\\'' + name + '\\',' + (i+1) + ')">'+(i+1)+'</button>').join('')
682
+ document.getElementById('content').innerHTML = '<div class="table-wrap"><div class="table-header"><span class="table-title">'+name+'s</span><span style="color:#64748b;font-size:.8125rem">'+meta.total+' records</span></div>' + (rows.length ? '<table><thead><tr>'+ths+'</tr></thead><tbody>'+trs+'</tbody></table>' : '<div class="empty">No records</div>') + '<div class="pagination">'+pages+'</div></div>'
683
+ }
684
+ async function del(table, id) {
685
+ if (!confirm('Delete this record?')) return
686
+ await api('DELETE', '/' + table + '/' + id)
687
+ const name = table.charAt(0).toUpperCase() + table.slice(1).replace(/s$/, '')
688
+ loadModel(name)
689
+ }
690
+ </script></body></html>`
691
+ }
692
+
693
+ function renderAdminLogin(prefix) {
694
+ return `<!DOCTYPE html><html><head><meta charset="UTF-8"><title>Admin Login</title>
695
+ <style>*{box-sizing:border-box;margin:0;padding:0}body{background:#030712;color:#f1f5f9;font-family:-apple-system,sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh}.card{background:#0f172a;border:1px solid #1e293b;border-radius:1.25rem;padding:2.5rem;width:100%;max-width:360px}.h1{font-size:1.5rem;font-weight:800;margin-bottom:1.75rem;letter-spacing:-.03em}.field{margin-bottom:1.25rem}label{display:block;font-size:.8125rem;font-weight:600;margin-bottom:.5rem;color:#94a3b8}input{width:100%;padding:.75rem 1rem;background:#020617;border:1px solid #1e293b;border-radius:.625rem;color:#f1f5f9;font-size:.9375rem;outline:none}input:focus{border-color:#2563eb}button{width:100%;padding:.875rem;background:#2563eb;color:#fff;border:none;border-radius:.625rem;font-size:.9375rem;font-weight:700;cursor:pointer;margin-top:.5rem}.err{color:#f87171;font-size:.8125rem;margin-top:.5rem;min-height:1.25rem}</style></head>
696
+ <body><div class="card"><div class="h1">aiplang Admin</div>
697
+ <div class="field"><label>Email</label><input id="email" type="email" placeholder="admin@app.com"></div>
698
+ <div class="field"><label>Password</label><input id="pass" type="password" placeholder="••••••••"></div>
699
+ <div class="err" id="err"></div>
700
+ <button onclick="login()">Sign in</button></div>
701
+ <script>
702
+ async function login(){
703
+ const r=await fetch('/api/auth/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({email:document.getElementById('email').value,password:document.getElementById('pass').value})})
704
+ const d=await r.json()
705
+ if(d.token){localStorage.setItem('admin_token',d.token);location.href='${prefix}'}
706
+ else document.getElementById('err').textContent=d.error||'Invalid credentials'
707
+ }
708
+ document.addEventListener('keydown',e=>{if(e.key==='Enter')login()})
709
+ </script></body></html>`
710
+ }
711
+
712
+ // ═══════════════════════════════════════════════════════════════════
713
+ // HTTP SERVER
714
+ // ═══════════════════════════════════════════════════════════════════
715
+ class AiplangServer {
716
+ constructor() { this.routes=[]; this.models={} }
717
+ addRoute(method, p, handler) { this.routes.push({method:method.toUpperCase(),path:p,handler,params:p.split('/').filter(s=>s.startsWith(':')).map(s=>s.slice(1))}) }
718
+ registerModel(name, def) { this.models[name]=new Model(name, def); return this.models[name] }
881
719
 
882
- function renderSect(line) {
883
- let inner=''
884
- parseItems(extractBody(line)).forEach((item,ii)=>item.forEach(f=>{
885
- if(f.isLink) inner+=`<a href="${esc(f.path)}" class="fx-sect-link">${esc(f.label)}</a>`
886
- else if(ii===0) inner+=`<h2 class="fx-sect-title">${esc(f.text)}</h2>`
887
- else inner+=`<p class="fx-sect-body">${esc(f.text)}</p>`
888
- }))
889
- return `<section class="fx-sect">${inner}</section>\n`
890
- }
720
+ async handle(req, res) {
721
+ if (req.method !== 'GET' && req.method !== 'DELETE') req.body = await parseBody(req)
722
+ else req.body = {}
723
+ const parsed = url.parse(req.url, true)
724
+ req.query = parsed.query; req.path = parsed.pathname
725
+ req.user = extractToken(req) ? verifyJWT(extractToken(req)) : null
891
726
 
892
- function renderFoot(line) {
893
- let inner=''
894
- for(const item of parseItems(extractBody(line))) for(const f of item){
895
- if(f.isLink) inner+=`<a href="${esc(f.path)}" class="fx-footer-link">${esc(f.label)}</a>`
896
- else inner+=`<p class="fx-footer-text">${esc(f.text)}</p>`
897
- }
898
- return `<footer class="fx-footer">${inner}</footer>\n`
899
- }
727
+ res.setHeader('Access-Control-Allow-Origin','*')
728
+ res.setHeader('Access-Control-Allow-Methods','GET,POST,PUT,PATCH,DELETE,OPTIONS')
729
+ res.setHeader('Access-Control-Allow-Headers','Content-Type,Authorization')
730
+ if (req.method==='OPTIONS') { res.writeHead(204); res.end(); return }
900
731
 
901
- function renderTable(line) {
902
- const bi=line.indexOf('{'),binding=line.slice(6,bi).trim()
903
- const content=extractBody(line)
904
- const em=content.match(/edit\s+(PUT|PATCH)\s+(\S+)/),dm=content.match(/delete\s+(?:DELETE\s+)?(\S+)/)
905
- const clean=content.replace(/edit\s+(PUT|PATCH)\s+\S+/g,'').replace(/delete\s+(?:DELETE\s+)?\S+/g,'')
906
- const cols=clean.split('|').map(c=>{c=c.trim();if(c.startsWith('empty:')||!c)return null;const[l,k]=c.split(':').map(x=>x.trim());return k?{label:l,key:k}:null}).filter(Boolean)
907
- const emptyMsg=clean.match(/empty:\s*([^|]+)/)?.[1]||'No data.'
908
- const ths=cols.map(c=>`<th class="fx-th">${esc(c.label)}</th>`).join('')
909
- const ea=em?` data-fx-edit="${esc(em[2])}" data-fx-edit-method="${esc(em[1])}"` :''
910
- const da=dm?` data-fx-delete="${esc(dm[1])}"` :''
911
- const at=(em||dm)?'<th class="fx-th fx-th-actions">Actions</th>':''
912
- return `<div class="fx-table-wrap"><table class="fx-table" data-fx-table="${esc(binding)}" data-fx-cols='${JSON.stringify(cols.map(c=>c.key))}'${ea}${da}><thead><tr>${ths}${at}</tr></thead><tbody class="fx-tbody"><tr><td colspan="${cols.length+(em||dm?1:0)}" class="fx-td-empty">${esc(emptyMsg)}</td></tr></tbody></table></div>\n`
913
- }
732
+ for (const route of this.routes) {
733
+ if (route.method !== req.method) continue
734
+ const match = matchRoute(route.path, req.path); if (!match) continue
735
+ req.params = match
736
+ res.json = (s, d) => { if(typeof s==='object'){d=s;s=200}; res.writeHead(s,{'Content-Type':'application/json'}); res.end(JSON.stringify(d)) }
737
+ res.error = (s, m) => res.json(s, {error:m})
738
+ res.noContent = () => { res.writeHead(204); res.end() }
739
+ res.redirect = (u) => { res.writeHead(302,{Location:u}); res.end() }
740
+ try { await route.handler(req, res) } catch(e) { console.error('[aiplang] Error:', e.message); if(!res.writableEnded) res.json(500,{error:'Internal server error'}) }
741
+ return
742
+ }
743
+ res.writeHead(404,{'Content-Type':'application/json'}); res.end(JSON.stringify({error:'Not found'}))
744
+ }
914
745
 
915
- function renderForm(line) {
916
- const bi=line.indexOf('{')
917
- let head=line.slice(5,bi).trim(),action='',method='POST',bpath='#'
918
- const ai=head.indexOf('=>');if(ai!==-1){action=head.slice(ai+2).trim();head=head.slice(0,ai).trim()}
919
- const pts=head.split(/\s+/);method=pts[0]||'POST';bpath=pts[1]||'#'
920
- const fields=extractBody(line).split('|').map(f=>{
921
- const[label,type,ph]=f.split(':').map(x=>x.trim())
922
- if(!label) return''
923
- const name=label.toLowerCase().replace(/\s+/g,'_')
924
- const inp=type==='select'?`<select class="fx-input" name="${esc(name)}"><option value="">Select...</option></select>`:`<input class="fx-input" type="${esc(type||'text')}" name="${esc(name)}" placeholder="${esc(ph||'')}">`
925
- return`<div class="fx-field"><label class="fx-label">${esc(label)}</label>${inp}</div>`
926
- }).join('')
927
- return `<div class="fx-form-wrap"><form class="fx-form" data-fx-form="${esc(bpath)}" data-fx-method="${esc(method)}" data-fx-action="${esc(action)}">${fields}<div class="fx-form-msg"></div><button type="submit" class="fx-btn">Submit</button></form></div>\n`
746
+ listen(port) {
747
+ http.createServer((req,res)=>this.handle(req,res)).listen(port,()=>console.log(`[aiplang] Server → http://localhost:${port}`))
748
+ }
928
749
  }
929
750
 
930
- function renderPricing(line) {
931
- const plans=extractBody(line).split('|').map(p=>{
932
- const pts=p.trim().split('>').map(x=>x.trim())
933
- return{name:pts[0],price:pts[1],desc:pts[2],linkRaw:pts[3]}
934
- }).filter(p=>p.name)
935
- const cards=plans.map((p,i)=>{
936
- let lh='#',ll='Get started'
937
- if(p.linkRaw){const m=p.linkRaw.match(/\/([^:]+):(.+)/);if(m){lh='/'+m[1];ll=m[2]}}
938
- const f=i===1?' fx-pricing-featured':''
939
- return`<div class="fx-pricing-card${f}">${i===1?'<div class="fx-pricing-badge">Most popular</div>':''}<div class="fx-pricing-name">${esc(p.name)}</div><div class="fx-pricing-price">${esc(p.price)}</div><p class="fx-pricing-desc">${esc(p.desc)}</p><a href="${esc(lh)}" class="fx-cta fx-pricing-cta">${esc(ll)}</a></div>`
940
- }).join('')
941
- return `<div class="fx-pricing">${cards}</div>\n`
751
+ // ── Utils ─────────────────────────────────────────────────────────
752
+ function matchRoute(pattern, reqPath) {
753
+ const pp=pattern.split('/'), rp=reqPath.split('/')
754
+ if(pp.length!==rp.length)return null
755
+ const params={}
756
+ for(let i=0;i<pp.length;i++){if(pp[i].startsWith(':'))params[pp[i].slice(1)]=rp[i];else if(pp[i]!==rp[i])return null}
757
+ return params
942
758
  }
943
-
944
- function renderFaq(line) {
945
- const items=extractBody(line).split('|').map(i=>{const idx=i.indexOf('>');return{q:i.slice(0,idx).trim(),a:i.slice(idx+1).trim()}}).filter(i=>i.q)
946
- const html=items.map(i=>`<div class="fx-faq-item" onclick="this.classList.toggle('open')"><div class="fx-faq-q">${esc(i.q)}<span class="fx-faq-arrow">▸</span></div><div class="fx-faq-a">${esc(i.a)}</div></div>`).join('')
947
- return `<section class="fx-sect"><div class="fx-faq">${html}</div></section>\n`
759
+ function extractToken(req) { const a=req.headers.authorization; return a?.startsWith('Bearer ')?a.slice(7):null }
760
+ async function parseBody(req) {
761
+ return new Promise(r=>{let d='';req.on('data',c=>d+=c);req.on('end',()=>{try{r(JSON.parse(d))}catch{r({})}});req.on('error',()=>r({}))})
948
762
  }
949
763
 
950
- // ─── Helpers ──────────────────────────────────────────────────
951
-
952
- function parseRouteParams(routePath) {
953
- return routePath.split('/').filter(s => s.startsWith(':')).map(s => s.slice(1))
764
+ // ═══════════════════════════════════════════════════════════════════
765
+ // FRONTEND RENDERER (same as v1)
766
+ // ═══════════════════════════════════════════════════════════════════
767
+ function renderHTML(page, allPages) {
768
+ const needsJS=page.queries.length>0||page.blocks.some(b=>['table','form','if','btn','select','faq'].includes(b.kind))
769
+ const body=page.blocks.map(b=>renderBlock(b)).join('')
770
+ const config=needsJS?JSON.stringify({id:page.id,theme:page.theme,state:page.state,routes:allPages.map(p=>p.route),queries:page.queries}):''
771
+ const hydrate=needsJS?`<script>window.__FLUX_PAGE__=${config};</script><script src="/aiplang-hydrate.js" defer></script>`:''
772
+ const themeCSS=page.themeVars?genThemeCSS(page.themeVars):''
773
+ const customCSS=page.customTheme?`body{background:${page.customTheme.bg};color:${page.customTheme.text}}.fx-cta,.fx-btn{background:${page.customTheme.accent};color:#fff}` :''
774
+ return `<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${page.id}</title><style>${baseCSS(page.theme)}${customCSS}${themeCSS}</style></head><body>${body}${hydrate}</body></html>`
954
775
  }
955
776
 
956
- function matchRoute(routePattern, reqPath) {
957
- const rParts = routePattern.split('/')
958
- const uParts = reqPath.split('/')
959
- if (rParts.length !== uParts.length) return null
960
- const params = {}
961
- for (let i = 0; i < rParts.length; i++) {
962
- if (rParts[i].startsWith(':')) params[rParts[i].slice(1)] = uParts[i]
963
- else if (rParts[i] !== uParts[i]) return null
777
+ function renderBlock(b) {
778
+ const line=b.rawLine
779
+ let animate='',extraClass=''
780
+ const am=line.match(/\banimate:(\S+)/); if(am)animate='fx-anim-'+am[1]
781
+ const cm=line.match(/\bclass:(\S+)/); if(cm)extraClass=cm[1]
782
+ const addCls=(html)=>animate||extraClass?html.replace(/class="([^"]*)"/, (_,c)=>`class="${c} ${animate} ${extraClass}".trim().replace(/ +/g,' ')`):html
783
+
784
+ switch(b.kind){
785
+ case 'nav': return addCls(rNav(line))
786
+ case 'hero': return addCls(rHero(line))
787
+ case 'stats':return addCls(rStats(line))
788
+ case 'row': return addCls(rRow(line))
789
+ case 'sect': return addCls(rSect(line))
790
+ case 'foot': return addCls(rFoot(line))
791
+ case 'table':return rTable(line)
792
+ case 'form': return rForm(line)
793
+ case 'pricing':return rPricing(line)
794
+ case 'faq': return rFaq(line)
795
+ case 'testimonial':return rTestimonial(line)
796
+ case 'gallery':return rGallery(line)
797
+ case 'raw': return extractBody(line)+'\n'
798
+ case 'if': return `<div class="fx-if-wrap" data-fx-if="${esc(extractCond(line))}" style="display:none"></div>\n`
799
+ default: return ''
964
800
  }
965
- return params
966
- }
967
-
968
- function extractToken(req) {
969
- const auth = req.headers.authorization
970
- if (auth?.startsWith('Bearer ')) return auth.slice(7)
971
- return null
972
- }
973
-
974
- async function parseBody(req) {
975
- return new Promise((resolve) => {
976
- let data = ''
977
- req.on('data', chunk => data += chunk)
978
- req.on('end', () => {
979
- try { resolve(JSON.parse(data)) }
980
- catch { resolve({}) }
981
- })
982
- req.on('error', () => resolve({}))
983
- })
984
801
  }
985
802
 
986
- function sanitize(obj) {
987
- if (!obj) return obj
988
- const s = { ...obj }
989
- delete s.password
990
- return s
991
- }
803
+ function extractBody(line){const bi=line.indexOf('{'),li=line.lastIndexOf('}');return bi!==-1&&li!==-1?line.slice(bi+1,li).trim():''}
804
+ function extractCond(line){return line.slice(3,line.indexOf('{')).trim()}
805
+ function parseItems(body){return body.split('|').map(raw=>{raw=raw.trim();if(!raw)return null;return raw.split('>').map(f=>{f=f.trim();if(f.startsWith('img:'))return{isImg:true,src:f.slice(4)};if(f.startsWith('/'))return{isLink:true,path:f.split(':')[0].trim(),label:(f.split(':')[1]||'').trim()};return{isLink:false,text:f}})}).filter(Boolean)}
992
806
 
993
- function genThemeVarCSS(t) {
994
- const r=[]
995
- if(t.accent) r.push(`.fx-cta,.fx-btn{background:${t.accent}!important;color:#fff!important}`)
996
- if(t.bg) r.push(`body{background:${t.bg}!important}`)
997
- if(t.text) r.push(`body{color:${t.text}!important}`)
998
- if(t.font) r.push(`@import url('https://fonts.googleapis.com/css2?family=${t.font.replace(/ /g,'+')}:wght@400;700;900&display=swap');body{font-family:'${t.font}',system-ui,sans-serif!important}`)
999
- if(t.radius) r.push(`.fx-card,.fx-form,.fx-btn,.fx-input,.fx-cta{border-radius:${t.radius}!important}`)
1000
- if(t.surface)r.push(`.fx-card,.fx-form{background:${t.surface}!important}`)
1001
- return r.join('')
1002
- }
807
+ function rNav(line){const items=parseItems(extractBody(line));if(!items[0])return '';const it=items[0],brand=!it[0]?.isLink?`<span class="fx-brand">${esc(it[0].text)}</span>`:'';const start=!it[0]?.isLink?1:0;const links=it.slice(start).filter(f=>f.isLink).map(f=>`<a href="${esc(f.path)}" class="fx-nav-link">${esc(f.label)}</a>`).join('');return`<nav class="fx-nav">${brand}<button class="fx-hamburger" onclick="this.classList.toggle('open');document.querySelector('.fx-nav-links').classList.toggle('open')"><span></span><span></span><span></span></button><div class="fx-nav-links">${links}</div></nav>\n`}
808
+ function rHero(line){const items=parseItems(extractBody(line));let h1='',sub='',img='',ctas='';for(const item of items)for(const f of item){if(f.isImg)img=`<img src="${esc(f.src)}" class="fx-hero-img" alt="hero" loading="eager">`;else if(f.isLink)ctas+=`<a href="${esc(f.path)}" class="fx-cta">${esc(f.label)}</a>`;else if(!h1)h1=`<h1 class="fx-title">${esc(f.text)}</h1>`;else sub+=`<p class="fx-sub">${esc(f.text)}</p>`};return`<section class="fx-hero${img?' fx-hero-split':''}"><div class="fx-hero-inner">${h1}${sub}${ctas}</div>${img}</section>\n`}
809
+ function rStats(line){return`<div class="fx-stats">${parseItems(extractBody(line)).map(item=>{const[val,lbl]=(item[0]?.text||'').split(':');const bind=(val?.includes('@')||val?.includes('$'))?` data-fx-bind="${esc(val?.trim())}"` :'';return`<div class="fx-stat"><div class="fx-stat-val"${bind}>${esc(val?.trim())}</div><div class="fx-stat-lbl">${esc(lbl?.trim())}</div></div>`}).join('')}</div>\n`}
810
+ function rRow(line){const bi=line.indexOf('{'),head=line.slice(0,bi).trim(),m=head.match(/row(\d+)/),cols=m?parseInt(m[1]):3;const cards=parseItems(extractBody(line)).map(item=>`<div class="fx-card">${item.map((f,fi)=>f.isImg?`<img src="${esc(f.src)}" class="fx-card-img" alt="" loading="lazy">`:f.isLink?`<a href="${esc(f.path)}" class="fx-card-link">${esc(f.label)} →</a>`:fi===0?`<div class="fx-icon">${ic(f.text)}</div>`:fi===1?`<h3 class="fx-card-title">${esc(f.text)}</h3>`:`<p class="fx-card-body">${esc(f.text)}</p>`).join('')}</div>`).join('');return`<div class="fx-grid fx-grid-${cols}">${cards}</div>\n`}
811
+ function rSect(line){let inner='';parseItems(extractBody(line)).forEach((item,ii)=>item.forEach(f=>{if(f.isLink)inner+=`<a href="${esc(f.path)}" class="fx-sect-link">${esc(f.label)}</a>`;else if(ii===0)inner+=`<h2 class="fx-sect-title">${esc(f.text)}</h2>`;else inner+=`<p class="fx-sect-body">${esc(f.text)}</p>`}));return`<section class="fx-sect">${inner}</section>\n`}
812
+ function rFoot(line){let inner='';for(const item of parseItems(extractBody(line)))for(const f of item){if(f.isLink)inner+=`<a href="${esc(f.path)}" class="fx-footer-link">${esc(f.label)}</a>`;else inner+=`<p class="fx-footer-text">${esc(f.text)}</p>`};return`<footer class="fx-footer">${inner}</footer>\n`}
813
+ function rTable(line){const bi=line.indexOf('{'),binding=line.slice(6,bi).trim(),content=extractBody(line),em=content.match(/edit\s+(PUT|PATCH)\s+(\S+)/),dm=content.match(/delete\s+(?:DELETE\s+)?(\S+)/);const clean=content.replace(/edit\s+(PUT|PATCH)\s+\S+/g,'').replace(/delete\s+(?:DELETE\s+)?\S+/g,'');const cols=clean.split('|').map(c=>{c=c.trim();if(c.startsWith('empty:')||!c)return null;const[l,k]=c.split(':').map(x=>x.trim());return k?{label:l,key:k}:null}).filter(Boolean);const emptyMsg=clean.match(/empty:\s*([^|]+)/)?.[1]||'No data.';const ths=cols.map(c=>`<th class="fx-th">${esc(c.label)}</th>`).join('');const at=(em||dm)?'<th class="fx-th fx-th-actions">Actions</th>':'';return`<div class="fx-table-wrap"><table class="fx-table" data-fx-table="${esc(binding)}" data-fx-cols='${JSON.stringify(cols.map(c=>c.key))}'${em?` data-fx-edit="${esc(em[2])}" data-fx-edit-method="${esc(em[1])}"` :'' }${dm?` data-fx-delete="${esc(dm[1])}"` :'' }><thead><tr>${ths}${at}</tr></thead><tbody class="fx-tbody"><tr><td colspan="${cols.length+(em||dm?1:0)}" class="fx-td-empty">${esc(emptyMsg)}</td></tr></tbody></table></div>\n`}
814
+ function rForm(line){const bi=line.indexOf('{');let head=line.slice(5,bi).trim(),action='',method='POST',bpath='#';const ai=head.indexOf('=>');if(ai!==-1){action=head.slice(ai+2).trim();head=head.slice(0,ai).trim()};const pts=head.split(/\s+/);method=pts[0]||'POST';bpath=pts[1]||'#';const fields=extractBody(line).split('|').map(f=>{const[label,type,ph]=f.split(':').map(x=>x.trim());if(!label)return'';const name=label.toLowerCase().replace(/\s+/g,'_');const inp=type==='select'?`<select class="fx-input" name="${esc(name)}"><option value="">Select...</option></select>`:`<input class="fx-input" type="${esc(type||'text')}" name="${esc(name)}" placeholder="${esc(ph||'')}">`;return`<div class="fx-field"><label class="fx-label">${esc(label)}</label>${inp}</div>`}).join('');return`<div class="fx-form-wrap"><form class="fx-form" data-fx-form="${esc(bpath)}" data-fx-method="${esc(method)}" data-fx-action="${esc(action)}">${fields}<div class="fx-form-msg"></div><button type="submit" class="fx-btn">Submit</button></form></div>\n`}
815
+ function rPricing(line){const plans=extractBody(line).split('|').map(p=>{const pts=p.trim().split('>').map(x=>x.trim());return{name:pts[0],price:pts[1],desc:pts[2],linkRaw:pts[3]}}).filter(p=>p.name);const cards=plans.map((p,i)=>{let lh='#',ll='Get started';if(p.linkRaw){const m=p.linkRaw.match(/\/([^:]+):(.+)/);if(m){lh='/'+m[1];ll=m[2]}};return`<div class="fx-pricing-card${i===1?' fx-pricing-featured':''}">${i===1?'<div class="fx-pricing-badge">Most popular</div>':''}<div class="fx-pricing-name">${esc(p.name)}</div><div class="fx-pricing-price">${esc(p.price)}</div><p class="fx-pricing-desc">${esc(p.desc)}</p><a href="${esc(lh)}" class="fx-cta fx-pricing-cta">${esc(ll)}</a></div>`}).join('');return`<div class="fx-pricing">${cards}</div>\n`}
816
+ function rFaq(line){const items=extractBody(line).split('|').map(i=>{const idx=i.indexOf('>');return{q:i.slice(0,idx).trim(),a:i.slice(idx+1).trim()}}).filter(i=>i.q);return`<section class="fx-sect"><div class="fx-faq">${items.map(i=>`<div class="fx-faq-item" onclick="this.classList.toggle('open')"><div class="fx-faq-q">${esc(i.q)}<span class="fx-faq-arrow">▸</span></div><div class="fx-faq-a">${esc(i.a)}</div></div>`).join('')}</div></section>\n`}
817
+ function rTestimonial(line){const parts=extractBody(line).split('|').map(x=>x.trim());const imgPart=parts.find(p=>p.startsWith('img:'));const img=imgPart?`<img src="${esc(imgPart.slice(4))}" class="fx-testi-img" alt="${esc(parts[0])}" loading="lazy">`:`<div class="fx-testi-avatar">${esc((parts[0]||'?').charAt(0))}</div>`;return`<section class="fx-testi-wrap"><div class="fx-testi">${img}<blockquote class="fx-testi-quote">"${esc(parts[1]?.replace(/^"|"$/g,''))}"</blockquote><div class="fx-testi-author">${esc(parts[0])}</div></div></section>\n`}
818
+ function rGallery(line){return`<div class="fx-gallery">${extractBody(line).split('|').map(src=>`<div class="fx-gallery-item"><img src="${esc(src.trim())}" alt="" loading="lazy"></div>`).join('')}</div>\n`}
1003
819
 
1004
- function genCustomCSS(ct) {
1005
- return `body{background:${ct.bg};color:${ct.text}}.fx-cta,.fx-btn{background:${ct.accent};color:#fff}`
1006
- }
820
+ function genThemeCSS(t){const r=[];if(t.accent)r.push(`.fx-cta,.fx-btn{background:${t.accent}!important;color:#fff!important}`);if(t.bg)r.push(`body{background:${t.bg}!important}`);if(t.text)r.push(`body{color:${t.text}!important}`);if(t.font)r.push(`@import url('https://fonts.googleapis.com/css2?family=${t.font.replace(/ /g,'+')}:wght@400;700;900&display=swap');body{font-family:'${t.font}',system-ui,sans-serif!important}`);if(t.radius)r.push(`.fx-card,.fx-form,.fx-btn,.fx-input,.fx-cta{border-radius:${t.radius}!important}`);if(t.surface)r.push(`.fx-card,.fx-form{background:${t.surface}!important}`);return r.join('')}
1007
821
 
1008
- function getBaseCSS(theme) {
1009
- const base=`*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}html{scroll-behavior:smooth}body{font-family:-apple-system,'Segoe UI',system-ui,sans-serif;-webkit-font-smoothing:antialiased;min-height:100vh}a{text-decoration:none;color:inherit}input,button,select{font-family:inherit}img{max-width:100%;height:auto}.fx-nav{display:flex;align-items:center;justify-content:space-between;padding:1rem 2.5rem;position:sticky;top:0;z-index:50;backdrop-filter:blur(12px);flex-wrap:wrap;gap:.5rem}.fx-brand{font-size:1.25rem;font-weight:800;letter-spacing:-.03em}.fx-nav-links{display:flex;align-items:center;gap:1.75rem}.fx-nav-link{font-size:.875rem;font-weight:500;opacity:.65;transition:opacity .15s}.fx-nav-link:hover{opacity:1}.fx-hamburger{display:none;flex-direction:column;gap:5px;background:none;border:none;cursor:pointer;padding:.25rem}.fx-hamburger span{display:block;width:22px;height:2px;background:currentColor;transition:all .2s;border-radius:1px}.fx-hamburger.open span:nth-child(1){transform:rotate(45deg) translate(5px,5px)}.fx-hamburger.open span:nth-child(2){opacity:0}.fx-hamburger.open span:nth-child(3){transform:rotate(-45deg) translate(5px,-5px)}@media(max-width:640px){.fx-hamburger{display:flex}.fx-nav-links{display:none;width:100%;flex-direction:column;align-items:flex-start;gap:.75rem;padding:.75rem 0}.fx-nav-links.open{display:flex}}.fx-hero{display:flex;align-items:center;justify-content:center;min-height:92vh;padding:4rem 1.5rem}.fx-hero-split{display:grid;grid-template-columns:1fr 1fr;gap:3rem;align-items:center;padding:4rem 2.5rem;min-height:70vh}.fx-hero-img{width:100%;border-radius:1.25rem;object-fit:cover;max-height:500px}.fx-hero-inner{max-width:56rem;text-align:center;display:flex;flex-direction:column;align-items:center;gap:1.5rem}.fx-hero-split .fx-hero-inner{text-align:left;align-items:flex-start;max-width:none}.fx-title{font-size:clamp(2.5rem,8vw,5.5rem);font-weight:900;letter-spacing:-.04em;line-height:1}.fx-sub{font-size:clamp(1rem,2vw,1.25rem);line-height:1.75;max-width:40rem}.fx-cta{display:inline-flex;align-items:center;padding:.875rem 2.5rem;border-radius:.75rem;font-weight:700;font-size:1rem;transition:transform .15s;margin:.25rem}.fx-cta:hover{transform:translateY(-1px)}.fx-stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:3rem;padding:5rem 2.5rem;text-align:center}.fx-stat-val{font-size:clamp(2.5rem,5vw,4rem);font-weight:900;letter-spacing:-.04em;line-height:1}.fx-stat-lbl{font-size:.75rem;font-weight:600;text-transform:uppercase;letter-spacing:.1em;margin-top:.5rem}.fx-grid{display:grid;gap:1.25rem;padding:1rem 2.5rem 5rem}.fx-grid-2{grid-template-columns:repeat(auto-fit,minmax(280px,1fr))}.fx-grid-3{grid-template-columns:repeat(auto-fit,minmax(240px,1fr))}.fx-grid-4{grid-template-columns:repeat(auto-fit,minmax(200px,1fr))}.fx-card{border-radius:1rem;padding:1.75rem;transition:transform .2s,box-shadow .2s}.fx-card:hover{transform:translateY(-2px)}.fx-card-img{width:100%;border-radius:.75rem;object-fit:cover;height:180px;margin-bottom:1rem}.fx-icon{font-size:2rem;margin-bottom:1rem}.fx-card-title{font-size:1.0625rem;font-weight:700;letter-spacing:-.02em;margin-bottom:.5rem}.fx-card-body{font-size:.875rem;line-height:1.65}.fx-sect{padding:5rem 2.5rem}.fx-sect-title{font-size:clamp(1.75rem,4vw,3rem);font-weight:800;letter-spacing:-.04em;margin-bottom:1.5rem;text-align:center}.fx-sect-body{font-size:1rem;line-height:1.75;text-align:center;max-width:48rem;margin:0 auto}.fx-form-wrap{padding:3rem 2.5rem;display:flex;justify-content:center}.fx-form{width:100%;max-width:28rem;border-radius:1.25rem;padding:2.5rem}.fx-field{margin-bottom:1.25rem}.fx-label{display:block;font-size:.8125rem;font-weight:600;margin-bottom:.5rem}.fx-input{width:100%;padding:.75rem 1rem;border-radius:.625rem;font-size:.9375rem;outline:none;transition:box-shadow .15s}.fx-input:focus{box-shadow:0 0 0 3px rgba(37,99,235,.35)}.fx-btn{width:100%;padding:.875rem 1.5rem;border:none;border-radius:.625rem;font-size:.9375rem;font-weight:700;cursor:pointer;margin-top:.5rem;transition:transform .15s,opacity .15s}.fx-btn:hover{transform:translateY(-1px)}.fx-btn:disabled{opacity:.5;cursor:not-allowed}.fx-form-msg{font-size:.8125rem;padding:.5rem 0;min-height:1.5rem;text-align:center}.fx-form-err{color:#f87171}.fx-form-ok{color:#4ade80}.fx-table-wrap{overflow-x:auto;padding:0 2.5rem 4rem}.fx-table{width:100%;border-collapse:collapse;font-size:.875rem}.fx-th{text-align:left;padding:.875rem 1.25rem;font-size:.75rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em}.fx-th-actions{opacity:.6}.fx-tr{transition:background .1s}.fx-td{padding:.875rem 1.25rem}.fx-td-empty{padding:2rem 1.25rem;text-align:center;opacity:.4}.fx-td-actions{white-space:nowrap;padding:.5rem 1rem!important}.fx-action-btn{border:none;cursor:pointer;font-size:.75rem;font-weight:600;padding:.3rem .75rem;border-radius:.375rem;margin-right:.375rem;font-family:inherit;transition:opacity .15s}.fx-action-btn:hover{opacity:.85}.fx-edit-btn{background:#1e40af;color:#93c5fd}.fx-delete-btn{background:#7f1d1d;color:#fca5a5}.fx-pricing{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:1.5rem;padding:2rem 2.5rem 5rem;align-items:start}.fx-pricing-card{border-radius:1.25rem;padding:2rem;position:relative;transition:transform .2s}.fx-pricing-featured{transform:scale(1.03)}.fx-pricing-badge{position:absolute;top:-12px;left:50%;transform:translateX(-50%);background:#2563eb;color:#fff;font-size:.7rem;font-weight:700;padding:.25rem .875rem;border-radius:999px;white-space:nowrap}.fx-pricing-name{font-size:.875rem;font-weight:700;text-transform:uppercase;letter-spacing:.1em;margin-bottom:.5rem;opacity:.7}.fx-pricing-price{font-size:3rem;font-weight:900;letter-spacing:-.05em;line-height:1;margin-bottom:.75rem}.fx-pricing-desc{font-size:.875rem;line-height:1.65;margin-bottom:1.5rem;opacity:.7}.fx-pricing-cta{display:block;text-align:center;padding:.75rem;border-radius:.625rem;font-weight:700;font-size:.9rem}.fx-faq{max-width:48rem;margin:0 auto}.fx-faq-item{border-radius:.75rem;margin-bottom:.625rem;cursor:pointer;overflow:hidden}.fx-faq-q{display:flex;justify-content:space-between;align-items:center;padding:1rem 1.25rem;font-size:.9375rem;font-weight:600}.fx-faq-arrow{transition:transform .2s;font-size:.75rem;opacity:.5}.fx-faq-item.open .fx-faq-arrow{transform:rotate(90deg)}.fx-faq-a{max-height:0;overflow:hidden;padding:0 1.25rem;font-size:.875rem;line-height:1.7;transition:max-height .3s,padding .3s}.fx-faq-item.open .fx-faq-a{max-height:300px;padding:.75rem 1.25rem 1.25rem}.fx-if-wrap{display:contents}.fx-footer{padding:3rem 2.5rem;text-align:center}.fx-footer-text{font-size:.8125rem}.fx-footer-link{font-size:.8125rem;margin:0 .75rem;opacity:.5;transition:opacity .15s}.fx-footer-link:hover{opacity:1}`
1010
- const T={dark:`body{background:#030712;color:#f1f5f9}.fx-nav{border-bottom:1px solid #1e293b;background:rgba(3,7,18,.85)}.fx-nav-link{color:#cbd5e1}.fx-sub{color:#94a3b8}.fx-cta{background:#2563eb;color:#fff;box-shadow:0 8px 24px rgba(37,99,235,.35)}.fx-stat-lbl{color:#64748b}.fx-card{background:#0f172a;border:1px solid #1e293b}.fx-card:hover{box-shadow:0 20px 40px rgba(0,0,0,.5)}.fx-card-body{color:#64748b}.fx-sect-body{color:#64748b}.fx-form{background:#0f172a;border:1px solid #1e293b}.fx-label{color:#94a3b8}.fx-input{background:#020617;border:1px solid #1e293b;color:#f1f5f9}.fx-input::placeholder{color:#334155}.fx-btn{background:#2563eb;color:#fff;box-shadow:0 4px 14px rgba(37,99,235,.4)}.fx-th{color:#475569;border-bottom:1px solid #1e293b}.fx-tr:hover{background:#0f172a}.fx-td{border-bottom:1px solid rgba(255,255,255,.03)}.fx-footer{border-top:1px solid #1e293b}.fx-footer-text{color:#334155}.fx-pricing-card{background:#0f172a;border:1px solid #1e293b}.fx-faq-item{background:#0f172a}`,light:`body{background:#fff;color:#0f172a}.fx-nav{border-bottom:1px solid #e2e8f0;background:rgba(255,255,255,.85)}.fx-cta{background:#2563eb;color:#fff}.fx-btn{background:#2563eb;color:#fff}.fx-card{background:#f8fafc;border:1px solid #e2e8f0}.fx-form{background:#f8fafc;border:1px solid #e2e8f0}.fx-input{background:#fff;border:1px solid #cbd5e1;color:#0f172a}.fx-th{color:#94a3b8;border-bottom:1px solid #e2e8f0}.fx-tr:hover{background:#f8fafc}.fx-footer{border-top:1px solid #e2e8f0}.fx-pricing-card{background:#f8fafc;border:1px solid #e2e8f0}.fx-faq-item{background:#f8fafc}`}
822
+ function baseCSS(theme) {
823
+ const base=`*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}html{scroll-behavior:smooth}body{font-family:-apple-system,'Segoe UI',system-ui,sans-serif;-webkit-font-smoothing:antialiased;min-height:100vh}a{text-decoration:none;color:inherit}input,button,select{font-family:inherit}img{max-width:100%;height:auto}.fx-nav{display:flex;align-items:center;justify-content:space-between;padding:1rem 2.5rem;position:sticky;top:0;z-index:50;backdrop-filter:blur(12px);flex-wrap:wrap;gap:.5rem}.fx-brand{font-size:1.25rem;font-weight:800;letter-spacing:-.03em}.fx-nav-links{display:flex;align-items:center;gap:1.75rem}.fx-nav-link{font-size:.875rem;font-weight:500;opacity:.65;transition:opacity .15s}.fx-nav-link:hover{opacity:1}.fx-hamburger{display:none;flex-direction:column;gap:5px;background:none;border:none;cursor:pointer;padding:.25rem}.fx-hamburger span{display:block;width:22px;height:2px;background:currentColor;transition:all .2s;border-radius:1px}.fx-hamburger.open span:nth-child(1){transform:rotate(45deg) translate(5px,5px)}.fx-hamburger.open span:nth-child(2){opacity:0}.fx-hamburger.open span:nth-child(3){transform:rotate(-45deg) translate(5px,-5px)}@media(max-width:640px){.fx-hamburger{display:flex}.fx-nav-links{display:none;width:100%;flex-direction:column;align-items:flex-start;gap:.75rem;padding:.75rem 0}.fx-nav-links.open{display:flex}}.fx-hero{display:flex;align-items:center;justify-content:center;min-height:92vh;padding:4rem 1.5rem}.fx-hero-split{display:grid;grid-template-columns:1fr 1fr;gap:3rem;align-items:center;padding:4rem 2.5rem;min-height:70vh}@media(max-width:768px){.fx-hero-split{grid-template-columns:1fr}}.fx-hero-img{width:100%;border-radius:1.25rem;object-fit:cover;max-height:500px}.fx-hero-inner{max-width:56rem;text-align:center;display:flex;flex-direction:column;align-items:center;gap:1.5rem}.fx-hero-split .fx-hero-inner{text-align:left;align-items:flex-start;max-width:none}.fx-title{font-size:clamp(2.5rem,8vw,5.5rem);font-weight:900;letter-spacing:-.04em;line-height:1}.fx-sub{font-size:clamp(1rem,2vw,1.25rem);line-height:1.75;max-width:40rem}.fx-cta{display:inline-flex;align-items:center;padding:.875rem 2.5rem;border-radius:.75rem;font-weight:700;font-size:1rem;transition:transform .15s;margin:.25rem}.fx-cta:hover{transform:translateY(-1px)}.fx-stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:3rem;padding:5rem 2.5rem;text-align:center}.fx-stat-val{font-size:clamp(2.5rem,5vw,4rem);font-weight:900;letter-spacing:-.04em;line-height:1}.fx-stat-lbl{font-size:.75rem;font-weight:600;text-transform:uppercase;letter-spacing:.1em;margin-top:.5rem}.fx-grid{display:grid;gap:1.25rem;padding:1rem 2.5rem 5rem}.fx-grid-2{grid-template-columns:repeat(auto-fit,minmax(280px,1fr))}.fx-grid-3{grid-template-columns:repeat(auto-fit,minmax(240px,1fr))}.fx-grid-4{grid-template-columns:repeat(auto-fit,minmax(200px,1fr))}.fx-card{border-radius:1rem;padding:1.75rem;transition:transform .2s,box-shadow .2s}.fx-card:hover{transform:translateY(-2px)}.fx-card-img{width:100%;border-radius:.75rem;object-fit:cover;height:180px;margin-bottom:1rem}.fx-icon{font-size:2rem;margin-bottom:1rem}.fx-card-title{font-size:1.0625rem;font-weight:700;letter-spacing:-.02em;margin-bottom:.5rem}.fx-card-body{font-size:.875rem;line-height:1.65}.fx-card-link{font-size:.8125rem;font-weight:600;display:inline-block;margin-top:1rem;opacity:.6;transition:opacity .15s}.fx-card-link:hover{opacity:1}.fx-sect{padding:5rem 2.5rem}.fx-sect-title{font-size:clamp(1.75rem,4vw,3rem);font-weight:800;letter-spacing:-.04em;margin-bottom:1.5rem;text-align:center}.fx-sect-body{font-size:1rem;line-height:1.75;text-align:center;max-width:48rem;margin:0 auto}.fx-form-wrap{padding:3rem 2.5rem;display:flex;justify-content:center}.fx-form{width:100%;max-width:28rem;border-radius:1.25rem;padding:2.5rem}.fx-field{margin-bottom:1.25rem}.fx-label{display:block;font-size:.8125rem;font-weight:600;margin-bottom:.5rem}.fx-input{width:100%;padding:.75rem 1rem;border-radius:.625rem;font-size:.9375rem;outline:none;transition:box-shadow .15s}.fx-input:focus{box-shadow:0 0 0 3px rgba(37,99,235,.35)}.fx-btn{width:100%;padding:.875rem 1.5rem;border:none;border-radius:.625rem;font-size:.9375rem;font-weight:700;cursor:pointer;margin-top:.5rem;transition:transform .15s,opacity .15s}.fx-btn:hover{transform:translateY(-1px)}.fx-btn:disabled{opacity:.5;cursor:not-allowed}.fx-form-msg{font-size:.8125rem;padding:.5rem 0;min-height:1.5rem;text-align:center}.fx-form-err{color:#f87171}.fx-form-ok{color:#4ade80}.fx-table-wrap{overflow-x:auto;padding:0 2.5rem 4rem}.fx-table{width:100%;border-collapse:collapse;font-size:.875rem}.fx-th{text-align:left;padding:.875rem 1.25rem;font-size:.75rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em}.fx-th-actions{opacity:.6}.fx-tr{transition:background .1s}.fx-td{padding:.875rem 1.25rem}.fx-td-empty{padding:2rem 1.25rem;text-align:center;opacity:.4}.fx-td-actions{white-space:nowrap;padding:.5rem 1rem!important}.fx-action-btn{border:none;cursor:pointer;font-size:.75rem;font-weight:600;padding:.3rem .75rem;border-radius:.375rem;margin-right:.375rem;font-family:inherit}.fx-edit-btn{background:#1e40af;color:#93c5fd}.fx-delete-btn{background:#7f1d1d;color:#fca5a5}.fx-pricing{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:1.5rem;padding:2rem 2.5rem 5rem;align-items:start}.fx-pricing-card{border-radius:1.25rem;padding:2rem;position:relative;transition:transform .2s}.fx-pricing-featured{transform:scale(1.03)}.fx-pricing-badge{position:absolute;top:-12px;left:50%;transform:translateX(-50%);background:#2563eb;color:#fff;font-size:.7rem;font-weight:700;padding:.25rem .875rem;border-radius:999px;white-space:nowrap}.fx-pricing-name{font-size:.875rem;font-weight:700;text-transform:uppercase;letter-spacing:.1em;margin-bottom:.5rem;opacity:.7}.fx-pricing-price{font-size:3rem;font-weight:900;letter-spacing:-.05em;line-height:1;margin-bottom:.75rem}.fx-pricing-desc{font-size:.875rem;line-height:1.65;margin-bottom:1.5rem;opacity:.7}.fx-pricing-cta{display:block;text-align:center;padding:.75rem;border-radius:.625rem;font-weight:700;font-size:.9rem}.fx-faq{max-width:48rem;margin:0 auto}.fx-faq-item{border-radius:.75rem;margin-bottom:.625rem;cursor:pointer;overflow:hidden}.fx-faq-q{display:flex;justify-content:space-between;align-items:center;padding:1rem 1.25rem;font-size:.9375rem;font-weight:600}.fx-faq-arrow{transition:transform .2s;font-size:.75rem;opacity:.5}.fx-faq-item.open .fx-faq-arrow{transform:rotate(90deg)}.fx-faq-a{max-height:0;overflow:hidden;padding:0 1.25rem;font-size:.875rem;line-height:1.7;transition:max-height .3s,padding .3s}.fx-faq-item.open .fx-faq-a{max-height:300px;padding:.75rem 1.25rem 1.25rem}.fx-testi-wrap{padding:5rem 2.5rem;display:flex;justify-content:center}.fx-testi{max-width:42rem;text-align:center;display:flex;flex-direction:column;align-items:center;gap:1.25rem}.fx-testi-img{width:64px;height:64px;border-radius:50%;object-fit:cover}.fx-testi-avatar{width:64px;height:64px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:1.5rem;font-weight:700;background:#1e293b}.fx-testi-quote{font-size:1.25rem;line-height:1.7;font-style:italic;opacity:.9}.fx-testi-author{font-size:.875rem;font-weight:600;opacity:.5}.fx-gallery{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:.75rem;padding:1rem 2.5rem 4rem}.fx-gallery-item{border-radius:.75rem;overflow:hidden;aspect-ratio:4/3}.fx-gallery-item img{width:100%;height:100%;object-fit:cover;transition:transform .3s}.fx-gallery-item:hover img{transform:scale(1.04)}.fx-if-wrap{display:contents}.fx-footer{padding:3rem 2.5rem;text-align:center}.fx-footer-text{font-size:.8125rem}.fx-footer-link{font-size:.8125rem;margin:0 .75rem;opacity:.5;transition:opacity .15s}.fx-footer-link:hover{opacity:1}@keyframes fx-fade-up{from{opacity:0;transform:translateY(20px)}to{opacity:1;transform:none}}@keyframes fx-blur-in{from{opacity:0;filter:blur(8px)}to{opacity:1;filter:blur(0)}}@keyframes fx-fade-in{from{opacity:0}to{opacity:1}}.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-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}`
824
+ const T={dark:`body{background:#030712;color:#f1f5f9}.fx-nav{border-bottom:1px solid #1e293b;background:rgba(3,7,18,.85)}.fx-nav-link{color:#cbd5e1}.fx-sub{color:#94a3b8}.fx-cta{background:#2563eb;color:#fff;box-shadow:0 8px 24px rgba(37,99,235,.35)}.fx-stat-lbl{color:#64748b}.fx-card{background:#0f172a;border:1px solid #1e293b}.fx-card:hover{box-shadow:0 20px 40px rgba(0,0,0,.5)}.fx-card-body{color:#64748b}.fx-sect-body{color:#64748b}.fx-form{background:#0f172a;border:1px solid #1e293b}.fx-label{color:#94a3b8}.fx-input{background:#020617;border:1px solid #1e293b;color:#f1f5f9}.fx-input::placeholder{color:#334155}.fx-btn{background:#2563eb;color:#fff}.fx-th{color:#475569;border-bottom:1px solid #1e293b}.fx-tr:hover{background:#0f172a}.fx-td{border-bottom:1px solid rgba(255,255,255,.03)}.fx-footer{border-top:1px solid #1e293b}.fx-footer-text{color:#334155}.fx-pricing-card{background:#0f172a;border:1px solid #1e293b}.fx-faq-item{background:#0f172a}`,light:`body{background:#fff;color:#0f172a}.fx-nav{border-bottom:1px solid #e2e8f0;background:rgba(255,255,255,.85)}.fx-cta{background:#2563eb;color:#fff}.fx-btn{background:#2563eb;color:#fff}.fx-card{background:#f8fafc;border:1px solid #e2e8f0}.fx-form{background:#f8fafc;border:1px solid #e2e8f0}.fx-input{background:#fff;border:1px solid #cbd5e1;color:#0f172a}.fx-th{color:#94a3b8;border-bottom:1px solid #e2e8f0}.fx-footer{border-top:1px solid #e2e8f0}.fx-pricing-card{background:#f8fafc;border:1px solid #e2e8f0}.fx-faq-item{background:#f8fafc}`}
1011
825
  return base+(T[theme]||T.dark)
1012
826
  }
1013
827
 
1014
- // ═══════════════════════════════════════════════════════════════
1015
- // MAIN ENTRY POINT
1016
- // ═══════════════════════════════════════════════════════════════
1017
-
828
+ // ═══════════════════════════════════════════════════════════════════
829
+ // MAIN
830
+ // ═══════════════════════════════════════════════════════════════════
1018
831
  async function startServer(fluxFile, port = 3000) {
1019
- const src = fs.readFileSync(fluxFile, 'utf8')
1020
- const app = parseApp(src)
1021
- const srv = new AiplangServer()
832
+ const src = fs.readFileSync(fluxFile, 'utf8')
833
+ const app = parseApp(src)
834
+ const srv = new AiplangServer()
1022
835
 
1023
- // Setup JWT
836
+ // Auth setup
1024
837
  if (app.auth) {
1025
- JWT_SECRET = resolveEnvValue(app.auth.secret) || JWT_SECRET
838
+ JWT_SECRET = resolveEnv(app.auth.secret) || JWT_SECRET
1026
839
  JWT_EXPIRE = app.auth.expire || '7d'
1027
840
  }
1028
841
 
1029
- // Setup DB
1030
- const dbFile = app.db ? resolveEnvValue(app.db.dsn) : ':memory:'
1031
- await getDB(dbFile === ':memory:' ? ':memory:' : dbFile)
842
+ // Mail setup
843
+ if (app.mail) setupMail(app.mail)
844
+
845
+ // DB setup
846
+ const dbFile = app.db ? resolveEnv(app.db.dsn) : ':memory:'
847
+ await getDB(dbFile)
1032
848
  console.log(`[aiplang] DB: ${dbFile}`)
1033
849
 
1034
- // Migrate models
1035
- console.log(`[aiplang] Migrations:`)
850
+ // Migrations
851
+ console.log(`[aiplang] Tables:`)
1036
852
  migrateModels(app.models)
1037
853
 
1038
- // Register models in server
1039
- for (const model of app.models) {
1040
- srv.registerModel(model.name)
1041
- }
854
+ // Register models
855
+ for (const m of app.models) srv.registerModel(m.name, { softDelete: m.softDelete, timestamps: true })
1042
856
 
1043
- // Register API routes
857
+ // Events
858
+ for (const ev of app.events) on(ev.event, (data) => console.log(`[aiplang:event] ${ev.event}:`, ev.action))
859
+
860
+ // Routes
1044
861
  for (const route of app.apis) {
1045
- compileAPIRoute(route, app, srv)
1046
- console.log(`[aiplang] Route: ${route.method} ${route.path}`)
862
+ compileRoute(route, srv)
863
+ console.log(`[aiplang] Route: ${route.method} ${route.path}${route.guards.length?' ['+route.guards.join('|')+']':''}`)
1047
864
  }
1048
865
 
1049
- // Register frontend pages
1050
- serveStaticFrontend(srv, app.pages)
866
+ // Admin panel
867
+ if (app.admin) registerAdminPanel(srv, app.admin, app.models)
868
+
869
+ // Frontend
1051
870
  for (const page of app.pages) {
1052
- console.log(`[aiplang] Page: GET ${page.route}`)
871
+ srv.addRoute('GET', page.route, (req, res) => {
872
+ const html = renderHTML(page, app.pages)
873
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); res.end(html)
874
+ })
875
+ console.log(`[aiplang] Page: ${page.route}`)
1053
876
  }
1054
877
 
1055
- // Health check
1056
- srv.addRoute('GET', '/health', (req, res) => {
1057
- res.json(200, {
1058
- status: 'ok', version: '1.0.0',
1059
- models: app.models.map(m => m.name),
1060
- routes: app.apis.length, pages: app.pages.length
1061
- })
878
+ // Static assets
879
+ srv.addRoute('GET', '/aiplang-hydrate.js', (req, res) => {
880
+ const p = path.join(__dirname, '..', 'flux-lang', 'runtime', 'aiplang-hydrate.js')
881
+ if (fs.existsSync(p)) { res.writeHead(200,{'Content-Type':'application/javascript'}); res.end(fs.readFileSync(p)) }
882
+ else { res.writeHead(404); res.end('// not found') }
1062
883
  })
1063
884
 
885
+ // Health
886
+ srv.addRoute('GET', '/health', (req, res) => res.json(200, {
887
+ status:'ok', version:'2.0.1',
888
+ models: app.models.map(m=>m.name),
889
+ routes: app.apis.length, pages: app.pages.length,
890
+ admin: app.admin?.prefix || null,
891
+ mail: !!app.mail, jobs: QUEUE.length
892
+ }))
893
+
1064
894
  srv.listen(port)
1065
895
  return srv
1066
896
  }
1067
897
 
1068
- function resolveEnvValue(val) {
1069
- if (!val) return val
1070
- if (val.startsWith('$')) return process.env[val.slice(1)] || val
1071
- return val
1072
- }
1073
-
1074
- module.exports = { startServer, parseApp, Model, getDB }
1075
-
1076
- // Run if called directly
898
+ module.exports = { startServer, parseApp, Model, getDB, dispatch, on, sendMail }
1077
899
  if (require.main === module) {
1078
- const fluxFile = process.argv[2]
1079
- const port = parseInt(process.argv[3] || process.env.PORT || '3000')
1080
- if (!fluxFile) { console.error('Usage: node server.js <app.flux> [port]'); process.exit(1) }
1081
- startServer(fluxFile, port).catch(e => { console.error(e); process.exit(1) })
900
+ const f=process.argv[2], p=parseInt(process.argv[3]||process.env.PORT||'3000')
901
+ if (!f) { console.error('Usage: node server.js <app.flux> [port]'); process.exit(1) }
902
+ startServer(f, p).catch(e=>{console.error(e);process.exit(1)})
1082
903
  }