tina4-nodejs 3.10.92 → 3.10.95

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/CLAUDE.md CHANGED
@@ -1,10 +1,10 @@
1
- # CLAUDE.md — AI Developer Guide for tina4-nodejs (v3.10.70)
1
+ # CLAUDE.md — AI Developer Guide for tina4-nodejs (v3.10.95)
2
2
 
3
3
  > This file helps AI assistants (Claude, Copilot, Cursor, etc.) understand and work on this codebase effectively.
4
4
 
5
5
  ## What This Project Is
6
6
 
7
- Tina4 for Node.js/TypeScript v3.10.70 — The Intelligent Native Application 4ramework. A convention-over-configuration structural paradigm. The developer writes TypeScript; Tina4 is invisible infrastructure.
7
+ Tina4 for Node.js/TypeScript v3.10.95 — The Intelligent Native Application 4ramework. A convention-over-configuration structural paradigm. The developer writes TypeScript; Tina4 is invisible infrastructure.
8
8
 
9
9
  The philosophy: zero ceremony, batteries included, file system as source of truth.
10
10
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tina4-nodejs",
3
- "version": "3.10.92",
3
+ "version": "3.10.95",
4
4
  "type": "module",
5
5
  "description": "Tina4 for Node.js/TypeScript — 54 built-in features, zero dependencies",
6
6
  "keywords": ["tina4", "framework", "web", "api", "orm", "graphql", "websocket", "typescript"],
