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 CHANGED
@@ -5,7 +5,51 @@ const fs = require('fs')
5
5
  const path = require('path')
6
6
  const http = require('http')
7
7
 
8
- const VERSION = '2.11.10'
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 v${VERSION}
30
- AI-first web language — full apps in ~20 lines.
31
-
32
- Usage:
33
- npx aiplang init [name] create project (default template)
34
- npx aiplang init [name] --template <t> use template: saas|landing|crud|dashboard|portfolio|blog
35
- npx aiplang init [name] --template ./my.aip use a local .aip file as template
36
- npx aiplang init [name] --template my-custom use a saved custom template
37
- npx aiplang serve [dir] dev server + hot reload
38
- npx aiplang build [dir/file] compile static HTML
39
- npx aiplang validate <app.aip> validate syntax with AI-friendly errors
40
- npx aiplang types <app.aip> generate TypeScript types (.d.ts)
41
- npx aiplang context [app.aip] dump minimal AI context (<500 tokens)
42
- npx aiplang new <page> new page template
43
- npx aiplang --version
44
-
45
- Full-stack:
46
- npx aiplang start app.aip start full-stack server (API + DB + frontend)
47
- PORT=8080 aiplang start app.aip custom port
48
-
49
- Templates:
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.10'`)
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 v${VERSION} — ${files.length} file(s)\n`)
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
- return src.split(/\n---\n/).map(s=>parsePage(s.trim())).filter(Boolean)
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
- <style>${css(page.theme)}${customVars}${themeVarCSS}</style>
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': return `<div class="fx-each fx-each-${b.variant||'list'}" data-fx-each="${esc(b.binding||'')}" data-fx-tpl="${esc(b.tpl||'')}"${b.style?` style="${b.style.replace(/,/g,';')}"`:''}>\n<div class="fx-each-empty fx-td-empty">Loading...</div></div>\n`
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aiplang",
3
- "version": "2.11.10",
3
+ "version": "2.11.12",
4
4
  "description": "AI-first web language. One .aip file = complete app. Frontend + backend + database + auth.",
5
5
  "keywords": [
6
6
  "aiplang",
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 body=page.blocks.map(b=>renderBlock(b)).join('')
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
- const html = renderHTML(page, app.pages)
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.10',
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,