aiplang 2.10.8 → 2.10.9

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.10.9'
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}
@@ -74,18 +70,12 @@ if (!cmd||cmd==='--help'||cmd==='-h') {
74
70
  }
75
71
  if (cmd==='--version'||cmd==='-v') { console.log(`aiplang v${VERSION}`); process.exit(0) }
76
72
 
77
- // ─────────────────────────────────────────────────────────────────
78
- // TEMPLATE SYSTEM
79
- // Custom templates stored at ~/.aip/templates/<name>.aip
80
- // ─────────────────────────────────────────────────────────────────
81
-
82
73
  const TEMPLATES_DIR = path.join(require('os').homedir(), '.aip', 'templates')
83
74
 
84
75
  function ensureTemplatesDir() {
85
76
  if (!fs.existsSync(TEMPLATES_DIR)) fs.mkdirSync(TEMPLATES_DIR, { recursive: true })
86
77
  }
87
78
 
88
- // Built-in templates (interpolate {{name}} and {{year}})
89
79
  const BUILTIN_TEMPLATES = {
90
80
  saas: `# {{name}}
91
81
  ~db sqlite ./app.db
@@ -255,24 +245,20 @@ function applyTemplateVars(src, name, year) {
255
245
  function getTemplate(tplName, name, year) {
256
246
  ensureTemplatesDir()
257
247
 
258
- // 1. Local file path: --template ./my-template.aip or --template /abs/path.aip
259
248
  if (tplName.startsWith('./') || tplName.startsWith('../') || tplName.startsWith('/')) {
260
249
  const full = path.resolve(tplName)
261
250
  if (!fs.existsSync(full)) { console.error(`\n ✗ Template file not found: ${full}\n`); process.exit(1) }
262
251
  return applyTemplateVars(fs.readFileSync(full, 'utf8'), name, year)
263
252
  }
264
253
 
265
- // 2. User custom template: ~/.aip/templates/<name>.aip
266
254
  const customPath = path.join(TEMPLATES_DIR, tplName + '.aip')
267
255
  if (fs.existsSync(customPath)) {
268
256
  return applyTemplateVars(fs.readFileSync(customPath, 'utf8'), name, year)
269
257
  }
270
258
 
271
- // 3. Built-in template
272
259
  const builtin = BUILTIN_TEMPLATES[tplName]
273
260
  if (builtin) return applyTemplateVars(builtin, name, year)
274
261
 
275
- // Not found — show what's available
276
262
  const customs = fs.existsSync(TEMPLATES_DIR)
277
263
  ? fs.readdirSync(TEMPLATES_DIR).filter(f=>f.endsWith('.aip')).map(f=>f.replace('.aip',''))
278
264
  : []
@@ -297,17 +283,14 @@ function listTemplates() {
297
283
  console.log()
298
284
  }
299
285
 
300
- // ── template subcommand ──────────────────────────────────────────
301
286
  if (cmd === 'template') {
302
287
  const sub = args[0]
303
288
  ensureTemplatesDir()
304
289
 
305
- // aiplang template list
306
290
  if (!sub || sub === 'list' || sub === 'ls') {
307
291
  listTemplates(); process.exit(0)
308
292
  }
309
293
 
310
- // aiplang template save <name> [--from <file>]
311
294
  if (sub === 'save' || sub === 'add') {
312
295
  const tname = args[1]
313
296
  if (!tname) { console.error('\n ✗ Usage: aiplang template save <name> [--from <file>]\n'); process.exit(1) }
@@ -318,7 +301,7 @@ if (cmd === 'template') {
318
301
  if (!fs.existsSync(fp)) { console.error(`\n ✗ File not found: ${fp}\n`); process.exit(1) }
319
302
  src = fs.readFileSync(fp, 'utf8')
320
303
  } else {
321
- // Auto-detect: use pages/ directory or app.aip
304
+
322
305
  const sources = ['pages', 'app.aip', 'index.aip']
323
306
  const found = sources.find(s => fs.existsSync(s))
324
307
  if (!found) { console.error('\n ✗ No .aip files found. Use --from <file> to specify source.\n'); process.exit(1) }
@@ -335,7 +318,6 @@ if (cmd === 'template') {
335
318
  process.exit(0)
336
319
  }
337
320
 
338
- // aiplang template remove <name>
339
321
  if (sub === 'remove' || sub === 'rm' || sub === 'delete') {
340
322
  const tname = args[1]
341
323
  if (!tname) { console.error('\n ✗ Usage: aiplang template remove <name>\n'); process.exit(1) }
@@ -345,13 +327,12 @@ if (cmd === 'template') {
345
327
  console.log(`\n ✓ Removed template: ${tname}\n`); process.exit(0)
346
328
  }
347
329
 
348
- // aiplang template edit <name>
349
330
  if (sub === 'edit' || sub === 'open') {
350
331
  const tname = args[1]
351
332
  if (!tname) { console.error('\n ✗ Usage: aiplang template edit <name>\n'); process.exit(1) }
352
333
  let dest = path.join(TEMPLATES_DIR, tname + '.aip')
353
334
  if (!fs.existsSync(dest)) {
354
- // create from built-in if exists
335
+
355
336
  const builtin = BUILTIN_TEMPLATES[tname]
356
337
  if (builtin) { fs.writeFileSync(dest, builtin); console.log(`\n ✓ Copied built-in "${tname}" to custom templates.\n`) }
357
338
  else { console.error(`\n ✗ Template "${tname}" not found.\n`); process.exit(1) }
@@ -362,7 +343,6 @@ if (cmd === 'template') {
362
343
  process.exit(0)
363
344
  }
364
345
 
365
- // aiplang template show <name>
366
346
  if (sub === 'show' || sub === 'cat') {
367
347
  const tname = args[1] || 'default'
368
348
  const customPath = path.join(TEMPLATES_DIR, tname + '.aip')
@@ -372,7 +352,6 @@ if (cmd === 'template') {
372
352
  console.error(`\n ✗ Template "${tname}" not found.\n`); process.exit(1)
373
353
  }
374
354
 
375
- // aiplang template export <name> [--out <file>]
376
355
  if (sub === 'export') {
377
356
  const tname = args[1]
378
357
  if (!tname) { console.error('\n ✗ Usage: aiplang template export <name>\n'); process.exit(1) }
@@ -390,7 +369,6 @@ if (cmd === 'template') {
390
369
  process.exit(1)
391
370
  }
392
371
 
393
- // ── Init ─────────────────────────────────────────────────────────
394
372
  if (cmd==='init') {
395
373
  const tplIdx = args.indexOf('--template')
396
374
  const tplName = tplIdx !== -1 ? args[tplIdx+1] : 'default'
@@ -399,15 +377,13 @@ if (cmd==='init') {
399
377
 
400
378
  if (fs.existsSync(dir)) { console.error(`\n ✗ Directory "${name}" already exists.\n`); process.exit(1) }
401
379
 
402
- // Get template source (built-in, custom, or file path)
403
380
  const tplSrc = getTemplate(tplName, name, year)
404
381
 
405
- // Check if template has full-stack backend (models/api blocks)
406
382
  const isFullStack = tplSrc.includes('\nmodel ') || tplSrc.includes('\napi ')
407
383
  const isMultiFile = tplSrc.includes('\n---\n')
408
384
 
409
385
  if (isFullStack) {
410
- // Full-stack project: single app.aip
386
+
411
387
  fs.mkdirSync(dir, { recursive: true })
412
388
  fs.writeFileSync(path.join(dir, 'app.aip'), tplSrc)
413
389
  fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({
@@ -421,7 +397,7 @@ if (cmd==='init') {
421
397
  const label = tplName !== 'default' ? ` (template: ${tplName})` : ''
422
398
  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
399
  } else if (isMultiFile) {
424
- // Multi-page SSG project: pages/*.aip
400
+
425
401
  fs.mkdirSync(path.join(dir,'pages'), {recursive:true})
426
402
  fs.mkdirSync(path.join(dir,'public'), {recursive:true})
427
403
  for (const f of ['aiplang-runtime.js','aiplang-hydrate.js']) {
@@ -439,7 +415,7 @@ if (cmd==='init') {
439
415
  const files = fs.readdirSync(path.join(dir,'pages')).map(f=>f).join(', ')
440
416
  console.log(`\n ✓ Created ${name}/${label}\n\n pages/{${files}} ← edit these\n\n Next:\n cd ${name} && npx aiplang serve\n`)
441
417
  } else {
442
- // Single-page SSG project
418
+
443
419
  fs.mkdirSync(path.join(dir,'pages'), {recursive:true})
444
420
  fs.mkdirSync(path.join(dir,'public'), {recursive:true})
445
421
  for (const f of ['aiplang-runtime.js','aiplang-hydrate.js']) {
@@ -454,7 +430,6 @@ if (cmd==='init') {
454
430
  process.exit(0)
455
431
  }
456
432
 
457
- // ── New ───────────────────────────────────────────────────────────
458
433
  if (cmd==='new') {
459
434
  const name=args[0]; if(!name){console.error('\n ✗ Usage: aiplang new <page>\n');process.exit(1)}
460
435
  const dir=fs.existsSync('pages')?'pages':'.'
@@ -466,7 +441,6 @@ if (cmd==='new') {
466
441
  process.exit(0)
467
442
  }
468
443
 
469
- // ── Build ─────────────────────────────────────────────────────────
470
444
  if (cmd==='build') {
471
445
  const outIdx=args.indexOf('--out')
472
446
  const outDir=outIdx!==-1?args[outIdx+1]:'dist'
@@ -476,7 +450,7 @@ if (cmd==='build') {
476
450
  fs.readdirSync(input).filter(f=>f.endsWith('.aip')).forEach(f=>files.push(path.join(input,f)))
477
451
  } else if(input.endsWith('.aip')&&fs.existsSync(input)){ files.push(input) }
478
452
  if(!files.length){console.error(`\n ✗ No .aip files in: ${input}\n`);process.exit(1)}
479
- // Resolve ~import directives recursively
453
+
480
454
  function resolveImports(content, baseDir, seen=new Set()) {
481
455
  return content.replace(/^~import\s+["']?([^"'\n]+)["']?$/mg, (_, importPath) => {
482
456
  const resolved = path.resolve(baseDir, importPath.trim())
@@ -511,7 +485,6 @@ if (cmd==='build') {
511
485
  process.exit(0)
512
486
  }
513
487
 
514
- // ── Serve (hot reload) ────────────────────────────────────────────
515
488
  if (cmd==='serve'||cmd==='dev') {
516
489
  const root=path.resolve(args[0]||'.')
517
490
  const port=parseInt(process.env.PORT||'3000')
@@ -549,7 +522,6 @@ if (cmd==='serve'||cmd==='dev') {
549
522
  return
550
523
  }
551
524
 
552
- // ── Dev server (full-stack) ──────────────────────────────────────
553
525
  if (cmd === 'start' || cmd === 'run') {
554
526
  const aipFile = args[0]
555
527
  if (!aipFile || !fs.existsSync(aipFile)) {
@@ -573,10 +545,6 @@ if (cmd === 'start' || cmd === 'run') {
573
545
  console.error(`\n ✗ Unknown command: ${cmd}\n Run aiplang --help\n`)
574
546
  process.exit(1)
575
547
 
576
- // ═════════════════════════════════════════════════════════════════
577
- // PARSER
578
- // ═════════════════════════════════════════════════════════════════
579
-
580
548
  function parsePages(src) {
581
549
  return src.split(/\n---\n/).map(s=>parsePage(s.trim())).filter(Boolean)
582
550
  }
@@ -619,8 +587,7 @@ function parseQuery(s) {
619
587
  }
620
588
 
621
589
  function parseBlock(line) {
622
- // ── Extract suffix modifiers FIRST ──────────────────────────
623
- // animate:fade-up class:my-class (can appear at end of any block line)
590
+
624
591
  let extraClass=null, animate=null
625
592
  const _cm=line.match(/\bclass:(\S+)/)
626
593
  if(_cm){extraClass=_cm[1];line=line.replace(_cm[0],'').trim()}
@@ -636,12 +603,10 @@ function parseBlock(line) {
636
603
  const _bgm=line.match(/\bbg:(#[0-9a-fA-F]+|[a-z]+)/)
637
604
  if(_bgm){bg=_bgm[1];line=line.replace(_bgm[0],'').trim()}
638
605
 
639
- // ── raw{} HTML passthrough ──────────────────────────────────
640
606
  if(line.startsWith('raw{')) {
641
607
  return{kind:'raw',html:line.slice(4,line.lastIndexOf('}')),extraClass,animate}
642
608
  }
643
609
 
644
- // ── table ───────────────────────────────────────────────────
645
610
  if(line.startsWith('table ') || line.startsWith('table{')) {
646
611
  const idx=line.indexOf('{');if(idx===-1) return null
647
612
  const start=line.startsWith('table{')?6:6
@@ -659,7 +624,6 @@ function parseBlock(line) {
659
624
  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
625
  }
661
626
 
662
- // ── form ────────────────────────────────────────────────────
663
627
  if(line.startsWith('form ') || line.startsWith('form{')) {
664
628
  const bi=line.indexOf('{');if(bi===-1) return null
665
629
  let head=line.slice(line.startsWith('form{')?4:5,bi).trim()
@@ -667,7 +631,7 @@ function parseBlock(line) {
667
631
  let action='', optimistic=false; const ai=head.indexOf('=>')
668
632
  if(ai!==-1){
669
633
  action=head.slice(ai+2).trim()
670
- // Optimistic: => @list.optimistic($result)
634
+
671
635
  if(action.includes('.optimistic(')){optimistic=true;action=action.replace('.optimistic','')}
672
636
  head=head.slice(0,ai).trim()
673
637
  }
@@ -677,7 +641,6 @@ function parseBlock(line) {
677
641
  return{kind:'form',method,bpath,action,optimistic,fields:parseFields(content)||[],extraClass,animate,variant,style,bg}
678
642
  }
679
643
 
680
- // ── pricing ─────────────────────────────────────────────────
681
644
  if(line.startsWith('pricing{')) {
682
645
  const body=line.slice(8,line.lastIndexOf('}')).trim()
683
646
  const plans=body.split('|').map(p=>{
@@ -687,14 +650,12 @@ function parseBlock(line) {
687
650
  return{kind:'pricing',plans,extraClass,animate,variant,style,bg}
688
651
  }
689
652
 
690
- // ── faq ─────────────────────────────────────────────────────
691
653
  if(line.startsWith('faq{')) {
692
654
  const body=line.slice(4,line.lastIndexOf('}')).trim()
693
655
  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
656
  return{kind:'faq',items,extraClass,animate}
695
657
  }
696
658
 
697
- // ── testimonial ──────────────────────────────────────────────
698
659
  if(line.startsWith('testimonial{')) {
699
660
  const body=line.slice(12,line.lastIndexOf('}')).trim()
700
661
  const parts=body.split('|').map(x=>x.trim())
@@ -702,12 +663,10 @@ function parseBlock(line) {
702
663
  return{kind:'testimonial',author:parts[0],quote:parts[1]?.replace(/^"|"$/g,''),img:imgPart?.slice(4)||null,extraClass,animate}
703
664
  }
704
665
 
705
- // ── gallery ──────────────────────────────────────────────────
706
666
  if(line.startsWith('gallery{')) {
707
667
  return{kind:'gallery',imgs:line.slice(8,line.lastIndexOf('}')).trim().split('|').map(x=>x.trim()).filter(Boolean),extraClass,animate}
708
668
  }
709
669
 
710
- // ── btn ──────────────────────────────────────────────────────
711
670
  if(line.startsWith('btn{')) {
712
671
  const parts=line.slice(4,line.lastIndexOf('}')).split('>').map(p=>p.trim())
713
672
  const label=parts[0]||'Click', method=parts[1]?.split(' ')[0]||'POST'
@@ -717,7 +676,6 @@ function parseBlock(line) {
717
676
  return{kind:'btn',label,method,bpath,action,confirm,extraClass,animate}
718
677
  }
719
678
 
720
- // ── card{} — standalone card customizável ──────────────────
721
679
  if(line.startsWith('card{') || line.startsWith('card ')) {
722
680
  const bi=line.indexOf('{'); if(bi===-1) return null
723
681
  const body=line.slice(bi+1,line.lastIndexOf('}')).trim()
@@ -731,7 +689,6 @@ function parseBlock(line) {
731
689
  return{kind:'card',title,subtitle,img:imgPart?.slice(4)||null,link:linkPart||null,badge,bind,extraClass,animate,variant,style,bg}
732
690
  }
733
691
 
734
- // ── cols{} — grid de conteúdo livre ─────────────────────────
735
692
  if(line.startsWith('cols{') || (line.startsWith('cols ') && line.includes('{'))) {
736
693
  const bi=line.indexOf('{'); if(bi===-1) return null
737
694
  const head=line.slice(0,bi).trim()
@@ -742,19 +699,16 @@ function parseBlock(line) {
742
699
  return{kind:'cols',n,items,extraClass,animate,variant,style,bg}
743
700
  }
744
701
 
745
- // ── divider{} — separador visual ─────────────────────────────
746
702
  if(line.startsWith('divider') || line.startsWith('hr{')) {
747
703
  const label=line.match(/\{([^}]*)\}/)?.[1]?.trim()||null
748
704
  return{kind:'divider',label,extraClass,animate,variant,style}
749
705
  }
750
706
 
751
- // ── badge{} — label/tag destacado ───────────────────────────
752
707
  if(line.startsWith('badge{') || line.startsWith('tag{')) {
753
708
  const content=line.slice(line.indexOf('{')+1,line.lastIndexOf('}')).trim()
754
709
  return{kind:'badge',content,extraClass,animate,variant,style}
755
710
  }
756
711
 
757
- // ── select ───────────────────────────────────────────────────
758
712
  if(line.startsWith('select ')) {
759
713
  const bi=line.indexOf('{')
760
714
  const varName=bi!==-1?line.slice(7,bi).trim():line.slice(7).trim()
@@ -762,13 +716,11 @@ function parseBlock(line) {
762
716
  return{kind:'select',binding:varName,options:body.split('|').map(o=>o.trim()).filter(Boolean),extraClass,animate}
763
717
  }
764
718
 
765
- // ── if ───────────────────────────────────────────────────────
766
719
  if(line.startsWith('if ')) {
767
720
  const bi=line.indexOf('{');if(bi===-1) return null
768
721
  return{kind:'if',cond:line.slice(3,bi).trim(),inner:line.slice(bi+1,line.lastIndexOf('}')).trim(),extraClass,animate}
769
722
  }
770
723
 
771
- // ── chart{} — data visualization ───────────────────────────────
772
724
  if(line.startsWith('chart{') || line.startsWith('chart ')) {
773
725
  const bi=line.indexOf('{'); if(bi===-1) return null
774
726
  const body=line.slice(bi+1,line.lastIndexOf('}')).trim()
@@ -781,7 +733,6 @@ function parseBlock(line) {
781
733
  return{kind:'chart',type,binding,labels,values,title,extraClass,animate,variant,style}
782
734
  }
783
735
 
784
- // ── kanban{} — drag-and-drop board ───────────────────────────────
785
736
  if(line.startsWith('kanban{') || line.startsWith('kanban ')) {
786
737
  const bi=line.indexOf('{'); if(bi===-1) return null
787
738
  const body=line.slice(bi+1,line.lastIndexOf('}')).trim()
@@ -793,7 +744,6 @@ function parseBlock(line) {
793
744
  return{kind:'kanban',binding,cols,statusField,updatePath,extraClass,animate,style}
794
745
  }
795
746
 
796
- // ── editor{} — rich text editor ──────────────────────────────────
797
747
  if(line.startsWith('editor{') || line.startsWith('editor ')) {
798
748
  const bi=line.indexOf('{'); if(bi===-1) return null
799
749
  const body=line.slice(bi+1,line.lastIndexOf('}')).trim()
@@ -804,7 +754,6 @@ function parseBlock(line) {
804
754
  return{kind:'editor',name,placeholder,submitPath,extraClass,animate,style}
805
755
  }
806
756
 
807
- // ── each @list { template } — loop como React .map() ────────
808
757
  if(line.startsWith('each ')) {
809
758
  const bi=line.indexOf('{');if(bi===-1) return null
810
759
  const binding=line.slice(5,bi).trim()
@@ -812,18 +761,15 @@ function parseBlock(line) {
812
761
  return{kind:'each',binding,tpl,extraClass,animate,variant}
813
762
  }
814
763
 
815
- // ── spacer{} — espaçamento customizável ──────────────────────
816
764
  if(line.startsWith('spacer{') || line.startsWith('spacer ')) {
817
765
  const h=line.match(/[{\s](\S+)[}]?/)?.[1]||'3rem'
818
766
  return{kind:'spacer',height:h,extraClass,animate}
819
767
  }
820
768
 
821
- // ── html{} — HTML inline com interpolação de @state ──────────
822
769
  if(line.startsWith('html{')) {
823
770
  return{kind:'html',content:line.slice(5,line.lastIndexOf('}')),extraClass,animate}
824
771
  }
825
772
 
826
- // ── regular blocks (nav, hero, stats, rowN, sect, foot) ──────
827
773
  const bi=line.indexOf('{');if(bi===-1) return null
828
774
  const head=line.slice(0,bi).trim()
829
775
  const body=line.slice(bi+1,line.lastIndexOf('}')).trim()
@@ -846,41 +792,43 @@ function parseCols(s){return s.split('|').map(c=>{c=c.trim();if(c.startsWith('em
846
792
  function parseEmpty(s){const m=s.match(/empty:\s*([^|]+)/);return m?m[1].trim():'No data.'}
847
793
  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
794
 
849
- // ═════════════════════════════════════════════════════════════════
850
- // RENDERER
851
- // ═════════════════════════════════════════════════════════════════
852
-
853
795
  function applyMods(html, b) {
854
796
  if(!html||(!b.extraClass&&!b.animate)) return html
855
797
  const cls=[(b.extraClass||''),(b.animate?'fx-anim-'+b.animate:'')].filter(Boolean).join(' ')
856
- // Inject into first tag's class attribute (handles multiline HTML)
798
+
857
799
  return html.replace(/class="([^"]*)"/, (_,c)=>`class="${c} ${cls}"`)
858
800
  }
859
801
 
860
802
  function renderPage(page, allPages) {
861
803
  const needsJS=page.queries.length>0||page.blocks.some(b=>['table','list','form','if','btn','select','faq'].includes(b.kind))
862
804
  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
805
+
864
806
  const tableBlocks = page.blocks.filter(b => b.kind === 'table' && b.binding && b.cols && b.cols.length)
865
807
  const numericKeys = ['score','count','total','amount','price','value','qty','age','rank','num','int','float','rate','pct','percent']
866
808
  const compiledDiffs = tableBlocks.map(b => {
867
809
  const binding = b.binding.replace(/^@/, '')
810
+
811
+ const safeId = s => (s||'').replace(/[^a-zA-Z0-9_]/g, '_').slice(0,64) || 'col'
812
+ const safeBinding = safeId(binding)
868
813
  const colDefs = b.cols.map((col, j) => ({
869
- key: col.key,
814
+ key: safeId(col.key),
815
+ origKey: col.key,
870
816
  idx: j,
871
817
  numeric: numericKeys.some(kw => col.key.toLowerCase().includes(kw))
872
818
  }))
873
819
  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}??'')`
820
+ 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))`
821
+ : `c${d.idx}:rows.map(r=>r[${JSON.stringify(d.origKey)}]??'')`
876
822
  ).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(';')
823
+ const diffParts = colDefs.map(d => {
824
+ const k = JSON.stringify(d.origKey)
825
+ return d.numeric
826
+ ? `if(c${d.idx}[i]!==(r[${k}]||0)){c${d.idx}[i]=r[${k}]||0;p.push(i<<4|${d.idx})}`
827
+ : `if(c${d.idx}[i]!==r[${k}]){c${d.idx}[i]=r[${k}];p.push(i<<4|${d.idx})}`
828
+ }).join(';')
881
829
  return [
882
- `window.__aip_init_${binding}=function(rows){return{${initParts}}};`,
883
- `window.__aip_diff_${binding}=function(rows,cache){`,
830
+ `window.__aip_init_${safeBinding}=function(rows){return{${initParts}}};`,
831
+ `window.__aip_diff_${safeBinding}=function(rows,cache){`,
884
832
  `const n=rows.length,p=[],${colDefs.map(d=>`c${d.idx}=cache.c${d.idx}`).join(',')};`,
885
833
  `for(let i=0;i<n;i++){const r=rows[i];${diffParts}}return p};`
886
834
  ].join('')
@@ -888,11 +836,11 @@ function renderPage(page, allPages) {
888
836
  const compiledScript = compiledDiffs.length
889
837
  ? `<script>/* aiplang compiled-diffs */\n${compiledDiffs}\n</script>`
890
838
  : ''
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(/^@/,''))}):''
839
+ 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
840
  const hydrate=needsJS?`\n<script>window.__AIPLANG_PAGE__=${config};</script>\n<script src="./aiplang-hydrate.js" defer></script>`:''
893
841
  const customVars=page.customTheme?genCustomThemeVars(page.customTheme):''
894
842
  const themeVarCSS=page.themeVars?genThemeVarCSS(page.themeVars):''
895
- // Extract app name from nav brand if available
843
+
896
844
  const _navBlock = page.blocks.find(b=>b.kind==='nav')
897
845
  const _brand = _navBlock?.brand || ''
898
846
  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 +896,6 @@ function renderBlock(b, page) {
948
896
  }
949
897
  }
950
898
 
951
- // ── Chart — lazy-loads Chart.js from CDN ────────────────────────
952
899
  function rChart(b) {
953
900
  const id = 'chart_' + Math.random().toString(36).slice(2,8)
954
901
  const binding = b.binding || ''
@@ -959,7 +906,6 @@ function rChart(b) {
959
906
  </div>\n`
960
907
  }
