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 +63 -22
- package/aiplang-knowledge.md +32 -0
- package/bin/aiplang.js +179 -19
- package/package.json +4 -2
- package/server/server.js +200 -17
package/README.md
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
# aiplang
|
|
2
2
|
|
|
3
|
-
AI-first web language. One `.aip` file = complete app
|
|
3
|
+
> AI-first web language. One `.aip` file = complete app. Built for LLMs, not humans.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
[](https://github.com/isacamartin/aiplang/actions/workflows/tests.yml)
|
|
6
|
+
[](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
|
-
|
|
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
|
|
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
|
-
##
|
|
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
|
-
- **
|
|
57
|
-
- **
|
|
58
|
-
- **
|
|
59
|
-
- **
|
|
60
|
-
- **
|
|
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
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
-
|
|
70
|
-
-
|
|
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
|
|
package/aiplang-knowledge.md
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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||'
|
|
866
|
-
|
|
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='
|
|
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">
|
|
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}
|
|
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.
|
|
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 —
|
|
2
|
+
// aiplang Full-Stack Server v2.9 — Next.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
|
-
// ──
|
|
30
|
+
// ── Database — SQLite (dev) + PostgreSQL (prod) ──────────────────
|
|
15
31
|
let SQL, DB_FILE, _db = null
|
|
16
|
-
|
|
17
|
-
|
|
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 (
|
|
21
|
-
_db = new SQL.Database(fs.readFileSync(
|
|
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 =
|
|
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
|
|
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
|
|
1372
|
-
|
|
1373
|
-
|
|
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.
|
|
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) }
|