aiplang 2.10.2 → 2.10.3

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.2'
8
+ const VERSION = '2.10.3'
9
9
  const RUNTIME_DIR = path.join(__dirname, '..', 'runtime')
10
10
  const cmd = process.argv[2]
11
11
  const args = process.argv.slice(3)
@@ -768,6 +768,42 @@ function parseBlock(line) {
768
768
  return{kind:'if',cond:line.slice(3,bi).trim(),inner:line.slice(bi+1,line.lastIndexOf('}')).trim(),extraClass,animate}
769
769
  }
770
770
 
771
+ // ── chart{} — data visualization ───────────────────────────────
772
+ if(line.startsWith('chart{') || line.startsWith('chart ')) {
773
+ const bi=line.indexOf('{'); if(bi===-1) return null
774
+ const body=line.slice(bi+1,line.lastIndexOf('}')).trim()
775
+ const parts=body.split('|').map(x=>x.trim())
776
+ const type=parts.find(p=>['bar','line','pie','area','donut'].includes(p))||'bar'
777
+ const binding=parts.find(p=>p.startsWith('@'))||''
778
+ const labels=parts.find(p=>p.startsWith('x:'))?.slice(2)||'label'
779
+ const values=parts.find(p=>p.startsWith('y:'))?.slice(2)||'value'
780
+ const title=parts.find(p=>!p.startsWith('@')&&!['bar','line','pie','area','donut'].includes(p)&&!p.startsWith('x:')&&!p.startsWith('y:'))||''
781
+ return{kind:'chart',type,binding,labels,values,title,extraClass,animate,variant,style}
782
+ }
783
+
784
+ // ── kanban{} — drag-and-drop board ───────────────────────────────
785
+ if(line.startsWith('kanban{') || line.startsWith('kanban ')) {
786
+ const bi=line.indexOf('{'); if(bi===-1) return null
787
+ const body=line.slice(bi+1,line.lastIndexOf('}')).trim()
788
+ const parts=body.split('|').map(x=>x.trim())
789
+ const binding=parts.find(p=>p.startsWith('@'))||''
790
+ const cols=parts.filter(p=>!p.startsWith('@')&&!p.startsWith('status:'))
791
+ const statusField=parts.find(p=>p.startsWith('status:'))?.slice(7)||'status'
792
+ const updatePath=parts.find(p=>p.startsWith('PUT ')||p.startsWith('PATCH '))||''
793
+ return{kind:'kanban',binding,cols,statusField,updatePath,extraClass,animate,style}
794
+ }
795
+
796
+ // ── editor{} — rich text editor ──────────────────────────────────
797
+ if(line.startsWith('editor{') || line.startsWith('editor ')) {
798
+ const bi=line.indexOf('{'); if(bi===-1) return null
799
+ const body=line.slice(bi+1,line.lastIndexOf('}')).trim()
800
+ const parts=body.split('|').map(x=>x.trim())
801
+ const name=parts[0]||'content'
802
+ const placeholder=parts[1]||'Start writing...'
803
+ const submitPath=parts.find(p=>p.startsWith('POST ')||p.startsWith('PUT '))||''
804
+ return{kind:'editor',name,placeholder,submitPath,extraClass,animate,style}
805
+ }
806
+
771
807
  // ── each @list { template } — loop como React .map() ────────
772
808
  if(line.startsWith('each ')) {
773
809
  const bi=line.indexOf('{');if(bi===-1) return null
@@ -824,7 +860,7 @@ function applyMods(html, b) {
824
860
  function renderPage(page, allPages) {
825
861
  const needsJS=page.queries.length>0||page.blocks.some(b=>['table','list','form','if','btn','select','faq'].includes(b.kind))
826
862
  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('')
827
- const config=needsJS?JSON.stringify({id:page.id,theme:page.theme,routes:allPages.map(p=>p.route),state:page.state,queries:page.queries}):''
863
+ 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||{}}):''
828
864
  const hydrate=needsJS?`\n<script>window.__AIPLANG_PAGE__=${config};</script>\n<script src="./aiplang-hydrate.js" defer></script>`:''
829
865
  const customVars=page.customTheme?genCustomThemeVars(page.customTheme):''
830
866
  const themeVarCSS=page.themeVars?genThemeVarCSS(page.themeVars):''
@@ -875,12 +911,55 @@ function renderBlock(b, page) {
875
911
  case 'badge': return `<div class="fx-badge-row"><span class="fx-badge-tag">${esc(b.content||'')}</span></div>\n`
876
912
  case 'card': return rCardBlock(b)
877
913
  case 'cols': return rColsBlock(b)
914
+ case 'chart': return rChart(b)
915
+ case 'kanban': return rKanban(b)
916
+ case 'editor': return rEditor(b)
878
917
  case 'each': return `<div class="fx-each fx-each-${b.variant||'list'}" data-fx-each="${esc(b.binding||'')}" data-fx-tpl="${esc(b.tpl||'')}"${b.style?` style="${b.style.replace(/,/g,';')}"`:''}>\n<div class="fx-each-empty fx-td-empty">Loading...</div></div>\n`
879
918
  case 'if': return `<div class="fx-if-wrap" data-fx-if="${esc(b.cond)}" style="display:none"></div>\n`
880
919
  default: return ''
881
920
  }
882
921
  }
