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.
- package/bin/aiplang.js +7 -7
- package/package.json +7 -5
- package/server/node_modules/.package-lock.json +9 -0
- package/server/node_modules/nodemailer/.gitattributes +6 -0
- package/server/node_modules/nodemailer/.ncurc.js +9 -0
- package/server/node_modules/nodemailer/.prettierignore +8 -0
- package/server/node_modules/nodemailer/.prettierrc +12 -0
- package/server/node_modules/nodemailer/.prettierrc.js +10 -0
- package/server/node_modules/nodemailer/.release-please-config.json +9 -0
- package/server/node_modules/nodemailer/CHANGELOG.md +976 -0
- package/server/node_modules/nodemailer/CODE_OF_CONDUCT.md +76 -0
- package/server/node_modules/nodemailer/LICENSE +16 -0
- package/server/node_modules/nodemailer/README.md +86 -0
- package/server/node_modules/nodemailer/SECURITY.txt +22 -0
- package/server/node_modules/nodemailer/eslint.config.js +88 -0
- package/server/node_modules/nodemailer/lib/addressparser/index.js +382 -0
- package/server/node_modules/nodemailer/lib/base64/index.js +140 -0
- package/server/node_modules/nodemailer/lib/dkim/index.js +245 -0
- package/server/node_modules/nodemailer/lib/dkim/message-parser.js +154 -0
- package/server/node_modules/nodemailer/lib/dkim/relaxed-body.js +154 -0
- package/server/node_modules/nodemailer/lib/dkim/sign.js +116 -0
- package/server/node_modules/nodemailer/lib/errors.js +58 -0
- package/server/node_modules/nodemailer/lib/fetch/cookies.js +276 -0
- package/server/node_modules/nodemailer/lib/fetch/index.js +278 -0
- package/server/node_modules/nodemailer/lib/json-transport/index.js +82 -0
- package/server/node_modules/nodemailer/lib/mail-composer/index.js +599 -0
- package/server/node_modules/nodemailer/lib/mailer/index.js +446 -0
- package/server/node_modules/nodemailer/lib/mailer/mail-message.js +312 -0
- package/server/node_modules/nodemailer/lib/mime-funcs/index.js +610 -0
- package/server/node_modules/nodemailer/lib/mime-funcs/mime-types.js +2109 -0
- package/server/node_modules/nodemailer/lib/mime-node/index.js +1334 -0
- package/server/node_modules/nodemailer/lib/mime-node/last-newline.js +33 -0
- package/server/node_modules/nodemailer/lib/mime-node/le-unix.js +40 -0
- package/server/node_modules/nodemailer/lib/mime-node/le-windows.js +49 -0
- package/server/node_modules/nodemailer/lib/nodemailer.js +151 -0
- package/server/node_modules/nodemailer/lib/punycode/index.js +460 -0
- package/server/node_modules/nodemailer/lib/qp/index.js +230 -0
- package/server/node_modules/nodemailer/lib/sendmail-transport/index.js +205 -0
- package/server/node_modules/nodemailer/lib/ses-transport/index.js +223 -0
- package/server/node_modules/nodemailer/lib/shared/index.js +698 -0
- package/server/node_modules/nodemailer/lib/smtp-connection/data-stream.js +105 -0
- package/server/node_modules/nodemailer/lib/smtp-connection/http-proxy-client.js +144 -0
- package/server/node_modules/nodemailer/lib/smtp-connection/index.js +1903 -0
- package/server/node_modules/nodemailer/lib/smtp-pool/index.js +641 -0
- package/server/node_modules/nodemailer/lib/smtp-pool/pool-resource.js +256 -0
- package/server/node_modules/nodemailer/lib/smtp-transport/index.js +402 -0
- package/server/node_modules/nodemailer/lib/stream-transport/index.js +135 -0
- package/server/node_modules/nodemailer/lib/well-known/index.js +47 -0
- package/server/node_modules/nodemailer/lib/well-known/services.json +619 -0
- package/server/node_modules/nodemailer/lib/xoauth2/index.js +436 -0
- package/server/node_modules/nodemailer/package.json +48 -0
- package/server/server.js +686 -865
- /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
|
-
//
|
|
4
|
-
|
|
5
|
-
const http
|
|
6
|
-
const fs
|
|
7
|
-
const path
|
|
8
|
-
const url
|
|
9
|
-
const crypto
|
|
10
|
-
const bcrypt
|
|
11
|
-
const jwt
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
let
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
function
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"')
|
|
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
|
-
|
|
74
|
+
QUEUE_RUNNING = false
|
|
192
75
|
}
|
|
193
76
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
255
|
-
|
|
109
|
+
function on(event, fn) {
|
|
110
|
+
EVENT_LISTENERS[event] = EVENT_LISTENERS[event] || []
|
|
111
|
+
EVENT_LISTENERS[event].push(fn)
|
|
256
112
|
}
|
|
257
113
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
|
|
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.
|
|
314
|
-
this.
|
|
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 (
|
|
321
|
-
if (opts.
|
|
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) {
|
|
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) {
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
const vals = Object.values(row)
|
|
353
|
-
|
|
354
|
-
|
|
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
|
-
|
|
360
|
-
delete
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
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
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
this.
|
|
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
|
-
|
|
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
|
-
|
|
413
|
-
this.
|
|
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
|
-
|
|
418
|
-
|
|
419
|
-
|
|
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
|
-
|
|
431
|
-
|
|
432
|
-
|
|
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
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
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
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
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
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
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
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
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
|
-
|
|
465
|
-
|
|
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
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
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
|
-
//
|
|
477
|
-
//
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
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
|
-
|
|
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
|
|
514
|
-
|
|
515
|
-
}
|
|
516
|
-
if (rule
|
|
517
|
-
|
|
518
|
-
}
|
|
519
|
-
if (rule.startsWith('
|
|
520
|
-
|
|
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
|
|
410
|
+
// Execute ops
|
|
548
411
|
for (const op of route.body) {
|
|
549
412
|
const result = await execOp(op, ctx, server)
|
|
550
|
-
if (result === '
|
|
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
|
-
|
|
557
|
-
|
|
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 |
|
|
427
|
+
// ~check password plain hashed | status
|
|
575
428
|
if (line.startsWith('~check ')) {
|
|
576
|
-
const
|
|
577
|
-
const plain
|
|
578
|
-
const
|
|
579
|
-
|
|
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
|
|
588
|
-
const
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
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
|
|
601
|
-
const varName
|
|
602
|
-
|
|
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
|
|
610
|
-
|
|
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
|
|
622
|
-
|
|
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
|
|
634
|
-
|
|
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
|
-
//
|
|
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
|
|
646
|
-
const
|
|
647
|
-
const
|
|
648
|
-
let result
|
|
649
|
-
if
|
|
650
|
-
ctx.res.json(status,
|
|
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
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
if (expr.
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
}
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
if (expr.includes('.
|
|
669
|
-
|
|
670
|
-
|
|
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
|
|
734
|
-
|
|
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
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
//
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
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
|
-
//
|
|
773
|
-
|
|
774
|
-
const
|
|
775
|
-
|
|
776
|
-
|
|
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
|
-
|
|
816
|
-
|
|
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
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
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
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
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
|
-
|
|
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
|
|
858
|
-
const
|
|
859
|
-
|
|
860
|
-
const
|
|
861
|
-
return`<div class="
|
|
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
|
-
|
|
864
|
-
}
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
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,'"')+'">'+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
|
-
|
|
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
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
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
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
if(
|
|
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
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
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
|
-
|
|
916
|
-
|
|
917
|
-
|
|
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
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
}
|
|
935
|
-
|
|
936
|
-
|
|
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
|
|
945
|
-
|
|
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
|
-
//
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
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
|
|
957
|
-
const
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
const
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
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
|
|
987
|
-
|
|
988
|
-
|
|
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
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1016
|
-
//
|
|
1017
|
-
|
|
828
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
829
|
+
// MAIN
|
|
830
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
1018
831
|
async function startServer(fluxFile, port = 3000) {
|
|
1019
|
-
const src
|
|
1020
|
-
const app
|
|
1021
|
-
const srv
|
|
832
|
+
const src = fs.readFileSync(fluxFile, 'utf8')
|
|
833
|
+
const app = parseApp(src)
|
|
834
|
+
const srv = new AiplangServer()
|
|
1022
835
|
|
|
1023
|
-
//
|
|
836
|
+
// Auth setup
|
|
1024
837
|
if (app.auth) {
|
|
1025
|
-
JWT_SECRET =
|
|
838
|
+
JWT_SECRET = resolveEnv(app.auth.secret) || JWT_SECRET
|
|
1026
839
|
JWT_EXPIRE = app.auth.expire || '7d'
|
|
1027
840
|
}
|
|
1028
841
|
|
|
1029
|
-
//
|
|
1030
|
-
|
|
1031
|
-
|
|
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
|
-
//
|
|
1035
|
-
console.log(`[aiplang]
|
|
850
|
+
// Migrations
|
|
851
|
+
console.log(`[aiplang] Tables:`)
|
|
1036
852
|
migrateModels(app.models)
|
|
1037
853
|
|
|
1038
|
-
// Register models
|
|
1039
|
-
for (const
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
1050
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1056
|
-
srv.addRoute('GET', '/
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
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
|
-
|
|
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
|
|
1079
|
-
|
|
1080
|
-
|
|
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
|
}
|