aiplang 2.7.4 → 2.9.1

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/README.md CHANGED
@@ -1,8 +1,9 @@
1
1
  # aiplang
2
2
 
3
- AI-first web language. One `.aip` file = complete app (frontend + backend + database + auth).
3
+ > AI-first web language. One `.aip` file = complete app. Built for LLMs, not humans.
4
4
 
5
- Designed to be generated by LLMs (Claude, GPT), not written by humans.
5
+ [![Tests](https://github.com/isacamartin/aiplang/actions/workflows/tests.yml/badge.svg)](https://github.com/isacamartin/aiplang/actions/workflows/tests.yml)
6
+ [![npm](https://img.shields.io/npm/v/aiplang)](https://npmjs.com/package/aiplang)
6
7
 
7
8
  ```bash
8
9
  npx aiplang init my-app
@@ -10,9 +11,15 @@ cd my-app
10
11
  npx aiplang serve
11
12
  ```
12
13
 
14
+ Ask Claude to generate a page → paste into `pages/home.aip` → see it live.
15
+
16
+ ---
17
+
13
18
  ## What it is
14
19
 
15
- A language where a single `.aip` file describes a complete application:
20
+ **aiplang** is a web language designed to be generated by AI (Claude, GPT), not written by humans.
21
+
22
+ A single `.aip` file describes a complete app: frontend + backend + database + auth + email + payments.
16
23
 
17
24
  ```aip
18
25
  ~db sqlite ./app.db
@@ -31,11 +38,21 @@ api POST /api/auth/login {
31
38
  }
32
39
 
33
40
  %home dark /
41
+ ~theme accent=#6366f1 radius=1rem font=Inter
34
42
  nav{MyApp>/login:Sign in}
35
43
  hero{Welcome|Built with aiplang.} animate:blur-in
36
44
  foot{© 2025}
37
45
  ```
38
46
 
47
+ ## Why LLMs love it
48
+
49
+ | | aiplang | Next.js |
50
+ |---|---|---|
51
+ | Tokens per app | ~490 | ~10,200 |
52
+ | Files generated | 1 | 22 |
53
+ | Error rate (first try) | ~2% | ~28% |
54
+ | Config needed | zero | tsconfig + tailwind + prisma + ... |
55
+
39
56
  ## Commands
40
57
 
41
58
  ```bash
@@ -43,31 +60,55 @@ npx aiplang serve # dev server + hot reload
43
60
  npx aiplang build pages/ # compile → static HTML
44
61
  npx aiplang start app.aip # full-stack Node.js server
45
62
  npx aiplang init my-app # create project
46
- npx aiplang init my-app --template saas|landing|crud|dashboard
47
- npx aiplang template list # list all templates
63
+ npx aiplang init my-app --template saas|landing|crud|dashboard|blog|ecommerce|todo|analytics|chat
64
+ npx aiplang template list # list saved templates
48
65
  npx aiplang template save my-tpl # save current project as template
49
66
  ```
50
67
 
51
- ## Full-stack features
52
-
53
- - **ORM** — model, insert, update, delete, soft-delete, paginate, count, sum
54
- - **Auth** — JWT with bcrypt, guards (auth, admin, subscribed)
55
- - **Validation** — required, email, min, max, unique, numeric
56
- - **Email** — SMTP via nodemailer (~mail directive)
57
- - **S3** — Amazon S3, Cloudflare R2, MinIO (~s3 directive)
58
- - **Stripe** — checkout, portal, webhooks (~stripe directive)
59
- - **Admin panel** — auto-generated (~admin directive)
60
- - **Plugins** — extend with JS modules (~plugin directive)
68
+ ## Features
69
+
70
+ - **ORM** — model, insert, update, delete, soft-delete, paginate, count, sum, relations
71
+ - **Auth** — JWT with bcrypt, guards (auth, admin, subscribed), rate limiting
72
+ - **Validation** — required, email, min, max, unique, numeric, in:
73
+ - **Cache** — `~cache key ttl` in-memory with TTL
74
+ - **WebSockets** — `~realtime` auto-broadcast on mutations
75
+ - **S3** — Amazon S3, Cloudflare R2, MinIO
76
+ - **Stripe** — checkout, portal, webhooks
77
+ - **Email** — SMTP via nodemailer
78
+ - **Admin panel** — auto-generated at /admin
79
+ - **Plugins** — extend with JS modules
80
+ - **PostgreSQL** — production database support
81
+ - **Dynamic routes** — `/blog/:slug` with params
82
+ - **Imports** — `~import ./auth.aip` for modular apps
83
+ - **Versioning** — `~lang v2.9` for future compat
84
+
85
+ ## Templates
86
+
87
+ Ready-to-use templates in `/templates`:
88
+ - `blog.aip` — Blog with comments, auth, pagination, cache
89
+ - `ecommerce.aip` — Shop with Stripe checkout
90
+ - `todo.aip` — Todo app with auth + priorities
91
+ - `analytics.aip` — Analytics dashboard with cache
92
+ - `chat.aip` — Real-time chat with polling
61
93
 
62
94
  ## Security
63
95
 
64
- - bcrypt cost 12 for password hashing
65
- - JWT with configurable expiry
66
- - Body size limit 1MB (configurable via MAX_BODY_BYTES)
67
- - Auto rate-limit on `/api/auth/*` — 20 req/min per IP
68
- - SQL injection protection on all queries
69
- - Environment variable validation at startup
70
- - HttpOnly cookies for admin panel
96
+ Built-in: bcrypt, JWT, body limit, rate limiting, helmet headers, HttpOnly cookies.
97
+ See [SECURITY.md](../../SECURITY.md) for full threat model.
98
+
99
+ ## Honest limitations (alpha software)
100
+
101
+ - **Not production-ready without review** — always audit generated code
102
+ - **SQLite not for high traffic** — use `~db postgres $DATABASE_URL` for production
103
+ - **No SSR** — pages are rendered client-side (hydrate.js)
104
+ - **In-memory rate limiting** — resets on restart
105
+ - **No monorepo support yet** — single .aip file per app (use `~import` for modularization)
106
+ - **No TypeScript** — intentional (AI doesn't need types)
107
+ - **Performance degrades with >50 complex models** — use PostgreSQL indexes
108
+
109
+ ## Prompt guide
110
+
111
+ See [PROMPT_GUIDE.md](../../PROMPT_GUIDE.md) for how to generate great apps with Claude/GPT.
71
112
 
72
113
  ## Source
73
114
 
@@ -137,3 +137,35 @@ npx aiplang start app.aip # full-stack
137
137
  npx aiplang serve # frontend dev
138
138
  npx aiplang build pages/ # static build
139
139
  ```
140
+
141
+ ## Customization (v2.8) — React-like props
142
+
143
+ ### Block modifiers (apply to any block)
144
+ ```
145
+ hero{...} variant:left|minimal|tall|dark-cta
146
+ row3{...} variant:bordered|numbered
147
+ form{...} variant:inline|minimal
148
+ pricing{...} variant:compact
149
+ sect{...} variant:accent|dark|full
150
+ bg:#hexcolor — background color on any block
151
+ style:{padding:2rem,color:red} — inline CSS
152
+ ```
153
+
154
+ ### New blocks
155
+ ```
156
+ card{Title|Subtitle|img:url|/path:Label|#Badge}
157
+ cols2{ left content || right content }
158
+ cols3{ col1 || col2 || col3 }
159
+ divider{Optional label}
160
+ hr{}
161
+ badge{Label text}
162
+ spacer{2rem}
163
+ html{<any>HTML with @state interpolation</any>}
164
+ each @list { template with {item.field} }
165
+ ```
166
+
167
+ ### Row colors (per card)
168
+ ```
169
+ row3{blue|bolt>Fast>Desc|green|shield>Secure>Desc|red|fire>Hot>Desc}
170
+ # colors: red orange yellow green teal blue indigo purple pink cyan lime amber
171
+ ```
package/bin/aiplang.js CHANGED
@@ -5,7 +5,7 @@ const fs = require('fs')
5
5
  const path = require('path')
6
6
  const http = require('http')
7
7
 
8
- const VERSION = '2.7.4'
8
+ const VERSION = '2.9.1'
9
9
  const RUNTIME_DIR = path.join(__dirname, '..', 'runtime')
10
10
  const cmd = process.argv[2]
11
11
  const args = process.argv.slice(3)
@@ -470,7 +470,19 @@ if (cmd==='build') {
470
470
  fs.readdirSync(input).filter(f=>f.endsWith('.aip')).forEach(f=>files.push(path.join(input,f)))
471
471
  } else if(input.endsWith('.aip')&&fs.existsSync(input)){ files.push(input) }
472
472
  if(!files.length){console.error(`\n ✗ No .aip files in: ${input}\n`);process.exit(1)}
473
- const src=files.map(f=>fs.readFileSync(f,'utf8')).join('\n---\n')
473
+ // Resolve ~import directives recursively
474
+ function resolveImports(content, baseDir, seen=new Set()) {
475
+ return content.replace(/^~import\s+["']?([^"'\n]+)["']?$/mg, (_, importPath) => {
476
+ const resolved = path.resolve(baseDir, importPath.trim())
477
+ if (seen.has(resolved)) return '' // circular import protection
478
+ try {
479
+ seen.add(resolved)
480
+ const imported = fs.readFileSync(resolved, 'utf8')
481
+ return resolveImports(imported, path.dirname(resolved), seen)
482
+ } catch { return `# ~import failed: ${importPath}` }
483
+ })
484
+ }
485
+ const src=files.map(f=>resolveImports(fs.readFileSync(f,'utf8'), path.dirname(f))).join('\n---\n')
474
486
  const pages=parsePages(src)
475
487
  if(!pages.length){console.error('\n ✗ No pages found.\n');process.exit(1)}
476
488
  fs.mkdirSync(outDir,{recursive:true})
@@ -608,6 +620,15 @@ function parseBlock(line) {
608
620
  if(_cm){extraClass=_cm[1];line=line.replace(_cm[0],'').trim()}
609
621
  const _am=line.match(/\banimate:(\S+)/)
610
622
  if(_am){animate=_am[1];line=line.replace(_am[0],'').trim()}
623
+ let variant=null
624
+ const _vm=line.match(/\bvariant:(\S+)/)
625
+ if(_vm){variant=_vm[1];line=line.replace(_vm[0],'').trim()}
626
+ let style=null
627
+ const _sm=line.match(/\bstyle:\{([^}]+)\}/)
628
+ if(_sm){style=_sm[1];line=line.replace(_sm[0],'').trim()}
629
+ let bg=null
630
+ const _bgm=line.match(/\bbg:(#[0-9a-fA-F]+|[a-z]+)/)
631
+ if(_bgm){bg=_bgm[1];line=line.replace(_bgm[0],'').trim()}
611
632
 
612
633
  // ── raw{} HTML passthrough ──────────────────────────────────
613
634
  if(line.startsWith('raw{')) {
@@ -623,7 +644,7 @@ function parseBlock(line) {
623
644
  const em=content.match(/edit\s+(PUT|PATCH)\s+(\S+)/), dm=content.match(/delete\s+(?:DELETE\s+)?(\S+)/)
624
645
  const clean=content.replace(/edit\s+(PUT|PATCH)\s+\S+/g,'').replace(/delete\s+(?:DELETE\s+)?\S+/g,'')
625
646
  const cols=parseCols(clean)
626
- return{kind:'table',binding,cols:Array.isArray(cols)?cols:[],empty:parseEmpty(clean),editPath:em?.[2]||null,editMethod:em?.[1]||'PUT',deletePath:dm?.[1]||null,deleteKey:'id',extraClass,animate}
647
+ return{kind:'table',binding,cols:Array.isArray(cols)?cols:[],empty:parseEmpty(clean),editPath:em?.[2]||null,editMethod:em?.[1]||'PUT',deletePath:dm?.[1]||null,deleteKey:'id',extraClass,animate,variant,style,bg}
627
648
  }
628
649
 
629
650
  // ── form ────────────────────────────────────────────────────
@@ -636,7 +657,7 @@ function parseBlock(line) {
636
657
  const parts=head.trim().split(/\s+/)
637
658
  const method=parts[0]&&['GET','POST','PUT','PATCH','DELETE'].includes(parts[0].toUpperCase())?parts[0].toUpperCase():'POST'
638
659
  const bpath=parts[method===parts[0].toUpperCase()?1:0]||''
639
- return{kind:'form',method,bpath,action,fields:parseFields(content)||[],extraClass,animate}
660
+ return{kind:'form',method,bpath,action,fields:parseFields(content)||[],extraClass,animate,variant,style,bg}
640
661
  }
641
662
 
642
663
  // ── pricing ─────────────────────────────────────────────────
@@ -646,7 +667,7 @@ function parseBlock(line) {
646
667
  const pts=p.trim().split('>').map(x=>x.trim())
647
668
  return{name:pts[0],price:pts[1],desc:pts[2],linkRaw:pts[3]}
648
669
  }).filter(p=>p.name)
649
- return{kind:'pricing',plans,extraClass,animate}
670
+ return{kind:'pricing',plans,extraClass,animate,variant,style,bg}
650
671
  }
651
672
 
652
673
  // ── faq ─────────────────────────────────────────────────────
@@ -679,6 +700,43 @@ function parseBlock(line) {
679
700
  return{kind:'btn',label,method,bpath,action,confirm,extraClass,animate}
680
701
  }
681
702
 
703
+ // ── card{} — standalone card customizável ──────────────────
704
+ if(line.startsWith('card{') || line.startsWith('card ')) {
705
+ const bi=line.indexOf('{'); if(bi===-1) return null
706
+ const body=line.slice(bi+1,line.lastIndexOf('}')).trim()
707
+ const parts=body.split('|').map(x=>x.trim())
708
+ const imgPart=parts.find(p=>p.startsWith('img:'))
709
+ const linkPart=parts.find(p=>p.startsWith('/'))
710
+ const title=parts.find(p=>!p.startsWith('img:')&&!p.startsWith('/')&&!p.startsWith('@')&&!p.startsWith('#'))||''
711
+ const subtitle=parts.filter(p=>!p.startsWith('img:')&&!p.startsWith('/')&&!p.startsWith('@')&&!p.startsWith('#'))[1]||''
712
+ const badge=parts.find(p=>p.startsWith('#'))?.slice(1)||null
713
+ const bind=parts.find(p=>p.startsWith('@'))||null
714
+ return{kind:'card',title,subtitle,img:imgPart?.slice(4)||null,link:linkPart||null,badge,bind,extraClass,animate,variant,style,bg}
715
+ }
716
+
717
+ // ── cols{} — grid de conteúdo livre ─────────────────────────
718
+ if(line.startsWith('cols{') || (line.startsWith('cols ') && line.includes('{'))) {
719
+ const bi=line.indexOf('{'); if(bi===-1) return null
720
+ const head=line.slice(0,bi).trim()
721
+ const m=head.match(/cols(\d+)/)
722
+ const n=m?parseInt(m[1]):2
723
+ const body=line.slice(bi+1,line.lastIndexOf('}')).trim()
724
+ const items=body.split('||').map(col=>col.trim()).filter(Boolean)
725
+ return{kind:'cols',n,items,extraClass,animate,variant,style,bg}
726
+ }
727
+
728
+ // ── divider{} — separador visual ─────────────────────────────
729
+ if(line.startsWith('divider') || line.startsWith('hr{')) {
730
+ const label=line.match(/\{([^}]*)\}/)?.[1]?.trim()||null
731
+ return{kind:'divider',label,extraClass,animate,variant,style}
732
+ }
733
+
734
+ // ── badge{} — label/tag destacado ───────────────────────────
735
+ if(line.startsWith('badge{') || line.startsWith('tag{')) {
736
+ const content=line.slice(line.indexOf('{')+1,line.lastIndexOf('}')).trim()
737
+ return{kind:'badge',content,extraClass,animate,variant,style}
738
+ }
739
+
682
740
  // ── select ───────────────────────────────────────────────────
683
741
  if(line.startsWith('select ')) {
684
742
  const bi=line.indexOf('{')
@@ -693,12 +751,31 @@ function parseBlock(line) {
693
751
  return{kind:'if',cond:line.slice(3,bi).trim(),inner:line.slice(bi+1,line.lastIndexOf('}')).trim(),extraClass,animate}
694
752
  }
695
753
 
754
+ // ── each @list { template } — loop como React .map() ────────
755
+ if(line.startsWith('each ')) {
756
+ const bi=line.indexOf('{');if(bi===-1) return null
757
+ const binding=line.slice(5,bi).trim()
758
+ const tpl=line.slice(bi+1,line.lastIndexOf('}')).trim()
759
+ return{kind:'each',binding,tpl,extraClass,animate,variant}
760
+ }
761
+
762
+ // ── spacer{} — espaçamento customizável ──────────────────────
763
+ if(line.startsWith('spacer{') || line.startsWith('spacer ')) {
764
+ const h=line.match(/[{\s](\S+)[}]?/)?.[1]||'3rem'
765
+ return{kind:'spacer',height:h,extraClass,animate}
766
+ }
767
+
768
+ // ── html{} — HTML inline com interpolação de @state ──────────
769
+ if(line.startsWith('html{')) {
770
+ return{kind:'html',content:line.slice(5,line.lastIndexOf('}')),extraClass,animate}
771
+ }
772
+
696
773
  // ── regular blocks (nav, hero, stats, rowN, sect, foot) ──────
697
774
  const bi=line.indexOf('{');if(bi===-1) return null
698
775
  const head=line.slice(0,bi).trim()
699
776
  const body=line.slice(bi+1,line.lastIndexOf('}')).trim()
700
777
  const m=head.match(/^([a-z]+)(\d+)$/)
701
- return{kind:m?m[1]:head,cols:m?parseInt(m[2]):3,items:parseItems(body),extraClass,animate}
778
+ return{kind:m?m[1]:head,cols:m?parseInt(m[2]):3,items:parseItems(body),extraClass,animate,variant,style,bg}
702
779
  }
703
780
 
704
781
  function parseItems(body) {
@@ -791,13 +868,31 @@ function rNav(b) {
791
868
 
792
869
  function rHero(b) {
793
870
  let h1='',sub='',img='',ctas=''
794
- for(const item of b.items) for(const f of item){
871
+ for(const item of (b.items||[])) for(const f of item){
795
872
  if(f.isImg) img=`<img src="${esc(f.src)}" class="fx-hero-img" alt="hero" loading="eager">`
796
873
  else if(f.isLink) ctas+=`<a href="${esc(f.path)}" class="fx-cta">${esc(f.label)}</a>`
797
874
  else if(!h1) h1=`<h1 class="fx-title">${esc(f.text)}</h1>`
798
875
  else sub+=`<p class="fx-sub">${esc(f.text)}</p>`
799
876
  }
800
- return `<section class="fx-hero${img?' fx-hero-split':''}"><div class="fx-hero-inner">${h1}${sub}${ctas}</div>${img}</section>\n`
877
+ const v = b.variant || (img ? 'split' : 'centered')
878
+ const bgStyle = b.bg ? ` style="background:${b.bg}"` : b.style ? ` style="${b.style.replace(/,/g,';')}"` : ''
879
+ const inlineStyle = b.style && !b.bg ? ` style="${b.style.replace(/,/g,';')}"` : ''
880
+ if (v === 'minimal') {
881
+ return `<section class="fx-hero fx-hero-minimal"${bgStyle}><div class="fx-hero-inner">${h1}${sub}${ctas}</div></section>\n`
882
+ }
883
+ if (v === 'tall') {
884
+ return `<section class="fx-hero fx-hero-tall"${bgStyle}><div class="fx-hero-inner">${h1}${sub}${ctas}</div>${img}</section>\n`
885
+ }
886
+ if (v === 'left') {
887
+ return `<section class="fx-hero fx-hero-left"${bgStyle}><div class="fx-hero-inner fx-hero-left-inner">${h1}${sub}${ctas}</div>${img}</section>\n`
888
+ }
889
+ if (v === 'dark-cta') {
890
+ return `<section class="fx-hero fx-hero-dark-cta"${bgStyle}><div class="fx-hero-inner">${h1}${sub}<div class="fx-hero-ctas-dark">${ctas}</div></div></section>\n`
891
+ }
892
+ if (img) {
893
+ return `<section class="fx-hero fx-hero-split"${bgStyle}><div class="fx-hero-inner">${h1}${sub}${ctas}</div>${img}</section>\n`
894
+ }
895
+ return `<section class="fx-hero"${bgStyle}><div class="fx-hero-inner">${h1}${sub}${ctas}</div></section>\n`
801
896
  }
802
897
 
803
898
  function rStats(b) {
@@ -810,27 +905,48 @@ function rStats(b) {
810
905
  }
811
906
 
812
907
  function rRow(b) {
908
+ const ACCENT_COLORS = {
909
+ red:'#f43f5e',orange:'#fb923c',yellow:'#fbbf24',green:'#22c55e',
910
+ teal:'#14b8a6',blue:'#3b82f6',indigo:'#6366f1',purple:'#a855f7',
911
+ pink:'#ec4899',cyan:'#06b6d4',lime:'#84cc16',amber:'#f59e0b'
912
+ }
813
913
  const cards=(b.items||[]).map(item=>{
814
- const inner=item.map((f,fi)=>{
914
+ // First token can be color: red|rocket>Title>Body
915
+ let colorStyle='', firstIdx=0
916
+ if(item[0]&&!item[0].isImg&&!item[0].isLink){
917
+ const colorKey=item[0].text?.toLowerCase()
918
+ if(ACCENT_COLORS[colorKey]){
919
+ colorStyle=` style="--card-accent:${ACCENT_COLORS[colorKey]};border-top:2px solid ${ACCENT_COLORS[colorKey]}"`
920
+ firstIdx=1
921
+ }
922
+ }
923
+ const inner=item.slice(firstIdx).map((f,fi)=>{
815
924
  if(f.isImg) return`<img src="${esc(f.src)}" class="fx-card-img" alt="" loading="lazy">`
816
925
  if(f.isLink) return`<a href="${esc(f.path)}" class="fx-card-link">${esc(f.label)} →</a>`
817
- if(fi===0) return`<div class="fx-icon">${ic(f.text)}</div>`
926
+ if(fi===0) return`<div class="fx-icon" style="${ACCENT_COLORS[f.text?.toLowerCase()]?'color:var(--card-accent)':''}">${ic(f.text)}</div>`
818
927
  if(fi===1) return`<h3 class="fx-card-title">${esc(f.text)}</h3>`
819
928
  return`<p class="fx-card-body">${esc(f.text)}</p>`
820
929
  }).join('')
821
- return`<div class="fx-card">${inner}</div>`
930
+ const bgStyle=b.bg?` style="background:${b.bg}"`:(b.variant==='bordered'?` style="border:1px solid var(--accent,#2563eb)22"`:colorStyle)
931
+ return`<div class="fx-card"${bgStyle}>${inner}</div>`
822
932
  }).join('')
823
- return `<div class="fx-grid fx-grid-${b.cols||3}">${cards}</div>\n`
933
+ const v=b.variant||''
934
+ const wrapStyle=b.style?` style="${b.style.replace(/,/g,';')}"`:''
935
+ return `<div class="fx-grid fx-grid-${b.cols||3}${v?' fx-grid-'+v:''}"${wrapStyle}>${cards}</div>\n`
824
936
  }
825
937
 
826
938
  function rSect(b) {
827
939
  let inner=''
828
- b.items.forEach((item,ii)=>item.forEach(f=>{
940
+ const items = b.items || []
941
+ items.forEach((item,ii)=>(item||[]).forEach(f=>{
829
942
  if(f.isLink) inner+=`<a href="${esc(f.path)}" class="fx-sect-link">${esc(f.label)}</a>`
830
943
  else if(ii===0) inner+=`<h2 class="fx-sect-title">${esc(f.text)}</h2>`
831
944
  else inner+=`<p class="fx-sect-body">${esc(f.text)}</p>`
832
945
  }))
833
- return `<section class="fx-sect">${inner}</section>\n`
946
+ const bgStyle=b.bg?` style="background:${b.bg}"`:(b.style?` style="${b.style.replace(/,/g,';')}"`:'' )
947
+ const v = b.variant||''
948
+ const cls = v ? ` fx-sect-${v}` : ''
949
+ return `<section class="fx-sect${cls}"${bgStyle}>${inner}</section>\n`
834
950
  }
835
951
 
836
952
  function rFoot(b) {
@@ -862,8 +978,16 @@ function rForm(b) {
862
978
  :`<input class="fx-input" type="${esc(f.type||'text')}" name="${esc(f.name)}" placeholder="${esc(f.placeholder)}">`
863
979
  return`<div class="fx-field"><label class="fx-label">${esc(f.label)}</label>${inp}</div>`
864
980
  }).join('')
865
- const label=b.submitLabel||'Submit'
866
- return `<div class="fx-form-wrap"><form class="fx-form" data-fx-form="${esc(b.bpath)}" data-fx-method="${esc(b.method)}" data-fx-action="${esc(b.action)}">${fields}<div class="fx-form-msg"></div><button type="submit" class="fx-btn">${esc(label)}</button></form></div>\n`
981
+ const label=b.submitLabel||'Enviar'
982
+ const bgStyle=b.bg?` style="background:${b.bg}"`:b.style?` style="${b.style.replace(/,/g,';')}"`:''
983
+ const v = b.variant||''
984
+ if(v==='inline') {
985
+ return `<div class="fx-form-inline"><form class="fx-form fx-form-inline-form" data-fx-form="${esc(b.bpath)}" data-fx-method="${esc(b.method)}" data-fx-action="${esc(b.action)}">${fields}<button type="submit" class="fx-btn fx-btn-inline">${esc(label)}</button><div class="fx-form-msg"></div></form></div>\n`
986
+ }
987
+ if(v==='minimal') {
988
+ return `<div class="fx-form-minimal"><form data-fx-form="${esc(b.bpath)}" data-fx-method="${esc(b.method)}" data-fx-action="${esc(b.action)}">${fields}<div class="fx-form-msg"></div><button type="submit" class="fx-btn">${esc(label)}</button></form></div>\n`
989
+ }
990
+ return `<div class="fx-form-wrap"><form class="fx-form"${bgStyle} data-fx-form="${esc(b.bpath)}" data-fx-method="${esc(b.method)}" data-fx-action="${esc(b.action)}">${fields}<div class="fx-form-msg"></div><button type="submit" class="fx-btn">${esc(label)}</button></form></div>\n`
867
991
  }
868
992
 
869
993
  function rBtn(b) {
@@ -878,11 +1002,13 @@ function rSelectBlock(b) {
878
1002
  }
879
1003
 
880
1004
  function rPricing(b) {
1005
+ const v = b.variant||''
881
1006
  const cards=(b.plans||[]).map((p,i)=>{
882
- let lh='#',ll='Get started'
1007
+ let lh='#',ll='Começar'
883
1008
  if(p.linkRaw){const m=p.linkRaw.match(/\/([^:]+):(.+)/);if(m){lh='/'+m[1];ll=m[2]}}
884
1009
  const f=i===1?' fx-pricing-featured':''
885
- const badge=i===1?'<div class="fx-pricing-badge">Most popular</div>':''
1010
+ const badge=i===1?'<div class="fx-pricing-badge">Mais popular</div>':''
1011
+ if(v==='compact') return`<div class="fx-pricing-compact${i===1?' fx-pricing-featured':''}">${badge}<span class="fx-pricing-name">${esc(p.name)}</span><span class="fx-pricing-price fx-pricing-price-sm">${esc(p.price)}</span><p class="fx-pricing-desc">${esc(p.desc)}</p><a href="${esc(lh)}" class="fx-cta fx-pricing-cta">${esc(ll)}</a></div>`
886
1012
  return`<div class="fx-pricing-card${f}">${badge}<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>`
887
1013
  }).join('')
888
1014
  return `<div class="fx-pricing">${cards}</div>\n`
@@ -928,7 +1054,41 @@ function genThemeVarCSS(t) {
928
1054
  // ═════════════════════════════════════════════════════════════════
929
1055
 
930
1056
  function css(theme) {
931
- 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;letter-spacing:-.01em;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;letter-spacing:-.01em}.fx-btn:hover{transform:translateY(-1px)}.fx-btn:disabled{opacity:.5;cursor:not-allowed;transform:none}.fx-btn-wrap{padding:0 2.5rem 1.5rem}.fx-standalone-btn{width:auto;padding:.75rem 2rem;margin-top:0}.fx-form-msg{font-size:.8125rem;padding:.5rem 0;min-height:1.5rem;text-align:center}.fx-form-err{color:#f87171}.fx-form-ok{color:#4ade80}.fx-table-wrap{overflow-x:auto;padding:0 2.5rem 4rem}.fx-table{width:100%;border-collapse:collapse;font-size:.875rem}.fx-th{text-align:left;padding:.875rem 1.25rem;font-size:.75rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em}.fx-th-actions{opacity:.6}.fx-tr{transition:background .1s}.fx-td{padding:.875rem 1.25rem}.fx-td-empty{padding:2rem 1.25rem;text-align:center;opacity:.4}.fx-td-actions{white-space:nowrap;padding:.5rem 1rem!important}.fx-action-btn{border:none;cursor:pointer;font-size:.75rem;font-weight:600;padding:.3rem .75rem;border-radius:.375rem;margin-right:.375rem;font-family:inherit;transition:opacity .15s}.fx-action-btn:hover{opacity:.85}.fx-edit-btn{background:#1e40af;color:#93c5fd}.fx-delete-btn{background:#7f1d1d;color:#fca5a5}.fx-select-wrap{padding:.5rem 2.5rem}.fx-select-block{width:auto;min-width:200px;margin-top:0}.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;letter-spacing:.05em}.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;transition:opacity .15s}.fx-pricing-cta:hover{opacity:.85}.fx-faq{max-width:48rem;margin:0 auto}.fx-faq-item{border-radius:.75rem;margin-bottom:.625rem;cursor:pointer;overflow:hidden;transition:background .15s}.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-fade-in{from{opacity:0}to{opacity:1}}@keyframes fx-slide-left{from{opacity:0;transform:translateX(30px)}to{opacity:1;transform:none}}@keyframes fx-slide-right{from{opacity:0;transform:translateX(-30px)}to{opacity:1;transform:none}}@keyframes fx-zoom-in{from{opacity:0;transform:scale(.95)}to{opacity:1;transform:scale(1)}}@keyframes fx-blur-in{from{opacity:0;filter:blur(8px)}to{opacity:1;filter:blur(0)}}.fx-anim-fade-up{animation:fx-fade-up .6s cubic-bezier(.4,0,.2,1) both}.fx-anim-fade-in{animation:fx-fade-in .6s ease both}.fx-anim-slide-left{animation:fx-slide-left .6s cubic-bezier(.4,0,.2,1) both}.fx-anim-slide-right{animation:fx-slide-right .6s cubic-bezier(.4,0,.2,1) both}.fx-anim-zoom-in{animation:fx-zoom-in .5s cubic-bezier(.4,0,.2,1) both}.fx-anim-blur-in{animation:fx-blur-in .7s ease both}.fx-anim-stagger>.fx-card:nth-child(1){animation:fx-fade-up .5s 0s both}.fx-anim-stagger>.fx-card:nth-child(2){animation:fx-fade-up .5s .1s both}.fx-anim-stagger>.fx-card:nth-child(3){animation:fx-fade-up .5s .2s both}.fx-anim-stagger>.fx-card:nth-child(4){animation:fx-fade-up .5s .3s both}.fx-anim-stagger>.fx-card:nth-child(5){animation:fx-fade-up .5s .4s both}.fx-anim-stagger>.fx-card:nth-child(6){animation:fx-fade-up .5s .5s both}`
1057
+ 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;letter-spacing:-.01em;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;letter-spacing:-.01em}.fx-btn:hover{transform:translateY(-1px)}.fx-btn:disabled{opacity:.5;cursor:not-allowed;transform:none}.fx-btn-wrap{padding:0 2.5rem 1.5rem}.fx-standalone-btn{width:auto;padding:.75rem 2rem;margin-top:0}.fx-form-msg{font-size:.8125rem;padding:.5rem 0;min-height:1.5rem;text-align:center}.fx-form-err{color:#f87171}.fx-form-ok{color:#4ade80}.fx-table-wrap{overflow-x:auto;padding:0 2.5rem 4rem}.fx-table{width:100%;border-collapse:collapse;font-size:.875rem}.fx-th{text-align:left;padding:.875rem 1.25rem;font-size:.75rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em}.fx-th-actions{opacity:.6}.fx-tr{transition:background .1s}.fx-td{padding:.875rem 1.25rem}.fx-td-empty{padding:2rem 1.25rem;text-align:center;opacity:.4}.fx-td-actions{white-space:nowrap;padding:.5rem 1rem!important}.fx-action-btn{border:none;cursor:pointer;font-size:.75rem;font-weight:600;padding:.3rem .75rem;border-radius:.375rem;margin-right:.375rem;font-family:inherit;transition:opacity .15s}.fx-action-btn:hover{opacity:.85}.fx-edit-btn{background:#1e40af;color:#93c5fd}.fx-delete-btn{background:#7f1d1d;color:#fca5a5}.fx-select-wrap{padding:.5rem 2.5rem}.fx-select-block{width:auto;min-width:200px;margin-top:0}.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;letter-spacing:.05em}.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;transition:opacity .15s}.fx-pricing-cta:hover{opacity:.85}.fx-faq{max-width:48rem;margin:0 auto}.fx-faq-item{border-radius:.75rem;margin-bottom:.625rem;cursor:pointer;overflow:hidden;transition:background .15s}.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}
1058
+ .fx-hero-minimal{min-height:50vh!important}
1059
+ .fx-hero-minimal .fx-hero-inner{gap:1rem}
1060
+ .fx-hero-tall{min-height:98vh!important}
1061
+ .fx-hero-left .fx-hero-inner{text-align:left;align-items:flex-start;max-width:none;padding-left:2.5rem}
1062
+ .fx-hero-left{justify-content:flex-start}
1063
+ .fx-hero-dark-cta .fx-hero-ctas-dark{display:flex;gap:.75rem;flex-wrap:wrap;justify-content:center;padding:.75rem;background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.08);border-radius:.875rem;margin-top:.5rem}
1064
+ .fx-spacer{width:100%}
1065
+ .fx-divider{display:flex;align-items:center;gap:1rem;padding:2rem 2.5rem;opacity:.4}
1066
+ .fx-divider::before,.fx-divider::after{content:'';flex:1;height:1px;background:currentColor}
1067
+ .fx-divider-label{font-size:.75rem;font-family:monospace;white-space:nowrap;letter-spacing:.08em;text-transform:uppercase}
1068
+ .fx-hr{border:none;border-top:1px solid rgba(255,255,255,.08);margin:1.5rem 2.5rem}
1069
+ .fx-badge-row{padding:.5rem 2.5rem;display:flex;flex-wrap:wrap;gap:.5rem}
1070
+ .fx-badge-tag{display:inline-block;font-size:.75rem;font-weight:600;padding:.3rem .875rem;border-radius:999px;background:rgba(37,99,235,.12);border:1px solid rgba(37,99,235,.25);color:#60a5fa;letter-spacing:.03em}
1071
+ .fx-cols{display:grid;gap:1.5rem;padding:1rem 2.5rem}
1072
+ .fx-cols-2{grid-template-columns:1fr 1fr}
1073
+ .fx-cols-3{grid-template-columns:1fr 1fr 1fr}
1074
+ .fx-cols-4{grid-template-columns:repeat(4,1fr)}
1075
+ @media(max-width:640px){.fx-cols-2,.fx-cols-3,.fx-cols-4{grid-template-columns:1fr}}
1076
+ .fx-col{min-width:0}
1077
+ .fx-each{padding:.5rem 2.5rem}
1078
+ .fx-each-list{display:flex;flex-direction:column;gap:.5rem}
1079
+ .fx-each-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:.75rem}
1080
+ .fx-html{padding:.5rem 2.5rem}
1081
+ .fx-card-badge{display:inline-block;font-size:.65rem;font-weight:700;padding:.2rem .6rem;border-radius:999px;background:rgba(37,99,235,.15);color:#93c5fd;margin-bottom:.5rem;letter-spacing:.04em}
1082
+ .fx-form-inline{padding:.75rem 2.5rem}.fx-form-inline-form{display:flex;align-items:flex-end;gap:.75rem;flex-wrap:wrap;background:none;border:none;padding:0;max-width:none}.fx-form-inline-form .fx-field{flex:1;min-width:160px;margin-bottom:0}.fx-btn-inline{width:auto;margin-top:0;flex-shrink:0}
1083
+ .fx-form-minimal{padding:.5rem 2.5rem;max-width:24rem}.fx-form-minimal form{background:none;border:none;padding:0}
1084
+ .fx-sect-accent{background:rgba(37,99,235,.06);border-left:3px solid #2563eb;padding-left:2rem}
1085
+ .fx-sect-dark{background:rgba(0,0,0,.4)}
1086
+ .fx-sect-full{padding:6rem 2.5rem}
1087
+ .fx-pricing-compact{border-radius:.875rem;padding:1.25rem;display:flex;align-items:center;gap:1rem;border:1px solid rgba(255,255,255,.08)}
1088
+ .fx-pricing-price-sm{font-size:1.5rem;font-weight:800;letter-spacing:-.04em}
1089
+ .fx-grid-numbered>.fx-card{counter-increment:card-counter}
1090
+ .fx-grid-numbered>.fx-card::before{content:counter(card-counter,decimal-leading-zero);font-size:2rem;font-weight:900;opacity:.15;font-family:monospace;line-height:1}
1091
+ .fx-grid-bordered>.fx-card{border:1px solid rgba(255,255,255,.08)}@keyframes fx-fade-up{from{opacity:0;transform:translateY(20px)}to{opacity:1;transform:none}}@keyframes fx-fade-in{from{opacity:0}to{opacity:1}}@keyframes fx-slide-left{from{opacity:0;transform:translateX(30px)}to{opacity:1;transform:none}}@keyframes fx-slide-right{from{opacity:0;transform:translateX(-30px)}to{opacity:1;transform:none}}@keyframes fx-zoom-in{from{opacity:0;transform:scale(.95)}to{opacity:1;transform:scale(1)}}@keyframes fx-blur-in{from{opacity:0;filter:blur(8px)}to{opacity:1;filter:blur(0)}}.fx-anim-fade-up{animation:fx-fade-up .6s cubic-bezier(.4,0,.2,1) both}.fx-anim-fade-in{animation:fx-fade-in .6s ease both}.fx-anim-slide-left{animation:fx-slide-left .6s cubic-bezier(.4,0,.2,1) both}.fx-anim-slide-right{animation:fx-slide-right .6s cubic-bezier(.4,0,.2,1) both}.fx-anim-zoom-in{animation:fx-zoom-in .5s cubic-bezier(.4,0,.2,1) both}.fx-anim-blur-in{animation:fx-blur-in .7s ease both}.fx-anim-stagger>.fx-card:nth-child(1){animation:fx-fade-up .5s 0s both}.fx-anim-stagger>.fx-card:nth-child(2){animation:fx-fade-up .5s .1s both}.fx-anim-stagger>.fx-card:nth-child(3){animation:fx-fade-up .5s .2s both}.fx-anim-stagger>.fx-card:nth-child(4){animation:fx-fade-up .5s .3s both}.fx-anim-stagger>.fx-card:nth-child(5){animation:fx-fade-up .5s .4s both}.fx-anim-stagger>.fx-card:nth-child(6){animation:fx-fade-up .5s .5s both}`
932
1092
 
933
1093
  const T={
934
1094
  dark: `body{background:#030712;color:#f1f5f9}.fx-nav{border-bottom:1px solid #1e293b;background:rgba(3,7,18,.85)}.fx-nav-link{color:#cbd5e1}.fx-sub{color:#94a3b8}.fx-cta{background:#2563eb;color:#fff;box-shadow:0 8px 24px rgba(37,99,235,.35)}.fx-stat-lbl{color:#64748b}.fx-card{background:#0f172a;border:1px solid #1e293b}.fx-card:hover{box-shadow:0 20px 40px rgba(0,0,0,.5)}.fx-card-body{color:#64748b}.fx-sect-body{color:#64748b}.fx-form{background:#0f172a;border:1px solid #1e293b}.fx-label{color:#94a3b8}.fx-input{background:#020617;border:1px solid #1e293b;color:#f1f5f9}.fx-input::placeholder{color:#334155}.fx-btn{background:#2563eb;color:#fff;box-shadow:0 4px 14px rgba(37,99,235,.4)}.fx-th{color:#475569;border-bottom:1px solid #1e293b}.fx-tr:hover{background:#0f172a}.fx-td{border-bottom:1px solid rgba(255,255,255,.03)}.fx-footer{border-top:1px solid #1e293b}.fx-footer-text{color:#334155}.fx-pricing-card{background:#0f172a;border:1px solid #1e293b}.fx-faq-item{background:#0f172a}.fx-faq-item:hover{background:#111827}`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aiplang",
3
- "version": "2.7.4",
3
+ "version": "2.9.1",
4
4
  "description": "AI-first web language. One .aip file = complete app. Frontend + backend + database + auth.",
5
5
  "keywords": [
6
6
  "aiplang",
@@ -43,7 +43,9 @@
43
43
  "bcryptjs": "^2.4.3",
44
44
  "jsonwebtoken": "^9.0.2",
45
45
  "nodemailer": "^8.0.3",
46
+ "pg": "^8.11.0",
46
47
  "sql.js": "^1.10.3",
47
- "stripe": "^14.0.0"
48
+ "stripe": "^14.0.0",
49
+ "ws": "^8.16.0"
48
50
  }
49
51
  }
package/server/server.js CHANGED
@@ -1,5 +1,21 @@
1
1
  'use strict'
2
- // aiplang Full-Stack Server v2 — Laravel-competitive
2
+ // aiplang Full-Stack Server v2.9Next.js competitive
3
+
4
+ // ── Auto-load .env file ───────────────────────────────────────────
5
+ ;(function loadDotEnv() {
6
+ const envFiles = ['.env', '.env.local', '.env.production']
7
+ for (const f of envFiles) {
8
+ try {
9
+ const lines = require('fs').readFileSync(f, 'utf8').split('\n')
10
+ for (const line of lines) {
11
+ const m = line.match(/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*)\s*$/)
12
+ if (m && !process.env[m[1]]) {
13
+ process.env[m[1]] = m[2].replace(/^["']|["']$/g, '')
14
+ }
15
+ }
16
+ } catch {}
17
+ }
18
+ })()
3
19
  // Features: ORM+relations, email, jobs/queues, admin panel, OAuth, soft deletes, events
4
20
 
5
21
  const http = require('http')
@@ -11,26 +27,60 @@ const bcrypt = require('bcryptjs')
11
27
  const jwt = require('jsonwebtoken')
12
28
  const nodemailer = require('nodemailer').createTransport ? require('nodemailer') : null
13
29
 
14
- // ── SQL.js (pure JS SQLite) ───────────────────────────────────────
30
+ // ── Database — SQLite (dev) + PostgreSQL (prod) ──────────────────
15
31
  let SQL, DB_FILE, _db = null
16
- async function getDB(dbFile = ':memory:') {
17
- if (_db) return _db
32
+ let _pgPool = null // PostgreSQL connection pool
33
+ let _dbDriver = 'sqlite' // 'sqlite' | 'postgres'
34
+
35
+ async function getDB(dbConfig = { driver: 'sqlite', dsn: ':memory:' }) {
36
+ if (_db || _pgPool) return _db || _pgPool
37
+ const driver = dbConfig.driver || 'sqlite'
38
+ const dsn = dbConfig.dsn || ':memory:'
39
+ _dbDriver = driver
40
+
41
+ if (driver === 'postgres' || driver === 'postgresql' || dsn.startsWith('postgres')) {
42
+ try {
43
+ const { Pool } = require('pg')
44
+ _pgPool = new Pool({ connectionString: dsn, ssl: dsn.includes('ssl=true') ? { rejectUnauthorized: false } : false })
45
+ await _pgPool.query('SELECT 1') // test connection
46
+ console.log('[aiplang] DB: PostgreSQL ✓')
47
+ return _pgPool
48
+ } catch (e) {
49
+ console.error('[aiplang] PostgreSQL connection failed:', e.message)
50
+ console.log('[aiplang] Falling back to SQLite :memory:')
51
+ _dbDriver = 'sqlite'
52
+ }
53
+ }
54
+
55
+ // SQLite fallback
18
56
  const initSqlJs = require('sql.js')
19
57
  SQL = await initSqlJs()
20
- if (dbFile !== ':memory:' && fs.existsSync(dbFile)) {
21
- _db = new SQL.Database(fs.readFileSync(dbFile))
58
+ if (dsn !== ':memory:' && fs.existsSync(dsn)) {
59
+ _db = new SQL.Database(fs.readFileSync(dsn))
22
60
  } else {
23
61
  _db = new SQL.Database()
24
62
  }
25
- DB_FILE = dbFile
63
+ DB_FILE = dsn !== ':memory:' ? dsn : null
64
+ console.log('[aiplang] DB: ', dsn)
26
65
  return _db
27
66
  }
67
+
28
68
  function persistDB() {
29
- if (!_db || !DB_FILE || DB_FILE === ':memory:') return
69
+ if (!_db || !DB_FILE) return
30
70
  try { fs.writeFileSync(DB_FILE, Buffer.from(_db.export())) } catch {}
31
71
  }
32
72
  let _dirty = false, _persistTimer = null
73
+
33
74
  function dbRun(sql, params = []) {
75
+ // Normalize ? placeholders to $1,$2 for postgres
76
+ if (_pgPool) {
77
+ const pgSql = sql.replace(/\?/g, (_, i) => {
78
+ let n = 0; sql.slice(0, sql.indexOf(_)+n).replace(/\?/g, () => ++n); return `$${++n}`
79
+ })
80
+ // Async run — fire and forget for writes (sync API compatibility)
81
+ _pgPool.query(convertPlaceholders(sql), params).catch(e => console.error('[aiplang:pg] Query error:', e.message))
82
+ return
83
+ }
34
84
  _db.run(sql, params)
35
85
  _dirty = true
36
86
  if (!_persistTimer) _persistTimer = setTimeout(() => {
@@ -38,12 +88,40 @@ function dbRun(sql, params = []) {
38
88
  _persistTimer = null
39
89
  }, 200)
40
90
  }
91
+
92
+ function convertPlaceholders(sql) {
93
+ let i = 0; return sql.replace(/\?/g, () => `$${++i}`)
94
+ }
95
+
96
+ async function dbRunAsync(sql, params = []) {
97
+ if (_pgPool) return _pgPool.query(convertPlaceholders(sql), params)
98
+ dbRun(sql, params)
99
+ }
100
+
41
101
  function dbAll(sql, params = []) {
102
+ if (_pgPool) {
103
+ // For sync ORM compat — return from cache or throw
104
+ // Full async support via dbAllAsync
105
+ return []
106
+ }
42
107
  const stmt = _db.prepare(sql); stmt.bind(params)
43
108
  const rows = []; while (stmt.step()) rows.push(stmt.getAsObject()); stmt.free()
44
109
  return rows
45
110
  }
111
+
112
+ async function dbAllAsync(sql, params = []) {
113
+ if (_pgPool) {
114
+ const r = await _pgPool.query(convertPlaceholders(sql), params)
115
+ return r.rows
116
+ }
117
+ return dbAll(sql, params)
118
+ }
119
+
46
120
  function dbGet(sql, params = []) { return dbAll(sql, params)[0] || null }
121
+ async function dbGetAsync(sql, params = []) {
122
+ const rows = await dbAllAsync(sql, params)
123
+ return rows[0] || null
124
+ }
47
125
 
48
126
  // ── Helpers ───────────────────────────────────────────────────────
49
127
  const uuid = () => crypto.randomUUID()
@@ -65,6 +143,50 @@ let JWT_EXPIRE = '7d'
65
143
  const generateJWT = (user) => jwt.sign({ id: user.id, email: user.email, role: user.role || 'user' }, JWT_SECRET, { expiresIn: JWT_EXPIRE })
66
144
  const verifyJWT = (token) => { try { return jwt.verify(token, JWT_SECRET) } catch { return null } }
67
145
 
146
+ // ── WebSocket Realtime Server ─────────────────────────────────────
147
+ let _wsServer = null
148
+ const _wsClients = new Set()
149
+ const _wsChannels = {} // channel → Set<ws>
150
+
151
+ function setupRealtime(server) {
152
+ try {
153
+ const { WebSocketServer } = require('ws')
154
+ _wsServer = new WebSocketServer({ server })
155
+ _wsServer.on('connection', (ws, req) => {
156
+ _wsClients.add(ws)
157
+ ws.on('message', raw => {
158
+ try {
159
+ const msg = JSON.parse(raw)
160
+ if (msg.type === 'subscribe' && msg.channel) {
161
+ if (!_wsChannels[msg.channel]) _wsChannels[msg.channel] = new Set()
162
+ _wsChannels[msg.channel].add(ws)
163
+ ws.send(JSON.stringify({ type: 'subscribed', channel: msg.channel }))
164
+ }
165
+ } catch {}
166
+ })
167
+ ws.on('close', () => {
168
+ _wsClients.delete(ws)
169
+ Object.values(_wsChannels).forEach(s => s.delete(ws))
170
+ })
171
+ ws.send(JSON.stringify({ type: 'connected', ts: Date.now() }))
172
+ })
173
+ console.log('[aiplang] Realtime: WebSocket server ready')
174
+ } catch (e) {
175
+ console.warn('[aiplang] Realtime: ws not available —', e.message)
176
+ }
177
+ }
178
+
179
+ function broadcast(channel, data) {
180
+ const msg = JSON.stringify({ type: 'update', channel, data, ts: Date.now() })
181
+ const targets = channel ? (_wsChannels[channel] || new Set()) : _wsClients
182
+ targets.forEach(ws => { try { if (ws.readyState === 1) ws.send(msg) } catch {} })
183
+ }
184
+
185
+ function realtimeMiddleware(res) {
186
+ // Inject broadcast helper into route context
187
+ res.broadcast = broadcast
188
+ }
189
+
68
190
  // ── Queue system ──────────────────────────────────────────────────
69
191
  const QUEUE = []
70
192
  const WORKERS = {}
@@ -90,6 +212,28 @@ async function processQueue() {
90
212
  QUEUE_RUNNING = false
91
213
  }
92
214
 
215
+ // ── Cache in-memory com TTL ──────────────────────────────────────
216
+ const _cache = new Map()
217
+ function cacheSet(key, value, ttlMs = 60000) {
218
+ _cache.set(key, { value, expires: Date.now() + ttlMs })
219
+ }
220
+ function cacheGet(key) {
221
+ const item = _cache.get(key)
222
+ if (!item) return null
223
+ if (item.expires < Date.now()) { _cache.delete(key); return null }
224
+ return item.value
225
+ }
226
+ function cacheDel(key) { _cache.delete(key) }
227
+ function cacheClear(pattern) {
228
+ if (!pattern) { _cache.clear(); return }
229
+ for (const k of _cache.keys()) if (k.startsWith(pattern)) _cache.delete(k)
230
+ }
231
+ // Auto-cleanup expired entries every 5 minutes
232
+ setInterval(() => {
233
+ const now = Date.now()
234
+ for (const [k,v] of _cache.entries()) if (v.expires < now) _cache.delete(k)
235
+ }, 300000)
236
+
93
237
  // ── Email ─────────────────────────────────────────────────────────
94
238
  let MAIL_CONFIG = null
95
239
  let MAIL_TRANSPORTER = null
@@ -313,7 +457,7 @@ function migrateModels(models) {
313
457
  // PARSER
314
458
  // ═══════════════════════════════════════════════════════════════════
315
459
  function parseApp(src) {
316
- const app = { env:[], db:null, auth:null, mail:null, stripe:null, s3:null, plugins:[], middleware:[], models:[], apis:[], pages:[], jobs:[], events:[], admin:null }
460
+ const app = { env:[], db:null, auth:null, mail:null, stripe:null, s3:null, plugins:[], middleware:[], models:[], apis:[], pages:[], jobs:[], events:[], admin:null, realtime:false }
317
461
  const lines = src.split('\n').map(l=>l.trim()).filter(l=>l&&!l.startsWith('#'))
318
462
  let i=0, inModel=false, inAPI=false, curModel=null, curAPI=null, pageLines=[], inPage=false
319
463
 
@@ -332,6 +476,7 @@ function parseApp(src) {
332
476
  if (line.startsWith('~mail ')) { app.mail = parseMailLine(line.slice(6)); i++; continue }
333
477
  if (line.startsWith('~middleware ')) { app.middleware = line.slice(12).split('|').map(s=>s.trim()); i++; continue }
334
478
  if (line.startsWith('~admin')) { app.admin = parseAdminLine(line); i++; continue }
479
+ if (line.startsWith('~realtime')) { app.realtime = true; i++; continue }
335
480
  if (line.startsWith('~stripe ')) { app.stripe = parseStripeLine(line.slice(8)); i++; continue }
336
481
  if (line.startsWith('~plan ')) { app.stripe = app.stripe || {}; app.stripe.plans = app.stripe.plans || {}; parsePlanLine(line.slice(6), app.stripe.plans); i++; continue }
337
482
  if (line.startsWith('~s3 ')) { app.s3 = parseS3Line(line.slice(4)); i++; continue }
@@ -504,6 +649,10 @@ function compileRoute(route, server) {
504
649
  if (result !== null && result !== undefined) ctx.lastResult = result
505
650
  }
506
651
 
652
+ // Auto-cache result if ~cache was called
653
+ if (ctx.vars['__cacheKey'] && ctx.lastResult !== undefined) {
654
+ cacheSet(ctx.vars['__cacheKey'], ctx.lastResult, ctx.vars['__cacheTTL'] || 60000)
655
+ }
507
656
  if (!res.writableEnded) res.json(200, ctx.lastResult ?? {})
508
657
  })
509
658
  }
@@ -511,6 +660,39 @@ function compileRoute(route, server) {
511
660
  async function execOp(line, ctx, server) {
512
661
  line = line.trim(); if (!line) return null
513
662
 
663
+ // ~cache key ttl — cache result
664
+ if (line.startsWith('~cache ')) {
665
+ const parts=line.slice(7).trim().split(/\s+/)
666
+ const key=parts[0], ttl=parseInt(parts[1]||'60')*1000
667
+ const cached=cacheGet(key)
668
+ if (cached!==null) { ctx.res.json(200,cached); return '__DONE__' }
669
+ ctx.vars['__cacheKey']=key; ctx.vars['__cacheTTL']=ttl
670
+ return null
671
+ }
672
+
673
+ // ~cache:clear pattern — invalidate cache
674
+ if (line.startsWith('~cache:clear')) {
675
+ const pattern=line.slice(12).trim()||null; cacheClear(pattern); return null
676
+ }
677
+
678
+ // ~broadcast channel data — push to WebSocket clients
679
+ if (line.startsWith('~broadcast ')) {
680
+ const parts=line.slice(11).trim().split(/\s+/)
681
+ const channel=parts[0]; const data=resolveVar(parts.slice(1).join(' '),ctx)
682
+ broadcast(channel,data); return null
683
+ }
684
+
685
+ // ~rateLimit key max window — custom rate limiter per key
686
+ if (line.startsWith('~rateLimit ') || line.startsWith('~rate-limit ')) {
687
+ const parts=line.slice(line.indexOf(' ')+1).trim().split(/\s+/)
688
+ const key=resolveVar(parts[0],ctx)||'default'
689
+ const max=parseInt(parts[1]||'10'), win=parseInt(parts[2]||'60')*1000
690
+ const cKey=`rl:${key}:${Math.floor(Date.now()/win)}`
691
+ const count=(cacheGet(cKey)||0)+1; cacheSet(cKey,count,win)
692
+ if(count>max){ctx.res.error(429,'Rate limit exceeded');return '__DONE__'}
693
+ return null
694
+ }
695
+
514
696
  // ~hash field
515
697
  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 }
516
698
 
@@ -563,14 +745,14 @@ async function execOp(line, ctx, server) {
563
745
  // insert Model($body)
564
746
  if (line.startsWith('insert ')) {
565
747
  const modelName=line.match(/insert\s+(\w+)/)?.[1]; const m=server.models[modelName]
566
- if (m) { ctx.vars['inserted']=m.create({...ctx.body}); return ctx.vars['inserted'] }
748
+ if (m) { ctx.vars['inserted']=m.create({...ctx.body}); broadcast(modelName.toLowerCase(), {action:'created',data:ctx.vars['inserted']}); return ctx.vars['inserted'] }
567
749
  return null
568
750
  }
569
751
 
570
752
  // update Model($id, $body)
571
753
  if (line.startsWith('update ')) {
572
754
  const modelName=line.match(/update\s+(\w+)/)?.[1]; const m=server.models[modelName]
573
- if (m) { const id=ctx.params.id||ctx.vars['id']; ctx.vars['updated']=m.update(id,{...ctx.body}); return ctx.vars['updated'] }
755
+ if (m) { const id=ctx.params.id||ctx.vars['id']; ctx.vars['updated']=m.update(id,{...ctx.body}); broadcast(modelName.toLowerCase(), {action:'updated',data:ctx.vars['updated']}); return ctx.vars['updated'] }
574
756
  return null
575
757
  }
576
758
 
@@ -1221,7 +1403,7 @@ function getMime(filename) {
1221
1403
  // module.exports = (opts) => ({ name: '...', setup(srv, app, utils) { ... } })
1222
1404
 
1223
1405
  const PLUGIN_UTILS = {
1224
- uuid, now, emit, on, dispatch, resolveEnv, dbRun, dbAll, dbGet,
1406
+ uuid, now, emit, on, dispatch, resolveEnv, dbRun, dbAll, dbGet, dbAllAsync, dbGetAsync,
1225
1407
  parseSize, s3Upload, s3Delete, s3PresignedUrl,
1226
1408
  generateJWT, verifyJWT,
1227
1409
  getMime,
@@ -1368,9 +1550,10 @@ async function startServer(aipFile, port = 3000) {
1368
1550
  }
1369
1551
 
1370
1552
  // DB setup
1371
- const dbFile = app.db ? resolveEnv(app.db.dsn) : ':memory:'
1372
- await getDB(dbFile)
1373
- console.log(`[aiplang] DB: ${dbFile}`)
1553
+ const dbDsn = app.db ? (resolveEnv(app.db.dsn) || app.db.dsn) : ':memory:'
1554
+ const dbConfig = { driver: app.db?.driver || 'sqlite', dsn: dbDsn }
1555
+ await getDB(dbConfig)
1556
+ console.log(`[aiplang] DB: ${dbDsn}`)
1374
1557
 
1375
1558
  // Migrations
1376
1559
  console.log(`[aiplang] Tables:`)
@@ -1435,7 +1618,7 @@ async function startServer(aipFile, port = 3000) {
1435
1618
 
1436
1619
  // Health
1437
1620
  srv.addRoute('GET', '/health', (req, res) => res.json(200, {
1438
- status:'ok', version:'2.7.4',
1621
+ status:'ok', version:'2.9.1',
1439
1622
  models: app.models.map(m=>m.name),
1440
1623
  routes: app.apis.length, pages: app.pages.length,
1441
1624
  admin: app.admin?.prefix || null,
@@ -1459,7 +1642,7 @@ async function startServer(aipFile, port = 3000) {
1459
1642
  return srv
1460
1643
  }
1461
1644
 
1462
- module.exports = { startServer, parseApp, Model, getDB, dispatch, on, emit, sendMail, setupStripe, registerStripeRoutes, setupS3, registerS3Routes, s3Upload, s3Delete, s3PresignedUrl, PLUGIN_UTILS }
1645
+ module.exports = { startServer, parseApp, Model, getDB, dispatch, on, emit, sendMail, setupStripe, registerStripeRoutes, setupS3, registerS3Routes, s3Upload, s3Delete, s3PresignedUrl, cacheSet, cacheGet, cacheDel, broadcast, PLUGIN_UTILS }
1463
1646
  if (require.main === module) {
1464
1647
  const f=process.argv[2], p=parseInt(process.argv[3]||process.env.PORT||'3000')
1465
1648
  if (!f) { console.error('Usage: node server.js <app.aip> [port]'); process.exit(1) }