961
908
 
962
- // ── Kanban — drag-and-drop board ─────────────────────────────────
963
909
  function rKanban(b) {
964
910
  const cols = (b.cols||['Todo','In Progress','Done'])
965
911
  const colsHtml = cols.map(col => `
@@ -971,7 +917,6 @@ function rKanban(b) {
971
917
  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
918
  }
973
919
 
974
- // ── Rich text editor ──────────────────────────────────────────────
975
920
  function rEditor(b) {
976
921
  const style = b.style ? ` style="${b.style.replace(/,/g,';')}"` : ''
977
922
  return `<div class="fx-editor-wrap"${style}>
@@ -1057,7 +1002,7 @@ function rRow(b) {
1057
1002
  pink:'#ec4899',cyan:'#06b6d4',lime:'#84cc16',amber:'#f59e0b'
1058
1003
  }
1059
1004
  const cards=(b.items||[]).map(item=>{
1060
- // First token can be color: red|rocket>Title>Body
1005
+
1061
1006
  let colorStyle='', firstIdx=0
1062
1007
  if(item[0]&&!item[0].isImg&&!item[0].isLink){
1063
1008
  const colorKey=item[0].text?.toLowerCase()
@@ -1178,7 +1123,6 @@ function rGallery(b) {
1178
1123
  return `<div class="fx-gallery">${imgs}</div>\n`
1179
1124
  }
1180
1125
 
1181
- // ── Theme helpers ─────────────────────────────────────────────────
1182
1126
  function genCustomThemeVars(ct) {
1183
1127
  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
1128
  }
@@ -1198,10 +1142,6 @@ function genThemeVarCSS(t) {
1198
1142
  return r.join('')
1199
1143
  }
1200
1144
 
1201
- // ═════════════════════════════════════════════════════════════════
1202
- // CSS
1203
- // ═════════════════════════════════════════════════════════════════
1204
-
1205
1145
  function css(theme) {
1206
1146
  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
1147
  .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.10.9",
4
4
  "description": "AI-first web language. One .aip file = complete app. Frontend + backend + database + auth.",
5
5
  "keywords": [
6
6
  "aiplang",
@@ -6,23 +6,20 @@
6
6
  const cfg = window.__AIPLANG_PAGE__
7
7
  if (!cfg) return
8
8
 
9
- // ── Global Store — cross-page state (like React Context / Zustand) ─
10
9
  const _STORE_KEY = 'aiplang_store_v1'
11
10
  const _globalStore = (() => {
12
11
  try { return JSON.parse(sessionStorage.getItem(_STORE_KEY) || '{}') } catch { return {} }
13
12
  })()
14
13
  function syncStore(key, value) {
15
14
  _globalStore[key] = value
16
- try { sessionStorage.setItem(_STORE_KEY, JSON.stringify(_globalStore)) } catch {}
17
- try { new BroadcastChannel(_STORE_KEY).postMessage({ key, value }) } catch {}
15
+ try { sessionStorage.setItem(_STORE_KEY, JSON.stringify(_globalStore)) } catch(_e) { if(typeof console !== 'undefined') console.debug('[aiplang]',_e?.message) }
16
+ try { new BroadcastChannel(_STORE_KEY).postMessage({ key, value }) } catch(_e) { if(typeof console !== 'undefined') console.debug('[aiplang]',_e?.message) }
18
17
  }
19
18
 
20
- // ── Page-level State ─────────────────────────────────────────────
21
19
  const _state = {}
22
20
  const _watchers = {}
23
21
  const _storeKeys = new Set((cfg.stores || []).map(s => s.key))
24
22
 
25
- // Bootstrap state: SSR data > global store > page state declarations
26
23
  const _boot = { ...(window.__SSR_DATA__ || {}), ..._globalStore }
27
24
  for (const [k, v] of Object.entries({ ...(cfg.state || {}), ..._boot })) {
28
25
  try { _state[k] = typeof v === 'string' && (v.startsWith('[') || v.startsWith('{') || v === 'true' || v === 'false' || !isNaN(v)) ? JSON.parse(v) : v } catch { _state[k] = v }
@@ -31,14 +28,14 @@ for (const [k, v] of Object.entries({ ...(cfg.state || {}), ..._boot })) {
31
28
  function get(key) { return _state[key] }
32
29
 
33
30
  function set(key, value, _persist) {
34
- // Fast equality check: primitives first (avoid JSON.stringify for numbers/strings)
31
+
35
32
  const old = _state[key]
36
33
  if (old === value) return
37
34
  if (typeof value !== 'object' && old === value) return
38
35
  if (typeof value === 'object' && value !== null && typeof old === 'object' && old !== null) {
39
- // Only deep check for objects/arrays — skip if different length (fast exit)
36
+
40
37
  if (Array.isArray(value) && Array.isArray(old) && value.length !== old.length) {
41
- // Different length — definitely changed
38
+
42
39
  } else if (JSON.stringify(old) === JSON.stringify(value)) return
43
40
  }
44
41
  _state[key] = value
@@ -46,13 +43,12 @@ function set(key, value, _persist) {
46
43
  notify(key)
47
44
  }
48
45
 
49
- // Cross-tab store sync (other pages update when store changes)
50
46
  try {
51
47
  const _bc = new BroadcastChannel(_STORE_KEY)
52
48
  _bc.onmessage = ({ data: { key, value } }) => {
53
49
  _state[key] = value; notify(key)
54
50
  }
55
- } catch {}
51
+ } catch(_e) { if(typeof console !== 'undefined') console.debug('[aiplang]',_e?.message) }
56
52
 
57
53
  function watch(key, cb) {
58
54
  if (!_watchers[key]) _watchers[key] = []
@@ -77,8 +73,7 @@ function notify(key) {
77
73
  _pending.add(key)
78
74
  if (!_batchScheduled) {
79
75
  _batchScheduled = true
80
- // Use microtask (Promise.resolve) for data fetches — fires faster than rAF
81
- // Use rAF for user interaction (avoids mid-frame layout thrash)
76
+
82
77
  Promise.resolve().then(() => {
83
78
  if (_batchScheduled) requestAnimationFrame(flushBatch)
84
79
  })
@@ -105,7 +100,6 @@ function resolvePath(tmpl, row) {
105
100
 
106
101
  const _intervals = []
107
102
 
108
- // Persistent session ID for delta tracking
109
103
  const _sid = Math.random().toString(36).slice(2)
110
104
 
111
105
  async function runQuery(q) {
@@ -119,13 +113,13 @@ async function runQuery(q) {
119
113
  'x-session-id': _sid
120
114
  }
121
115
  }
122
- // Enable delta updates for GET requests after first load
116
+
123
117
  if (isGet && q._deltaReady) opts.headers['x-aiplang-delta'] = '1'
124
118
  if (q.body) opts.body = JSON.stringify(q.body)
125
119
  try {
126
120
  const res = await fetch(path, opts)
127
- // 304 = nothing changed — skip re-render
128
- if (res.status === 304) { q._deltaReady = true; return null }
121
+
122
+ if (res.status === 304) { q._deltaReady = true; return get(q.target?.replace(/^@/,'')) || null }
129
123
  if (!res.ok) throw new Error('HTTP ' + res.status)
130
124
  const ct = res.headers.get('Content-Type') || ''
131
125
  let data
@@ -135,17 +129,17 @@ async function runQuery(q) {
135
129
  } else {
136
130
  data = await res.json()
137
131
  }
138
- // Delta response: merge into existing state instead of replacing
132
+
139
133
  if (data && data.__delta) {
140
134
  const key = q.target ? q.target.replace(/^@/, '') : null
141
135
  if (key) {
142
136
  const current = [...(get(key) || [])]
143
- // Apply changes
137
+
144
138
  for (const row of (data.changed || [])) {
145
139
  const idx = current.findIndex(r => r.id === row.id)
146
140
  if (idx >= 0) current[idx] = row; else current.push(row)
147
141
  }
148
- // Remove deleted
142
+
149
143
  const delSet = new Set(data.deleted || [])
150
144
  const merged = current.filter(r => !delSet.has(r.id))
151
145
  set(key, merged)
@@ -188,7 +182,7 @@ function applyAction(data, target, action) {
188
182
  return true
189
183
  })
190
184
  set(fm[1], filtered)
191
- } catch {}
185
+ } catch(_e) { if(typeof console !== 'undefined') console.debug('[aiplang]',_e?.message) }
192
186
  return
193
187
  }
194
188
  const am = action.match(/^@([a-zA-Z_]+)\s*=\s*\$result$/)
@@ -322,15 +316,12 @@ function hydrateTables() {
322
316
  }
323
317
  }
324
318
 
325
- // ── Row cache for surgical DOM updates ──────────────────────────
326
- // First render: full DocumentFragment build (fast)
327
- // Re-renders: only update cells that actually changed
328
- const _rowCache = new Map() // id → {score, status, ..., tr element}
319
+ const _rowCache = new Map()
329
320
  const _colKeys = cols.map(c => c.key)
330
321
  let _initialized = false
331
322
 
332
323
  const renderRow = (row, idx) => {
333
- // (defined above, used by virtual scroll too)
324
+
334
325
  }
335
326
 
336
327
  const render = () => {
@@ -338,7 +329,6 @@ function hydrateTables() {
338
329
  let rows = get(key)
339
330
  if (!Array.isArray(rows)) rows = []
340
331
 
341
- // Empty state
342
332
  if (!rows.length) {
343
333
  tbody.innerHTML = ''
344
334
  _rowCache.clear()
@@ -469,9 +459,8 @@ function hydrateTables() {
469
459
  tbody.appendChild(tr)
470
460
  }
471
461
 
472
- // ── Surgical update vs full initial build ────────────────────
473
462
  if (!_initialized) {
474
- // INITIAL: DocumentFragment for single layout pass
463
+
475
464
  _initialized = true
476
465
  tbody.innerHTML = ''
477
466
  const frag = document.createDocumentFragment()
@@ -487,7 +476,7 @@ function hydrateTables() {
487
476
  td.textContent = row[col.key] != null ? row[col.key] : ''
488
477
  tr.appendChild(td)
489
478
  }
490
- // Action cells (edit + delete)
479
+
491
480
  if (editPath || delPath) {
492
481
  const actTd = document.createElement('td')
493
482
  actTd.className = 'fx-td fx-td-actions'
@@ -525,23 +514,19 @@ function hydrateTables() {
525
514
  frag.appendChild(tr)
526
515
  }
527
516
  tbody.appendChild(frag)
528
- // Build compiled cache (Tier 1) + typed cache (Tier 2)
517
+
529
518
  try {
530
519
  const tc = _buildTypedCache(rows, _colKeys)
531
520
  _rowCache._typed = tc
532
- // Compiled diff: use __aip_init_binding if available
521
+
533
522
  const compiledInit = window['__aip_init_' + stateKey]
534
523
  if (compiledInit) {
535
524
  _rowCache._compiled = compiledInit(rows)
536
525
  _rowCache._compiled_n = rows.length
537
526
  _rowCache._ids = rows.map(r => r.id != null ? r.id : null)
538
527
  }
539
- } catch {}
528
+ } catch(_e) { if(typeof console !== 'undefined') console.debug('[aiplang]',_e?.message) }
540
529
  } else {
541
- // UPDATE: off-main-thread diff + requestIdleCallback
542
- // For 500+ rows: Worker computes diff on separate CPU core
543
- // For <500 rows: sync diff (worker overhead not worth it)
544
- // DOM patches always run on main thread but are minimal
545
530
 
546
531
  const _makeRow = (row, idx) => {
547
532
  const tr = document.createElement('tr')
@@ -579,10 +564,10 @@ function hydrateTables() {
579
564
  }
580
565
 
581
566
  if (rows.length >= 500) {
582
- // Large: worker diff → idle callback apply (zero main thread impact)
567
+
583
568
  _diffAsync(rows, _colKeys, _rowCache).then(result => _schedIdle(() => _applyResult(result)))
584
569
  } else {
585
- // Small: sync diff, immediate apply
570
+
586
571
  _applyResult(_diffSync(rows, _colKeys, _rowCache))
587
572
  }
588
573
  }
@@ -696,7 +681,7 @@ function hydrateBindings() {
696
681
  document.querySelectorAll('[data-fx-bind]').forEach(el => {
697
682
  const expr = el.getAttribute('data-fx-bind')
698
683
  const keys = (expr.match(/[@$][a-zA-Z_][a-zA-Z0-9_.]*/g) || []).map(m => m.slice(1).split('.')[0])
699
- // Fast path: single key with simple path — direct textContent assignment
684
+
700
685
  const simpleM = expr.match(/^[@$]([a-zA-Z_][a-zA-Z0-9_.]*)$/)
701
686
  if (simpleM) {
702
687
  const path = simpleM[1].split('.')
@@ -874,25 +859,14 @@ function injectActionCSS() {
874
859
  document.head.appendChild(style)
875
860
  }
876
861
 
877
- // ═══════════════════════════════════════════════════════════════════
878
- // OFF-MAIN-THREAD ENGINE — better than React Fiber
879
- // Fiber splits work across frames on the SAME thread.
880
- // This moves diff computation to a SEPARATE CPU core via Web Worker.
881
- // Main thread only handles tiny DOM patches — never competes with animations.
882
- // ═══════════════════════════════════════════════════════════════════
883
-
884
862
  const _workerSrc = `
885
863
  'use strict'
886
- // aiplang Diff Worker v2 — SharedArrayBuffer edition
887
- // Uses SAB when available (HTTPS + COOP/COEP headers)
888
- // Falls back to postMessage for unsupported environments
889
864
 
890
865
  let _sab = null, _sabView = null
891
866
 
892
867
  self.onmessage = function(e) {
893
868
  const { type, rows, colKeys, cache, reqId, sab } = e.data
894
869
 
895
- // Accept SharedArrayBuffer reference
896
870
  if (sab) { _sab = sab; _sabView = new Int32Array(sab) }
897
871
 
898
872
  if (type !== 'diff') return
@@ -916,17 +890,16 @@ self.onmessage = function(e) {
916
890
  if (!seenIds.has(String(id)) && !seenIds.has(nid)) deletes.push(id)
917
891
  }
918
892
 
919
- // Write to SharedArrayBuffer if available (zero-copy)
920
893
  if (_sabView && patches.length < 8000) {
921
894
  Atomics.store(_sabView, 0, patches.length)
922
895
  for (let i = 0; i < patches.length; i++) {
923
- // Pack: patchIdx[i*3+0]=id_hash, [i*3+1]=col, [i*3+2]=encoded_val
896
+
924
897
  _sabView[1 + i*3] = typeof patches[i].id === 'number' ? patches[i].id : patches[i].id.length
925
898
  _sabView[1 + i*3+1] = patches[i].col
926
- _sabView[1 + i*3+2] = 0 // signal: read from patches array
899
+ _sabView[1 + i*3+2] = 0
927
900
  }
928
- Atomics.store(_sabView, 0, patches.length | 0x80000000) // MSB = done flag
929
- // Still send full patch data for non-numeric values
901
+ Atomics.store(_sabView, 0, patches.length | 0x80000000)
902
+
930
903
  self.postMessage({ type: 'patches', patches, inserts, deletes, reqId, usedSAB: true })
931
904
  } else {
932
905
  self.postMessage({ type: 'patches', patches, inserts, deletes, reqId, usedSAB: false })
@@ -937,7 +910,7 @@ self.onmessage = function(e) {
937
910
  let _diffWorker = null
938
911
  const _wCbs = new Map()
939
912
  let _wReq = 0
940
- let _wSAB = null // SharedArrayBuffer view for zero-copy transfers
913
+ let _wSAB = null
941
914
 
942
915
  function _getWorker() {
943
916
  if (_diffWorker) return _diffWorker
@@ -948,12 +921,12 @@ function _getWorker() {
948
921
  if (cb) { cb(e.data); _wCbs.delete(e.data.reqId) }
949
922
  }
950
923
  _diffWorker.onerror = () => { _diffWorker = null }
951
- // Share a buffer for high-frequency zero-copy patch transfer
924
+
952
925
  try {
953
- const sab = new SharedArrayBuffer(8000 * 3 * 4 + 4) // 8k patches × 3 ints × 4 bytes + header
926
+ const sab = new SharedArrayBuffer(8000 * 3 * 4 + 4)
954
927
  _diffWorker.postMessage({ type: 'init_sab', sab }, [])
955
928
  _wSAB = new Int32Array(sab)
956
- } catch {} // SAB not available (requires HTTPS + COOP headers)
929
+ } catch(_e) { if(typeof console !== 'undefined') console.debug('[aiplang]',_e?.message) }
957
930
  } catch { _diffWorker = null }
958
931
  return _diffWorker
959
932
  }
@@ -970,35 +943,30 @@ function _diffAsync(rows, colKeys, rowCache) {
970
943
  })
971
944
  }
972
945
 
973
- // ── TypedArray positional cache — beats Vue Vapor at all sizes ───
974
- // Float64Array for numeric fields, Uint8Array for status/enum
975
- // No Map.get in hot loop — pure positional array scan
976
- // Strategy: string-compare first for status (cheap), only encode int on change
977
-
978
946
  function _buildTypedCache(rows, colKeys) {
979
947
  const n = rows.length
980
948
  const isNum = colKeys.map(k => {
981
949
  const v = rows[0]?.[k]; return typeof v === 'number' || (v != null && !isNaN(Number(v)) && typeof v !== 'string')
982
950
  })
983
951
  const scores = new Float64Array(n)
984
- const statuses = new Uint8Array(n) // status/enum col (first non-numeric)
985
- const strCols = [] // which colKeys are strings
952
+ const statuses = new Uint8Array(n)
953
+ const strCols = []
986
954
  colKeys.forEach((k,j) => {
987
955
  if (!isNum[j]) strCols.push(j)
988
956
  })
989
957
  rows.forEach((r,i) => {
990
- // Numeric fields → Float64Array
958
+
991
959
  colKeys.forEach((k,j) => { if(isNum[j]) { const buf=j===0?scores:null; if(buf) buf[i]=Number(r[k])||0 } })
992
- // Primary numeric col (usually score/value)
960
+
993
961
  const numIdx = colKeys.findIndex((_,j)=>isNum[j] && j>1)
994
962
  if(numIdx>=0) scores[i] = Number(rows[i][colKeys[numIdx]])||0
995
- // Primary enum col
963
+
996
964
  const enumIdx = colKeys.findIndex((_,j)=>!isNum[j] && j>1)
997
965
  if(enumIdx>=0) statuses[i] = _STATUS_INT[rows[i][colKeys[enumIdx]]]??0
998
966
  })
999
967
  return {
1000
968
  scores, statuses,
1001
- prevVals: colKeys.map(k => rows.map(r => r[k])), // string cache per column
969
+ prevVals: colKeys.map(k => rows.map(r => r[k])),
1002
970
  isNum, colKeys, n,
1003
971
  ids: rows.map(r => r.id)
1004
972
  }
@@ -1008,14 +976,13 @@ function _diffSync(rows, colKeys, rowCache) {
1008
976
  const patches = [], inserts = [], deletes = [], seen = new Set()
1009
977
  const nCols = colKeys.length
1010
978
 
1011
- // TIER 1 — COMPILED DIFF: use generated monomorphic function if available
1012
- // Generated at build time — specific field access, no generic loops
1013
- const compiledKey = stateKey // table binding name
979
+ const compiledKey = stateKey
1014
980
  const compiledInit = window['__aip_init_' + compiledKey]
1015
981
  const compiledDiff = window['__aip_diff_' + compiledKey]
1016
982
 
1017
- if (compiledDiff && rowCache._compiled && rows.length === rowCache._compiled_n) {
1018
- // Decode bitpacked patches: i<<4|colIdx
983
+ if (compiledDiff && rowCache._compiled && rows.length === rowCache._compiled_n
984
+ && rows.length > 0 && rowCache._ids?.length === rows.length) {
985
+
1019
986
  const raw = compiledDiff(rows, rowCache._compiled)
1020
987
  for (const pack of raw) {
1021
988
  const i = pack >> 4, col = pack & 0xf
@@ -1029,7 +996,6 @@ function _diffSync(rows, colKeys, rowCache) {
1029
996
  return { patches, inserts, deletes }
1030
997
  }
1031
998
 
1032
- // TIER 2 — TYPED CACHE fast path
1033
999
  const tc = rowCache._typed
1034
1000
  if (tc && rows.length === tc.n) {
1035
1001
  for (let i = 0; i < rows.length; i++) {
@@ -1049,7 +1015,6 @@ function _diffSync(rows, colKeys, rowCache) {
1049
1015
  return { patches, inserts, deletes }
1050
1016
  }
1051
1017
 
1052
- // STANDARD PATH: Map-based diff (first render or variable-length data)
1053
1018
  for (let i = 0; i < rows.length; i++) {
1054
1019
  const r = rows[i], id = r.id != null ? r.id : i
1055
1020
  seen.add(id)
@@ -1073,8 +1038,6 @@ function _diffSync(rows, colKeys, rowCache) {
1073
1038
  return { patches, inserts, deletes }
1074
1039
  }
1075
1040
 
1076
- // requestIdleCallback scheduler — runs low-priority work when browser is idle
1077
- // Polling updates (30s intervals) don't need to be urgent — let animations breathe
1078
1041
  const _idleQ = []
1079
1042
  let _idleSched = false
1080
1043
  const _ric = window.requestIdleCallback
@@ -1092,13 +1055,11 @@ function _schedIdle(fn) {
1092
1055
  function _flushIdle(dl) {
1093
1056
  _idleSched = false
1094
1057
  while (_idleQ.length && dl.timeRemaining() > 1) {
1095
- try { _idleQ.shift()() } catch {}
1058
+ try { _idleQ.shift()() } catch(_e) { if(typeof console !== 'undefined') console.debug('[aiplang]',_e?.message) }
1096
1059
  }
1097
1060
  if (_idleQ.length) { _idleSched = true; _ric(_flushIdle, { timeout: 5000 }) }
1098
1061
  }
1099
1062
 
1100
- // Incremental renderer — processes rows in chunks between animation frames
1101
- // Zero dropped frames on 100k+ row datasets
1102
1063
  async function _renderIncremental(items, renderFn, chunkSize = 200) {
1103
1064
  for (let i = 0; i < items.length; i += chunkSize) {
1104
1065
  const chunk = items.slice(i, i + chunkSize)
@@ -1109,9 +1070,6 @@ async function _renderIncremental(items, renderFn, chunkSize = 200) {
1109
1070
  }
1110
1071
  }
1111
1072
 
1112
-
1113
- // ── Global reusable TypedArray buffers — zero allocation in hot path ──
1114
- // Pre-allocated at startup, reused across every table render cycle
1115
1073
  const _MAX_ROWS = 100000
1116
1074
  const _scoreBuf = new Float64Array(_MAX_ROWS)
1117
1075
  const _statusBuf = new Uint8Array(_MAX_ROWS)
@@ -1120,9 +1078,6 @@ const _STATUS_INT = {active:0,inactive:1,pending:2,blocked:3,
1120
1078
  pending:2,done:3,todo:0,doing:1,done:2,
1121
1079
  new:0,open:1,closed:2,resolved:3}
1122
1080
 
1123
- // ── Minimal MessagePack decoder (~1.8KB) ─────────────────────────
1124
- // Handles all types produced by aiplang server's msgpack encoder
1125
- // No external dependencies — AI-maintained, human-unreadable by design
1126
1081
  const _mp = (() => {
1127
1082
  const td = new TextDecoder()
1128
1083
  function decode(buf) {
@@ -1131,11 +1086,11 @@ const _mp = (() => {
1131
1086
  }
1132
1087
  function _read(b, s) {
1133
1088
  const t = b[s.p++]
1134
- if (t <= 0x7f) return t // positive fixint
1135
- if (t >= 0xe0) return t - 256 // negative fixint
1136
- if ((t & 0xe0) === 0xa0) return _str(b, s, t & 0x1f) // fixstr
1137
- if ((t & 0xf0) === 0x90) return _arr(b, s, t & 0xf) // fixarray
1138
- if ((t & 0xf0) === 0x80) return _map(b, s, t & 0xf) // fixmap
1089
+ if (t <= 0x7f) return t
1090
+ if (t >= 0xe0) return t - 256
1091
+ if ((t & 0xe0) === 0xa0) return _str(b, s, t & 0x1f)
1092
+ if ((t & 0xf0) === 0x90) return _arr(b, s, t & 0xf)
1093
+ if ((t & 0xf0) === 0x80) return _map(b, s, t & 0xf)
1139
1094
  switch (t) {
1140
1095
  case 0xc0: return null
1141
1096
  case 0xc2: return false
@@ -1221,10 +1176,21 @@ function hydrateTableErrors() {
1221
1176
  if (val === '__error__') {
1222
1177
  if (tbody) {
1223
1178
  const cols = JSON.parse(tbl.getAttribute('data-fx-cols') || '[]')
1224
- tbody.innerHTML = `<tr><td colspan="${cols.length + 2}" class="fx-td-empty" style="color:#f87171">
1225
- ${fallback}
1226
- ${retryPath ? `<button onclick="window.__aiplang_retry('${binding}','${retryPath}')" style="margin-left:.75rem;padding:.3rem .75rem;background:rgba(248,113,113,.1);border:1px solid rgba(248,113,113,.3);color:#f87171;border-radius:.375rem;cursor:pointer;font-size:.75rem">↻ Retry</button>` : ''}
1227
- </td></tr>`
1179
+
1180
+ tbody.innerHTML = ''
1181
+ const _fe = (() => {
1182
+ const tr=document.createElement('tr'), td=document.createElement('td')
1183
+ td.colSpan=cols.length+2; td.className='fx-td-empty'; td.style.color='#f87171'
1184
+ td.textContent=fallback
1185
+ if(retryPath){
1186
+ const btn=document.createElement('button')
1187
+ btn.textContent='↻ Retry'; btn.style.cssText='margin-left:.75rem;padding:.3rem .75rem;background:rgba(248,113,113,.1);border:1px solid rgba(248,113,113,.3);color:#f87171;border-radius:.375rem;cursor:pointer;font-size:.75rem'
1188
+ btn.onclick=()=>window.__aiplang_retry&&window.__aiplang_retry(binding,retryPath)
1189
+ td.appendChild(btn)
1190
+ }
1191
+ tr.appendChild(td); return tr
1192
+ })()
1193
+ tbody.appendChild(_fe)
1228
1194
  }
1229
1195
  }
1230
1196
  })
package/server/server.js CHANGED
@@ -250,17 +250,24 @@ const _cache = new Map()
250
250
  // Stores a hash of each row per client session to send only changes
251
251
  // Key: sessionId:binding → Map<rowId, hash>
252
252
  const _deltaCache = new Map()
253
- const _DELTA_TTL = 60000 // 1 min: expire client state
253
+ const _DELTA_TTL = 60000 // 1 min: expire client state
254
+ const _DELTA_MAX = 2000 // max concurrent sessions (prevent memory DoS)
254
255
 
255
256
  function _rowHash(row) {
256
- // Fast hash for change detection — FNV-1a variant
257
+ // FNV-1a 32-bit — fast, low collision for row change detection
258
+ // Only hashes enumerable own properties to avoid prototype pollution
257
259
  let h = 2166136261
258
- const s = JSON.stringify(row)
259
- for (let i = 0; i < s.length; i++) {
260
- h ^= s.charCodeAt(i)
261
- h = (h * 16777619) >>> 0
260
+ const keys = Object.keys(row).sort()
261
+ for (const k of keys) {
262
+ const v = row[k]
263
+ if (v === null || v === undefined) continue
264
+ const s = k + ':' + String(v)
265
+ for (let i = 0; i < s.length; i++) {
266
+ h = (h ^ s.charCodeAt(i)) >>> 0
267
+ h = Math.imul(h, 16777619) >>> 0
268
+ }
262
269
  }
263
- return h
270
+ return h >>> 0
264
271
  }
265
272
 
266
273
  function _getDelta(sessionId, binding, rows) {
@@ -268,6 +275,12 @@ function _getDelta(sessionId, binding, rows) {
268
275
  const prev = _deltaCache.get(key)
269
276
  // First request — send full dataset, store hashes
270
277
  if (!prev) {
278
+ // Evict oldest entry when at capacity (LRU approximation)
279
+ if (_deltaCache.size >= _DELTA_MAX) {
280
+ let oldest = null, oldestTs = Infinity
281
+ for (const [k, v] of _deltaCache) if (v.ts < oldestTs) { oldest = k; oldestTs = v.ts }
282
+ if (oldest) _deltaCache.delete(oldest)
283
+ }
271
284
  const hashes = new Map(rows.map(r => [r.id, _rowHash(r)]))
272
285
  _deltaCache.set(key, { hashes, ts: Date.now() })
273
286
  return { full: true, rows }
@@ -1032,8 +1045,9 @@ async function execOp(line, ctx, server) {
1032
1045
  if (Array.isArray(result) && ctx.req?.headers?.['x-aiplang-delta'] === '1' && result.length > 0) {
1033
1046
  try {
1034
1047
  const _req = ctx.req
1035
- const sid = (_req.headers['x-session-id'] || _req.socket?.remoteAddress || 'default').slice(0,64)
1036
- const binding = (_req.url?.split('?')[0]?.replace(/^\/api\//,'')?.split('/')[0]) || 'data'
1048
+ const sid = (_req.headers['x-session-id'] || _req.socket?.remoteAddress || 'anon').slice(0,64)
1049
+ // Key = session + full path (not just binding) to handle multi-table pages correctly
1050
+ const binding = (_req.url?.split('?')[0] || '/data').slice(0,128)
1037
1051
  const delta = _getDelta(sid, binding, result)
1038
1052
  if (delta.full) {
1039
1053
  ctx.res.json(status, result); return '__DONE__'
@@ -1928,7 +1942,7 @@ async function startServer(aipFile, port = 3000) {
1928
1942
 
1929
1943
  // Health
1930
1944
  srv.addRoute('GET', '/health', (req, res) => res.json(200, {
1931
- status:'ok', version:'2.10.8',
1945
+ status:'ok', version:'2.10.9',
1932
1946
  models: app.models.map(m=>m.name),
1933
1947
  routes: app.apis.length, pages: app.pages.length,
1934
1948
  admin: app.admin?.prefix || null,