@@ -1,6 +1,6 @@
1
- "use strict";var Tina4=(()=>{var H=Object.defineProperty;var pe=Object.getOwnPropertyDescriptor;var ge=Object.getOwnPropertyNames;var he=Object.prototype.hasOwnProperty;var me=(e,n)=>{for(var t in n)H(e,t,{get:n[t],enumerable:!0})},ye=(e,n,t,o)=>{if(n&&typeof n=="object"||typeof n=="function")for(let r of ge(n))!he.call(e,r)&&r!==t&&H(e,r,{get:()=>n[r],enumerable:!(o=pe(n,r))||o.enumerable});return e};var ve=e=>ye(H({},"__esModule",{value:!0}),e);var Ae={};me(Ae,{Tina4Element:()=>I,api:()=>ie,batch:()=>q,computed:()=>z,effect:()=>m,html:()=>X,isSignal:()=>w,navigate:()=>L,pwa:()=>le,route:()=>oe,router:()=>re,signal:()=>h,ws:()=>ue});var C=null,_=null,M=null;function b(e){M=e}function J(){return M}var B=null,V=null;var N=0,P=new Set;function h(e,n){let t=e,o=new Set,r={_t4:!0,get value(){if(C&&(o.add(C),_)){let a=C;_.push(()=>o.delete(a))}return t},set value(a){if(Object.is(a,t))return;let i=t;if(t=a,r._debugInfo&&r._debugInfo.updateCount++,V&&V(r,i,a),N>0)for(let s of o)P.add(s);else{let s;for(let c of[...o])try{c()}catch(l){s===void 0&&(s=l)}if(s!==void 0)throw s}},_subscribe(a){return o.add(a),()=>{o.delete(a)}},peek(){return t}};return B&&(r._debugInfo={label:n,createdAt:Date.now(),updateCount:0,subs:o},B(r,n)),r}function z(e){let n=h(void 0);return m(()=>{n.value=e()}),{_t4:!0,get value(){return n.value},set value(t){throw new Error("[tina4] computed signals are read-only")},_subscribe(t){return n._subscribe(t)},peek(){return n.peek()}}}function m(e){let n=!1,t=[],o=()=>{if(n)return;for(let s of t)s();t=[];let a=C,i=_;C=o,_=t;try{e()}finally{C=a,_=i}};o();let r=()=>{n=!0;for(let a of t)a();t=[]};return M&&M.push(r),r}function q(e){N++;try{e()}finally{if(N--,N===0){let n=[...P];P.clear();let t;for(let o of n)try{o()}catch(r){t===void 0&&(t=r)}if(t!==void 0)throw t}}}function w(e){return e!==null&&typeof e=="object"&&e._t4===!0}var Q=new WeakMap,D="t4:";function X(e,...n){let t=Q.get(e);if(!t){t=document.createElement("template");let i="";for(let s=0;s<e.length;s++)i+=e[s],s<n.length&&(Te(i)?i+=`__t4_${s}__`:i+=`<!--${D}${s}-->`);t.innerHTML=i,Q.set(e,t)}let o=t.content.cloneNode(!0),r=be(o);for(let{marker:i,index:s}of r)ke(i,n[s]);let a=we(o);for(let i of a)Ce(i,n);return o}function be(e){let n=[];return W(e,t=>{if(t.nodeType===8){let o=t.data;if(o&&o.startsWith(D)){let r=parseInt(o.slice(D.length),10);n.push({marker:t,index:r})}}}),n}function we(e){let n=[];return W(e,t=>{t.nodeType===1&&n.push(t)}),n}function W(e,n){let t=e.childNodes;for(let o=0;o<t.length;o++){let r=t[o];n(r),W(r,n)}}function ke(e,n){let t=e.parentNode;if(t)if(w(n)){let o=document.createTextNode("");t.replaceChild(o,e),m(()=>{o.data=String(n.value??"")})}else if(typeof n=="function"){let o=document.createComment("");t.replaceChild(o,e);let r=[],a=[];m(()=>{for(let d of a)d();a=[];let i=[],s=J();b(i);let c=n();b(s),a=i;for(let d of r)d.parentNode?.removeChild(d);r=[];let l=F(c),f=o.parentNode;if(f)for(let d of l)f.insertBefore(d,o),r.push(d)})}else if(Y(n))t.replaceChild(n,e);else if(n instanceof Node)t.replaceChild(n,e);else if(Array.isArray(n)){let o=document.createDocumentFragment();for(let r of n){let a=F(r);for(let i of a)o.appendChild(i)}t.replaceChild(o,e)}else{let o=document.createTextNode(String(n??""));t.replaceChild(o,e)}}function Ce(e,n){let t=[];for(let o of Array.from(e.attributes)){let r=o.name,a=o.value;if(r.startsWith("@")){let s=r.slice(1),c=a.match(/__t4_(\d+)__/);if(c){let l=n[parseInt(c[1],10)];typeof l=="function"&&e.addEventListener(s,f=>q(()=>l(f)))}t.push(r);continue}if(r.startsWith("?")){let s=r.slice(1),c=a.match(/__t4_(\d+)__/);if(c){let l=n[parseInt(c[1],10)];if(w(l)){let f=l;m(()=>{f.value?e.setAttribute(s,""):e.removeAttribute(s)})}else typeof l=="function"?m(()=>{l()?e.setAttribute(s,""):e.removeAttribute(s)}):l&&e.setAttribute(s,"")}t.push(r);continue}if(r.startsWith(".")){let s=r.slice(1),c=a.match(/__t4_(\d+)__/);if(c){let l=n[parseInt(c[1],10)];w(l)?m(()=>{e[s]=l.value}):e[s]=l}t.push(r);continue}let i=a.match(/__t4_(\d+)__/);if(i){let s=n[parseInt(i[1],10)];if(w(s)){let c=s;m(()=>{e.setAttribute(r,String(c.value??""))})}else typeof s=="function"?m(()=>{e.setAttribute(r,String(s()??""))}):e.setAttribute(r,String(s??""))}}for(let o of t)e.removeAttribute(o)}function F(e){if(e==null||e===!1)return[];if(Y(e))return Array.from(e.childNodes);if(e instanceof Node)return[e];if(Array.isArray(e)){let n=[];for(let t of e)n.push(...F(t));return n}return[document.createTextNode(String(e))]}function Y(e){return e!=null&&typeof e=="object"&&e.nodeType===11}function Te(e){let n=!1,t=!1,o=!1;for(let r=0;r<e.length;r++){let a=e[r];a==="<"&&!n&&!t&&(o=!0),a===">"&&!n&&!t&&(o=!1),o&&(a==='"'&&!n&&(t=!t),a==="'"&&!t&&(n=!n))}return o}var Z=null,ee=null;var I=class extends HTMLElement{constructor(){super();this._props={};this._rendered=!1;let t=this.constructor;this._root=t.shadow?this.attachShadow({mode:"open"}):this;for(let[o,r]of Object.entries(t.props))this._props[o]=h(this._coerce(this.getAttribute(o),r))}static{this.props={}}static{this.styles=""}static{this.shadow=!0}static get observedAttributes(){return Object.keys(this.props)}connectedCallback(){if(this._rendered)return;this._rendered=!0;let t=this.constructor;if(t.styles&&t.shadow&&this._root instanceof ShadowRoot){let r=document.createElement("style");r.textContent=t.styles,this._root.appendChild(r)}let o=this.render();o&&this._root.appendChild(o),this.onMount(),Z&&Z(this)}disconnectedCallback(){this.onUnmount(),ee&&ee(this)}attributeChangedCallback(t,o,r){let i=this.constructor.props[t];i&&this._props[t]&&(this._props[t].value=this._coerce(r,i))}prop(t){if(!this._props[t])throw new Error(`[tina4] Prop '${t}' not declared in static props of <${this.tagName.toLowerCase()}>`);return this._props[t]}emit(t,o){this.dispatchEvent(new CustomEvent(t,{bubbles:!0,composed:!0,...o}))}onMount(){}onUnmount(){}_coerce(t,o){return o===Boolean?t!==null:o===Number?t!==null?Number(t):0:t??""}};var U=[],T=null,S="history",_e=!1,E=[],O=[],te=0;function oe(e,n){let t=[],o;e==="*"?o=".*":o=e.replace(/\{(\w+)\}/g,(a,i)=>(t.push(i),"([^/]+)"));let r=new RegExp(`^${o}$`);typeof n=="function"?U.push({pattern:e,regex:r,paramNames:t,handler:n}):U.push({pattern:e,regex:r,paramNames:t,handler:n.handler,guard:n.guard})}function L(e,n){if(S==="hash")if(n?.replace){let t=new URL(location.href);t.hash="#"+e,history.replaceState(null,"",t.toString()),R()}else location.hash="#"+e;else n?.replace?history.replaceState(null,"",e):history.pushState(null,"",e),R()}function R(){if(!T)return;let e=performance.now(),n=++te,t=S==="hash"?location.hash.slice(1)||"/":location.pathname;for(let o of U){let r=t.match(o.regex);if(!r)continue;let a={};if(o.paramNames.forEach((c,l)=>{a[c]=decodeURIComponent(r[l+1])}),o.guard){let c=o.guard();if(c===!1)return;if(typeof c=="string"){L(c,{replace:!0});return}}for(let c of O)c();O=[],T.innerHTML="";let i=[];b(i);let s=o.handler(a);if(s instanceof Promise)s.then(c=>{if(b(null),n!==te){for(let f of i)f();return}ne(T,c),O=i;let l=performance.now()-e;for(let f of E)f({path:t,params:a,pattern:o.pattern,durationMs:l})});else{b(null),ne(T,s),O=i;let c=performance.now()-e;for(let l of E)l({path:t,params:a,pattern:o.pattern,durationMs:c})}return}}function ne(e,n){n instanceof DocumentFragment||n instanceof Node?e.replaceChildren(n):typeof n=="string"?e.innerHTML=n:n!=null&&e.replaceChildren(document.createTextNode(String(n)))}var re={start(e){if(T=document.querySelector(e.target),!T)throw new Error(`[tina4] Router target '${e.target}' not found in DOM`);S=e.mode??"history",_e=!0,window.addEventListener("popstate",R),S==="hash"&&window.addEventListener("hashchange",R),document.addEventListener("click",n=>{if(n.metaKey||n.ctrlKey||n.shiftKey||n.altKey)return;let t=n.target.closest("a[href]");if(!t||t.origin!==location.origin||t.hasAttribute("target")||t.hasAttribute("download")||t.getAttribute("rel")?.includes("external"))return;n.preventDefault();let o=S==="hash"?t.getAttribute("href"):t.pathname;L(o)}),R()},on(e,n){return E.push(n),()=>{let t=E.indexOf(n);t>=0&&E.splice(t,1)}}};var y={baseUrl:"",auth:!1,tokenKey:"tina4_token",headers:{}},j=[],K=[],Se=0;function se(){try{return localStorage.getItem(y.tokenKey)}catch{return null}}function Ee(e){try{localStorage.setItem(y.tokenKey,e)}catch{}}async function x(e,n,t,o){let r={method:e,headers:{"Content-Type":"application/json",...y.headers}};if(y.auth){let d=se();d&&(r.headers.Authorization=`Bearer ${d}`)}if(t!==void 0&&e!=="GET"){let d=typeof t=="object"&&t!==null?{...t}:t;if(y.auth&&typeof d=="object"&&d!==null){let p=se();p&&(d.formToken=p)}r.body=JSON.stringify(d)}if(o?.headers&&Object.assign(r.headers,o.headers),o?.params){let d=Object.entries(o.params).map(([p,v])=>`${encodeURIComponent(p)}=${encodeURIComponent(String(v))}`).join("&");n+=(n.includes("?")?"&":"?")+d}let a=y.baseUrl+n;r._url=a,r._requestId=++Se;for(let d of j){let p=d(r);p&&(r=p)}let i=await fetch(a,r),s=i.headers.get("FreshToken");s&&Ee(s);let c=i.headers.get("Content-Type")??"",l;c.includes("json")?l=await i.json():l=await i.text();let f={status:i.status,data:l,ok:i.ok,headers:i.headers,_requestId:r._requestId};for(let d of K){let p=d(f);p&&(f=p)}if(!i.ok)throw f;return f.data}var ie={configure(e){Object.assign(y,e)},get(e,n){return x("GET",e,void 0,n)},post(e,n,t){return x("POST",e,n,t)},put(e,n,t){return x("PUT",e,n,t)},patch(e,n,t){return x("PATCH",e,n,t)},delete(e,n){return x("DELETE",e,void 0,n)},intercept(e,n){e==="request"?j.push(n):K.push(n)},_reset(){y.baseUrl="",y.auth=!1,y.tokenKey="tina4_token",y.headers={},j.length=0,K.length=0}};function ae(e){let n=e.cacheStrategy??"network-first",t=JSON.stringify(e.precache??[]),o=e.offlineRoute?`'${e.offlineRoute}'`:"null";return`
1
+ "use strict";var Tina4=(()=>{var B=Object.defineProperty;var Me=Object.getOwnPropertyDescriptor;var qe=Object.getOwnPropertyNames;var Ne=Object.prototype.hasOwnProperty;var Oe=(t,n)=>{for(var e in n)B(t,e,{get:n[e],enumerable:!0})},Ie=(t,n,e,o)=>{if(n&&typeof n=="object"||typeof n=="function")for(let r of qe(n))!Ne.call(t,r)&&r!==e&&B(t,r,{get:()=>n[r],enumerable:!(o=Me(n,r))||o.enumerable});return t};var De=t=>Ie(B({},"__esModule",{value:!0}),t);var Ve={};Oe(Ve,{Tina4Element:()=>W,api:()=>we,batch:()=>F,computed:()=>ce,effect:()=>b,html:()=>ue,isSignal:()=>R,navigate:()=>G,pwa:()=>Se,route:()=>me,router:()=>ve,signal:()=>v,sse:()=>Te,ws:()=>Ee});var q=null,I=null,j=null;function S(t){j=t}function U(){return j}var ae=null,ie=null;var K=0,z=new Set;function v(t,n){let e=t,o=new Set,r={_t4:!0,get value(){if(q&&(o.add(q),I)){let s=q;I.push(()=>o.delete(s))}return e},set value(s){if(Object.is(s,e))return;let i=e;if(e=s,r._debugInfo&&r._debugInfo.updateCount++,ie&&ie(r,i,s),K>0)for(let a of o)z.add(a);else{let a;for(let c of[...o])try{c()}catch(l){a===void 0&&(a=l)}if(a!==void 0)throw a}},_subscribe(s){return o.add(s),()=>{o.delete(s)}},peek(){return e}};return ae&&(r._debugInfo={label:n,createdAt:Date.now(),updateCount:0,subs:o},ae(r,n)),r}function ce(t){let n=v(void 0);return b(()=>{n.value=t()}),{_t4:!0,get value(){return n.value},set value(e){throw new Error("[tina4] computed signals are read-only")},_subscribe(e){return n._subscribe(e)},peek(){return n.peek()}}}function b(t){let n=!1,e=[],o=()=>{if(n)return;for(let a of e)a();e=[];let s=q,i=I;q=o,I=e;try{t()}finally{q=s,I=i}};o();let r=()=>{n=!0;for(let s of e)s();e=[]};return j&&j.push(r),r}function F(t){K++;try{t()}finally{if(K--,K===0){let n=[...z];z.clear();let e;for(let o of n)try{o()}catch(r){e===void 0&&(e=r)}if(e!==void 0)throw e}}}function R(t){return t!==null&&typeof t=="object"&&t._t4===!0}var le=new WeakMap,Q="t4:";function ue(t,...n){let e=le.get(t);if(!e){e=document.createElement("template");let i="";for(let a=0;a<t.length;a++)i+=t[a],a<n.length&&(je(i)?i+=`__t4_${a}__`:i+=`<!--${Q}${a}-->`);e.innerHTML=i,le.set(t,e)}let o=e.content.cloneNode(!0),r=He(o);for(let{marker:i,index:a}of r)Pe(i,n[a]);let s=Le(o);for(let i of s)Ke(i,n);return o}function He(t){let n=[];return Y(t,e=>{if(e.nodeType===8){let o=e.data;if(o&&o.startsWith(Q)){let r=parseInt(o.slice(Q.length),10);n.push({marker:e,index:r})}}}),n}function Le(t){let n=[];return Y(t,e=>{e.nodeType===1&&n.push(e)}),n}function Y(t,n){let e=t.childNodes;for(let o=0;o<e.length;o++){let r=e[o];n(r),Y(r,n)}}function Pe(t,n){let e=t.parentNode;if(e)if(R(n)){let o=document.createTextNode("");e.replaceChild(o,t),b(()=>{o.data=String(n.value??"")})}else if(typeof n=="function"){let o=document.createComment("");e.replaceChild(o,t);let r=[],s=[];b(()=>{for(let u of s)u();s=[];let i=[],a=U();S(i);let c=n();S(a),s=i;for(let u of r)u.parentNode?.removeChild(u);r=[];let l=X(c),d=o.parentNode;if(d)for(let u of l)d.insertBefore(u,o),r.push(u)})}else if(de(n))e.replaceChild(n,t);else if(n instanceof Node)e.replaceChild(n,t);else if(Array.isArray(n)){let o=document.createDocumentFragment();for(let r of n){let s=X(r);for(let i of s)o.appendChild(i)}e.replaceChild(o,t)}else{let o=document.createTextNode(String(n??""));e.replaceChild(o,t)}}function Ke(t,n){let e=[];for(let o of Array.from(t.attributes)){let r=o.name,s=o.value;if(r.startsWith("@")){let a=r.slice(1),c=s.match(/__t4_(\d+)__/);if(c){let l=n[parseInt(c[1],10)];typeof l=="function"&&t.addEventListener(a,d=>F(()=>l(d)))}e.push(r);continue}if(r.startsWith("?")){let a=r.slice(1),c=s.match(/__t4_(\d+)__/);if(c){let l=n[parseInt(c[1],10)];if(R(l)){let d=l;b(()=>{d.value?t.setAttribute(a,""):t.removeAttribute(a)})}else typeof l=="function"?b(()=>{l()?t.setAttribute(a,""):t.removeAttribute(a)}):l&&t.setAttribute(a,"")}e.push(r);continue}if(r.startsWith(".")){let a=r.slice(1),c=s.match(/__t4_(\d+)__/);if(c){let l=n[parseInt(c[1],10)];R(l)?b(()=>{t[a]=l.value}):t[a]=l}e.push(r);continue}let i=s.match(/__t4_(\d+)__/);if(i){let a=n[parseInt(i[1],10)];if(R(a)){let c=a;b(()=>{t.setAttribute(r,String(c.value??""))})}else typeof a=="function"?b(()=>{t.setAttribute(r,String(a()??""))}):t.setAttribute(r,String(a??""))}}for(let o of e)t.removeAttribute(o)}function X(t){if(t==null||t===!1)return[];if(de(t))return Array.from(t.childNodes);if(t instanceof Node)return[t];if(Array.isArray(t)){let n=[];for(let e of t)n.push(...X(e));return n}return[document.createTextNode(String(t))]}function de(t){return t!=null&&typeof t=="object"&&t.nodeType===11}function je(t){let n=!1,e=!1,o=!1;for(let r=0;r<t.length;r++){let s=t[r];s==="<"&&!n&&!e&&(o=!0),s===">"&&!n&&!e&&(o=!1),o&&(s==='"'&&!n&&(e=!e),s==="'"&&!e&&(n=!n))}return o}var fe=null,pe=null;var W=class extends HTMLElement{constructor(){super();this._props={};this._rendered=!1;this._disposeRender=null;this._innerDisposers=[];let e=this.constructor;this._root=e.shadow?this.attachShadow({mode:"open"}):this;for(let[o,r]of Object.entries(e.props))this._props[o]=v(this._coerce(this.getAttribute(o),r))}static{this.props={}}static{this.styles=""}static{this.shadow=!0}static get observedAttributes(){return Object.keys(this.props)}connectedCallback(){if(this._rendered)return;this._rendered=!0;let e=this.constructor,o=null;if(e.styles&&e.shadow&&this._root instanceof ShadowRoot){let r=document.createElement("style");r.textContent=e.styles,this._root.appendChild(r),o=r}this._disposeRender=b(()=>{for(let c of this._innerDisposers)c();this._innerDisposers=[];let r=[],s=U();S(r);let i=this.render();S(s),this._innerDisposers=r;let a=Array.from(this._root.childNodes);for(let c of a)c!==o&&this._root.removeChild(c);i&&this._root.appendChild(i)}),this.onMount(),fe&&fe(this)}disconnectedCallback(){this._disposeRender&&(this._disposeRender(),this._disposeRender=null);for(let e of this._innerDisposers)e();this._innerDisposers=[],this.onUnmount(),pe&&pe(this)}attributeChangedCallback(e,o,r){let i=this.constructor.props[e];i&&this._props[e]&&(this._props[e].value=this._coerce(r,i))}prop(e){if(!this._props[e])throw new Error(`[tina4] Prop '${e}' not declared in static props of <${this.tagName.toLowerCase()}>`);return this._props[e]}emit(e,o){this.dispatchEvent(new CustomEvent(e,{bubbles:!0,composed:!0,...o}))}onMount(){}onUnmount(){}_coerce(e,o){return o===Boolean?e!==null:o===Number?e!==null?Number(e):0:e??""}};var Z=[],N=null,D="history",Ue=!1,H=[],$=[],ge=0;function me(t,n){let e=[],o;t==="*"?o=".*":o=t.replace(/\{(\w+)\}/g,(s,i)=>(e.push(i),"([^/]+)"));let r=new RegExp(`^${o}$`);typeof n=="function"?Z.push({pattern:t,regex:r,paramNames:e,handler:n}):Z.push({pattern:t,regex:r,paramNames:e,handler:n.handler,guard:n.guard})}function G(t,n){if(D==="hash")if(n?.replace){let e=new URL(location.href);e.hash="#"+t,history.replaceState(null,"",e.toString()),L()}else location.hash="#"+t;else n?.replace?history.replaceState(null,"",t):history.pushState(null,"",t),L()}function L(){if(!N)return;let t=performance.now(),n=++ge,e=D==="hash"?location.hash.slice(1)||"/":location.pathname;for(let o of Z){let r=e.match(o.regex);if(!r)continue;let s={};if(o.paramNames.forEach((c,l)=>{s[c]=decodeURIComponent(r[l+1])}),o.guard){let c=o.guard();if(c===!1)return;if(typeof c=="string"){G(c,{replace:!0});return}}for(let c of $)c();$=[],N.innerHTML="";let i=[];S(i);let a=o.handler(s);if(a instanceof Promise)a.then(c=>{if(S(null),n!==ge){for(let d of i)d();return}he(N,c),$=i;let l=performance.now()-t;for(let d of H)d({path:e,params:s,pattern:o.pattern,durationMs:l})});else{S(null),he(N,a),$=i;let c=performance.now()-t;for(let l of H)l({path:e,params:s,pattern:o.pattern,durationMs:c})}return}}function he(t,n){n instanceof DocumentFragment||n instanceof Node?t.replaceChildren(n):typeof n=="string"?t.innerHTML=n:n!=null&&t.replaceChildren(document.createTextNode(String(n)))}var ve={start(t){if(N=document.querySelector(t.target),!N)throw new Error(`[tina4] Router target '${t.target}' not found in DOM`);D=t.mode??"history",Ue=!0,window.addEventListener("popstate",L),D==="hash"&&window.addEventListener("hashchange",L),document.addEventListener("click",n=>{if(n.metaKey||n.ctrlKey||n.shiftKey||n.altKey)return;let e=n.target.closest("a[href]");if(!e||e.origin!==location.origin||e.hasAttribute("target")||e.hasAttribute("download")||e.getAttribute("rel")?.includes("external"))return;n.preventDefault();let o=D==="hash"?e.getAttribute("href"):e.pathname;G(o)}),L()},on(t,n){return H.push(n),()=>{let e=H.indexOf(n);e>=0&&H.splice(e,1)}}};var y={baseUrl:"",auth:!1,tokenKey:"tina4_token",headers:{}},J=[],V=[],ye=0;function ee(){try{return localStorage.getItem(y.tokenKey)}catch{return null}}function be(t){try{localStorage.setItem(y.tokenKey,t)}catch{}}async function O(t,n,e,o){let r={method:t,headers:{"Content-Type":"application/json",...y.headers}};if(y.auth){let u=ee();u&&(r.headers.Authorization=`Bearer ${u}`)}if(e!==void 0&&t!=="GET"){let u=typeof e=="object"&&e!==null?{...e}:e;if(y.auth&&typeof u=="object"&&u!==null){let g=ee();g&&(u.formToken=g)}r.body=JSON.stringify(u)}if(o?.headers&&Object.assign(r.headers,o.headers),o?.params){let u=Object.entries(o.params).map(([g,w])=>`${encodeURIComponent(g)}=${encodeURIComponent(String(w))}`).join("&");n+=(n.includes("?")?"&":"?")+u}let s=y.baseUrl+n;r._url=s,r._requestId=++ye;for(let u of J){let g=u(r);g&&(r=g)}let i=await fetch(s,r),a=i.headers.get("FreshToken");a&&be(a);let c=i.headers.get("Content-Type")??"",l;c.includes("json")?l=await i.json():l=await i.text();let d={status:i.status,data:l,ok:i.ok,headers:i.headers,_requestId:r._requestId};for(let u of V){let g=u(d);g&&(d=g)}if(!i.ok)throw d;return d.data}var we={configure(t){Object.assign(y,t)},get(t,n){return O("GET",t,void 0,n)},post(t,n,e){return O("POST",t,n,e)},put(t,n,e){return O("PUT",t,n,e)},patch(t,n,e){return O("PATCH",t,n,e)},delete(t,n){return O("DELETE",t,void 0,n)},async graphql(t,n,e,o){return O("POST",t,{query:n,variables:e||{}},o)},async upload(t,n,e){let o={method:"POST",headers:{...y.headers},body:n};if(delete o.headers["Content-Type"],delete o.headers["content-type"],y.auth){let d=ee();d&&(o.headers.Authorization=`Bearer ${d}`)}if(e?.headers&&Object.assign(o.headers,e.headers),e?.params){let d=Object.entries(e.params).map(([u,g])=>`${encodeURIComponent(u)}=${encodeURIComponent(String(g))}`).join("&");t+=(t.includes("?")?"&":"?")+d}let r=y.baseUrl+t;o._url=r,o._requestId=++ye;for(let d of J){let u=d(o);u&&(o=u)}let s=await fetch(r,o),i=s.headers.get("FreshToken");i&&be(i);let a=s.headers.get("Content-Type")??"",c;a.includes("json")?c=await s.json():c=await s.text();let l={status:s.status,data:c,ok:s.ok,headers:s.headers,_requestId:o._requestId};for(let d of V){let u=d(l);u&&(l=u)}if(!s.ok)throw l;return l.data},intercept(t,n){t==="request"?J.push(n):V.push(n)},_reset(){y.baseUrl="",y.auth=!1,y.tokenKey="tina4_token",y.headers={},J.length=0,V.length=0}};function Fe(t){let n=t.cacheStrategy??"network-first",e=JSON.stringify(t.precache??[]),o=t.offlineRoute?`'${t.offlineRoute}'`:"null";return`
2
2
  const CACHE = 'tina4-v1';
3
- const PRECACHE = ${t};
3
+ const PRECACHE = ${e};
4
4
  const OFFLINE = ${o};
5
5
 
6
6
  self.addEventListener('install', (e) => {
@@ -44,4 +44,5 @@ self.addEventListener('fetch', (e) => {
44
44
  ))
45
45
  );`}
46
46
  });
47
- `.trim()}function ce(e){let n={name:e.name,short_name:e.shortName??e.name,start_url:"/",display:e.display??"standalone",background_color:e.backgroundColor??"#ffffff",theme_color:e.themeColor??"#000000"};return e.icon&&(n.icons=[{src:e.icon,sizes:"192x192",type:"image/png"},{src:e.icon,sizes:"512x512",type:"image/png"}]),n}var le={register(e){let n=ce(e),t=new Blob([JSON.stringify(n)],{type:"application/json"}),o=document.createElement("link");o.rel="manifest",o.href=URL.createObjectURL(t),document.head.appendChild(o);let r=document.querySelector('meta[name="theme-color"]');if(r||(r=document.createElement("meta"),r.name="theme-color",document.head.appendChild(r)),r.content=e.themeColor??"#000000","serviceWorker"in navigator){let a=ae(e),i=new Blob([a],{type:"text/javascript"}),s=URL.createObjectURL(i);navigator.serviceWorker.register(s).catch(c=>{console.warn("[tina4] Service worker registration failed:",c)})}},generateServiceWorker(e){return ae(e)},generateManifest(e){return ce(e)}};var Re={reconnect:!0,reconnectDelay:1e3,reconnectMaxDelay:3e4,reconnectAttempts:1/0,protocols:[]};function xe(e,n={}){let t={...Re,...n},o=h("connecting"),r=h(!1),a=h(null),i=h(null),s=h(0),c={message:[],open:[],close:[],error:[]},l=null,f=!1,d=t.reconnectDelay,p=null,v=0;function de(u){if(typeof u!="string")return u;try{return JSON.parse(u)}catch{return u}}function $(){o.value=v>0?"reconnecting":"connecting";try{l=new WebSocket(e,t.protocols)}catch{o.value="closed",r.value=!1;return}l.onopen=()=>{o.value="open",r.value=!0,i.value=null,v=0,d=t.reconnectDelay,s.value=0;for(let u of c.open)u()},l.onmessage=u=>{let g=de(u.data);a.value=g;for(let k of c.message)k(g)},l.onclose=u=>{o.value="closed",r.value=!1;for(let g of c.close)g(u.code,u.reason);!f&&t.reconnect&&v<t.reconnectAttempts&&fe()},l.onerror=u=>{i.value=u;for(let g of c.error)g(u)}}function fe(){v++,s.value=v,o.value="reconnecting",p=setTimeout(()=>{p=null,$()},d),d=Math.min(d*2,t.reconnectMaxDelay)}let G={status:o,connected:r,lastMessage:a,error:i,reconnectCount:s,send(u){if(!l||l.readyState!==WebSocket.OPEN)throw new Error("[tina4] WebSocket is not connected");let g=typeof u=="string"?u:JSON.stringify(u);l.send(g)},on(u,g){return c[u].push(g),()=>{let k=c[u],A=k.indexOf(g);A>=0&&k.splice(A,1)}},pipe(u,g){let k=A=>{u.value=g(A,u.value)};return G.on("message",k)},close(u,g){f=!0,p&&(clearTimeout(p),p=null),l&&l.close(u??1e3,g??""),o.value="closed",r.value=!1}};return $(),G}var ue={connect:xe};return ve(Ae);})();
47
+ `.trim()}function ke(t){let n={name:t.name,short_name:t.shortName??t.name,start_url:"/",display:t.display??"standalone",background_color:t.backgroundColor??"#ffffff",theme_color:t.themeColor??"#000000"};return t.icon&&(n.icons=[{src:t.icon,sizes:"192x192",type:"image/png"},{src:t.icon,sizes:"512x512",type:"image/png"}]),n}var Se={register(t){let n=ke(t),e=new Blob([JSON.stringify(n)],{type:"application/json"}),o=document.createElement("link");o.rel="manifest",o.href=URL.createObjectURL(e),document.head.appendChild(o);let r=document.querySelector('meta[name="theme-color"]');r||(r=document.createElement("meta"),r.name="theme-color",document.head.appendChild(r)),r.content=t.themeColor??"#000000","serviceWorker"in navigator&&(t.swUrl?navigator.serviceWorker.register(t.swUrl).catch(s=>{console.warn("[tina4] Service worker registration failed:",s)}):navigator.serviceWorker.register("/sw.js").catch(()=>{console.info("[tina4] No service worker at /sw.js. Use pwa.generateServiceWorker() to create one, or pass swUrl in config.")}))},generateServiceWorker(t){return Fe(t)},generateManifest(t){return ke(t)}};var We={reconnect:!0,reconnectDelay:1e3,reconnectMaxDelay:3e4,reconnectAttempts:1/0,protocols:[]};function $e(t,n={}){let e={...We,...n},o=v("connecting"),r=v(!1),s=v(null),i=v(null),a=v(0),c={message:[],open:[],close:[],error:[]},l=null,d=!1,u=e.reconnectDelay,g=null,w=0;function C(p){if(typeof p!="string")return p;try{return JSON.parse(p)}catch{return p}}function E(){o.value=w>0?"reconnecting":"connecting";try{l=new WebSocket(t,e.protocols)}catch{o.value="closed",r.value=!1;return}l.onopen=()=>{o.value="open",r.value=!0,i.value=null,w=0,u=e.reconnectDelay,a.value=0;for(let p of c.open)p()},l.onmessage=p=>{let h=C(p.data);s.value=h;for(let k of c.message)k(h)},l.onclose=p=>{o.value="closed",r.value=!1;for(let h of c.close)h(p.code,p.reason);!d&&e.reconnect&&w<e.reconnectAttempts&&x()},l.onerror=p=>{i.value=p;for(let h of c.error)h(p)}}function x(){w++,a.value=w,o.value="reconnecting",g=setTimeout(()=>{g=null,E()},u),u=Math.min(u*2,e.reconnectMaxDelay)}let _={status:o,connected:r,lastMessage:s,error:i,reconnectCount:a,send(p){if(!l||l.readyState!==WebSocket.OPEN)throw new Error("[tina4] WebSocket is not connected");let h=typeof p=="string"?p:JSON.stringify(p);l.send(h)},on(p,h){return c[p].push(h),()=>{let k=c[p],A=k.indexOf(h);A>=0&&k.splice(A,1)}},pipe(p,h){let k=A=>{p.value=h(A,p.value)};return _.on("message",k)},close(p,h){d=!0,g&&(clearTimeout(g),g=null),l&&l.close(p??1e3,h??""),o.value="closed",r.value=!1}};return E(),_}var Ee={connect:$e};var Ge={mode:"eventsource",method:"GET",headers:{},body:void 0,reconnect:!0,reconnectDelay:1e3,reconnectMaxDelay:3e4,reconnectAttempts:1/0,events:[],json:!0};function Je(t,n={}){let e={...Ge,...n},o=v("connecting"),r=v(!1),s=v(null),i=v(null),a=v(null),c=v(0),l={message:[],open:[],close:[],error:[]},d=null,u=null,g=!1,w=e.reconnectDelay,C=null,E=0;function x(f){if(!e.json||typeof f!="string")return f;try{return JSON.parse(f)}catch{return f}}function _(f,m){s.value=f,i.value=m;for(let T of l.message)T(f,m??void 0)}function p(){o.value="open",r.value=!0,a.value=null,E=0,w=e.reconnectDelay,c.value=0;for(let f of l.open)f()}function h(){o.value="closed",r.value=!1;for(let f of l.close)f();!g&&e.reconnect&&E<e.reconnectAttempts&&_e()}function k(f){a.value=f;for(let m of l.error)m(f)}function A(){o.value=E>0?"reconnecting":"connecting";try{d=new EventSource(t)}catch{o.value="closed",r.value=!1;return}d.onopen=()=>p(),d.onmessage=f=>{_(x(f.data),null)};for(let f of e.events)d.addEventListener(f,m=>{_(x(m.data),f)});d.onerror=f=>{k(f),d&&d.readyState===2&&(d=null,h())}}function Ce(){o.value=E>0?"reconnecting":"connecting",u=new AbortController;let f={method:e.method,headers:e.headers,signal:u.signal};e.body!==void 0&&(f.body=typeof e.body=="string"?e.body:JSON.stringify(e.body)),fetch(t,f).then(async m=>{if(!m.ok){k(new Error(`[tina4] SSE fetch ${m.status}`)),h();return}p();let T=m.body.getReader(),M=new TextDecoder,P="";for(;;){let{done:Re,value:xe}=await T.read();if(Re)break;P+=M.decode(xe,{stream:!0});let re=P.split(`
48
+ `);P=re.pop();for(let Ae of re){let se=Ae.trim();se&&_(x(se),null)}}let oe=P.trim();oe&&_(x(oe),null),u=null,h()}).catch(m=>{m.name!=="AbortError"&&(u=null,k(m),h())})}function _e(){E++,c.value=E,o.value="reconnecting",C=setTimeout(()=>{C=null,te()},w),w=Math.min(w*2,e.reconnectMaxDelay)}function te(){e.mode==="fetch"?Ce():A()}let ne={status:o,connected:r,lastMessage:s,lastEvent:i,error:a,reconnectCount:c,on(f,m){return l[f].push(m),()=>{let T=l[f],M=T.indexOf(m);M>=0&&T.splice(M,1)}},pipe(f,m){let T=M=>{f.value=m(M,f.value)};return ne.on("message",T)},close(){g=!0,C&&(clearTimeout(C),C=null),d&&(d.close(),d=null),u&&(u.abort(),u=null),o.value="closed",r.value=!1}};return te(),ne}var Te={connect:Je};return De(Ve);})();
@@ -174,6 +174,18 @@ export class I18n {
174
174
  result[fullKey] = String(value);
175
175
  }
176
176
  }
177
+ // Add leaf-key aliases: "nav.home" → also store as "home" (first-wins on conflict)
178
+ if (!prefix) {
179
+ for (const [dotKey, val] of Object.entries(result)) {
180
+ const lastDot = dotKey.lastIndexOf(".");
181
+ if (lastDot !== -1) {
182
+ const leafKey = dotKey.substring(lastDot + 1);
183
+ if (!(leafKey in result)) {
184
+ result[leafKey] = val;
185
+ }
186
+ }
187
+ }
188
+ }
177
189
  return result;
178
190
  }
179
191
  }
@@ -8,7 +8,7 @@ import cluster from "node:cluster";
8
8
  import os from "node:os";
9
9
  import type { Tina4Config, Tina4Request, Tina4Response } from "./types.js";
10
10
  import { Router, defaultRouter, runRouteMiddlewares } from "./router.js";
11
- import { validToken, getPayload } from "./auth.js";
11
+ import { validToken, getPayload, refreshToken } from "./auth.js";
12
12
  import { discoverRoutes } from "./routeDiscovery.js";
13
13
  import { createRequest } from "./request.js";
14
14
  import { createResponse, setDefaultTemplatesDir } from "./response.js";
@@ -19,6 +19,7 @@ import { createHealthRoute } from "./health.js";
19
19
  import { rateLimiter } from "./rateLimiter.js";
20
20
  import { Log } from "./logger.js";
21
21
  import { DevAdmin, RequestInspector } from "./devAdmin.js";
22
+ import { I18n } from "./i18n.js";
22
23
 
23
24
  const __filename = fileURLToPath(import.meta.url);
24
25
  const __dirname = dirname(__filename);
@@ -447,6 +448,26 @@ let _serverHandle: { close: () => void; router: Router; port: number } | null =
447
448
  * Thin wrapper around startServer() for cross-framework parity with PHP and Ruby.
448
449
  */
449
450
  export async function start(config?: Tina4Config): Promise<{ close: () => void; router: Router; port: number }> {
451
+ if (process.env.TINA4_CLI !== 'true' && process.env.TINA4_OVERRIDE_CLIENT !== 'true') {
452
+ console.log();
453
+ console.log('='.repeat(60));
454
+ console.log();
455
+ console.log(' Tina4 must be started with the tina4 CLI:');
456
+ console.log();
457
+ console.log(' tina4 serve (development)');
458
+ console.log(' tina4 serve --production (production)');
459
+ console.log();
460
+ console.log(' Install: cargo install tina4');
461
+ console.log(' Docs: https://tina4.com');
462
+ console.log();
463
+ console.log(' To run directly, add to .env:');
464
+ console.log(' TINA4_OVERRIDE_CLIENT=true');
465
+ console.log();
466
+ console.log('='.repeat(60));
467
+ console.log();
468
+ process.exit(1);
469
+ }
470
+
450
471
  _serverHandle = await startServer(config);
451
472
  return _serverHandle;
452
473
  }
@@ -568,6 +589,22 @@ ${reset}
568
589
  // Frond not available
569
590
  }
570
591
 
592
+ // Auto-wire i18n → template global t() when locale files exist
593
+ if (frondEngine) {
594
+ const localeDir = resolve(base, process.env.TINA4_LOCALE_DIR ?? "src/locales");
595
+ if (existsSync(localeDir)) {
596
+ try {
597
+ const localeFiles = readdirSync(localeDir).filter((f: string) => f.endsWith(".json"));
598
+ if (localeFiles.length > 0 && !frondEngine.globals?.t) {
599
+ const i18nInstance = new I18n(localeDir, process.env.TINA4_LOCALE ?? "en");
600
+ frondEngine.addGlobal("t", (key: string, params?: Record<string, string>) => i18nInstance.t(key, params));
601
+ }
602
+ } catch {
603
+ // Locale directory unreadable — skip auto-wire
604
+ }
605
+ }
606
+ }
607
+
571
608
  // Built-in middleware
572
609
  middleware.use(cors());
573
610
  middleware.use(requestLogger());
@@ -807,18 +844,56 @@ ${reset}
807
844
  if (!proceed || res.raw.writableEnded) return;
808
845
  }
809
846
 
810
- // Auth enforcement: secure routes require a valid Bearer token
847
+ // Auth enforcement: secure routes require a valid token
848
+ // Check sources in priority order: Authorization header > body formToken > session token
811
849
  // Dev admin routes (/__dev) are always public
812
850
  const isDevAdmin = pathname.startsWith("/__dev");
813
851
  if (match.secure === true && match.noAuth !== true && !isDevAdmin) {
814
852
  const authHeader = req.headers.authorization ?? "";
815
- const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : "";
816
- if (!token || !validToken(token)) {
853
+ const headerToken = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : "";
854
+
855
+ // Priority 1: Authorization Bearer header
856
+ let resolvedToken = "";
857
+ let tokenSource: "header" | "body" | "session" | "" = "";
858
+
859
+ if (headerToken && validToken(headerToken)) {
860
+ resolvedToken = headerToken;
861
+ tokenSource = "header";
862
+ }
863
+
864
+ // Priority 2: formToken from request body
865
+ if (!resolvedToken) {
866
+ const bodyToken = (req.body as Record<string, unknown>)?.formToken as string | undefined;
867
+ if (bodyToken && validToken(bodyToken)) {
868
+ resolvedToken = bodyToken;
869
+ tokenSource = "body";
870
+ }
871
+ }
872
+
873
+ // Priority 3: Session token
874
+ if (!resolvedToken) {
875
+ const sessionToken = (req as any).session?.get?.("token") as string | undefined;
876
+ if (sessionToken && validToken(sessionToken)) {
877
+ resolvedToken = sessionToken;
878
+ tokenSource = "session";
879
+ }
880
+ }
881
+
882
+ if (!resolvedToken) {
817
883
  res.raw.writeHead(401, { "Content-Type": "application/json" });
818
884
  res.raw.end(JSON.stringify({ error: "Unauthorized" }));
819
885
  return;
820
886
  }
821
- req.user = getPayload(token) ?? {};
887
+
888
+ req.user = getPayload(resolvedToken) ?? {};
889
+
890
+ // When body formToken validates, return a FreshToken header with a refreshed JWT
891
+ if (tokenSource === "body") {
892
+ const fresh = refreshToken(resolvedToken);
893
+ if (fresh) {
894
+ res.header("FreshToken", fresh);
895
+ }
896
+ }
822
897
  }
823
898
 
824
899
  // Inject path params by name into handler arguments, then request/response
@@ -26,6 +26,9 @@ import { createHash } from "node:crypto";
26
26
  import { randomUUID } from "node:crypto";
27
27
  import type { Socket } from "node:net";
28
28
  import type { Server } from "node:http";
29
+ import type { WebSocketConnection } from "./websocketConnection.js";
30
+ import type { WebSocketRouteHandler } from "./types.js";
31
+ import { Router } from "./router.js";
29
32
 
30
33
  // ── Constants ────────────────────────────────────────────────
31
34
 
@@ -174,6 +177,8 @@ export class WebSocketServer {
174
177
  private rooms: Map<string, Set<string>> = new Map();
175
178
  /** clientRooms[clientId] = Set of roomNames */
176
179
  private clientRooms: Map<string, Set<string>> = new Map();
180
+ /** Route-style handlers registered via route(), keyed by path */
181
+ private _routeHandlers: Map<string, (conn: WebSocketConnection) => void | Promise<void>> = new Map();
177
182
 
178
183
  constructor(options?: { port?: number }) {
179
184
  this.port = options?.port ?? parseInt(process.env.TINA4_WS_PORT ?? "8080", 10);
@@ -189,6 +194,46 @@ export class WebSocketServer {
189
194
  return this;
190
195
  }
191
196
 
197
+ /**
198
+ * Register a WebSocket handler for a path (decorator style, matches Python).
199
+ *
200
+ * The handler receives a WebSocketConnection and sets up callbacks via
201
+ * `conn.onMessage(handler)` and `conn.onClose(handler)`.
202
+ *
203
+ * Internally this creates an adapter that converts from the decorator style
204
+ * to the Router's `(conn, event, data)` style and registers it via
205
+ * `Router.websocket()`.
206
+ */
207
+ route(path: string, handler: (conn: WebSocketConnection) => void | Promise<void>): void {
208
+ this._routeHandlers.set(path, handler);
209
+
210
+ // Adapt to Router's (conn, event, data) style
211
+ const adapter: WebSocketRouteHandler = async (conn, event, data) => {
212
+ if (event === "open") {
213
+ const result = handler(conn);
214
+ if (result instanceof Promise) {
215
+ await result;
216
+ }
217
+ } else if (event === "message") {
218
+ if (conn._onMessage) {
219
+ const result = conn._onMessage(data);
220
+ if (result instanceof Promise) {
221
+ await result;
222
+ }
223
+ }
224
+ } else if (event === "close") {
225
+ if (conn._onClose) {
226
+ const result = conn._onClose();
227
+ if (result instanceof Promise) {
228
+ await result;
229
+ }
230
+ }
231
+ }
232
+ };
233
+
234
+ Router.websocket(path, adapter);
235
+ }
236
+
192
237
  /**
193
238
  * Broadcast a message to all connected clients.
194
239
  *
@@ -23,4 +23,16 @@ export interface WebSocketConnection {
23
23
  leaveRoom(roomName: string): void;
24
24
  /** Close this connection */
25
25
  close(): void;
26
+
27
+ // ── Callback properties (used by WebSocketServer.route() adapter) ──
28
+
29
+ /** Internal message callback, set via onMessage(). */
30
+ _onMessage: ((data: string) => void | Promise<void>) | null;
31
+ /** Internal close callback, set via onClose(). */
32
+ _onClose: (() => void | Promise<void>) | null;
33
+
34
+ /** Register a message handler (decorator style, matches Python). */
35
+ onMessage(handler: (data: string) => void | Promise<void>): void;
36
+ /** Register a close handler (decorator style, matches Python). */
37
+ onClose(handler: () => void | Promise<void>): void;
26
38
  }
@@ -357,10 +357,26 @@ function resolveVar(expr: string, context: Record<string, unknown>): unknown {
357
357
  return null;
358
358
  }
359
359
 
360
+ // Slice syntax: value[1:5], value[:10], value[start:end]
361
+ const isQuotedPart = (part.startsWith('"') && part.endsWith('"')) ||
362
+ (part.startsWith("'") && part.endsWith("'"));
363
+ if (isBracket && part.includes(":") && !isQuotedPart) {
364
+ const sliceParts = part.split(":", 2);
365
+ const sStart = sliceParts[0].trim() ? parseInt(String(evalExpr(sliceParts[0].trim(), context)), 10) : undefined;
366
+ const sEnd = sliceParts[1].trim() ? parseInt(String(evalExpr(sliceParts[1].trim(), context)), 10) : undefined;
367
+ if (Array.isArray(value)) {
368
+ value = (value as unknown[]).slice(sStart ?? 0, sEnd);
369
+ } else if (typeof value === "string") {
370
+ value = (value as string).slice(sStart ?? 0, sEnd);
371
+ } else {
372
+ return null;
373
+ }
374
+ continue;
375
+ }
376
+
360
377
  let key: string | number;
361
378
  // Check if this part came from bracket access and needs variable resolution
362
- if ((part.startsWith('"') && part.endsWith('"')) ||
363
- (part.startsWith("'") && part.endsWith("'"))) {
379
+ if (isQuotedPart) {
364
380
  // Quoted string literal — strip quotes
365
381
  key = part.slice(1, -1);
366
382
  } else {
@@ -369,7 +385,7 @@ function resolveVar(expr: string, context: Record<string, unknown>): unknown {
369
385
  key = asNum;
370
386
  } else if (isBracket) {
371
387
  // Only resolve as a variable from context for bracket-derived parts
372
- const resolved = context[part];
388
+ const resolved = evalExpr(part, context);
373
389
  key = resolved !== undefined ? String(resolved) : part;
374
390
  } else {
375
391
  // Dot-derived parts or root — use the part name directly as the key
@@ -397,10 +413,11 @@ function resolveVar(expr: string, context: Record<string, unknown>): unknown {
397
413
  function findOutsideQuotes(expr: string, needle: string): number {
398
414
  let inQuote: string | null = null;
399
415
  let depth = 0;
416
+ let bracketDepth = 0;
400
417
  let i = 0;
401
418
  while (i <= expr.length - needle.length) {
402
419
  const ch = expr[i];
403
- if ((ch === '"' || ch === "'") && depth === 0) {
420
+ if ((ch === '"' || ch === "'") && depth === 0 && bracketDepth === 0) {
404
421
  if (inQuote === null) {
405
422
  inQuote = ch;
406
423
  } else if (ch === inQuote) {
@@ -412,7 +429,9 @@ function findOutsideQuotes(expr: string, needle: string): number {
412
429
  if (inQuote) { i++; continue; }
413
430
  if (ch === "(") depth++;
414
431
  else if (ch === ")") depth--;
415
- if (depth === 0 && expr.slice(i, i + needle.length) === needle) {
432
+ else if (ch === "[") bracketDepth++;
433
+ else if (ch === "]") bracketDepth--;
434
+ if (depth === 0 && bracketDepth === 0 && expr.slice(i, i + needle.length) === needle) {
416
435
  return i;
417
436
  }
418
437
  i++;
@@ -425,10 +444,11 @@ function splitOutsideQuotes(expr: string, sep: string): string[] {
425
444
  let currentStart = 0;
426
445
  let inQuote: string | null = null;
427
446
  let depth = 0;
447
+ let bracketDepth = 0;
428
448
  let i = 0;
429
449
  while (i <= expr.length - sep.length) {
430
450
  const ch = expr[i];
431
- if ((ch === '"' || ch === "'") && depth === 0) {
451
+ if ((ch === '"' || ch === "'") && depth === 0 && bracketDepth === 0) {
432
452
  if (inQuote === null) {
433
453
  inQuote = ch;
434
454
  } else if (ch === inQuote) {
@@ -440,7 +460,9 @@ function splitOutsideQuotes(expr: string, sep: string): string[] {
440
460
  if (inQuote) { i++; continue; }
441
461
  if (ch === "(") depth++;
442
462
  else if (ch === ")") depth--;
443
- if (depth === 0 && expr.slice(i, i + sep.length) === sep) {
463
+ else if (ch === "[") bracketDepth++;
464
+ else if (ch === "]") bracketDepth--;
465
+ if (depth === 0 && bracketDepth === 0 && expr.slice(i, i + sep.length) === sep) {
444
466
  parts.push(expr.slice(currentStart, i));
445
467
  i += sep.length;
446
468
  currentStart = i;
@@ -1446,10 +1468,46 @@ export class Frond {
1446
1468
 
1447
1469
  private extractBlocks(source: string): Record<string, string> {
1448
1470
  const blocks: Record<string, string> = {};
1449
- const pattern = /\{%[-\s]*block\s+(\w+)\s*[-]?%\}([\s\S]*?)\{%[-\s]*endblock\s*[-]?%\}/g;
1450
- let m: RegExpExecArray | null;
1451
- while ((m = pattern.exec(source)) !== null) {
1452
- blocks[m[1]] = m[2];
1471
+ const blockOpen = /\{%[-\s]*block\s+(\w+)\s*[-]?%\}/g;
1472
+ const blockClose = /\{%[-\s]*endblock\s*[-]?%\}/g;
1473
+
1474
+ let pos = 0;
1475
+ while (pos < source.length) {
1476
+ blockOpen.lastIndex = pos;
1477
+ const mOpen = blockOpen.exec(source);
1478
+ if (!mOpen) break;
1479
+
1480
+ const name = mOpen[1];
1481
+ const contentStart = mOpen.index + mOpen[0].length;
1482
+ let depth = 1;
1483
+ let scan = contentStart;
1484
+
1485
+ while (depth > 0 && scan < source.length) {
1486
+ blockOpen.lastIndex = scan;
1487
+ blockClose.lastIndex = scan;
1488
+ const nextOpen = blockOpen.exec(source);
1489
+ const nextClose = blockClose.exec(source);
1490
+
1491
+ if (!nextClose) break; // malformed — no matching endblock
1492
+
1493
+ if (nextOpen && nextOpen.index < nextClose.index) {
1494
+ depth++;
1495
+ scan = nextOpen.index + nextOpen[0].length;
1496
+ } else {
1497
+ depth--;
1498
+ if (depth === 0) {
1499
+ blocks[name] = source.slice(contentStart, nextClose.index);
1500
+ pos = nextClose.index + nextClose[0].length;
1501
+ break;
1502
+ }
1503
+ scan = nextClose.index + nextClose[0].length;
1504
+ }
1505
+ }
1506
+
1507
+ if (depth > 0) {
1508
+ // malformed, skip forward
1509
+ pos = contentStart;
1510
+ }
1453
1511
  }
1454
1512
  return blocks;
1455
1513
  }
@@ -1459,6 +1517,40 @@ export class Frond {
1459
1517
  context: Record<string, unknown>,
1460
1518
  childBlocks: Record<string, string>,
1461
1519
  ): string {
1520
+ // --- Multi-level extends: check if parent itself extends a grandparent ---
1521
+ const extendsMatch = parentSource.trimStart().match(/\{%[-\s]*extends\s+["'](.+?)["']\s*[-]?%\}/);
1522
+ if (extendsMatch) {
1523
+ const grandparentName = extendsMatch[1];
1524
+ const grandparentSource = this.load(grandparentName);
1525
+
1526
+ // Extract block defaults defined in the parent template
1527
+ const parentBlocks = this.extractBlocks(parentSource);
1528
+
1529
+ // Child blocks override parent blocks at the same name
1530
+ const mergedBlocks: Record<string, string> = { ...parentBlocks, ...childBlocks };
1531
+
1532
+ // Resolve nested blocks: if a block value contains {% block inner %} tags,
1533
+ // replace them with mergedBlocks values too
1534
+ const nestedBlockRe = /\{%[-\s]*block\s+(\w+)\s*[-]?%\}([\s\S]*?)\{%[-\s]*endblock\s*[-]?%\}/g;
1535
+ let changed = true;
1536
+ while (changed) {
1537
+ changed = false;
1538
+ for (const name of Object.keys(mergedBlocks)) {
1539
+ const resolved = mergedBlocks[name].replace(nestedBlockRe, (_m, innerName: string, innerDefault: string) => {
1540
+ return mergedBlocks[innerName] ?? innerDefault;
1541
+ });
1542
+ if (resolved !== mergedBlocks[name]) {
1543
+ mergedBlocks[name] = resolved;
1544
+ changed = true;
1545
+ }
1546
+ }
1547
+ }
1548
+
1549
+ // Recurse up the chain (handles 3+, 4+, ... levels)
1550
+ return this.renderWithBlocks(grandparentSource, context, mergedBlocks);
1551
+ }
1552
+
1553
+ // --- Leaf parent (no extends) — resolve blocks and render ---
1462
1554
  const pattern = /\{%[-\s]*block\s+(\w+)\s*[-]?%\}([\s\S]*?)\{%[-\s]*endblock\s*[-]?%\}/g;
1463
1555
  const engine = this;
1464
1556