883
922
 
923
+ // ── Chart — lazy-loads Chart.js from CDN ────────────────────────
924
+ function rChart(b) {
925
+ const id = 'chart_' + Math.random().toString(36).slice(2,8)
926
+ const binding = b.binding || ''
927
+ const style = b.style ? ` style="${b.style.replace(/,/g,';')}"` : ''
928
+ return `<div class="fx-chart-wrap"${style}>
929
+ ${b.title ? `<div class="fx-chart-title">${esc(b.title)}</div>` : ''}
930
+ <canvas id="${id}" class="fx-chart" data-fx-chart="${esc(binding)}" data-chart-type="${esc(b.type||'bar')}" data-chart-labels="${esc(b.labels||'label')}" data-chart-values="${esc(b.values||'value')}"></canvas>
931
+ </div>\n`
932
+ }
933
+
934
+ // ── Kanban — drag-and-drop board ─────────────────────────────────
935
+ function rKanban(b) {
936
+ const cols = (b.cols||['Todo','In Progress','Done'])
937
+ const colsHtml = cols.map(col => `
938
+ <div class="fx-kanban-col" data-col="${esc(col)}">
939
+ <div class="fx-kanban-col-title">${esc(col)}</div>
940
+ <div class="fx-kanban-cards" data-status="${esc(col)}"></div>
941
+ </div>`).join('')
942
+ const style = b.style ? ` style="${b.style.replace(/,/g,';')}"` : ''
943
+ 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`
944
+ }
945
+
946
+ // ── Rich text editor ──────────────────────────────────────────────
947
+ function rEditor(b) {
948
+ const style = b.style ? ` style="${b.style.replace(/,/g,';')}"` : ''
949
+ return `<div class="fx-editor-wrap"${style}>
950
+ <div class="fx-editor-toolbar">
951
+ <button type="button" onclick="document.execCommand('bold')" class="fx-editor-btn" title="Bold"><b>B</b></button>
952
+ <button type="button" onclick="document.execCommand('italic')" class="fx-editor-btn" title="Italic"><i>I</i></button>
953
+ <button type="button" onclick="document.execCommand('underline')" class="fx-editor-btn" title="Underline"><u>U</u></button>
954
+ <button type="button" onclick="document.execCommand('insertUnorderedList')" class="fx-editor-btn" title="List">≡</button>
955
+ <button type="button" onclick="document.execCommand('createLink',false,prompt('URL:'))" class="fx-editor-btn" title="Link">🔗</button>
956
+ ${b.submitPath ? `<button type="button" class="fx-editor-save fx-btn" data-editor-save="${esc(b.submitPath)}" data-editor-field="${esc(b.name||'content')}">Save</button>` : ''}
957
+ </div>
958
+ <div class="fx-editor" contenteditable="true" data-fx-editor="${esc(b.name||'content')}" placeholder="${esc(b.placeholder||'Start writing...')}"></div>
959
+ <input type="hidden" name="${esc(b.name||'content')}" class="fx-editor-hidden">
960
+ </div>\n`
961
+ }
962
+
884
963
  function rCardBlock(b) {
885
964
  const img=b.img?`<img src="${esc(b.img)}" class="fx-card-img" alt="${esc(b.title||'')}" loading="lazy">`:'';
886
965
  const badge=b.badge?`<span class="fx-card-badge">${esc(b.badge)}</span>`:'';
@@ -1129,6 +1208,9 @@ function css(theme) {
1129
1208
  .fx-pricing-compact{border-radius:.875rem;padding:1.25rem;display:flex;align-items:center;gap:1rem;border:1px solid rgba(255,255,255,.08)}
1130
1209
  .fx-pricing-price-sm{font-size:1.5rem;font-weight:800;letter-spacing:-.04em}
1131
1210
  .fx-grid-numbered>.fx-card{counter-increment:card-counter}
1211
+ .fx-chart-wrap{padding:1rem 2.5rem;position:relative}.fx-chart-title{font-family:monospace;font-size:.65rem;letter-spacing:.1em;text-transform:uppercase;color:#475569;margin-bottom:.75rem}.fx-chart{max-height:320px}
1212
+ .fx-kanban{display:flex;gap:1rem;padding:1rem 2.5rem;overflow-x:auto;align-items:flex-start}.fx-kanban-col{flex:0 0 280px;background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.08);border-radius:.875rem;padding:1rem}.fx-kanban-col-title{font-family:monospace;font-size:.65rem;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:#64748b;margin-bottom:.875rem}.fx-kanban-cards{min-height:80px;display:flex;flex-direction:column;gap:.5rem}.fx-kanban-card{background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.1);border-radius:.5rem;padding:.75rem;cursor:grab;font-size:.8125rem;line-height:1.5;transition:transform .15s,box-shadow .15s}.fx-kanban-card:hover{transform:translateY(-1px);box-shadow:0 4px 12px rgba(0,0,0,.3)}.fx-kanban-card.dragging{opacity:.5;cursor:grabbing}
1213
+ .fx-editor-wrap{padding:.75rem 2.5rem}.fx-editor-toolbar{display:flex;gap:.25rem;margin-bottom:.5rem;flex-wrap:wrap}.fx-editor-btn{background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.1);color:#e2e8f0;border-radius:.375rem;padding:.25rem .625rem;cursor:pointer;font-size:.8125rem;font-family:inherit;transition:background .1s}.fx-editor-btn:hover{background:rgba(255,255,255,.12)}.fx-editor{min-height:160px;padding:1rem;background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.1);border-radius:.625rem;color:#e2e8f0;font-size:.875rem;line-height:1.7;outline:none}.fx-editor:empty::before{content:attr(placeholder);color:#475569;pointer-events:none}.fx-editor-save{margin-left:auto}
1132
1214
  .fx-grid-numbered>.fx-card::before{content:counter(card-counter,decimal-leading-zero);font-size:2rem;font-weight:900;opacity:.15;font-family:monospace;line-height:1}
1133
1215
  .fx-grid-bordered>.fx-card{border:1px solid rgba(255,255,255,.08)}@keyframes fx-fade-up{from{opacity:0;transform:translateY(20px)}to{opacity:1;transform:none}}@keyframes fx-fade-in{from{opacity:0}to{opacity:1}}@keyframes fx-slide-left{from{opacity:0;transform:translateX(30px)}to{opacity:1;transform:none}}@keyframes fx-slide-right{from{opacity:0;transform:translateX(-30px)}to{opacity:1;transform:none}}@keyframes fx-zoom-in{from{opacity:0;transform:scale(.95)}to{opacity:1;transform:scale(1)}}@keyframes fx-blur-in{from{opacity:0;filter:blur(8px)}to{opacity:1;filter:blur(0)}}.fx-anim-fade-up{animation:fx-fade-up .6s cubic-bezier(.4,0,.2,1) both}.fx-anim-fade-in{animation:fx-fade-in .6s ease both}.fx-anim-slide-left{animation:fx-slide-left .6s cubic-bezier(.4,0,.2,1) both}.fx-anim-slide-right{animation:fx-slide-right .6s cubic-bezier(.4,0,.2,1) both}.fx-anim-zoom-in{animation:fx-zoom-in .5s cubic-bezier(.4,0,.2,1) both}.fx-anim-blur-in{animation:fx-blur-in .7s ease both}.fx-anim-stagger>.fx-card:nth-child(1){animation:fx-fade-up .5s 0s both}.fx-anim-stagger>.fx-card:nth-child(2){animation:fx-fade-up .5s .1s both}.fx-anim-stagger>.fx-card:nth-child(3){animation:fx-fade-up .5s .2s both}.fx-anim-stagger>.fx-card:nth-child(4){animation:fx-fade-up .5s .3s both}.fx-anim-stagger>.fx-card:nth-child(5){animation:fx-fade-up .5s .4s both}.fx-anim-stagger>.fx-card:nth-child(6){animation:fx-fade-up .5s .5s both}`
1134
1216
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aiplang",
3
- "version": "2.10.2",
3
+ "version": "2.10.3",
4
4
  "description": "AI-first web language. One .aip file = complete app. Frontend + backend + database + auth.",
5
5
  "keywords": [
6
6
  "aiplang",
@@ -6,21 +6,45 @@
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
+ const _STORE_KEY = 'aiplang_store_v1'
11
+ const _globalStore = (() => {
12
+ try { return JSON.parse(sessionStorage.getItem(_STORE_KEY) || '{}') } catch { return {} }
13
+ })()
14
+ function syncStore(key, value) {
15
+ _globalStore[key] = value
16
+ try { sessionStorage.setItem(_STORE_KEY, JSON.stringify(_globalStore)) } catch {}
17
+ try { new BroadcastChannel(_STORE_KEY).postMessage({ key, value }) } catch {}
18
+ }
19
+
20
+ // ── Page-level State ─────────────────────────────────────────────
9
21
  const _state = {}
10
22
  const _watchers = {}
23
+ const _storeKeys = new Set((cfg.stores || []).map(s => s.key))
11
24
 
12
- for (const [k, v] of Object.entries(cfg.state || {})) {
13
- try { _state[k] = JSON.parse(v) } catch { _state[k] = v }
25
+ // Bootstrap state: SSR data > global store > page state declarations
26
+ const _boot = { ...(window.__SSR_DATA__ || {}), ..._globalStore }
27
+ for (const [k, v] of Object.entries({ ...(cfg.state || {}), ..._boot })) {
28
+ try { _state[k] = typeof v === 'string' && (v.startsWith('[') || v.startsWith('{') || v === 'true' || v === 'false' || !isNaN(v)) ? JSON.parse(v) : v } catch { _state[k] = v }
14
29
  }
15
30
 
16
31
  function get(key) { return _state[key] }
17
32
 
18
- function set(key, value) {
33
+ function set(key, value, _persist) {
19
34
  if (JSON.stringify(_state[key]) === JSON.stringify(value)) return
20
35
  _state[key] = value
36
+ if (_storeKeys.has(key) || _persist) syncStore(key, value)
21
37
  notify(key)
22
38
  }
23
39
 
40
+ // Cross-tab store sync (other pages update when store changes)
41
+ try {
42
+ const _bc = new BroadcastChannel(_STORE_KEY)
43
+ _bc.onmessage = ({ data: { key, value } }) => {
44
+ _state[key] = value; notify(key)
45
+ }
46
+ } catch {}
47
+
24
48
  function watch(key, cb) {
25
49
  if (!_watchers[key]) _watchers[key] = []
26
50
  _watchers[key].push(cb)
@@ -741,6 +765,9 @@ function boot() {
741
765
  hydrateSelects()
742
766
  hydrateIfs()
743
767
  hydrateEach()
768
+ hydrateCharts()
769
+ hydrateKanban()
770
+ hydrateEditors()
744
771
  mountQueries()
745
772
  }
746
773
 
package/server/server.js CHANGED
@@ -761,7 +761,18 @@ function parseFrontPage(src) {
761
761
  // Auto-detect target from path if not specified: ~mount GET /api/users → @users
762
762
  const autoTarget = pts[3] || ('@' + (pts[2]?.split('/').filter(Boolean).pop()?.split('?')[0]||'data'))
763
763
  p.queries.push({trigger:'mount',method:pts[1],path:pts[2],target:ai===-1?autoTarget:null,action:ai!==-1?pts.slice(ai+1).join(' '):null})
764
- } else if(pts[0]==='interval')p.queries.push({trigger:'interval',interval:parseInt(pts[1]),method:pts[2],path:pts[3],target:ai===-1?pts[4]:null,action:ai!==-1?pts.slice(ai+1).join(' '):null})}
764
+ } else if(pts[0]==='interval') {
765
+ p.queries.push({trigger:'interval',interval:parseInt(pts[1]),method:pts[2],path:pts[3],target:ai===-1?pts[4]:null,action:ai!==-1?pts.slice(ai+1).join(' '):null})
766
+ } else if(pts[0]==='ssr') {
767
+ const target = (ai!==-1 ? pts[ai+1] : pts[3] || ('@'+(pts[2]?.split('/').filter(Boolean).pop()||'data'))).replace(/^@/,'')
768
+ p.ssr = p.ssr || []
769
+ p.ssr.push({ method: pts[1]||'GET', path: pts[2], target })
770
+ } else if(pts[0]==='store') {
771
+ const sk = (pts[1]||'').replace(/^@/,'')
772
+ p.stores = p.stores || []
773
+ p.stores.push({ key: sk, method: pts[2], path: pts[3], persist: pts.find(x=>x.startsWith('persist='))?.slice(8)||'session' })
774
+ p.queries.push({ trigger:'mount', method:pts[2], path:pts[3], target:'@'+sk })
775
+ }}
765
776
  else p.blocks.push({kind:blockKind(line),rawLine:line})
766
777
  }
767
778
  return p
@@ -1828,7 +1839,7 @@ async function startServer(aipFile, port = 3000) {
1828
1839
 
1829
1840
  // Health
1830
1841
  srv.addRoute('GET', '/health', (req, res) => res.json(200, {
1831
- status:'ok', version:'2.10.2',
1842
+ status:'ok', version:'2.10.3',
1832
1843
  models: app.models.map(m=>m.name),
1833
1844
  routes: app.apis.length, pages: app.pages.length,
1834
1845
  admin: app.admin?.prefix || null,