aiplang 2.10.8 → 2.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/aiplang.js 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.10.8'
8
+ const VERSION = '2.11.0'
9
9
  const RUNTIME_DIR = path.join(__dirname, '..', 'runtime')
10
10
  const cmd = process.argv[2]
11
11
  const args = process.argv.slice(3)
@@ -24,10 +24,6 @@ const ic = n => ICONS[n] || n
24
24
  const isDyn = s => s&&(s.includes('@')||s.includes('$'))
25
25
  const hSize = n => n<1024?`${n}B`:`${(n/1024).toFixed(1)}KB`
26
26
 
27
- // ─────────────────────────────────────────────────────────────────
28
- // CLI COMMANDS
29
- // ─────────────────────────────────────────────────────────────────
30
-
31
27
  if (!cmd||cmd==='--help'||cmd==='-h') {
32
28
  console.log(`
33
29
  aiplang v${VERSION}
@@ -40,6 +36,8 @@ if (!cmd||cmd==='--help'||cmd==='-h') {
40
36
  npx aiplang init [name] --template my-custom use a saved custom template
41
37
  npx aiplang serve [dir] dev server + hot reload
42
38
  npx aiplang build [dir/file] compile → static HTML
39
+ npx aiplang validate <app.aip> validate syntax with AI-friendly errors
40
+ npx aiplang context [app.aip] dump minimal AI context (<500 tokens)
43
41
  npx aiplang new <page> new page template
44
42
  npx aiplang --version
45
43
 
@@ -74,18 +72,12 @@ if (!cmd||cmd==='--help'||cmd==='-h') {
74
72
  }
75
73
  if (cmd==='--version'||cmd==='-v') { console.log(`aiplang v${VERSION}`); process.exit(0) }
76
74
 
77
- // ─────────────────────────────────────────────────────────────────
78
- // TEMPLATE SYSTEM
79
- // Custom templates stored at ~/.aip/templates/<name>.aip
80
- // ─────────────────────────────────────────────────────────────────
81
-
82
75
  const TEMPLATES_DIR = path.join(require('os').homedir(), '.aip', 'templates')
83
76
 
84
77
  function ensureTemplatesDir() {
85
78
  if (!fs.existsSync(TEMPLATES_DIR)) fs.mkdirSync(TEMPLATES_DIR, { recursive: true })
86
79
  }
87
80
 
88
- // Built-in templates (interpolate {{name}} and {{year}})
89
81
  const BUILTIN_TEMPLATES = {
90
82
  saas: `# {{name}}
91
83
  ~db sqlite ./app.db
@@ -255,24 +247,20 @@ function applyTemplateVars(src, name, year) {
255
247
  function getTemplate(tplName, name, year) {
256
248
  ensureTemplatesDir()
257
249
 
258
- // 1. Local file path: --template ./my-template.aip or --template /abs/path.aip
259
250
  if (tplName.startsWith('./') || tplName.startsWith('../') || tplName.startsWith('/')) {
260
251
  const full = path.resolve(tplName)
261
252
  if (!fs.existsSync(full)) { console.error(`\n ✗ Template file not found: ${full}\n`); process.exit(1) }
262
253
  return applyTemplateVars(fs.readFileSync(full, 'utf8'), name, year)
263
254
  }
264
255
 
265
- // 2. User custom template: ~/.aip/templates/<name>.aip
266
256
  const customPath = path.join(TEMPLATES_DIR, tplName + '.aip')
267
257
  if (fs.existsSync(customPath)) {
268
258
  return applyTemplateVars(fs.readFileSync(customPath, 'utf8'), name, year)
269
259
  }
270
260
 
271
- // 3. Built-in template
272
261
  const builtin = BUILTIN_TEMPLATES[tplName]
273
262
  if (builtin) return applyTemplateVars(builtin, name, year)
274
263
 
275
- // Not found — show what's available
276
264
  const customs = fs.existsSync(TEMPLATES_DIR)
277
265
  ? fs.readdirSync(TEMPLATES_DIR).filter(f=>f.endsWith('.aip')).map(f=>f.replace('.aip',''))
278
266
  : []
@@ -297,17 +285,14 @@ function listTemplates() {
297
285
  console.log()
298
286
  }
299
287
 
300
- // ── template subcommand ──────────────────────────────────────────
301
288
  if (cmd === 'template') {
302
289
  const sub = args[0]
303
290
  ensureTemplatesDir()
304
291
 
305
- // aiplang template list
306
292
  if (!sub || sub === 'list' || sub === 'ls') {
307
293
  listTemplates(); process.exit(0)
308
294
  }
309
295
 
310
- // aiplang template save <name> [--from <file>]
311
296
  if (sub === 'save' || sub === 'add') {
312
297
  const tname = args[1]
313
298
  if (!tname) { console.error('\n ✗ Usage: aiplang template save <name> [--from <file>]\n'); process.exit(1) }
@@ -318,7 +303,7 @@ if (cmd === 'template') {
318
303
  if (!fs.existsSync(fp)) { console.error(`\n ✗ File not found: ${fp}\n`); process.exit(1) }
319
304
  src = fs.readFileSync(fp, 'utf8')
320
305
  } else {
321
- // Auto-detect: use pages/ directory or app.aip
306
+
322
307
  const sources = ['pages', 'app.aip', 'index.aip']
323
308
  const found = sources.find(s => fs.existsSync(s))
324
309
  if (!found) { console.error('\n ✗ No .aip files found. Use --from <file> to specify source.\n'); process.exit(1) }
@@ -335,7 +320,6 @@ if (cmd === 'template') {
335
320
  process.exit(0)
336
321
  }
337
322
 
338
- // aiplang template remove <name>
339
323
  if (sub === 'remove' || sub === 'rm' || sub === 'delete') {
340
324
  const tname = args[1]
341
325
  if (!tname) { console.error('\n ✗ Usage: aiplang template remove <name>\n'); process.exit(1) }
@@ -345,13 +329,12 @@ if (cmd === 'template') {
345
329
  console.log(`\n ✓ Removed template: ${tname}\n`); process.exit(0)
346
330
  }
347
331
 
348
- // aiplang template edit <name>
349
332
  if (sub === 'edit' || sub === 'open') {
350
333
  const tname = args[1]
351
334
  if (!tname) { console.error('\n ✗ Usage: aiplang template edit <name>\n'); process.exit(1) }
352
335
  let dest = path.join(TEMPLATES_DIR, tname + '.aip')
353
336
  if (!fs.existsSync(dest)) {
354
- // create from built-in if exists
337
+
355
338
  const builtin = BUILTIN_TEMPLATES[tname]
356
339
  if (builtin) { fs.writeFileSync(dest, builtin); console.log(`\n ✓ Copied built-in "${tname}" to custom templates.\n`) }
357
340
  else { console.error(`\n ✗ Template "${tname}" not found.\n`); process.exit(1) }
@@ -362,7 +345,6 @@ if (cmd === 'template') {
362
345
  process.exit(0)
363
346
  }
364
347
 
365
- // aiplang template show <name>
366
348
  if (sub === 'show' || sub === 'cat') {
367
349
  const tname = args[1] || 'default'
368
350
  const customPath = path.join(TEMPLATES_DIR, tname + '.aip')
@@ -372,7 +354,6 @@ if (cmd === 'template') {
372
354
  console.error(`\n ✗ Template "${tname}" not found.\n`); process.exit(1)
373
355
  }
374
356
 
375
- // aiplang template export <name> [--out <file>]
376
357
  if (sub === 'export') {
377
358
  const tname = args[1]
378
359
  if (!tname) { console.error('\n ✗ Usage: aiplang template export <name>\n'); process.exit(1) }
@@ -390,7 +371,6 @@ if (cmd === 'template') {
390
371
  process.exit(1)
391
372
  }
392
373
 
393
- // ── Init ─────────────────────────────────────────────────────────
394
374
  if (cmd==='init') {
395
375
  const tplIdx = args.indexOf('--template')
396
376
  const tplName = tplIdx !== -1 ? args[tplIdx+1] : 'default'
@@ -399,15 +379,13 @@ if (cmd==='init') {
399
379
 
400
380
  if (fs.existsSync(dir)) { console.error(`\n ✗ Directory "${name}" already exists.\n`); process.exit(1) }
401
381
 
402
- // Get template source (built-in, custom, or file path)
403
382
  const tplSrc = getTemplate(tplName, name, year)
404
383
 
405
- // Check if template has full-stack backend (models/api blocks)
406
384
  const isFullStack = tplSrc.includes('\nmodel ') || tplSrc.includes('\napi ')
407
385
  const isMultiFile = tplSrc.includes('\n---\n')
408
386
 
409
387
  if (isFullStack) {
410
- // Full-stack project: single app.aip
388
+
411
389
  fs.mkdirSync(dir, { recursive: true })
412
390
  fs.writeFileSync(path.join(dir, 'app.aip'), tplSrc)
413
391
  fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({
@@ -421,7 +399,7 @@ if (cmd==='init') {
421
399
  const label = tplName !== 'default' ? ` (template: ${tplName})` : ''
422
400
  console.log(`\n ✓ Created ${name}/${label}\n\n app.aip ← full-stack app (backend + frontend)\n\n Next:\n cd ${name} && npx aiplang start app.aip\n`)
423
401
  } else if (isMultiFile) {
424
- // Multi-page SSG project: pages/*.aip
402
+
425
403
  fs.mkdirSync(path.join(dir,'pages'), {recursive:true})
426
404
  fs.mkdirSync(path.join(dir,'public'), {recursive:true})
427
405
  for (const f of ['aiplang-runtime.js','aiplang-hydrate.js']) {
@@ -439,7 +417,7 @@ if (cmd==='init') {
439
417
  const files = fs.readdirSync(path.join(dir,'pages')).map(f=>f).join(', ')
440
418
  console.log(`\n ✓ Created ${name}/${label}\n\n pages/{${files}} ← edit these\n\n Next:\n cd ${name} && npx aiplang serve\n`)
441
419
  } else {
442
- // Single-page SSG project
420
+
443
421
  fs.mkdirSync(path.join(dir,'pages'), {recursive:true})
444
422
  fs.mkdirSync(path.join(dir,'public'), {recursive:true})
445
423
  for (const f of ['aiplang-runtime.js','aiplang-hydrate.js']) {
@@ -454,7 +432,6 @@ if (cmd==='init') {
454
432
  process.exit(0)
455
433
  }
456
434
 
457
- // ── New ───────────────────────────────────────────────────────────
458
435
  if (cmd==='new') {
459
436
  const name=args[0]; if(!name){console.error('\n ✗ Usage: aiplang new <page>\n');process.exit(1)}
460
437
  const dir=fs.existsSync('pages')?'pages':'.'
@@ -466,7 +443,104 @@ if (cmd==='new') {
466
443
  process.exit(0)
467
444
  }
468
445
 
469
- // ── Build ─────────────────────────────────────────────────────────
446
+ function validateAipSrc(source) {
447
+ const errors = []
448
+ const lines = source.split('\n')
449
+ const knownDirs = new Set(['db','auth','env','mail','s3','stripe','plan','admin','realtime','use','plugin','import','store','ssr','interval','mount','theme','guard','validate','unique','hash','check','cache','rateLimit','broadcast','soft-delete','belongs'])
450
+ for (let i=0; i<lines.length; i++) {
451
+ const line = lines[i].trim()
452
+ if (!line || line.startsWith('#')) continue
453
+ const dm = line.match(/^(guard|validate|unique|hash|check|cache|mount|store|ssr|interval|auth|db|env|use|plugin|import|theme|rateLimit|broadcast)\b/)
454
+ if (dm && !line.startsWith('~') && !line.startsWith('api ') && !line.startsWith('model ') && !line.startsWith('%')) {
455
+ errors.push({ line:i+1, code:line, message:`Missing ~ before '${dm[1]}'`, fix:`~${line}`, severity:'error' })
456
+ }
457
+ if (line.startsWith('api ') && !line.includes('{')) {
458
+ errors.push({ line:i+1, code:line, message:"api block missing '{'", fix:line+' { return {} }', severity:'error' })
459
+ }
460
+ if (/^[a-z_]+\s+-\s+[a-z]/.test(line) && !line.startsWith('api') && !line.startsWith('model')) {
461
+ errors.push({ line:i+1, code:line, message:"Use ':' not '-' in field definitions", fix:line.replace(/\s*-\s*/g,' : '), severity:'error' })
462
+ }
463
+ if (line.startsWith('~')) {
464
+ const dir = line.slice(1).split(/\s/)[0]
465
+ if (!knownDirs.has(dir)) errors.push({ line:i+1, code:line, message:`Unknown directive ~${dir}`, severity:'warning' })
466
+ }
467
+ if (/^table\s*\{/.test(line)) {
468
+ errors.push({ line:i+1, code:line, message:"table missing @binding — e.g.: table @users { Name:name | ... }", severity:'error' })
469
+ }
470
+ }
471
+ return errors
472
+ }
473
+
474
+ if (cmd==='validate'||cmd==='check'||cmd==='lint') {
475
+ const file = args[0]
476
+ if (!file) { console.error('\n Usage: aiplang validate <app.aip>\n'); process.exit(1) }
477
+ if (!require('fs').existsSync(file)) { console.error(`\n ✗ File not found: ${file}\n`); process.exit(1) }
478
+ const src = require('fs').readFileSync(file,'utf8')
479
+ const errs = validateAipSrc(src)
480
+ if (!errs.length) { console.log('\n ✓ Syntax OK — safe to run\n'); process.exit(0) }
481
+ console.log(`\n ✗ ${errs.length} issue(s) found in ${file}:\n`)
482
+ errs.forEach(e => {
483
+ const icon = e.severity==='error' ? '✗' : '⚠'
484
+ console.log(` ${icon} Line ${e.line}: ${e.message}`)
485
+ console.log(` ${e.code}`)
486
+ if (e.fix) console.log(` Fix: ${e.fix}`)
487
+ })
488
+ console.log()
489
+ process.exit(errs.some(e=>e.severity==='error') ? 1 : 0)
490
+ }
491
+
492
+ if (cmd==='context'||cmd==='ctx') {
493
+ const file = args[0] || 'app.aip'
494
+ const exists = require('fs').existsSync
495
+ const src = exists(file) ? require('fs').readFileSync(file,'utf8') : null
496
+ if (!src) { console.log('\n Usage: aiplang context [app.aip]\n Dumps minimal AI context (~200 tokens).\n'); process.exit(0) }
497
+ // Use server's parseApp for full app structure
498
+ const serverPath = require('path').join(__dirname,'../server/server.js')
499
+ let app = { models:[], apis:[], pages:[], db:null, auth:null }
500
+ try {
501
+ const srv = require(serverPath)
502
+ if (srv.parseApp) app = srv.parseApp(src)
503
+ } catch {
504
+ // Fallback: basic parse for models + routes
505
+ const modelRx = /^model\s+(\w+)/gm
506
+ const apiRx = /^api\s+(\w+)\s+(\S+)/gm
507
+ let m
508
+ while((m=modelRx.exec(src))) app.models.push({name:m[1],fields:[]})
509
+ while((m=apiRx.exec(src))) app.apis.push({method:m[1],path:m[2],guards:[]})
510
+ const pageRx = /^%(\w+)\s+(\w+)\s+(\S+)/gm
511
+ while((m=pageRx.exec(src))) app.pages.push({id:m[1],theme:m[2],route:m[3],state:{},queries:[]})
512
+ }
513
+ const out = [
514
+ `# aiplang app — ${file}`,
515
+ '# paste into AI for maintenance/customization',
516
+ '',
517
+ '## MODELS'
518
+ ]
519
+ for (const m of app.models||[]) {
520
+ const fields = m.fields.map(f=>`${f.name}:${f.type}${f.modifiers?.length?':'+f.modifiers.join(':'):''}`).join(' ')
521
+ out.push(`model ${m.name} { ${fields} }`)
522
+ }
523
+ out.push('')
524
+ out.push('## ROUTES')
525
+ for (const r of app.apis||[]) {
526
+ const g = r.guards?.length ? ` [${r.guards.join(',')}]` : ''
527
+ const v = r.validate?.length ? ` validate:${r.validate.length}` : ''
528
+ out.push(`${r.method.padEnd(7)}${r.path}${g}${v}`)
529
+ }
530
+ out.push('')
531
+ out.push('## PAGES')
532
+ for (const p of app.pages||[]) {
533
+ const state = Object.keys(p.state||{}).map(k=>`@${k}`).join(' ')
534
+ const queries = (p.queries||[]).map(q=>`${q.trigger}:${q.path}`).join(' ')
535
+ out.push(`%${p.id} ${p.theme||'dark'} ${p.route} | state:${state||'none'} | queries:${queries||'none'}`)
536
+ }
537
+ if (app.db) { out.push(''); out.push(`## CONFIG\ndb:${app.db.driver} auth:${app.auth?'jwt':'none'}`) }
538
+ const ctx = out.join('\n')
539
+ console.log(ctx)
540
+ console.log(`\n# ~${Math.ceil(ctx.length/4)} tokens`)
541
+ process.exit(0)
542
+ }
543
+
470
544
  if (cmd==='build') {
471
545
  const outIdx=args.indexOf('--out')
472
546
  const outDir=outIdx!==-1?args[outIdx+1]:'dist'
@@ -476,7 +550,7 @@ if (cmd==='build') {
476
550
  fs.readdirSync(input).filter(f=>f.endsWith('.aip')).forEach(f=>files.push(path.join(input,f)))
477
551
  } else if(input.endsWith('.aip')&&fs.existsSync(input)){ files.push(input) }
478
552
  if(!files.length){console.error(`\n ✗ No .aip files in: ${input}\n`);process.exit(1)}
479
- // Resolve ~import directives recursively
553
+
480
554
  function resolveImports(content, baseDir, seen=new Set()) {
481
555
  return content.replace(/^~import\s+["']?([^"'\n]+)["']?$/mg, (_, importPath) => {
482
556
  const resolved = path.resolve(baseDir, importPath.trim())
@@ -511,7 +585,6 @@ if (cmd==='build') {
511
585
  process.exit(0)
512
586
  }
513
587
 
514
- // ── Serve (hot reload) ────────────────────────────────────────────
515
588
  if (cmd==='serve'||cmd==='dev') {
516
589
  const root=path.resolve(args[0]||'.')
517
590
  const port=parseInt(process.env.PORT||'3000')
@@ -549,7 +622,6 @@ if (cmd==='serve'||cmd==='dev') {
549
622
  return
550
623
  }
551
624
 
552
- // ── Dev server (full-stack) ──────────────────────────────────────
553
625
  if (cmd === 'start' || cmd === 'run') {
554
626
  const aipFile = args[0]
555
627
  if (!aipFile || !fs.existsSync(aipFile)) {
@@ -573,10 +645,6 @@ if (cmd === 'start' || cmd === 'run') {
573
645
  console.error(`\n ✗ Unknown command: ${cmd}\n Run aiplang --help\n`)
574
646
  process.exit(1)
575
647
 
576
- // ═════════════════════════════════════════════════════════════════
577
- // PARSER
578
- // ═════════════════════════════════════════════════════════════════
579
-
580
648
  function parsePages(src) {
581
649
  return src.split(/\n---\n/).map(s=>parsePage(s.trim())).filter(Boolean)
582
650
  }
@@ -619,8 +687,7 @@ function parseQuery(s) {
619
687
  }
620
688
 
621
689
  function parseBlock(line) {
622
- // ── Extract suffix modifiers FIRST ──────────────────────────
623
- // animate:fade-up class:my-class (can appear at end of any block line)
690
+
624
691
  let extraClass=null, animate=null
625
692
  const _cm=line.match(/\bclass:(\S+)/)
626
693
  if(_cm){extraClass=_cm[1];line=line.replace(_cm[0],'').trim()}
@@ -636,12 +703,10 @@ function parseBlock(line) {
636
703
  const _bgm=line.match(/\bbg:(#[0-9a-fA-F]+|[a-z]+)/)
637
704
  if(_bgm){bg=_bgm[1];line=line.replace(_bgm[0],'').trim()}
638
705
 
639
- // ── raw{} HTML passthrough ──────────────────────────────────
640
706
  if(line.startsWith('raw{')) {
641
707
  return{kind:'raw',html:line.slice(4,line.lastIndexOf('}')),extraClass,animate}
642
708
  }
643
709
 
644
- // ── table ───────────────────────────────────────────────────
645
710
  if(line.startsWith('table ') || line.startsWith('table{')) {
646
711
  const idx=line.indexOf('{');if(idx===-1) return null
647
712
  const start=line.startsWith('table{')?6:6
@@ -659,7 +724,6 @@ function parseBlock(line) {
659
724
  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',fallback:fallbackM?.[1]?.trim()||null,retry:retryM?.[1]||null,extraClass,animate,variant,style,bg}
660
725
  }
661
726
 
662
- // ── form ────────────────────────────────────────────────────
663
727
  if(line.startsWith('form ') || line.startsWith('form{')) {
664
728
  const bi=line.indexOf('{');if(bi===-1) return null
665
729
  let head=line.slice(line.startsWith('form{')?4:5,bi).trim()
@@ -667,7 +731,7 @@ function parseBlock(line) {
667
731
  let action='', optimistic=false; const ai=head.indexOf('=>')
668
732
  if(ai!==-1){
669
733
  action=head.slice(ai+2).trim()
670
- // Optimistic: => @list.optimistic($result)
734
+
671
735
  if(action.includes('.optimistic(')){optimistic=true;action=action.replace('.optimistic','')}
672
736
  head=head.slice(0,ai).trim()
673
737
  }
@@ -677,7 +741,6 @@ function parseBlock(line) {
677
741
  return{kind:'form',method,bpath,action,optimistic,fields:parseFields(content)||[],extraClass,animate,variant,style,bg}
678
742
  }
679
743
 
680
- // ── pricing ─────────────────────────────────────────────────
681
744
  if(line.startsWith('pricing{')) {
682
745
  const body=line.slice(8,line.lastIndexOf('}')).trim()
683
746
  const plans=body.split('|').map(p=>{
@@ -687,14 +750,12 @@ function parseBlock(line) {
687
750
  return{kind:'pricing',plans,extraClass,animate,variant,style,bg}
688
751
  }
689
752
 
690
- // ── faq ─────────────────────────────────────────────────────
691
753
  if(line.startsWith('faq{')) {
692
754
  const body=line.slice(4,line.lastIndexOf('}')).trim()
693
755
  const items=body.split('|').map(i=>{const idx=i.indexOf('>');return{q:i.slice(0,idx).trim(),a:i.slice(idx+1).trim()}}).filter(i=>i.q&&i.a)
694
756
  return{kind:'faq',items,extraClass,animate}
695
757
  }
696
758
 
697
- // ── testimonial ──────────────────────────────────────────────
698
759
  if(line.startsWith('testimonial{')) {
699
760
  const body=line.slice(12,line.lastIndexOf('}')).trim()
700
761
  const parts=body.split('|').map(x=>x.trim())
@@ -702,12 +763,10 @@ function parseBlock(line) {
702
763
  return{kind:'testimonial',author:parts[0],quote:parts[1]?.replace(/^"|"$/g,''),img:imgPart?.slice(4)||null,extraClass,animate}
703
764
  }
704
765
 
705
- // ── gallery ──────────────────────────────────────────────────
706
766
  if(line.startsWith('gallery{')) {
707
767
  return{kind:'gallery',imgs:line.slice(8,line.lastIndexOf('}')).trim().split('|').map(x=>x.trim()).filter(Boolean),extraClass,animate}
708
768
  }
709
769
 
710
- // ── btn ──────────────────────────────────────────────────────
711
770
  if(line.startsWith('btn{')) {
712
771
  const parts=line.slice(4,line.lastIndexOf('}')).split('>').map(p=>p.trim())
713
772
  const label=parts[0]||'Click', method=parts[1]?.split(' ')[0]||'POST'
@@ -717,7 +776,6 @@ function parseBlock(line) {
717
776
  return{kind:'btn',label,method,bpath,action,confirm,extraClass,animate}
718
777
  }
719
778
 
720
- // ── card{} — standalone card customizável ──────────────────
721
779
  if(line.startsWith('card{') || line.startsWith('card ')) {
722
780
  const bi=line.indexOf('{'); if(bi===-1) return null
723
781
  const body=line.slice(bi+1,line.lastIndexOf('}')).trim()
@@ -731,7 +789,6 @@ function parseBlock(line) {
731
789
  return{kind:'card',title,subtitle,img:imgPart?.slice(4)||null,link:linkPart||null,badge,bind,extraClass,animate,variant,style,bg}
732
790
  }
733
791
 
734
- // ── cols{} — grid de conteúdo livre ─────────────────────────
735
792
  if(line.startsWith('cols{') || (line.startsWith('cols ') && line.includes('{'))) {
736
793
  const bi=line.indexOf('{'); if(bi===-1) return null
737
794
  const head=line.slice(0,bi).trim()
@@ -742,19 +799,16 @@ function parseBlock(line) {
742
799
  return{kind:'cols',n,items,extraClass,animate,variant,style,bg}
743
800
  }
744
801
 
745
- // ── divider{} — separador visual ─────────────────────────────
746
802
  if(line.startsWith('divider') || line.startsWith('hr{')) {
747
803
  const label=line.match(/\{([^}]*)\}/)?.[1]?.trim()||null
748
804
  return{kind:'divider',label,extraClass,animate,variant,style}
749
805
  }
750
806
 
751
- // ── badge{} — label/tag destacado ───────────────────────────
752
807
  if(line.startsWith('badge{') || line.startsWith('tag{')) {
753
808
  const content=line.slice(line.indexOf('{')+1,line.lastIndexOf('}')).trim()
754
809
  return{kind:'badge',content,extraClass,animate,variant,style}
755
810
  }
756
811
 
757
- // ── select ───────────────────────────────────────────────────
758
812
  if(line.startsWith('select ')) {
759
813
  const bi=line.indexOf('{')
760
814
  const varName=bi!==-1?line.slice(7,bi).trim():line.slice(7).trim()
@@ -762,13 +816,11 @@ function parseBlock(line) {
762
816
  return{kind:'select',binding:varName,options:body.split('|').map(o=>o.trim()).filter(Boolean),extraClass,animate}
763
817
  }
764
818
 
765
- // ── if ───────────────────────────────────────────────────────
766
819
  if(line.startsWith('if ')) {
767
820
  const bi=line.indexOf('{');if(bi===-1) return null
768
821
  return{kind:'if',cond:line.slice(3,bi).trim(),inner:line.slice(bi+1,line.lastIndexOf('}')).trim(),extraClass,animate}
769
822
  }
770
823
 
771
- // ── chart{} — data visualization ───────────────────────────────
772
824
  if(line.startsWith('chart{') || line.startsWith('chart ')) {
773
825
  const bi=line.indexOf('{'); if(bi===-1) return null
774
826
  const body=line.slice(bi+1,line.lastIndexOf('}')).trim()
@@ -781,7 +833,6 @@ function parseBlock(line) {
781
833
  return{kind:'chart',type,binding,labels,values,title,extraClass,animate,variant,style}
782
834
  }
783
835
 
784
- // ── kanban{} — drag-and-drop board ───────────────────────────────
785
836
  if(line.startsWith('kanban{') || line.startsWith('kanban ')) {
786
837
  const bi=line.indexOf('{'); if(bi===-1) return null
787
838
  const body=line.slice(bi+1,line.lastIndexOf('}')).trim()
@@ -793,7 +844,6 @@ function parseBlock(line) {
793
844
  return{kind:'kanban',binding,cols,statusField,updatePath,extraClass,animate,style}
794
845
  }
795
846
 
796
- // ── editor{} — rich text editor ──────────────────────────────────
797
847
  if(line.startsWith('editor{') || line.startsWith('editor ')) {
798
848
  const bi=line.indexOf('{'); if(bi===-1) return null
799
849
  const body=line.slice(bi+1,line.lastIndexOf('}')).trim()
@@ -804,7 +854,6 @@ function parseBlock(line) {
804
854
  return{kind:'editor',name,placeholder,submitPath,extraClass,animate,style}
805
855
  }
806
856
 
807
- // ── each @list { template } — loop como React .map() ────────
808
857
  if(line.startsWith('each ')) {
809
858
  const bi=line.indexOf('{');if(bi===-1) return null
810
859
  const binding=line.slice(5,bi).trim()
@@ -812,18 +861,15 @@ function parseBlock(line) {
812
861
  return{kind:'each',binding,tpl,extraClass,animate,variant}
813
862
  }
814
863
 
815
- // ── spacer{} — espaçamento customizável ──────────────────────
816
864
  if(line.startsWith('spacer{') || line.startsWith('spacer ')) {
817
865
  const h=line.match(/[{\s](\S+)[}]?/)?.[1]||'3rem'
818
866
  return{kind:'spacer',height:h,extraClass,animate}
819
867
  }
820
868
 
821
- // ── html{} — HTML inline com interpolação de @state ──────────
822
869
  if(line.startsWith('html{')) {
823
870
  return{kind:'html',content:line.slice(5,line.lastIndexOf('}')),extraClass,animate}
824
871
  }
825
872
 
826
- // ── regular blocks (nav, hero, stats, rowN, sect, foot) ──────
827
873
  const bi=line.indexOf('{');if(bi===-1) return null
828
874
  const head=line.slice(0,bi).trim()
829
875
  const body=line.slice(bi+1,line.lastIndexOf('}')).trim()
@@ -846,41 +892,43 @@ function parseCols(s){return s.split('|').map(c=>{c=c.trim();if(c.startsWith('em
846
892
  function parseEmpty(s){const m=s.match(/empty:\s*([^|]+)/);return m?m[1].trim():'No data.'}
847
893
  function parseFields(s){return s.split('|').map(f=>{const[label,type,ph]=f.split(':').map(x=>x.trim());return label?{label,type:type||'text',placeholder:ph||'',name:label.toLowerCase().replace(/\s+/g,'_')}:null}).filter(Boolean)}
848
894
 
849
- // ═════════════════════════════════════════════════════════════════
850
- // RENDERER
851
- // ═════════════════════════════════════════════════════════════════
852
-
853
895
  function applyMods(html, b) {
854
896
  if(!html||(!b.extraClass&&!b.animate)) return html
855
897
  const cls=[(b.extraClass||''),(b.animate?'fx-anim-'+b.animate:'')].filter(Boolean).join(' ')
856
- // Inject into first tag's class attribute (handles multiline HTML)
898
+
857
899
  return html.replace(/class="([^"]*)"/, (_,c)=>`class="${c} ${cls}"`)
858
900
  }
859
901
 
860
902
  function renderPage(page, allPages) {
861
903
  const needsJS=page.queries.length>0||page.blocks.some(b=>['table','list','form','if','btn','select','faq'].includes(b.kind))
862
904
  const body=page.blocks.map(b=>{try{return applyMods(renderBlock(b,page),b)}catch(e){console.error('[aiplang] Block render error:',b.kind,e.message);return ''}}).join('')
863
- // Compiled diff functions per table
905
+
864
906
  const tableBlocks = page.blocks.filter(b => b.kind === 'table' && b.binding && b.cols && b.cols.length)
865
907
  const numericKeys = ['score','count','total','amount','price','value','qty','age','rank','num','int','float','rate','pct','percent']
866
908
  const compiledDiffs = tableBlocks.map(b => {
867
909
  const binding = b.binding.replace(/^@/, '')
910
+
911
+ const safeId = s => (s||'').replace(/[^a-zA-Z0-9_]/g, '_').slice(0,64) || 'col'
912
+ const safeBinding = safeId(binding)
868
913
  const colDefs = b.cols.map((col, j) => ({
869
- key: col.key,
914
+ key: safeId(col.key),
915
+ origKey: col.key,
870
916
  idx: j,
871
917
  numeric: numericKeys.some(kw => col.key.toLowerCase().includes(kw))
872
918
  }))
873
919
  const initParts = colDefs.map(d =>
874
- d.numeric ? `c${d.idx}:new Float64Array(rows.map(r=>+(r.${d.key})||0))`
875
- : `c${d.idx}:rows.map(r=>r.${d.key}??'')`
920
+ d.numeric ? `c${d.idx}:new Float64Array(rows.map(r=>+(r.${JSON.stringify(d.origKey)}===undefined?r['${d.origKey}']:r[${JSON.stringify(d.origKey)}])||0))`
921
+ : `c${d.idx}:rows.map(r=>r[${JSON.stringify(d.origKey)}]??'')`
876
922
  ).join(',')
877
- const diffParts = colDefs.map(d =>
878
- d.numeric ? `if(c${d.idx}[i]!==(r.${d.key}||0)){c${d.idx}[i]=r.${d.key}||0;p.push(i<<4|${d.idx})}`
879
- : `if(c${d.idx}[i]!==r.${d.key}){c${d.idx}[i]=r.${d.key};p.push(i<<4|${d.idx})}`
880
- ).join(';')
923
+ const diffParts = colDefs.map(d => {
924
+ const k = JSON.stringify(d.origKey)
925
+ return d.numeric
926
+ ? `if(c${d.idx}[i]!==(r[${k}]||0)){c${d.idx}[i]=r[${k}]||0;p.push(i<<4|${d.idx})}`
927
+ : `if(c${d.idx}[i]!==r[${k}]){c${d.idx}[i]=r[${k}];p.push(i<<4|${d.idx})}`
928
+ }).join(';')
881
929
  return [
882
- `window.__aip_init_${binding}=function(rows){return{${initParts}}};`,
883
- `window.__aip_diff_${binding}=function(rows,cache){`,
930
+ `window.__aip_init_${safeBinding}=function(rows){return{${initParts}}};`,
931
+ `window.__aip_diff_${safeBinding}=function(rows,cache){`,
884
932
  `const n=rows.length,p=[],${colDefs.map(d=>`c${d.idx}=cache.c${d.idx}`).join(',')};`,
885
933
  `for(let i=0;i<n;i++){const r=rows[i];${diffParts}}return p};`
886
934
  ].join('')
@@ -888,11 +936,11 @@ function renderPage(page, allPages) {
888
936
  const compiledScript = compiledDiffs.length
889
937
  ? `<script>/* aiplang compiled-diffs */\n${compiledDiffs}\n</script>`
890
938
  : ''
891
- const config=needsJS?JSON.stringify({id:page.id,theme:page.theme,routes:allPages.map(p=>p.route),state:page.state,queries:page.queries,stores:page.stores||[],computed:page.computed||{},compiledTables:tableBlocks.map(b=>b.binding.replace(/^@/,''))}):''
939
+ const config=needsJS?JSON.stringify({id:page.id,theme:page.theme,routes:allPages.map(p=>p.route),state:page.state,queries:page.queries,stores:page.stores||[],computed:page.computed||{},compiledTables:tableBlocks.map(b=>(b.binding||'').replace(/^@/,'').replace(/[^a-zA-Z0-9_]/g,'_').slice(0,64))}):''
892
940
  const hydrate=needsJS?`\n<script>window.__AIPLANG_PAGE__=${config};</script>\n<script src="./aiplang-hydrate.js" defer></script>`:''
893
941
  const customVars=page.customTheme?genCustomThemeVars(page.customTheme):''
894
942
  const themeVarCSS=page.themeVars?genThemeVarCSS(page.themeVars):''
895
- // Extract app name from nav brand if available
943
+
896
944
  const _navBlock = page.blocks.find(b=>b.kind==='nav')
897
945
  const _brand = _navBlock?.brand || ''
898
946
  const _title = _brand ? `${esc(_brand)} — ${esc(page.id.charAt(0).toUpperCase()+page.id.slice(1))}` : esc(page.id.charAt(0).toUpperCase()+page.id.slice(1))
@@ -948,7 +996,6 @@ function renderBlock(b, page) {
948
996
  }
949
997
  }
950
998
 
951
- // ── Chart — lazy-loads Chart.js from CDN ────────────────────────
952
999
  function rChart(b) {
953
1000
  const id = 'chart_' + Math.random().toString(36).slice(2,8)
954
1001
  const binding = b.binding || ''
@@ -959,7 +1006,6 @@ function rChart(b) {
959
1006
  </div>\n`
960
1007
  }
961
1008
 
962
- // ── Kanban — drag-and-drop board ─────────────────────────────────
963
1009
  function rKanban(b) {
964
1010
  const cols = (b.cols||['Todo','In Progress','Done'])
965
1011
  const colsHtml = cols.map(col => `
@@ -971,7 +1017,6 @@ function rKanban(b) {
971
1017
  return `<div class="fx-kanban" data-fx-kanban="${esc(b.binding||'')}" data-status-field="${esc(b.statusField||'status')}" data-update-path="${esc(b.updatePath||'')}"${style}>${colsHtml}</div>\n`
972
1018
  }
973
1019
 
974
- // ── Rich text editor ──────────────────────────────────────────────
975
1020
  function rEditor(b) {
976
1021
  const style = b.style ? ` style="${b.style.replace(/,/g,';')}"` : ''
977
1022
  return `<div class="fx-editor-wrap"${style}>
@@ -1057,7 +1102,7 @@ function rRow(b) {
1057
1102
  pink:'#ec4899',cyan:'#06b6d4',lime:'#84cc16',amber:'#f59e0b'
1058
1103
  }
1059
1104
  const cards=(b.items||[]).map(item=>{
1060
- // First token can be color: red|rocket>Title>Body
1105
+
1061
1106
  let colorStyle='', firstIdx=0
1062
1107
  if(item[0]&&!item[0].isImg&&!item[0].isLink){
1063
1108
  const colorKey=item[0].text?.toLowerCase()
@@ -1115,7 +1160,8 @@ function rTable(b) {
1115
1160
  const span=cols.length+((b.editPath||b.deletePath)?1:0)
1116
1161
  const fallbackAttr=b.fallback?` data-fx-fallback="${esc(b.fallback)}"`:''
1117
1162
  const retryAttr=b.retry?` data-fx-retry="${esc(b.retry)}"`:''
1118
- return `<div class="fx-table-wrap"><table class="fx-table" data-fx-table="${esc(b.binding)}" data-fx-cols='${keys}' data-fx-col-map='${cm}'${ea}${da}${fallbackAttr}${retryAttr}><thead><tr>${ths}${at}</tr></thead><tbody class="fx-tbody"><tr><td colspan="${span}" class="fx-td-empty">${esc(b.empty)}</td></tr></tbody></table></div>\n`
1163
+ const exitAttr=b.animateExit?` data-fx-exit="${esc(b.animateExit)}"`:'';
1164
+ return `<div class="fx-table-wrap"><table class="fx-table"${exitAttr} data-fx-table="${esc(b.binding)}" data-fx-cols='${keys}' data-fx-col-map='${cm}'${ea}${da}${fallbackAttr}${retryAttr}><thead><tr>${ths}${at}</tr></thead><tbody class="fx-tbody"><tr><td colspan="${span}" class="fx-td-empty">${esc(b.empty)}</td></tr></tbody></table></div>\n`
1119
1165
  }
1120
1166
 
1121
1167
  function rForm(b) {
@@ -1178,7 +1224,6 @@ function rGallery(b) {
1178
1224
  return `<div class="fx-gallery">${imgs}</div>\n`
1179
1225
  }
1180
1226
 
1181
- // ── Theme helpers ─────────────────────────────────────────────────
1182
1227
  function genCustomThemeVars(ct) {
1183
1228
  return `body{background:${ct.bg};color:${ct.text}}.fx-nav{background:${ct.bg}cc;border-bottom:1px solid ${ct.text}18}.fx-cta,.fx-btn{background:${ct.accent};color:#fff}.fx-card{background:${ct.surface||ct.bg};border:1px solid ${ct.text}15}.fx-form{background:${ct.surface||ct.bg};border:1px solid ${ct.text}15}.fx-input{background:${ct.bg};border:1px solid ${ct.text}30;color:${ct.text}}.fx-stat-lbl,.fx-card-body,.fx-sub,.fx-sect-body,.fx-footer-text{color:${ct.text}88}.fx-th,.fx-nav-link{color:${ct.text}77}.fx-footer{border-top:1px solid ${ct.text}15}.fx-th{border-bottom:1px solid ${ct.text}15}`
1184
1229
  }
@@ -1198,10 +1243,6 @@ function genThemeVarCSS(t) {
1198
1243
  return r.join('')
1199
1244
  }
1200
1245
 
1201
- // ═════════════════════════════════════════════════════════════════
1202
- // CSS
1203
- // ═════════════════════════════════════════════════════════════════
1204
-
1205
1246
  function css(theme) {
1206
1247
  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}
1207
1248
  .fx-hero-minimal{min-height:50vh!important}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aiplang",
3
- "version": "2.10.8",
3
+ "version": "2.11.0",
4
4
  "description": "AI-first web language. One .aip file = complete app. Frontend + backend + database + auth.",
5
5
  "keywords": [
6
6
  "aiplang",