tina4-nodejs 3.13.51 → 3.13.52

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,4 +1,4 @@
1
- # CLAUDE.md - AI Developer Guide for tina4-nodejs (v3.13.50)
1
+ # CLAUDE.md - AI Developer Guide for tina4-nodejs (v3.13.52)
2
2
 
3
3
  > This file helps AI assistants (Claude, Copilot, Cursor, etc.) understand and work on this codebase effectively.
4
4
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tina4-nodejs",
3
- "version": "3.13.51",
3
+ "version": "3.13.52",
4
4
  "type": "module",
5
5
  "description": "Tina4 for Node.js/TypeScript - 54 built-in features, zero dependencies",
6
6
  "keywords": [
@@ -558,6 +558,132 @@ var _frondModule = (() => {
558
558
  }
559
559
  });
560
560
  }
561
+ function _liveKey(el) {
562
+ const d = el.dataset;
563
+ return d && d.key ? d.key : null;
564
+ }
565
+ function _liveSyncAttrs(oldNode, newNode) {
566
+ const na = newNode.attributes;
567
+ for (let i = 0; i < na.length; i++) {
568
+ const a = na[i];
569
+ if (oldNode.getAttribute(a.name) !== a.value) oldNode.setAttribute(a.name, a.value);
570
+ }
571
+ const oa = Array.prototype.slice.call(oldNode.attributes);
572
+ oa.forEach(function(a) {
573
+ if (!newNode.hasAttribute(a.name)) oldNode.removeAttribute(a.name);
574
+ });
575
+ }
576
+ function _liveMorphNode(oldNode, newNode) {
577
+ const tag = newNode.tagName;
578
+ if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
579
+ _liveSyncAttrs(oldNode, newNode);
580
+ if (oldNode.children.length || newNode.children.length) {
581
+ _liveReconcile(oldNode, newNode);
582
+ } else if (oldNode.innerHTML !== newNode.innerHTML) {
583
+ oldNode.innerHTML = newNode.innerHTML;
584
+ }
585
+ }
586
+ function _liveReconcile(parent, next) {
587
+ const oldKids = Array.prototype.slice.call(parent.children);
588
+ const newKids = Array.prototype.slice.call(next.children);
589
+ const oldByKey = {};
590
+ oldKids.forEach(function(c) {
591
+ const k = _liveKey(c);
592
+ if (k) oldByKey[k] = c;
593
+ });
594
+ const order = [];
595
+ for (let i = 0; i < newKids.length; i++) {
596
+ const nk = newKids[i];
597
+ const k = _liveKey(nk);
598
+ let match = null;
599
+ if (k && oldByKey[k]) {
600
+ match = oldByKey[k];
601
+ } else if (!k && oldKids[i] && !_liveKey(oldKids[i]) && oldKids[i].tagName === nk.tagName) {
602
+ match = oldKids[i];
603
+ }
604
+ if (match && match.tagName === nk.tagName) {
605
+ _liveMorphNode(match, nk);
606
+ reused.push(match);
607
+ order.push(match);
608
+ } else {
609
+ order.push(nk);
610
+ }
611
+ }
612
+ let cursor = parent.firstElementChild;
613
+ for (let i = 0; i < order.length; i++) {
614
+ const node = order[i];
615
+ if (node === cursor) {
616
+ cursor = cursor.nextElementSibling;
617
+ } else {
618
+ parent.insertBefore(node, cursor);
619
+ }
620
+ }
621
+ oldKids.forEach(function(c) {
622
+ if (order.indexOf(c) === -1 && c.parentNode === parent) parent.removeChild(c);
623
+ });
624
+ void reused;
625
+ }
626
+ function _liveSwap(container, html) {
627
+ const tmp = document.createElement("div");
628
+ tmp.innerHTML = html;
629
+ if (!tmp.children.length || !container.children.length) {
630
+ container.innerHTML = html;
631
+ return;
632
+ }
633
+ _liveReconcile(container, tmp);
634
+ }
635
+ function _liveWsUrl(path) {
636
+ if (/^wss?:\/\//.test(path)) return path;
637
+ const proto = typeof location !== "undefined" && location.protocol === "https:" ? "wss" : "ws";
638
+ return proto + "://" + location.host + path;
639
+ }
640
+ function _liveExtract(msg, name) {
641
+ if (msg && typeof msg === "object") {
642
+ if (msg.type === "live") {
643
+ if (name && msg.name && msg.name !== name) return null;
644
+ return msg.html != null ? String(msg.html) : null;
645
+ }
646
+ return null;
647
+ }
648
+ return typeof msg === "string" ? msg : null;
649
+ }
650
+ function liveInit(root) {
651
+ if (typeof document === "undefined") return;
652
+ const scope = root || document;
653
+ const blocks = scope.querySelectorAll("[data-frond-live]");
654
+ Array.prototype.slice.call(blocks).forEach(function(el) {
655
+ if (el.__frondLive) return;
656
+ el.__frondLive = true;
657
+ const mode = el.getAttribute("data-mode");
658
+ const name = el.getAttribute("data-frond-live");
659
+ if (mode === "poll") {
660
+ const src = el.getAttribute("data-src");
661
+ const interval = (parseInt(el.getAttribute("data-interval"), 10) || 5) * 1e3;
662
+ const timer = setInterval(function() {
663
+ if (typeof document !== "undefined" && document.hidden) return;
664
+ request(src, { method: "GET", onSuccess: function(data) {
665
+ _liveSwap(el, typeof data === "string" ? data : String(data));
666
+ } });
667
+ }, interval);
668
+ el.__frondLiveStop = function() {
669
+ clearInterval(timer);
670
+ };
671
+ } else if (mode === "ws") {
672
+ const sock = wsConnect(_liveWsUrl(el.getAttribute("data-ws")));
673
+ sock.on("message", function(msg) {
674
+ const h = _liveExtract(msg, name);
675
+ if (h !== null) _liveSwap(el, h);
676
+ });
677
+ el.__frondLiveStop = function() {
678
+ sock.close();
679
+ };
680
+ } else if (mode === "sse") {
681
+ if (typeof console !== "undefined" && console.warn) {
682
+ console.warn("[frond.live] sse transport is not wired yet (v1 supports poll and ws); block '" + name + "' shows first paint only. Use poll or ws.");
683
+ }
684
+ }
685
+ });
686
+ }
561
687
  var frond = {
562
688
  /** Core HTTP request. */
563
689
  request,
@@ -573,6 +699,8 @@ var _frondModule = (() => {
573
699
  ws: wsConnect,
574
700
  /** Server-Sent Events with auto-reconnect. */
575
701
  sse: sseConnect,
702
+ /** Wire {% live %} blocks (poll/ws) with keyed morph. Auto-runs on DOMContentLoaded. */
703
+ live: liveInit,
576
704
  /** Cookie helpers: get, set, remove. */
577
705
  cookie,
578
706
  /** Display alert message in #message element. */
@@ -593,8 +721,17 @@ var _frondModule = (() => {
593
721
  };
594
722
  if (typeof window !== "undefined") {
595
723
  window.frond = frond;
724
+ if (typeof document !== "undefined") {
725
+ if (document.readyState === "loading") {
726
+ document.addEventListener("DOMContentLoaded", function() {
727
+ liveInit();
728
+ });
729
+ } else {
730
+ liveInit();
731
+ }
732
+ }
596
733
  }
597
734
  return __toCommonJS(frond_exports);
598
735
  })();
599
- /* Frond v2.1.3 tina4.com */
736
+ /* Frond v2.2.0 - tina4.com */
600
737
  //# sourceMappingURL=frond.js.map
@@ -1,2 +1,2 @@
1
- var _frondModule=(()=>{var b=Object.defineProperty;var k=Object.getOwnPropertyDescriptor;var x=Object.getOwnPropertyNames;var C=Object.prototype.hasOwnProperty;var O=(o,s)=>{for(var e in s)b(o,e,{get:s[e],enumerable:!0})},M=(o,s,e,t)=>{if(s&&typeof s=="object"||typeof s=="function")for(let n of x(s))!C.call(o,n)&&n!==e&&b(o,n,{get:()=>s[n],enumerable:!(t=k(s,n))||t.enumerable});return o};var q=o=>M(b({},"__esModule",{value:!0}),o);var j={};O(j,{frond:()=>R});var g=null;function w(o,s){let e;typeof s=="function"?e={onSuccess:s}:e=s||{};let t=(e.method||"GET").toUpperCase(),n=new XMLHttpRequest;if(n.open(t,o,!0),g!==null&&n.setRequestHeader("Authorization","Bearer "+g),e.headers)for(let r in e.headers)Object.prototype.hasOwnProperty.call(e.headers,r)&&n.setRequestHeader(r,e.headers[r]);let i=null;e.body!==void 0&&e.body!==null&&(e.body instanceof FormData?i=e.body:typeof e.body=="object"?(i=JSON.stringify(e.body),n.setRequestHeader("Content-Type","application/json; charset=UTF-8")):typeof e.body=="string"&&(i=e.body,n.setRequestHeader("Content-Type","text/plain; charset=UTF-8"))),n.onload=function(){let r=n.getResponseHeader("FreshToken");r&&r!==""&&(g=r);let u=n.response;try{u=JSON.parse(u)}catch{}if(n.responseURL){let c=new URL(o,window.location.href).href;if(n.responseURL!==c){window.location.href=n.responseURL;return}}n.status>=200&&n.status<400?e.onSuccess&&e.onSuccess(u,n.status,n):e.onError&&e.onError(n.status,n)},n.onerror=function(){e.onError&&e.onError(n.status,n)},n.send(i)}function h(o,s){if(!o)return"";let e=new DOMParser,t=o.includes("<html>")?o:"<body>"+o+"</body></html>",i=e.parseFromString(t,"text/html").querySelector("body"),r=i.querySelectorAll("script");if(r.forEach(function(u){u.remove()}),s!==null){let u=document.getElementById(s);return u&&(i.children.length>0?u.replaceChildren.apply(u,Array.from(i.children)):u.innerHTML=i.innerHTML,r.forEach(function(c){let d=document.createElement("script");d.type="text/javascript",d.async=!0,c.src?d.src=c.src:d.textContent=c.textContent,u.appendChild(d)})),""}return r.forEach(function(u){let c=document.createElement("script");c.type="text/javascript",c.async=!0,c.textContent=u.textContent,document.body.appendChild(c)}),i.innerHTML}function H(o,s,e){let t=s||"content";w(o,{method:"GET",onSuccess:function(n,i){if(document.getElementById(t)){let r=h(n,t);e&&e(r,n)}else e&&e(n)}})}function S(o,s,e,t){let n=e||"content";w(o,{method:"POST",body:s,onSuccess:function(i){let r="";if(i&&i.message!==void 0)r=h(i.message,n);else if(document.getElementById(n))r=h(i,n);else{t&&t(i);return}t&&t(r,i)}})}var T={collect:function(o){let s=new FormData,e=document.querySelectorAll("#"+o+" select, #"+o+" input, #"+o+" textarea");for(let t=0;t<e.length;t++){let n=e[t];if(n.name==="formToken"&&g!==null&&(n.value=g),!!n.name)if(n.type==="file"){let i=n.files;if(i)for(let r=0;r<i.length;r++){let u=i[r];if(u!==void 0){let c=n.name;i.length>1&&!c.includes("[")&&(c=c+"[]"),s.append(c,u,u.name)}}}else n.type==="checkbox"||n.type==="radio"?n.checked?s.append(n.name,n.value):n.type!=="radio"&&s.append(n.name,"0"):s.append(n.name,n.value===""?"":n.value)}return s},submit:function(o,s,e,t){let n=T.collect(o);S(s,n,e||"message",t)},show:function(o,s,e,t){let n=o.toUpperCase();(o==="create"||o==="edit")&&(n="GET"),o==="delete"&&(n="DELETE");let i=e||"form";w(s,{method:n,onSuccess:function(r){let u="";if(r&&r.message!==void 0)u=h(r.message,i);else if(document.getElementById(i))u=h(r,i);else{t&&t(r);return}t&&t(u)}})}};function L(o,s){let e={reconnect:!0,reconnectDelay:1e3,maxReconnectDelay:3e4,maxReconnectAttempts:1/0,protocols:[],onOpen:function(){},onClose:function(){},onError:function(){},...s||{}},t=null,n=!1,i=e.reconnectDelay,r=0,u=null,c={message:[],open:[],close:[],error:[]},d={status:"connecting",send:function(l){if(!t||t.readyState!==WebSocket.OPEN)throw new Error("[frond] WebSocket is not connected");t.send(typeof l=="string"?l:JSON.stringify(l))},on:function(l,a){return c[l]||(c[l]=[]),c[l].push(a),function(){let f=c[l],m=f.indexOf(a);m>=0&&f.splice(m,1)}},close:function(l,a){n=!0,u&&(clearTimeout(u),u=null),t&&t.close(l||1e3,a||""),d.status="closed"}};function y(l){if(typeof l!="string")return l;try{return JSON.parse(l)}catch{return l}}function p(){!e.reconnect||r>=e.maxReconnectAttempts||(r++,d.status="reconnecting",u=setTimeout(function(){u=null,v()},i),i=Math.min(i*2,e.maxReconnectDelay))}function v(){d.status=r>0?"reconnecting":"connecting";try{t=new WebSocket(o,e.protocols)}catch{d.status="closed";return}t.onopen=function(){d.status="open",r=0,i=e.reconnectDelay,e.onOpen();for(let l of c.open)l()},t.onmessage=function(l){let a=y(l.data);for(let f of c.message)f(a)},t.onclose=function(l){d.status="closed",e.onClose(l.code,l.reason);for(let a of c.close)a(l.code,l.reason);n||p()},t.onerror=function(l){e.onError(l);for(let a of c.error)a(l)}}return v(),d}function D(o,s){let e={reconnect:!0,reconnectDelay:1e3,maxReconnectDelay:3e4,maxReconnectAttempts:1/0,events:[],json:!0,onOpen:function(){},onClose:function(){},onError:function(){},...s||{}},t=null,n=!1,i=e.reconnectDelay,r=0,u=null,c={message:[],open:[],close:[],error:[]},d={status:"connecting",on:function(a,f){return c[a]||(c[a]=[]),c[a].push(f),function(){let m=c[a],E=m.indexOf(f);E>=0&&m.splice(E,1)}},close:function(){n=!0,u&&(clearTimeout(u),u=null),t&&(t.close(),t=null),d.status="closed"}};function y(a){if(!e.json)return a;try{return JSON.parse(a)}catch{return a}}function p(a,f){for(let m of c.message)m(a,f||void 0)}function v(){!e.reconnect||r>=e.maxReconnectAttempts||(r++,d.status="reconnecting",u=setTimeout(function(){u=null,l()},i),i=Math.min(i*2,e.maxReconnectDelay))}function l(){d.status=r>0?"reconnecting":"connecting";try{t=new EventSource(o)}catch{d.status="closed";return}t.onopen=function(){d.status="open",r=0,i=e.reconnectDelay,e.onOpen();for(let a of c.open)a(null)},t.onmessage=function(a){p(y(a.data),null)};for(let a of e.events)t.addEventListener(a,function(f){p(y(f.data),a)});t.onerror=function(a){e.onError(a);for(let f of c.error)f(a);if(t&&t.readyState===2){t=null,d.status="closed",e.onClose();for(let f of c.close)f(null);n||v()}}}return l(),d}var W={set:function(o,s,e){let t="";if(e){let n=new Date;n.setTime(n.getTime()+e*24*60*60*1e3),t="; expires="+n.toUTCString()}document.cookie=o+"="+(s||"")+t+"; path=/"},get:function(o){let s=o+"=",e=document.cookie.split(";");for(let t=0;t<e.length;t++){let n=e[t];for(;n.charAt(0)===" ";)n=n.substring(1);if(n.indexOf(s)===0)return n.substring(s.length)}return null},remove:function(o){document.cookie=o+"=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/"}};function A(o,s){let e=document.getElementById("message");if(!e)return;let t=s||"info";e.innerHTML='<div class="alert alert-'+t+' alert-dismissible">'+o+'<button type="button" class="btn-close" data-t4-dismiss="alert">&times;</button></div>'}function I(o,s,e,t){let n=window.screenLeft!==void 0?window.screenLeft:window.screenX,i=window.screenTop!==void 0?window.screenTop:window.screenY,r=window.innerWidth||document.documentElement.clientWidth||screen.width,u=window.innerHeight||document.documentElement.clientHeight||screen.height,c=r/window.screen.availWidth,d=(r-e)/2/c+n,y=(u-t)/2/c+i,p=window.open(o,s,"directories=no,toolbar=no,location=no,status=no,menubar=no,scrollbars=yes,resizable=yes,width="+e/c+",height="+t/c+",top="+y+",left="+d);return window.focus&&p&&p.focus(),p}function N(o){if(o.indexOf("No data available")>=0){window.alert("No data available for this report.");return}window.open(o,"_blank","toolbar=no,scrollbars=yes,resizable=yes,width=800,height=600,top=0,left=0")}function U(o,s,e,t){w(o,{method:"POST",body:{query:s,variables:e||{}},onSuccess:function(n){t&&t(n.data||null,n.errors||void 0)},onError:function(n){t&&t(null,[{message:"GraphQL request failed with status "+n}])}})}var R={request:w,load:H,post:S,inject:h,form:T,ws:L,sse:D,cookie:W,message:A,popup:I,report:N,graphql:U,get token(){return g},set token(o){g=o}};typeof window<"u"&&(window.frond=R);return q(j);})();
2
- /* Frond v2.1.3 tina4.com */
1
+ var _frondModule=(()=>{var E=Object.defineProperty;var O=Object.getOwnPropertyDescriptor;var A=Object.getOwnPropertyNames;var H=Object.prototype.hasOwnProperty;var q=(t,s)=>{for(var e in s)E(t,e,{get:s[e],enumerable:!0})},D=(t,s,e,o)=>{if(s&&typeof s=="object"||typeof s=="function")for(let n of A(s))!H.call(t,n)&&n!==e&&E(t,n,{get:()=>s[n],enumerable:!(o=O(s,n))||o.enumerable});return t};var W=t=>D(E({},"__esModule",{value:!0}),t);var z={};q(z,{frond:()=>L});var y=null;function v(t,s){let e;typeof s=="function"?e={onSuccess:s}:e=s||{};let o=(e.method||"GET").toUpperCase(),n=new XMLHttpRequest;if(n.open(o,t,!0),y!==null&&n.setRequestHeader("Authorization","Bearer "+y),e.headers)for(let u in e.headers)Object.prototype.hasOwnProperty.call(e.headers,u)&&n.setRequestHeader(u,e.headers[u]);let c=null;e.body!==void 0&&e.body!==null&&(e.body instanceof FormData?c=e.body:typeof e.body=="object"?(c=JSON.stringify(e.body),n.setRequestHeader("Content-Type","application/json; charset=UTF-8")):typeof e.body=="string"&&(c=e.body,n.setRequestHeader("Content-Type","text/plain; charset=UTF-8"))),n.onload=function(){let u=n.getResponseHeader("FreshToken");u&&u!==""&&(y=u);let r=n.response;try{r=JSON.parse(r)}catch{}if(n.responseURL){let i=new URL(t,window.location.href).href;if(n.responseURL!==i){window.location.href=n.responseURL;return}}n.status>=200&&n.status<400?e.onSuccess&&e.onSuccess(r,n.status,n):e.onError&&e.onError(n.status,n)},n.onerror=function(){e.onError&&e.onError(n.status,n)},n.send(c)}function h(t,s){if(!t)return"";let e=new DOMParser,o=t.includes("<html>")?t:"<body>"+t+"</body></html>",c=e.parseFromString(o,"text/html").querySelector("body"),u=c.querySelectorAll("script");if(u.forEach(function(r){r.remove()}),s!==null){let r=document.getElementById(s);return r&&(c.children.length>0?r.replaceChildren.apply(r,Array.from(c.children)):r.innerHTML=c.innerHTML,u.forEach(function(i){let l=document.createElement("script");l.type="text/javascript",l.async=!0,i.src?l.src=i.src:l.textContent=i.textContent,r.appendChild(l)})),""}return u.forEach(function(r){let i=document.createElement("script");i.type="text/javascript",i.async=!0,i.textContent=r.textContent,document.body.appendChild(i)}),c.innerHTML}function _(t,s,e){let o=s||"content";v(t,{method:"GET",onSuccess:function(n,c){if(document.getElementById(o)){let u=h(n,o);e&&e(u,n)}else e&&e(n)}})}function R(t,s,e,o){let n=e||"content";v(t,{method:"POST",body:s,onSuccess:function(c){let u="";if(c&&c.message!==void 0)u=h(c.message,n);else if(document.getElementById(n))u=h(c,n);else{o&&o(c);return}o&&o(u,c)}})}var x={collect:function(t){let s=new FormData,e=document.querySelectorAll("#"+t+" select, #"+t+" input, #"+t+" textarea");for(let o=0;o<e.length;o++){let n=e[o];if(n.name==="formToken"&&y!==null&&(n.value=y),!!n.name)if(n.type==="file"){let c=n.files;if(c)for(let u=0;u<c.length;u++){let r=c[u];if(r!==void 0){let i=n.name;c.length>1&&!i.includes("[")&&(i=i+"[]"),s.append(i,r,r.name)}}}else n.type==="checkbox"||n.type==="radio"?n.checked?s.append(n.name,n.value):n.type!=="radio"&&s.append(n.name,"0"):s.append(n.name,n.value===""?"":n.value)}return s},submit:function(t,s,e,o){let n=x.collect(t);R(s,n,e||"message",o)},show:function(t,s,e,o){let n=t.toUpperCase();(t==="create"||t==="edit")&&(n="GET"),t==="delete"&&(n="DELETE");let c=e||"form";v(s,{method:n,onSuccess:function(u){let r="";if(u&&u.message!==void 0)r=h(u.message,c);else if(document.getElementById(c))r=h(u,c);else{o&&o(u);return}o&&o(r)}})}};function M(t,s){let e={reconnect:!0,reconnectDelay:1e3,maxReconnectDelay:3e4,maxReconnectAttempts:1/0,protocols:[],onOpen:function(){},onClose:function(){},onError:function(){},...s||{}},o=null,n=!1,c=e.reconnectDelay,u=0,r=null,i={message:[],open:[],close:[],error:[]},l={status:"connecting",send:function(f){if(!o||o.readyState!==WebSocket.OPEN)throw new Error("[frond] WebSocket is not connected");o.send(typeof f=="string"?f:JSON.stringify(f))},on:function(f,a){return i[f]||(i[f]=[]),i[f].push(a),function(){let d=i[f],g=d.indexOf(a);g>=0&&d.splice(g,1)}},close:function(f,a){n=!0,r&&(clearTimeout(r),r=null),o&&o.close(f||1e3,a||""),l.status="closed"}};function p(f){if(typeof f!="string")return f;try{return JSON.parse(f)}catch{return f}}function m(){!e.reconnect||u>=e.maxReconnectAttempts||(u++,l.status="reconnecting",r=setTimeout(function(){r=null,w()},c),c=Math.min(c*2,e.maxReconnectDelay))}function w(){l.status=u>0?"reconnecting":"connecting";try{o=new WebSocket(t,e.protocols)}catch{l.status="closed";return}o.onopen=function(){l.status="open",u=0,c=e.reconnectDelay,e.onOpen();for(let f of i.open)f()},o.onmessage=function(f){let a=p(f.data);for(let d of i.message)d(a)},o.onclose=function(f){l.status="closed",e.onClose(f.code,f.reason);for(let a of i.close)a(f.code,f.reason);n||m()},o.onerror=function(f){e.onError(f);for(let a of i.error)a(f)}}return w(),l}function I(t,s){let e={reconnect:!0,reconnectDelay:1e3,maxReconnectDelay:3e4,maxReconnectAttempts:1/0,events:[],json:!0,onOpen:function(){},onClose:function(){},onError:function(){},...s||{}},o=null,n=!1,c=e.reconnectDelay,u=0,r=null,i={message:[],open:[],close:[],error:[]},l={status:"connecting",on:function(a,d){return i[a]||(i[a]=[]),i[a].push(d),function(){let g=i[a],T=g.indexOf(d);T>=0&&g.splice(T,1)}},close:function(){n=!0,r&&(clearTimeout(r),r=null),o&&(o.close(),o=null),l.status="closed"}};function p(a){if(!e.json)return a;try{return JSON.parse(a)}catch{return a}}function m(a,d){for(let g of i.message)g(a,d||void 0)}function w(){!e.reconnect||u>=e.maxReconnectAttempts||(u++,l.status="reconnecting",r=setTimeout(function(){r=null,f()},c),c=Math.min(c*2,e.maxReconnectDelay))}function f(){l.status=u>0?"reconnecting":"connecting";try{o=new EventSource(t)}catch{l.status="closed";return}o.onopen=function(){l.status="open",u=0,c=e.reconnectDelay,e.onOpen();for(let a of i.open)a(null)},o.onmessage=function(a){m(p(a.data),null)};for(let a of e.events)o.addEventListener(a,function(d){m(p(d.data),a)});o.onerror=function(a){e.onError(a);for(let d of i.error)d(a);if(o&&o.readyState===2){o=null,l.status="closed",e.onClose();for(let d of i.close)d(null);n||w()}}}return f(),l}var U={set:function(t,s,e){let o="";if(e){let n=new Date;n.setTime(n.getTime()+e*24*60*60*1e3),o="; expires="+n.toUTCString()}document.cookie=t+"="+(s||"")+o+"; path=/"},get:function(t){let s=t+"=",e=document.cookie.split(";");for(let o=0;o<e.length;o++){let n=e[o];for(;n.charAt(0)===" ";)n=n.substring(1);if(n.indexOf(s)===0)return n.substring(s.length)}return null},remove:function(t){document.cookie=t+"=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/"}};function j(t,s){let e=document.getElementById("message");if(!e)return;let o=s||"info";e.innerHTML='<div class="alert alert-'+o+' alert-dismissible">'+t+'<button type="button" class="btn-close" data-t4-dismiss="alert">&times;</button></div>'}function B(t,s,e,o){let n=window.screenLeft!==void 0?window.screenLeft:window.screenX,c=window.screenTop!==void 0?window.screenTop:window.screenY,u=window.innerWidth||document.documentElement.clientWidth||screen.width,r=window.innerHeight||document.documentElement.clientHeight||screen.height,i=u/window.screen.availWidth,l=(u-e)/2/i+n,p=(r-o)/2/i+c,m=window.open(t,s,"directories=no,toolbar=no,location=no,status=no,menubar=no,scrollbars=yes,resizable=yes,width="+e/i+",height="+o/i+",top="+p+",left="+l);return window.focus&&m&&m.focus(),m}function P(t){if(t.indexOf("No data available")>=0){window.alert("No data available for this report.");return}window.open(t,"_blank","toolbar=no,scrollbars=yes,resizable=yes,width=800,height=600,top=0,left=0")}function F(t,s,e,o){v(t,{method:"POST",body:{query:s,variables:e||{}},onSuccess:function(n){o&&o(n.data||null,n.errors||void 0)},onError:function(n){o&&o(null,[{message:"GraphQL request failed with status "+n}])}})}function b(t){let s=t.dataset;return s&&s.key?s.key:null}function X(t,s){let e=s.attributes;for(let n=0;n<e.length;n++){let c=e[n];t.getAttribute(c.name)!==c.value&&t.setAttribute(c.name,c.value)}Array.prototype.slice.call(t.attributes).forEach(function(n){s.hasAttribute(n.name)||t.removeAttribute(n.name)})}function G(t,s){let e=s.tagName;e==="INPUT"||e==="TEXTAREA"||e==="SELECT"||(X(t,s),t.children.length||s.children.length?C(t,s):t.innerHTML!==s.innerHTML&&(t.innerHTML=s.innerHTML))}function C(t,s){let e=Array.prototype.slice.call(t.children),o=Array.prototype.slice.call(s.children),n={};e.forEach(function(r){let i=b(r);i&&(n[i]=r)});let c=[];for(let r=0;r<o.length;r++){let i=o[r],l=b(i),p=null;l&&n[l]?p=n[l]:!l&&e[r]&&!b(e[r])&&e[r].tagName===i.tagName&&(p=e[r]),p&&p.tagName===i.tagName?(G(p,i),reused.push(p),c.push(p)):c.push(i)}let u=t.firstElementChild;for(let r=0;r<c.length;r++){let i=c[r];i===u?u=u.nextElementSibling:t.insertBefore(i,u)}e.forEach(function(r){c.indexOf(r)===-1&&r.parentNode===t&&t.removeChild(r)}),reused}function k(t,s){let e=document.createElement("div");if(e.innerHTML=s,!e.children.length||!t.children.length){t.innerHTML=s;return}C(t,e)}function J(t){return/^wss?:\/\//.test(t)?t:(typeof location<"u"&&location.protocol==="https:"?"wss":"ws")+"://"+location.host+t}function N(t,s){return t&&typeof t=="object"?t.type==="live"?s&&t.name&&t.name!==s?null:t.html!=null?String(t.html):null:null:typeof t=="string"?t:null}function S(t){if(typeof document>"u")return;let e=(t||document).querySelectorAll("[data-frond-live]");Array.prototype.slice.call(e).forEach(function(o){if(o.__frondLive)return;o.__frondLive=!0;let n=o.getAttribute("data-mode"),c=o.getAttribute("data-frond-live");if(n==="poll"){let u=o.getAttribute("data-src"),r=(parseInt(o.getAttribute("data-interval"),10)||5)*1e3,i=setInterval(function(){typeof document<"u"&&document.hidden||v(u,{method:"GET",onSuccess:function(l){k(o,typeof l=="string"?l:String(l))}})},r);o.__frondLiveStop=function(){clearInterval(i)}}else if(n==="ws"){let u=M(J(o.getAttribute("data-ws")));u.on("message",function(r){let i=N(r,c);i!==null&&k(o,i)}),o.__frondLiveStop=function(){u.close()}}else n==="sse"&&typeof console<"u"&&console.warn&&console.warn("[frond.live] sse transport is not wired yet (v1 supports poll and ws); block '"+c+"' shows first paint only. Use poll or ws.")})}var L={request:v,load:_,post:R,inject:h,form:x,ws:M,sse:I,live:S,cookie:U,message:j,popup:B,report:P,graphql:F,get token(){return y},set token(t){y=t}};typeof window<"u"&&(window.frond=L,typeof document<"u"&&(document.readyState==="loading"?document.addEventListener("DOMContentLoaded",function(){S()}):S()));return W(z);})();
2
+ /* Frond v2.2.0 - tina4.com */
@@ -131,6 +131,10 @@ function compileString(
131
131
  // 7. Evaluate basic math in property values
132
132
  scss = evalMath(scss);
133
133
 
134
+ // 7.5. Resolve color functions (lighten/darken/rgba/rgb/mix) — after variable
135
+ // substitution so a $colour arg is already a hex literal (issue #124).
136
+ scss = resolveColorFunctions(scss);
137
+
134
138
  // 8. Flatten nested rules
135
139
  const css = flattenNesting(scss);
136
140
 
@@ -340,6 +344,99 @@ function evalMath(scss: string): string {
340
344
  });
341
345
  }
342
346
 
347
+ // ── Color Functions ──────────────────────────────────────────────
348
+
349
+ /**
350
+ * Resolve lighten(), darken(), rgba()/rgb(), and mix() color functions.
351
+ *
352
+ * rgba(<hex>, <alpha>) is the damaging case (issue #124): the functional rgba()
353
+ * notation cannot take a hex, so rgba(#0f3460, 0.12) is invalid CSS and browsers
354
+ * drop the whole declaration. Convert the hex to its r,g,b components. Runs after
355
+ * variable substitution so a $colour arg is already a hex literal.
356
+ */
357
+ function resolveColorFunctions(scss: string): string {
358
+ scss = scss.replace(/lighten\(\s*([^,]+)\s*,\s*([^)]+)\s*\)/g, (_m, color: string, amt: string) =>
359
+ adjustLightness(color.trim(), parseFloat(amt.trim().replace(/%$/, "")) / 100)
360
+ );
361
+ scss = scss.replace(/darken\(\s*([^,]+)\s*,\s*([^)]+)\s*\)/g, (_m, color: string, amt: string) =>
362
+ adjustLightness(color.trim(), -(parseFloat(amt.trim().replace(/%$/, "")) / 100))
363
+ );
364
+ // rgba(<hex>, <alpha>) — only the two-arg hex form; leave rgba(r,g,b,a) alone.
365
+ scss = scss.replace(/rgba\(\s*(#[0-9a-fA-F]{3,8})\s*,\s*([\d.]+)\s*\)/g, (whole, hex: string, alpha: string) => {
366
+ const rgb = hexToRgb(hex);
367
+ return rgb === null ? whole : `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, ${alpha.trim()})`;
368
+ });
369
+ scss = scss.replace(/rgb\(\s*(#[0-9a-fA-F]{3,8})\s*\)/g, (whole, hex: string) => {
370
+ const rgb = hexToRgb(hex);
371
+ return rgb === null ? whole : `rgb(${rgb[0]}, ${rgb[1]}, ${rgb[2]})`;
372
+ });
373
+ // mix(<c1>, <c2>[, <weight>]) — Sass weight is c1's proportion (default 50%).
374
+ scss = scss.replace(
375
+ /mix\(\s*(#[0-9a-fA-F]{3,8})\s*,\s*(#[0-9a-fA-F]{3,8})\s*(?:,\s*([\d.]+%?)\s*)?\)/g,
376
+ (whole, h1: string, h2: string, weight?: string) => {
377
+ const c1 = hexToRgb(h1);
378
+ const c2 = hexToRgb(h2);
379
+ if (c1 === null || c2 === null) return whole;
380
+ const w = weight ? parseFloat(weight.replace(/%$/, "")) / 100 : 0.5;
381
+ const mixed = [0, 1, 2].map((i) => Math.round(c1[i] * w + c2[i] * (1 - w)));
382
+ return `#${mixed.map((v) => v.toString(16).padStart(2, "0")).join("")}`;
383
+ }
384
+ );
385
+ return scss;
386
+ }
387
+
388
+ /** Parse a #rgb / #rrggbb hex string into an [r, g, b] tuple, or null. */
389
+ function hexToRgb(color: string): [number, number, number] | null {
390
+ let c = color.trim().replace(/^#/, "");
391
+ if (c.length === 3) {
392
+ c = c.split("").map((ch) => ch + ch).join("");
393
+ }
394
+ if (!/^[0-9a-fA-F]{6}$/.test(c)) return null;
395
+ return [parseInt(c.slice(0, 2), 16), parseInt(c.slice(2, 4), 16), parseInt(c.slice(4, 6), 16)];
396
+ }
397
+
398
+ /** Adjust the HSL lightness of a hex color by `amount` (-1..1), return hex.
399
+ * Truncates (Math.trunc) to match the Python master's int(x*255) byte-for-byte. */
400
+ function adjustLightness(color: string, amount: number): string {
401
+ const rgb = hexToRgb(color);
402
+ if (rgb === null) return color;
403
+ let [r, g, b] = rgb.map((v) => v / 255) as [number, number, number];
404
+ const max = Math.max(r, g, b);
405
+ const min = Math.min(r, g, b);
406
+ let l = (max + min) / 2;
407
+ const d = max - min;
408
+ let h = 0;
409
+ let s = 0;
410
+ if (d !== 0) {
411
+ s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
412
+ if (max === r) h = (g - b) / d + (g < b ? 6 : 0);
413
+ else if (max === g) h = (b - r) / d + 2;
414
+ else h = (r - g) / d + 4;
415
+ h /= 6;
416
+ }
417
+ l = Math.max(0, Math.min(1, l + amount));
418
+ if (s === 0) {
419
+ r = g = b = l;
420
+ } else {
421
+ const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
422
+ const p = 2 * l - q;
423
+ r = hueToRgb(p, q, h + 1 / 3);
424
+ g = hueToRgb(p, q, h);
425
+ b = hueToRgb(p, q, h - 1 / 3);
426
+ }
427
+ const hex = (v: number): string => Math.trunc(v * 255).toString(16).padStart(2, "0");
428
+ return `#${hex(r)}${hex(g)}${hex(b)}`;
429
+ }
430
+
431
+ function hueToRgb(p: number, q: number, t: number): number {
432
+ if (t < 0) t += 1;
433
+ if (t > 1) t -= 1;
434
+ if (t < 1 / 6) return p + (q - p) * 6 * t;
435
+ if (t < 1 / 2) return q;
436
+ if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
437
+ return p;
438
+ }
439
+
343
440
  // ── Nesting Flattener ────────────────────────────────────────────
344
441
 
345
442
  function flattenNesting(scss: string): string {
@@ -867,6 +867,30 @@ ${reset}
867
867
  try {
868
868
  const { Frond } = await import("../../frond/src/engine.js");
869
869
  frondEngine = new Frond(templatesDir);
870
+
871
+ // Always-on Frond {% live %} refresh endpoint. Re-renders a server-rendered
872
+ // live block on demand (poll / sse), re-running its provider with the live
873
+ // request so auth re-applies. Parity with Python/PHP/Ruby /__frond/live/{name}.
874
+ router.addRoute({
875
+ method: "GET",
876
+ pattern: "/__frond/live/{name}",
877
+ handler: (req: any, res: any) => {
878
+ const { status, body } = Frond.respondLive(req, String(req.params?.name ?? ""));
879
+ return res.html(body, status);
880
+ },
881
+ meta: {
882
+ summary: "Frond live block refresh",
883
+ description: "Re-render a server-rendered {% live %} block by name.",
884
+ tags: ["System"],
885
+ },
886
+ });
887
+
888
+ // Wire the WebSocket broadcaster so Frond.pushLive can push live-block
889
+ // updates to connected clients (best-effort). Broadcasts to the block's
890
+ // declared data-ws path, else falls back to a path named after the block.
891
+ Frond.setLiveBroadcaster((wsPath: string | null, name: string, envelope: string) => {
892
+ wsRouteManager.broadcastPath(wsPath || name, envelope);
893
+ });
870
894
  } catch {
871
895
  // Frond not available
872
896
  }
@@ -13,6 +13,21 @@ import { join, resolve } from "node:path";
13
13
  export type FilterFn = (value: unknown, ...args: unknown[]) => unknown;
14
14
  export type TestFn = (value: unknown) => boolean;
15
15
 
16
+ /** A minimal request shape a {% live %} data provider receives. */
17
+ export interface LiveRequest {
18
+ headers?: Record<string, unknown>;
19
+ params?: Record<string, string>;
20
+ }
21
+ /** A {% live %} data provider — re-runs with the live request each refresh. */
22
+ export type LiveProvider = (req: LiveRequest) => Record<string, unknown>;
23
+ /** Result of respondLive — a pure {status, body} descriptor a route applies. */
24
+ export interface LiveResponse {
25
+ status: number;
26
+ body: string;
27
+ }
28
+ /** WebSocket broadcaster hook wired by @tina4/core so pushLive can broadcast. */
29
+ export type LiveBroadcaster = (wsPath: string | null, name: string, envelope: string) => void;
30
+
16
31
  /** Marker class for strings that should not be auto-escaped. */
17
32
  class SafeString {
18
33
  constructor(public value: string) {}
@@ -163,6 +178,21 @@ const FORMAT_RE = /%%|%([-+ 0]*)(\d+)?(?:\.(\d+))?([sdifFeEgGxXob])/g;
163
178
  const LEADING_WS_RE = /^\s+/;
164
179
  const TRAILING_WS_RE = /\s+$/;
165
180
  const THOUSANDS_RE = /\B(?=(\d{3})+(?!\d))/g;
181
+ // {% live "name" poll N | sse | ws "path" [src "url"] %}
182
+ const LIVE_RE = /^live\s+["']([^"']+)["']([\s\S]*)$/;
183
+ const LIVE_WS_RE = /ws\s+["']([^"']+)["']/;
184
+ const LIVE_SRC_RE = /src\s+["']([^"']+)["']/;
185
+
186
+ /** Escape a value for a live-marker HTML attribute. Byte-identical order to
187
+ * the Python master / PHP liveAttr / Ruby live_attr so the emitted marker
188
+ * element matches across all four frameworks (& " < > — no apostrophe). */
189
+ function liveAttr(value: unknown): string {
190
+ return String(value)
191
+ .replace(/&/g, "&amp;")
192
+ .replace(/"/g, "&quot;")
193
+ .replace(/</g, "&lt;")
194
+ .replace(/>/g, "&gt;");
195
+ }
166
196
 
167
197
  // ── Caches (module level) ─────────────────────────────────────
168
198
 
@@ -1374,6 +1404,24 @@ export class Frond {
1374
1404
  private static classGlobals: Map<string, unknown> = new Map();
1375
1405
  private static classTests: Map<string, TestFn> = new Map();
1376
1406
 
1407
+ // ── Live-block registries (server-rendered {% live %} regions) ──
1408
+ // A {% live %} block registers three things when its page first renders:
1409
+ // liveFragments[name] -> the raw body source, re-rendered on every
1410
+ // refresh by GET /__frond/live/<name> or pushLive
1411
+ // liveSources[name] -> an optional data provider (liveSource) that
1412
+ // re-runs with the LIVE request each refresh, so
1413
+ // auth re-applies (IDOR guard)
1414
+ // liveWsPaths[name] -> the ws path a `ws "path"` block declared, the
1415
+ // pushLive broadcast target
1416
+ // Static so they persist across requests in the long-lived server. Mirrors
1417
+ // the Python master's class-level dicts and PHP/Ruby static registries.
1418
+ private static liveFragments: Map<string, string> = new Map();
1419
+ private static liveSources: Map<string, LiveProvider> = new Map();
1420
+ private static liveWsPaths: Map<string, string> = new Map();
1421
+ // Best-effort WebSocket broadcaster wired by @tina4/core at boot (frond is a
1422
+ // zero-dep leaf package and cannot import core). pushLive calls it if set.
1423
+ private static liveBroadcaster: LiveBroadcaster | null = null;
1424
+
1377
1425
  /**
1378
1426
  * Register a custom filter at the class level — available to every
1379
1427
  * future ``new Frond()`` instance. Callable as ``Frond.addFilter()``
@@ -1412,6 +1460,9 @@ export class Frond {
1412
1460
  Frond.classFilters.clear();
1413
1461
  Frond.classGlobals.clear();
1414
1462
  Frond.classTests.clear();
1463
+ Frond.liveFragments.clear();
1464
+ Frond.liveSources.clear();
1465
+ Frond.liveWsPaths.clear();
1415
1466
  }
1416
1467
 
1417
1468
  private templateDir: string;
@@ -1850,6 +1901,10 @@ export class Frond {
1850
1901
  const [result, skip] = this.handleCache(tokens, i, context);
1851
1902
  output.push(result);
1852
1903
  i = skip;
1904
+ } else if (tag === "live") {
1905
+ const [result, skip] = this.handleLive(tokens, i, context);
1906
+ output.push(result);
1907
+ i = skip;
1853
1908
  } else if (tag === "spaceless") {
1854
1909
  const [result, skip] = this.handleSpaceless(tokens, i, context);
1855
1910
  output.push(result);
@@ -2502,6 +2557,175 @@ export class Frond {
2502
2557
  return [rendered, i];
2503
2558
  }
2504
2559
 
2560
+ /**
2561
+ * Handle {% live "name" poll N | sse | ws "path" [src "url"] %}...{% endlive %}.
2562
+ *
2563
+ * Server-rendered live region. The body renders once for first paint, is
2564
+ * registered under <name> so GET /__frond/live/<name> (or a liveSource
2565
+ * provider) can re-render it, and is wrapped in a marker element that
2566
+ * frond.js wires to the chosen transport (poll / sse / ws). Mirrors the
2567
+ * Python master's _handle_live and PHP/Ruby handleLive.
2568
+ */
2569
+ private handleLive(tokens: Token[], start: number, context: Record<string, unknown>): [string, number] {
2570
+ const [content] = stripTag(tokens[start][1]);
2571
+ const m = content.match(LIVE_RE);
2572
+ if (!m) {
2573
+ throw new Error('live: expected {% live "name" poll N | sse | ws "path" %}');
2574
+ }
2575
+ const name = m[1];
2576
+ const rest = (m[2] || "").trim();
2577
+ const parts = rest.split(/\s+/).filter(Boolean);
2578
+ const mode = parts[0] || "";
2579
+
2580
+ const sm = rest.match(LIVE_SRC_RE);
2581
+ const src = sm ? sm[1] : null;
2582
+ if (src && (src.startsWith("http://") || src.startsWith("https://") || src.startsWith("//"))) {
2583
+ throw new Error("live: src must be a same-origin path, not an absolute URL");
2584
+ }
2585
+
2586
+ let interval: number | null = null;
2587
+ let wsPath: string | null = null;
2588
+ if (mode === "poll") {
2589
+ if (!parts[1] || !/^\d+$/.test(parts[1])) {
2590
+ throw new Error('live: poll requires seconds, e.g. {% live "x" poll 5 %}');
2591
+ }
2592
+ interval = parseInt(parts[1], 10);
2593
+ } else if (mode === "sse") {
2594
+ // no extra config
2595
+ } else if (mode === "ws") {
2596
+ const wm = rest.match(LIVE_WS_RE);
2597
+ if (!wm) {
2598
+ throw new Error('live: ws requires a path, e.g. {% live "x" ws "/ws/x" %}');
2599
+ }
2600
+ wsPath = wm[1];
2601
+ } else {
2602
+ throw new Error(`live: unknown transport "${mode}" (use poll N, sse, or ws "path")`);
2603
+ }
2604
+
2605
+ // Collect body tokens up to {% endlive %}. Nested live is unsupported.
2606
+ const bodyTokens: Token[] = [];
2607
+ let i = start + 1;
2608
+ while (i < tokens.length) {
2609
+ if (tokens[i][0] === "BLOCK") {
2610
+ const [tagContent] = stripTag(tokens[i][1]);
2611
+ const tag = tagContent.split(/\s+/)[0] || "";
2612
+ if (tag === "live") throw new Error("live: nested live blocks are not supported");
2613
+ if (tag === "endlive") {
2614
+ i++;
2615
+ break;
2616
+ }
2617
+ bodyTokens.push(tokens[i]);
2618
+ } else {
2619
+ bodyTokens.push(tokens[i]);
2620
+ }
2621
+ i++;
2622
+ }
2623
+
2624
+ // Register the raw body source so the auto endpoint can re-render it.
2625
+ Frond.liveFragments.set(name, bodyTokens.map((t) => t[1]).join(""));
2626
+
2627
+ const endpoint = src || `/__frond/live/${name}`;
2628
+ const attrs = [`data-frond-live="${liveAttr(name)}"`, `id="live-${liveAttr(name)}"`];
2629
+ if (mode === "poll") {
2630
+ attrs.push('data-mode="poll"', `data-interval="${interval}"`, `data-src="${liveAttr(endpoint)}"`);
2631
+ } else if (mode === "sse") {
2632
+ attrs.push('data-mode="sse"', `data-src="${liveAttr(endpoint)}"`);
2633
+ } else if (mode === "ws") {
2634
+ Frond.liveWsPaths.set(name, wsPath as string);
2635
+ attrs.push('data-mode="ws"', `data-ws="${liveAttr(wsPath)}"`);
2636
+ }
2637
+
2638
+ const firstPaint = this.renderTokens([...bodyTokens], context);
2639
+ return [`<div ${attrs.join(" ")}>${firstPaint}</div>`, i];
2640
+ }
2641
+
2642
+ // ── Live-block class API (mirrors Python master + PHP/Ruby facades) ──
2643
+
2644
+ /**
2645
+ * Re-render a registered {% live %} fragment by name with fresh data.
2646
+ * Returns the rendered HTML, or null if no fragment is registered under that
2647
+ * name yet (its page has not rendered). GET /__frond/live/<name> calls this
2648
+ * after resolving the provider data.
2649
+ */
2650
+ static renderLive(name: string, data?: Record<string, unknown>): string | null {
2651
+ const source = Frond.liveFragments.get(name);
2652
+ if (source === undefined) return null;
2653
+ return new Frond().renderString(source, data || {});
2654
+ }
2655
+
2656
+ /** Register a data provider for a {% live %} block. Invoked with the live
2657
+ * request on every refresh so auth re-applies. Mirrors Python's @live_source. */
2658
+ static liveSource(name: string, fn: LiveProvider): void {
2659
+ Frond.liveSources.set(name, fn);
2660
+ }
2661
+
2662
+ /** The provider registered for a live block, or null. */
2663
+ static getLiveSource(name: string): LiveProvider | null {
2664
+ return Frond.liveSources.get(name) ?? null;
2665
+ }
2666
+
2667
+ /** Whether a live fragment has been registered (its page rendered). */
2668
+ static hasLiveFragment(name: string): boolean {
2669
+ return Frond.liveFragments.has(name);
2670
+ }
2671
+
2672
+ /** The ws path a live block declared (data-ws), or null. */
2673
+ static getLiveWsPath(name: string): string | null {
2674
+ return Frond.liveWsPaths.get(name) ?? null;
2675
+ }
2676
+
2677
+ /**
2678
+ * Resolve GET /__frond/live/{name}: run the provider with the live request
2679
+ * (auth re-applies), re-render the fragment, and return a pure {status, body}
2680
+ * descriptor the route handler applies to the response. 404 for an unknown
2681
+ * name / unrendered fragment. Mirrors Python's live_endpoint / PHP respondLive.
2682
+ */
2683
+ static respondLive(req: LiveRequest, name: string): LiveResponse {
2684
+ const provider = Frond.liveSources.get(name);
2685
+ if (!Frond.liveFragments.has(name) && provider === undefined) {
2686
+ return { status: 404, body: `live block not found: ${name}` };
2687
+ }
2688
+ let context: Record<string, unknown> = {};
2689
+ if (provider !== undefined) {
2690
+ const result = provider(req);
2691
+ context = result && typeof result === "object" ? result : {};
2692
+ }
2693
+ const html = Frond.renderLive(name, context);
2694
+ if (html === null) {
2695
+ return { status: 404, body: `live fragment not registered yet: ${name}` };
2696
+ }
2697
+ return { status: 200, body: html };
2698
+ }
2699
+
2700
+ /** Wire the WebSocket broadcaster used by pushLive. Called once by @tina4/core
2701
+ * at server boot (frond is a zero-dep leaf and cannot import core). */
2702
+ static setLiveBroadcaster(fn: LiveBroadcaster | null): void {
2703
+ Frond.liveBroadcaster = fn;
2704
+ }
2705
+
2706
+ /**
2707
+ * Re-render the '<name>' live fragment and push it to connected clients.
2708
+ * Broadcasts a {type,name,html} envelope over WebSocket to the block's
2709
+ * declared data-ws path (else a room named <name>). Returns the rendered
2710
+ * HTML, or null if the fragment is not registered. Mirrors Python push_live
2711
+ * / PHP pushLive. The broadcast is best-effort — a missing/failed broadcaster
2712
+ * never throws into the caller.
2713
+ */
2714
+ static pushLive(name: string, data?: Record<string, unknown>): string | null {
2715
+ const html = Frond.renderLive(name, data);
2716
+ if (html === null) return null;
2717
+
2718
+ if (Frond.liveBroadcaster) {
2719
+ try {
2720
+ const envelope = JSON.stringify({ type: "live", name, html });
2721
+ Frond.liveBroadcaster(Frond.getLiveWsPath(name), name, envelope);
2722
+ } catch {
2723
+ // best-effort — never let a broadcast failure escape pushLive
2724
+ }
2725
+ }
2726
+ return html;
2727
+ }
2728
+
2505
2729
  private handleSpaceless(tokens: Token[], start: number, context: Record<string, unknown>): [string, number] {
2506
2730
  const bodyTokens: Token[] = [];
2507
2731
  let i = start + 1;
@@ -1,2 +1,2 @@
1
1
  export { Frond } from "./engine.js";
2
- export type { FilterFn, TestFn } from "./engine.js";
2
+ export type { FilterFn, TestFn, LiveProvider, LiveRequest, LiveResponse, LiveBroadcaster } from "./engine.js";
@@ -393,9 +393,10 @@ export function parseDatabaseUrl(url: string, username?: string, password?: stri
393
393
  database,
394
394
  };
395
395
  } else {
396
- // Normalize postgres:// to postgresql:// for URL parsing
397
- const normalizedUrl = url.startsWith("postgres://")
398
- ? url.replace(/^postgres:\/\//, "postgresql://")
396
+ // Normalize postgres:// and pgsql:// (the PDO/Laravel/Doctrine scheme
397
+ // name, issue #58) to postgresql:// for URL parsing.
398
+ const normalizedUrl = /^(postgres|pgsql):\/\//.test(url)
399
+ ? url.replace(/^(postgres|pgsql):\/\//, "postgresql://")
399
400
  : url;
400
401
 
401
402
  let parsed: URL;