aiplang 2.11.10 → 2.11.12
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 +165 -57
- package/package.json +1 -1
- package/server/server.js +68 -6
package/bin/aiplang.js
CHANGED
|
@@ -5,7 +5,51 @@ const fs = require('fs')
|
|
|
5
5
|
const path = require('path')
|
|
6
6
|
const http = require('http')
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
|
|
9
|
+
// ── ANSI colors — sem dependências ───────────────────────────────
|
|
10
|
+
const CLR = {
|
|
11
|
+
reset: '\x1b[0m',
|
|
12
|
+
bold: '\x1b[1m',
|
|
13
|
+
dim: '\x1b[2m',
|
|
14
|
+
green: '\x1b[32m',
|
|
15
|
+
blue: '\x1b[34m',
|
|
16
|
+
cyan: '\x1b[36m',
|
|
17
|
+
yellow:'\x1b[33m',
|
|
18
|
+
red: '\x1b[31m',
|
|
19
|
+
white: '\x1b[37m',
|
|
20
|
+
gray: '\x1b[90m',
|
|
21
|
+
}
|
|
22
|
+
const c_ = (color, text) => process.stdout.isTTY ? CLR[color]+text+CLR.reset : text
|
|
23
|
+
const bold = t => c_('bold', t)
|
|
24
|
+
const dim = t => c_('dim', t)
|
|
25
|
+
const green = t => c_('green', t)
|
|
26
|
+
const blue = t => c_('blue', t)
|
|
27
|
+
const cyan = t => c_('cyan', t)
|
|
28
|
+
const yellow= t => c_('yellow', t)
|
|
29
|
+
const red = t => c_('red', t)
|
|
30
|
+
const gray = t => c_('gray', t)
|
|
31
|
+
|
|
32
|
+
// Spinner simples
|
|
33
|
+
class Spinner {
|
|
34
|
+
constructor(text) {
|
|
35
|
+
this.frames = ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏']
|
|
36
|
+
this.i = 0; this.text = text; this.timer = null
|
|
37
|
+
}
|
|
38
|
+
start() {
|
|
39
|
+
if (!process.stdout.isTTY) { process.stdout.write(this.text+'...\n'); return this }
|
|
40
|
+
this.timer = setInterval(() => {
|
|
41
|
+
process.stdout.write('\r ' + cyan(this.frames[this.i++ % this.frames.length]) + ' ' + this.text + ' ')
|
|
42
|
+
}, 80)
|
|
43
|
+
return this
|
|
44
|
+
}
|
|
45
|
+
stop(ok=true, msg='') {
|
|
46
|
+
if (this.timer) { clearInterval(this.timer); this.timer = null }
|
|
47
|
+
if (process.stdout.isTTY) process.stdout.write('\r')
|
|
48
|
+
console.log(' ' + (ok ? green('✓') : red('✗')) + ' ' + (msg||this.text) + ' ')
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const VERSION = '2.11.12'
|
|
9
53
|
const RUNTIME_DIR = path.join(__dirname, '..', 'runtime')
|
|
10
54
|
const cmd = process.argv[2]
|
|
11
55
|
const args = process.argv.slice(3)
|
|
@@ -26,55 +70,27 @@ const hSize = n => n<1024?`${n}B`:`${(n/1024).toFixed(1)}KB`
|
|
|
26
70
|
|
|
27
71
|
if (!cmd||cmd==='--help'||cmd==='-h') {
|
|
28
72
|
console.log(`
|
|
29
|
-
aiplang
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
npx aiplang
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
npx aiplang template list list all templates (built-in + custom)
|
|
51
|
-
npx aiplang template save <n> save current project as template
|
|
52
|
-
npx aiplang template save <n> --from <f> save a specific .aip file as template
|
|
53
|
-
npx aiplang template edit <n> open template in editor
|
|
54
|
-
npx aiplang template show <n> print template source
|
|
55
|
-
npx aiplang template export <n> export template to .aip file
|
|
56
|
-
npx aiplang template remove <n> delete a custom template
|
|
57
|
-
|
|
58
|
-
Custom template variables:
|
|
59
|
-
{{name}} project name
|
|
60
|
-
{{year}} current year
|
|
61
|
-
|
|
62
|
-
Customization:
|
|
63
|
-
# Bancos de dados suportados:
|
|
64
|
-
# ~db sqlite ./app.db (padrão — sem configuração)
|
|
65
|
-
# ~db pg $DATABASE_URL (PostgreSQL)
|
|
66
|
-
# ~db mysql $MYSQL_URL (MySQL / MariaDB)
|
|
67
|
-
# ~db mongodb $MONGODB_URL (MongoDB)
|
|
68
|
-
# ~db redis $REDIS_URL (Redis — cache/session)
|
|
69
|
-
~theme accent=#7c3aed radius=1.5rem font=Syne bg=#000 text=#fff
|
|
70
|
-
hero{...} animate:fade-up
|
|
71
|
-
row3{...} class:my-class animate:stagger
|
|
72
|
-
raw{<div>any HTML here</div>}
|
|
73
|
-
|
|
74
|
-
GitHub: https://github.com/isacamartin/aiplang
|
|
75
|
-
npm: https://npmjs.com/package/aiplang
|
|
76
|
-
Docs: https://isacamartin.github.io/aiplang
|
|
77
|
-
`)
|
|
73
|
+
${bold(cyan('aiplang'))} ${dim('v'+VERSION)} ${gray('AI-First Web Language')}
|
|
74
|
+
|
|
75
|
+
${bold('Usage:')}
|
|
76
|
+
${cyan('npx aiplang')} ${yellow('<command>')} ${gray('[options]')}
|
|
77
|
+
|
|
78
|
+
${bold('Commands:')}
|
|
79
|
+
${green('start')} ${gray('<file.aip> [port]')} Start dev server
|
|
80
|
+
${green('build')} ${gray('<file.aip> --out <dir>')} Build static HTML
|
|
81
|
+
${green('validate')}${gray(' <file.aip>')} Lint syntax
|
|
82
|
+
${green('types')} ${gray('<file.aip>')} Generate TypeScript .d.ts
|
|
83
|
+
${green('context')} ${gray('<file.aip>')} AI context summary
|
|
84
|
+
${green('init')} ${gray('[project-name]')} Create new project
|
|
85
|
+
|
|
86
|
+
${bold('Examples:')}
|
|
87
|
+
${dim('npx aiplang start app.aip')}
|
|
88
|
+
${dim('npx aiplang build app.aip --out ./dist')}
|
|
89
|
+
${dim('npx aiplang init my-saas')}
|
|
90
|
+
|
|
91
|
+
${bold('Docs:')} ${blue('https://github.com/isacamartin/aiplang')}
|
|
92
|
+
${bold('npm:')} ${blue('npmjs.com/package/aiplang')}
|
|
93
|
+
`)
|
|
78
94
|
process.exit(0)
|
|
79
95
|
}
|
|
80
96
|
if (cmd==='--version'||cmd==='-v') { console.log(`aiplang v${VERSION}`); process.exit(0) }
|
|
@@ -686,7 +702,7 @@ function generateTypes(app, srcFile) {
|
|
|
686
702
|
}
|
|
687
703
|
|
|
688
704
|
lines.push(`// ── aiplang version ──────────────────────────────────────────`)
|
|
689
|
-
lines.push(`export const AIPLANG_VERSION = '2.11.
|
|
705
|
+
lines.push(`export const AIPLANG_VERSION = '2.11.12'`)
|
|
690
706
|
lines.push(``)
|
|
691
707
|
return lines.join('\n')
|
|
692
708
|
}
|
|
@@ -697,7 +713,7 @@ function _cap(s) { return s ? s[0].toUpperCase() + s.slice(1) : s }
|
|
|
697
713
|
function validateAipSrc(source) {
|
|
698
714
|
const errors = []
|
|
699
715
|
const lines = source.split('\n')
|
|
700
|
-
const knownDirs = new Set(['db','auth','env','mail','s3','stripe','plan','admin','realtime','use','plugin','import','store','ssr','interval','mount','theme','guard','validate','unique','hash','check','cache','rateLimit','broadcast','soft-delete','belongs'])
|
|
716
|
+
const knownDirs = new Set(['db','auth','env','mail','s3','stripe','plan','admin','realtime','use','plugin','import','store','ssr','interval','mount','theme','guard','validate','unique','hash','check','cache','rateLimit','broadcast','soft-delete','belongs','var','component','end'])
|
|
701
717
|
for (let i=0; i<lines.length; i++) {
|
|
702
718
|
const line = lines[i].trim()
|
|
703
719
|
if (!line || line.startsWith('#')) continue
|
|
@@ -856,7 +872,7 @@ if (cmd==='build') {
|
|
|
856
872
|
const pages=parsePages(src)
|
|
857
873
|
if(!pages.length){console.error('\n ✗ No pages found.\n');process.exit(1)}
|
|
858
874
|
fs.mkdirSync(outDir,{recursive:true})
|
|
859
|
-
console.log(`\n aiplang build
|
|
875
|
+
console.log(`\n ${bold(cyan('aiplang'))} build ${dim('v'+VERSION)} — ${files.length} file(s)\n`)
|
|
860
876
|
let total=0
|
|
861
877
|
for(const page of pages){
|
|
862
878
|
const html=renderPage(page,pages)
|
|
@@ -936,7 +952,49 @@ console.error(`\n ✗ Unknown command: ${cmd}\n Run aiplang --help\n`)
|
|
|
936
952
|
process.exit(1)
|
|
937
953
|
|
|
938
954
|
function parsePages(src) {
|
|
939
|
-
|
|
955
|
+
// ── Extrair variáveis globais ~var ──────────────────────────────
|
|
956
|
+
const gVars = {}
|
|
957
|
+
src.split('\n').forEach(line => {
|
|
958
|
+
const vm = line.trim().match(/^~var\s+(\w+)\s*=\s*(.+)$/)
|
|
959
|
+
if (vm) gVars[vm[1].trim()] = vm[2].trim().replace(/^["']|["']$/g,'')
|
|
960
|
+
})
|
|
961
|
+
|
|
962
|
+
// ── Extrair componentes ~component name(params) ... ~end ────────
|
|
963
|
+
const gComponents = {}
|
|
964
|
+
const compRx = /^~component\s+(\w+)\s*\(([^)]*)\)\s*$([\s\S]*?)^~end\s*$/mg
|
|
965
|
+
let m; while((m=compRx.exec(src))!==null) {
|
|
966
|
+
const name = m[1].trim()
|
|
967
|
+
const params = m[2].split(',').map(p=>p.trim()).filter(Boolean)
|
|
968
|
+
const body = m[3]
|
|
969
|
+
gComponents[name] = { params, body }
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
// ── Expandir ~use name(arg1, arg2) → corpo do componente ───────
|
|
973
|
+
function expandComponents(s) {
|
|
974
|
+
if (!Object.keys(gComponents).length) return s
|
|
975
|
+
return s.replace(/^~use\s+(\w+)\s*\(([^)]*)\)\s*$/mg, (_, name, argsStr) => {
|
|
976
|
+
const comp = gComponents[name]
|
|
977
|
+
if (!comp) return `# ~use: component "${name}" not found`
|
|
978
|
+
const args = argsStr.split(',').map(a=>a.trim())
|
|
979
|
+
let body = comp.body
|
|
980
|
+
comp.params.forEach((p,i) => {
|
|
981
|
+
body = body.replace(new RegExp('\\$'+p, 'g'), args[i] !== undefined ? args[i] : '')
|
|
982
|
+
})
|
|
983
|
+
return body
|
|
984
|
+
})
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
// ── Expandir $var ───────────────────────────────────────────────
|
|
988
|
+
function expand(s) {
|
|
989
|
+
let r = expandComponents(s)
|
|
990
|
+
if (Object.keys(gVars).length)
|
|
991
|
+
r = r.replace(/\$(\w+)/g, (m,k) => gVars[k]!==undefined ? gVars[k] : m)
|
|
992
|
+
return r
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
// Remover blocos ~component...~end antes de parsear
|
|
996
|
+
const cleanSrc = src.replace(/^~component[\s\S]*?^~end\s*$/mg, '')
|
|
997
|
+
return cleanSrc.split(/\n---\n/).map(s=>parsePage(expand(s.trim()))).filter(Boolean)
|
|
940
998
|
}
|
|
941
999
|
|
|
942
1000
|
function parsePage(src) {
|
|
@@ -944,6 +1002,10 @@ function parsePage(src) {
|
|
|
944
1002
|
if(!lines.length) return null
|
|
945
1003
|
const p={id:'page',theme:'dark',route:'/',customTheme:null,themeVars:null,state:{},queries:[],blocks:[]}
|
|
946
1004
|
for(const line of lines) {
|
|
1005
|
+
if(line.startsWith('~var ')) continue // já processado globalmente
|
|
1006
|
+
if(line.startsWith('~component ')) continue // componente já expandido
|
|
1007
|
+
if(line.startsWith('~use ')) continue // já expandido
|
|
1008
|
+
if(line.startsWith('~end')) continue // fim de componente
|
|
947
1009
|
if(line.startsWith('%')) {
|
|
948
1010
|
const pts=line.slice(1).trim().split(/\s+/)
|
|
949
1011
|
p.id=pts[0]||'page'; p.route=pts[2]||'/'
|
|
@@ -1206,6 +1268,18 @@ function applyMods(html, b) {
|
|
|
1206
1268
|
|
|
1207
1269
|
function renderPage(page, allPages) {
|
|
1208
1270
|
const needsJS=page.queries.length>0||page.blocks.some(b=>['table','list','form','if','btn','select','faq'].includes(b.kind))
|
|
1271
|
+
// Expandir variáveis $var no conteúdo da página
|
|
1272
|
+
const _vars = (page.appVars||{})
|
|
1273
|
+
function expandVars(s) {
|
|
1274
|
+
if (!s || typeof s !== 'string' || !Object.keys(_vars).length) return s
|
|
1275
|
+
return s.replace(/\$(\w+)/g, (m, k) => _vars[k] !== undefined ? _vars[k] : m)
|
|
1276
|
+
}
|
|
1277
|
+
// Pré-processar rawLine de cada bloco
|
|
1278
|
+
page.blocks.forEach(b => {
|
|
1279
|
+
if (b.rawLine) b.rawLine = expandVars(b.rawLine)
|
|
1280
|
+
if (b.title) b.title = expandVars(b.title)
|
|
1281
|
+
if (b.sub) b.sub = expandVars(b.sub)
|
|
1282
|
+
})
|
|
1209
1283
|
const body=page.blocks.map(b=>{try{return applyMods(renderBlock(b,page),b)}catch(e){console.error('[aiplang] Block render error:',b.kind,e.message);return ''}}).join('')
|
|
1210
1284
|
|
|
1211
1285
|
const tableBlocks = page.blocks.filter(b => b.kind === 'table' && b.binding && b.cols && b.cols.length)
|
|
@@ -1260,8 +1334,12 @@ function renderPage(page, allPages) {
|
|
|
1260
1334
|
<meta name="description" content="${esc((()=>{const h=page.blocks.find(b=>b.kind==='hero');if(!h)return '';const m=h.rawLine&&h.rawLine.match(/\{([^}]+)\}/);if(!m)return '';const p=m[1].split('>');const s=p[0].split('|')[1];return s?s.trim():''})())}">
|
|
1261
1335
|
<meta property="og:title" content="${_title}">
|
|
1262
1336
|
<meta property="og:type" content="website">
|
|
1337
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
1338
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
1263
1339
|
<meta property="og:type" content="website">
|
|
1264
|
-
<
|
|
1340
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
1341
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
1342
|
+
<style>${minCSS(css(page.theme)+customVars+themeVarCSS)}</style>
|
|
1265
1343
|
</head>
|
|
1266
1344
|
<body>
|
|
1267
1345
|
${body}${hydrate}
|
|
@@ -1305,7 +1383,27 @@ function renderBlock(b, page) {
|
|
|
1305
1383
|
case 'chart': return rChart(b)
|
|
1306
1384
|
case 'kanban': return rKanban(b)
|
|
1307
1385
|
case 'editor': return rEditor(b)
|
|
1308
|
-
case 'each':
|
|
1386
|
+
case 'each': {
|
|
1387
|
+
// SSR: se a página tem dados do binding (via ~ssr), renderizar server-side
|
|
1388
|
+
const _eachBinding = b.binding||''
|
|
1389
|
+
const _eachData = page && page.ssrData && page.ssrData[_eachBinding.replace('@','')]
|
|
1390
|
+
if (_eachData && Array.isArray(_eachData) && _eachData.length > 0) {
|
|
1391
|
+
// SSR: expandir template para cada item
|
|
1392
|
+
const _tpl = b.tpl||''
|
|
1393
|
+
const _items = _eachData.map((item, idx) => {
|
|
1394
|
+
let row = _tpl
|
|
1395
|
+
// Substituir {item.campo} e {item} pelos valores reais
|
|
1396
|
+
row = row.replace(/\{item\.(\w+)\}/g, (_, k) => esc(String(item[k]??'')))
|
|
1397
|
+
row = row.replace(/\{item\}/g, esc(String(item??'')))
|
|
1398
|
+
// Renderizar o bloco expandido
|
|
1399
|
+
try { return applyMods(renderBlock(parseBlock(row)||{kind:'html',content:row}, page), {}) } catch { return '' }
|
|
1400
|
+
}).join('')
|
|
1401
|
+
const style = b.style ? ` style="${b.style.replace(/,/g,';')}"` : ''
|
|
1402
|
+
return `<div class="fx-each fx-each-${b.variant||'list'} fx-each-ssr" data-fx-each="${esc(_eachBinding)}"${style}>${_items}</div>\n`
|
|
1403
|
+
}
|
|
1404
|
+
// Client-side fallback (comportamento original)
|
|
1405
|
+
return `<div class="fx-each fx-each-${b.variant||'list'}" data-fx-each="${esc(_eachBinding)}" data-fx-tpl="${esc(b.tpl||'')}"${b.style?` style="${b.style.replace(/,/g,';')}"`:''}>\n<div class="fx-each-empty fx-td-empty">Loading...</div></div>\n`
|
|
1406
|
+
}
|
|
1309
1407
|
case 'if': return `<div class="fx-if-wrap" data-fx-if="${esc(b.cond)}" style="display:none"></div>\n`
|
|
1310
1408
|
default: return ''
|
|
1311
1409
|
}
|
|
@@ -1862,6 +1960,16 @@ function genThemeVarCSS(t) {
|
|
|
1862
1960
|
}
|
|
1863
1961
|
|
|
1864
1962
|
|
|
1963
|
+
function minCSS(s) {
|
|
1964
|
+
if(!s) return ''
|
|
1965
|
+
return s
|
|
1966
|
+
.replace(/\/\*[\s\S]*?\*\//g,'')
|
|
1967
|
+
.replace(/\s*([{};:,>~+|])\s*/g,'$1')
|
|
1968
|
+
.replace(/\s+/g,' ')
|
|
1969
|
+
.replace(/;}/g,'}')
|
|
1970
|
+
.trim()
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1865
1973
|
function css(theme) {
|
|
1866
1974
|
// ════════════════════════════════════════════════════════════════
|
|
1867
1975
|
// GEIST DESIGN SYSTEM — Vercel's design language for aiplang
|
|
@@ -1869,7 +1977,7 @@ function css(theme) {
|
|
|
1869
1977
|
// Fonts: Geist Sans + Geist Mono (Google Fonts)
|
|
1870
1978
|
// ════════════════════════════════════════════════════════════════
|
|
1871
1979
|
const base = `
|
|
1872
|
-
@import url('https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600;700;800;900&family=Geist+Mono:wght@400;500;700&display=swap');
|
|
1980
|
+
@import url('https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600;700;800;900&family=Geist+Mono:wght@400;500;700&display=swap&display=swap');
|
|
1873
1981
|
|
|
1874
1982
|
:root {
|
|
1875
1983
|
--geist-font:'Geist','Geist Sans',-apple-system,BlinkMacSystemFont,system-ui,sans-serif;
|
package/package.json
CHANGED
package/server/server.js
CHANGED
|
@@ -1806,6 +1806,25 @@ class AiplangServer {
|
|
|
1806
1806
|
if (this._requestLogger) this._requestLogger(req, Date.now() - _start)
|
|
1807
1807
|
return
|
|
1808
1808
|
}
|
|
1809
|
+
// Servir arquivos estaticos /public/*
|
|
1810
|
+
const _sext = req.path && req.path.match(/\.([a-zA-Z0-9]+)$/)
|
|
1811
|
+
if (_sext && req.method === 'GET') {
|
|
1812
|
+
const _appDir = server && server._appDir ? server._appDir : process.cwd()
|
|
1813
|
+
const _cands = [
|
|
1814
|
+
require('path').join(_appDir,'public',req.path),
|
|
1815
|
+
require('path').join(process.cwd(),'public',req.path),
|
|
1816
|
+
require('path').join(_appDir,req.path.slice(1)),
|
|
1817
|
+
]
|
|
1818
|
+
for (const _fp of _cands) {
|
|
1819
|
+
if (require('fs').existsSync(_fp)) {
|
|
1820
|
+
const _mime = getMimeType(_fp)
|
|
1821
|
+
const _buf = require('fs').readFileSync(_fp)
|
|
1822
|
+
res.setHeader('Cache-Control','public,max-age=86400')
|
|
1823
|
+
res.writeHead(200,{'Content-Type':_mime,'Content-Length':_buf.length})
|
|
1824
|
+
return res.end(_buf)
|
|
1825
|
+
}
|
|
1826
|
+
}
|
|
1827
|
+
}
|
|
1809
1828
|
const _404b='{"error":"Not found"}'; res.writeHead(404,{'Content-Type':'application/json','Content-Length':18}); res.end(_404b)
|
|
1810
1829
|
}
|
|
1811
1830
|
|
|
@@ -1858,7 +1877,8 @@ async function parseBody(req) {
|
|
|
1858
1877
|
// ═══════════════════════════════════════════════════════════════════
|
|
1859
1878
|
function renderHTML(page, allPages) {
|
|
1860
1879
|
const needsJS=page.queries.length>0||page.blocks.some(b=>['table','form','if','btn','select','faq'].includes(b.kind))
|
|
1861
|
-
const
|
|
1880
|
+
const ssrData = page.ssrData || {}
|
|
1881
|
+
const body=page.blocks.map(b=>renderBlock(b, ssrData)).join('')
|
|
1862
1882
|
const config=needsJS?JSON.stringify({id:page.id,theme:page.theme,state:page.state,routes:allPages.map(p=>p.route),queries:page.queries}):''
|
|
1863
1883
|
const hydrate=needsJS?`<script>window.__AIPLANG_PAGE__=${config};</script><script src="/aiplang-hydrate.js" defer></script>`:''
|
|
1864
1884
|
const themeCSS=page.themeVars?genThemeCSS(page.themeVars):''
|
|
@@ -1866,8 +1886,9 @@ function renderHTML(page, allPages) {
|
|
|
1866
1886
|
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>`
|
|
1867
1887
|
}
|
|
1868
1888
|
|
|
1869
|
-
function renderBlock(b) {
|
|
1889
|
+
function renderBlock(b, ssrData) {
|
|
1870
1890
|
const line=b.rawLine
|
|
1891
|
+
ssrData = ssrData || {}
|
|
1871
1892
|
let animate='',extraClass=''
|
|
1872
1893
|
const am=line.match(/\banimate:(\S+)/); if(am)animate='fx-anim-'+am[1]
|
|
1873
1894
|
const cm=line.match(/\bclass:(\S+)/); if(cm)extraClass=cm[1]
|
|
@@ -1886,6 +1907,23 @@ function renderBlock(b) {
|
|
|
1886
1907
|
case 'faq': return rFaq(line)
|
|
1887
1908
|
case 'testimonial':return rTestimonial(line)
|
|
1888
1909
|
case 'gallery':return rGallery(line)
|
|
1910
|
+
case 'each': {
|
|
1911
|
+
const _bind = line.match(/data-fx-each="([^"]+)"/)
|
|
1912
|
+
const _tpl = line.match(/data-fx-tpl="([^"]+)"/)
|
|
1913
|
+
const _bkey = (_bind && _bind[1]) ? _bind[1].replace('@','') : ''
|
|
1914
|
+
const _data = ssrData[_bkey]
|
|
1915
|
+
if (_data && Array.isArray(_data) && _data.length > 0) {
|
|
1916
|
+
const _t = _tpl ? _tpl[1] : ''
|
|
1917
|
+
const _items = _data.map(item => {
|
|
1918
|
+
let row = _t
|
|
1919
|
+
row = row.replace(/\{item\.(\w+)\}/g, (_,k) => esc(String(item[k]??'')))
|
|
1920
|
+
row = row.replace(/\{item\}/g, esc(String(item??'')))
|
|
1921
|
+
return row
|
|
1922
|
+
}).join('')
|
|
1923
|
+
return `<div class="fx-each fx-each-ssr">${_items}</div>\n`
|
|
1924
|
+
}
|
|
1925
|
+
return line + '\n'
|
|
1926
|
+
}
|
|
1889
1927
|
case 'raw': return extractBody(line)+'\n'
|
|
1890
1928
|
case 'if': return `<div class="fx-if-wrap" data-fx-if="${esc(extractCond(line))}" style="display:none"></div>\n`
|
|
1891
1929
|
default: return ''
|
|
@@ -2137,6 +2175,17 @@ function getMime(filename) {
|
|
|
2137
2175
|
return types[ext] || 'application/octet-stream'
|
|
2138
2176
|
}
|
|
2139
2177
|
|
|
2178
|
+
// Cache-Control helper
|
|
2179
|
+
function _cacheHeaders(res, type, maxAge=0) {
|
|
2180
|
+
if (type && (type.startsWith('image/') || type.startsWith('font/') || type.endsWith('javascript') || type.endsWith('css'))) {
|
|
2181
|
+
res.setHeader('Cache-Control', `public, max-age=${maxAge||86400}, immutable`)
|
|
2182
|
+
} else if (type && type.includes('html')) {
|
|
2183
|
+
res.setHeader('Cache-Control', 'no-cache')
|
|
2184
|
+
} else {
|
|
2185
|
+
res.setHeader('Cache-Control', 'public, max-age=3600')
|
|
2186
|
+
}
|
|
2187
|
+
}
|
|
2188
|
+
|
|
2140
2189
|
// ═══════════════════════════════════════════════════════════════════
|
|
2141
2190
|
// PLUGIN SYSTEM — ~plugin ./my-plugin.js | ~use rate-limit max=100
|
|
2142
2191
|
// ═══════════════════════════════════════════════════════════════════
|
|
@@ -2377,8 +2426,21 @@ async function startServer(aipFile, port = 3000) {
|
|
|
2377
2426
|
|
|
2378
2427
|
// Frontend
|
|
2379
2428
|
for (const page of app.pages) {
|
|
2380
|
-
srv.addRoute('GET', page.route, (req, res) => {
|
|
2381
|
-
|
|
2429
|
+
srv.addRoute('GET', page.route, async (req, res) => {
|
|
2430
|
+
// Executar SSR queries para each{} blocks
|
|
2431
|
+
const ssrData = {}
|
|
2432
|
+
for (const q of (page.queries||[])) {
|
|
2433
|
+
if (q.trigger === 'ssr' || q.trigger === 'mount') {
|
|
2434
|
+
try {
|
|
2435
|
+
const Model = q.model && srv.models[q.model]
|
|
2436
|
+
if (Model) {
|
|
2437
|
+
const data = await Model.all()
|
|
2438
|
+
if (q.binding) ssrData[q.binding.replace('@','')] = data
|
|
2439
|
+
}
|
|
2440
|
+
} catch(e) { /* ignore SSR query errors */ }
|
|
2441
|
+
}
|
|
2442
|
+
}
|
|
2443
|
+
const html = renderHTML({...page, ssrData}, app.pages)
|
|
2382
2444
|
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); res.end(html)
|
|
2383
2445
|
})
|
|
2384
2446
|
console.log(`[aiplang] Page: ${page.route}`)
|
|
@@ -2387,7 +2449,7 @@ async function startServer(aipFile, port = 3000) {
|
|
|
2387
2449
|
// Static assets
|
|
2388
2450
|
srv.addRoute('GET', '/aiplang-hydrate.js', (req, res) => {
|
|
2389
2451
|
const p = path.join(__dirname, '..', 'runtime', 'aiplang-hydrate.js')
|
|
2390
|
-
if (fs.existsSync(p)) { res.writeHead(200,{'Content-Type':'application/javascript'}); res.end(fs.readFileSync(p)) }
|
|
2452
|
+
if (fs.existsSync(p)) { res.setHeader('Cache-Control','public,max-age=86400'); res.writeHead(200,{'Content-Type':'application/javascript'}); res.end(fs.readFileSync(p)) }
|
|
2391
2453
|
else { res.writeHead(404); res.end('// not found') }
|
|
2392
2454
|
})
|
|
2393
2455
|
|
|
@@ -2432,7 +2494,7 @@ async function startServer(aipFile, port = 3000) {
|
|
|
2432
2494
|
})
|
|
2433
2495
|
|
|
2434
2496
|
srv.addRoute('GET', '/health', (req, res) => res.json(200, {
|
|
2435
|
-
status:'ok', version:'2.11.
|
|
2497
|
+
status:'ok', version:'2.11.12',
|
|
2436
2498
|
models: app.models.map(m=>m.name),
|
|
2437
2499
|
routes: app.apis.length, pages: app.pages.length,
|
|
2438
2500
|
admin: app.admin?.prefix || null,
|