tina4-nodejs 3.13.51 → 3.13.53
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 +2 -2
- package/package.json +1 -1
- package/packages/core/public/js/frond.js +138 -1
- package/packages/core/public/js/frond.min.js +2 -2
- package/packages/core/src/scss.ts +97 -0
- package/packages/core/src/server.ts +24 -0
- package/packages/frond/src/engine.ts +224 -0
- package/packages/frond/src/index.ts +1 -1
- package/packages/orm/src/adapters/firebird.ts +6 -2
- package/packages/orm/src/adapters/mssql.ts +6 -2
- package/packages/orm/src/adapters/mysql.ts +7 -2
- package/packages/orm/src/adapters/odbc.ts +5 -2
- package/packages/orm/src/adapters/postgres.ts +6 -2
- package/packages/orm/src/adapters/sqlite.ts +5 -2
- package/packages/orm/src/baseModel.ts +49 -5
- package/packages/orm/src/database.ts +4 -3
- package/packages/orm/src/types.ts +1 -1
- package/packages/orm/src/validation.ts +9 -0
- package/packages/swagger/src/generator.ts +5 -0
package/CLAUDE.md
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
# CLAUDE.md - AI Developer Guide for tina4-nodejs (v3.13.
|
|
1
|
+
# CLAUDE.md - AI Developer Guide for tina4-nodejs (v3.13.53)
|
|
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.13.
|
|
7
|
+
Tina4 for Node.js/TypeScript v3.13.53 — 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
|
@@ -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.
|
|
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">×</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
|
+
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">×</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, "&")
|
|
192
|
+
.replace(/"/g, """)
|
|
193
|
+
.replace(/</g, "<")
|
|
194
|
+
.replace(/>/g, ">");
|
|
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";
|
|
@@ -486,10 +486,12 @@ export class FirebirdAdapter implements DatabaseAdapter {
|
|
|
486
486
|
|
|
487
487
|
if (def.primaryKey && !def.autoIncrement) parts.push("PRIMARY KEY");
|
|
488
488
|
if (def.required && !def.primaryKey) parts.push("NOT NULL");
|
|
489
|
-
|
|
489
|
+
// A json column carries no DDL DEFAULT (parity with the Python master): an
|
|
490
|
+
// object/array default is applied per instance, not a portable SQL literal.
|
|
491
|
+
if (def.type !== "json" && def.default !== undefined && def.default !== "now") {
|
|
490
492
|
parts.push(`DEFAULT ${sqlDefault(def.default)}`);
|
|
491
493
|
}
|
|
492
|
-
if (def.default === "now") {
|
|
494
|
+
if (def.type !== "json" && def.default === "now") {
|
|
493
495
|
parts.push("DEFAULT CURRENT_TIMESTAMP");
|
|
494
496
|
}
|
|
495
497
|
|
|
@@ -548,6 +550,8 @@ function fieldTypeToFirebird(def: FieldDefinition): string {
|
|
|
548
550
|
return "TIMESTAMP";
|
|
549
551
|
case "text":
|
|
550
552
|
return "BLOB SUB_TYPE TEXT";
|
|
553
|
+
case "json":
|
|
554
|
+
return "BLOB SUB_TYPE TEXT"; // Firebird has no TEXT/JSON type; store JSON in a text BLOB
|
|
551
555
|
case "string":
|
|
552
556
|
return def.maxLength ? `VARCHAR(${def.maxLength})` : "VARCHAR(255)";
|
|
553
557
|
default:
|
|
@@ -467,10 +467,12 @@ export class MssqlAdapter implements DatabaseAdapter {
|
|
|
467
467
|
|
|
468
468
|
if (def.primaryKey && !def.autoIncrement) parts.push("PRIMARY KEY");
|
|
469
469
|
if (def.required && !def.primaryKey) parts.push("NOT NULL");
|
|
470
|
-
|
|
470
|
+
// A json column carries no DDL DEFAULT (parity with the Python master): an
|
|
471
|
+
// object/array default is applied per instance, not a portable SQL literal.
|
|
472
|
+
if (def.type !== "json" && def.default !== undefined && def.default !== "now") {
|
|
471
473
|
parts.push(`DEFAULT ${sqlDefault(def.default)}`);
|
|
472
474
|
}
|
|
473
|
-
if (def.default === "now") {
|
|
475
|
+
if (def.type !== "json" && def.default === "now") {
|
|
474
476
|
parts.push("DEFAULT GETDATE()");
|
|
475
477
|
}
|
|
476
478
|
|
|
@@ -499,6 +501,8 @@ function fieldTypeToMssql(def: FieldDefinition): string {
|
|
|
499
501
|
return "DATETIME";
|
|
500
502
|
case "text":
|
|
501
503
|
return "NTEXT";
|
|
504
|
+
case "json":
|
|
505
|
+
return "NVARCHAR(MAX)"; // MSSQL stores JSON text; its JSON functions read NVARCHAR
|
|
502
506
|
case "string":
|
|
503
507
|
return def.maxLength ? `NVARCHAR(${def.maxLength})` : "NVARCHAR(255)";
|
|
504
508
|
default:
|
|
@@ -364,10 +364,13 @@ export class MysqlAdapter implements DatabaseAdapter {
|
|
|
364
364
|
|
|
365
365
|
if (def.primaryKey && !def.autoIncrement) parts.push("PRIMARY KEY");
|
|
366
366
|
if (def.required && !def.primaryKey) parts.push("NOT NULL");
|
|
367
|
-
|
|
367
|
+
// A json column carries no DDL DEFAULT (parity with the Python master; and
|
|
368
|
+
// MySQL's native JSON type rejects a literal DEFAULT anyway). The instance
|
|
369
|
+
// still gets its object/array default at construction.
|
|
370
|
+
if (def.type !== "json" && def.default !== undefined && def.default !== "now") {
|
|
368
371
|
parts.push(`DEFAULT ${sqlDefault(def.default)}`);
|
|
369
372
|
}
|
|
370
|
-
if (def.default === "now") {
|
|
373
|
+
if (def.type !== "json" && def.default === "now") {
|
|
371
374
|
parts.push("DEFAULT CURRENT_TIMESTAMP");
|
|
372
375
|
}
|
|
373
376
|
|
|
@@ -395,6 +398,8 @@ function fieldTypeToMysql(def: FieldDefinition): string {
|
|
|
395
398
|
return "DATETIME";
|
|
396
399
|
case "text":
|
|
397
400
|
return "TEXT";
|
|
401
|
+
case "json":
|
|
402
|
+
return "JSON";
|
|
398
403
|
case "string":
|
|
399
404
|
return def.maxLength ? `VARCHAR(${def.maxLength})` : "VARCHAR(255)";
|
|
400
405
|
default:
|
|
@@ -353,10 +353,12 @@ export class OdbcAdapter implements DatabaseAdapter {
|
|
|
353
353
|
if (def.primaryKey) parts.push("PRIMARY KEY");
|
|
354
354
|
if (def.autoIncrement) parts.push("GENERATED ALWAYS AS IDENTITY"); // ANSI SQL
|
|
355
355
|
if (def.required && !def.primaryKey) parts.push("NOT NULL");
|
|
356
|
-
|
|
356
|
+
// A json column carries no DDL DEFAULT (parity with the Python master): an
|
|
357
|
+
// object/array default is applied per instance, not a portable SQL literal.
|
|
358
|
+
if (def.type !== "json" && def.default !== undefined && def.default !== "now") {
|
|
357
359
|
parts.push(`DEFAULT ${sqlDefault(def.default)}`);
|
|
358
360
|
}
|
|
359
|
-
if (def.default === "now") parts.push("DEFAULT CURRENT_TIMESTAMP");
|
|
361
|
+
if (def.type !== "json" && def.default === "now") parts.push("DEFAULT CURRENT_TIMESTAMP");
|
|
360
362
|
colDefs.push(parts.join(" "));
|
|
361
363
|
}
|
|
362
364
|
await this.connection.query(`CREATE TABLE IF NOT EXISTS "${name}" (${colDefs.join(", ")})`);
|
|
@@ -405,6 +407,7 @@ function fieldTypeToOdbc(type: string): string {
|
|
|
405
407
|
case "boolean": return "SMALLINT";
|
|
406
408
|
case "datetime": return "TIMESTAMP";
|
|
407
409
|
case "text": return "CLOB";
|
|
410
|
+
case "json": return "CLOB"; // store JSON text in a CLOB
|
|
408
411
|
case "string":
|
|
409
412
|
default: return "VARCHAR(255)";
|
|
410
413
|
}
|
|
@@ -423,10 +423,12 @@ export class PostgresAdapter implements DatabaseAdapter {
|
|
|
423
423
|
|
|
424
424
|
if (def.primaryKey && !def.autoIncrement) parts.push("PRIMARY KEY");
|
|
425
425
|
if (def.required && !def.primaryKey) parts.push("NOT NULL");
|
|
426
|
-
|
|
426
|
+
// A json column carries no DDL DEFAULT (parity with the Python master): an
|
|
427
|
+
// object/array default is applied per instance, not a portable SQL literal.
|
|
428
|
+
if (def.type !== "json" && def.default !== undefined && def.default !== "now") {
|
|
427
429
|
parts.push(`DEFAULT ${sqlDefault(def.default)}`);
|
|
428
430
|
}
|
|
429
|
-
if (def.default === "now") {
|
|
431
|
+
if (def.type !== "json" && def.default === "now") {
|
|
430
432
|
parts.push("DEFAULT CURRENT_TIMESTAMP");
|
|
431
433
|
}
|
|
432
434
|
|
|
@@ -460,6 +462,8 @@ function fieldTypeToPostgres(def: FieldDefinition): string {
|
|
|
460
462
|
return "TIMESTAMP";
|
|
461
463
|
case "text":
|
|
462
464
|
return "TEXT";
|
|
465
|
+
case "json":
|
|
466
|
+
return "JSONB";
|
|
463
467
|
case "string":
|
|
464
468
|
return def.maxLength ? `VARCHAR(${def.maxLength})` : "VARCHAR(255)";
|
|
465
469
|
default:
|
|
@@ -366,8 +366,10 @@ export class SQLiteAdapter implements DatabaseAdapter {
|
|
|
366
366
|
if (def.primaryKey) parts.push("PRIMARY KEY");
|
|
367
367
|
if (def.autoIncrement) parts.push("AUTOINCREMENT");
|
|
368
368
|
if (def.required && !def.primaryKey) parts.push("NOT NULL");
|
|
369
|
-
|
|
370
|
-
|
|
369
|
+
// A json column carries no DDL DEFAULT (parity with the Python master): an
|
|
370
|
+
// object/array default is applied per instance, not a portable SQL literal.
|
|
371
|
+
if (def.type !== "json" && def.default !== undefined && def.default !== "now") parts.push(`DEFAULT ${sqlDefault(def.default)}`);
|
|
372
|
+
if (def.type !== "json" && def.default === "now") parts.push("DEFAULT CURRENT_TIMESTAMP");
|
|
371
373
|
colDefs.push(parts.join(" "));
|
|
372
374
|
}
|
|
373
375
|
this.db.exec(`CREATE TABLE IF NOT EXISTS "${name}" (${colDefs.join(", ")})`);
|
|
@@ -393,6 +395,7 @@ function fieldTypeToSQLite(type: string): string {
|
|
|
393
395
|
case "boolean": return "INTEGER";
|
|
394
396
|
case "datetime": return "TEXT";
|
|
395
397
|
case "text": return "TEXT";
|
|
398
|
+
case "json": return "TEXT"; // no native JSON type; queryable via json1
|
|
396
399
|
case "string": default: return "TEXT";
|
|
397
400
|
}
|
|
398
401
|
}
|
|
@@ -26,6 +26,41 @@ export function camelToSnake(name: string): string {
|
|
|
26
26
|
return name.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`);
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Convert an in-memory field value to its database representation.
|
|
31
|
+
* A "json" field serialises its object/array to a JSON string for the driver
|
|
32
|
+
* (parity with the Python master's JSONField.to_db). A value that can't be
|
|
33
|
+
* serialised (e.g. a circular reference or a BigInt) throws — save() builds
|
|
34
|
+
* the row inside its try/catch, so it fails loud (rolls back, returns false,
|
|
35
|
+
* records the cause). null/undefined and an already-serialised string pass
|
|
36
|
+
* through untouched. Every other field type is returned as-is.
|
|
37
|
+
*/
|
|
38
|
+
export function toDbFieldValue(def: FieldDefinition | undefined, value: unknown): unknown {
|
|
39
|
+
if (def?.type === "json" && value !== null && value !== undefined && typeof value !== "string") {
|
|
40
|
+
return JSON.stringify(value);
|
|
41
|
+
}
|
|
42
|
+
return value;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Convert a database value to its in-memory representation for a field.
|
|
47
|
+
* A "json" column comes back from the driver as a JSON string (SQLite TEXT,
|
|
48
|
+
* MySQL JSON, PostgreSQL JSONB via the text protocol, MSSQL NVARCHAR); decode
|
|
49
|
+
* it to the object/array the property expects (parity with the Python master's
|
|
50
|
+
* JSONField parse-on-read). A value already an object/array is left untouched;
|
|
51
|
+
* null stays null; a non-decodable string keeps its raw form.
|
|
52
|
+
*/
|
|
53
|
+
export function fromDbFieldValue(def: FieldDefinition | undefined, value: unknown): unknown {
|
|
54
|
+
if (def?.type === "json" && typeof value === "string") {
|
|
55
|
+
try {
|
|
56
|
+
return JSON.parse(value);
|
|
57
|
+
} catch {
|
|
58
|
+
return value; // leave the raw string in place
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return value;
|
|
62
|
+
}
|
|
63
|
+
|
|
29
64
|
/**
|
|
30
65
|
* Check whether TINA4_ORM_PLURAL_TABLE_NAMES is enabled in .env.
|
|
31
66
|
* When true, hasMany relationship keys get an "s" suffix (e.g. "posts" instead of "post").
|
|
@@ -130,9 +165,15 @@ export class BaseModel {
|
|
|
130
165
|
const fields0 = ModelClass0.fields ?? {};
|
|
131
166
|
for (const [name, def] of Object.entries(fields0)) {
|
|
132
167
|
if (def.default === undefined) continue;
|
|
133
|
-
|
|
168
|
+
let dv = typeof def.default === "function"
|
|
134
169
|
? (def.default as () => unknown)()
|
|
135
170
|
: def.default;
|
|
171
|
+
// Deep-clone a mutable object/array default so two instances never alias
|
|
172
|
+
// the same object (e.g. a json field `default: {}` — mutating a.meta must
|
|
173
|
+
// not leak into b.meta). Parity with the Python master's per-instance
|
|
174
|
+
// deepcopy and Ruby's Marshal round-trip.
|
|
175
|
+
if (dv !== null && typeof dv === "object") dv = structuredClone(dv);
|
|
176
|
+
this[name] = dv;
|
|
136
177
|
}
|
|
137
178
|
|
|
138
179
|
if (data) {
|
|
@@ -153,7 +194,10 @@ export class BaseModel {
|
|
|
153
194
|
for (const [key, value] of Object.entries(data)) {
|
|
154
195
|
// Lowercase the DB column key so UPPERCASE columns (Firebird/Oracle) match the mapping
|
|
155
196
|
const jsProp = reverseMapping[key] ?? reverseMapping[key.toLowerCase()] ?? key;
|
|
156
|
-
|
|
197
|
+
// A json column arrives as a JSON string from the driver (or as a raw
|
|
198
|
+
// string a caller passed); decode it to the object/array the property
|
|
199
|
+
// holds. A value already an object is left as-is.
|
|
200
|
+
this[jsProp] = fromDbFieldValue(fields0[jsProp], value);
|
|
157
201
|
}
|
|
158
202
|
}
|
|
159
203
|
}
|
|
@@ -176,7 +220,7 @@ export class BaseModel {
|
|
|
176
220
|
for (const key of Object.keys(ModelClass.fields)) {
|
|
177
221
|
if (this[key] !== undefined) {
|
|
178
222
|
const dbCol = ModelClass.getDbColumn(key);
|
|
179
|
-
result[dbCol] = this[key];
|
|
223
|
+
result[dbCol] = toDbFieldValue(ModelClass.fields[key], this[key]);
|
|
180
224
|
}
|
|
181
225
|
}
|
|
182
226
|
return result;
|
|
@@ -649,7 +693,7 @@ export class BaseModel {
|
|
|
649
693
|
if (updateFields.length === 0) { await adapterCommit(db); return this; }
|
|
650
694
|
|
|
651
695
|
const setClause = updateFields.map(([k]) => `"${ModelClass.getDbColumn(k)}" = ?`).join(", ");
|
|
652
|
-
const values = [...updateFields.map(([k]) => this[k]), pkValue];
|
|
696
|
+
const values = [...updateFields.map(([k, def]) => toDbFieldValue(def, this[k])), pkValue];
|
|
653
697
|
|
|
654
698
|
await adapterExecute(db, `UPDATE "${ModelClass.tableName}" SET ${setClause} WHERE "${pkCol}" = ?`, values);
|
|
655
699
|
} else {
|
|
@@ -660,7 +704,7 @@ export class BaseModel {
|
|
|
660
704
|
|
|
661
705
|
const columns = insertFields.map(([k]) => `"${ModelClass.getDbColumn(k)}"`).join(", ");
|
|
662
706
|
const placeholders = insertFields.map(() => "?").join(", ");
|
|
663
|
-
const values = insertFields.map(([k]) => this[k]);
|
|
707
|
+
const values = insertFields.map(([k, def]) => toDbFieldValue(def, this[k]));
|
|
664
708
|
|
|
665
709
|
// For auto-increment PKs on engines that need it (PostgreSQL),
|
|
666
710
|
// RETURNING the PK column lets us read the engine-assigned id back.
|
|
@@ -393,9 +393,10 @@ export function parseDatabaseUrl(url: string, username?: string, password?: stri
|
|
|
393
393
|
database,
|
|
394
394
|
};
|
|
395
395
|
} else {
|
|
396
|
-
// Normalize postgres://
|
|
397
|
-
|
|
398
|
-
|
|
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;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type FieldType = "string" | "integer" | "number" | "numeric" | "boolean" | "datetime" | "text" | "foreignKey";
|
|
1
|
+
export type FieldType = "string" | "integer" | "number" | "numeric" | "boolean" | "datetime" | "text" | "json" | "foreignKey";
|
|
2
2
|
|
|
3
3
|
export interface FieldDefinition {
|
|
4
4
|
type: FieldType;
|
|
@@ -87,6 +87,15 @@ export function validate(
|
|
|
87
87
|
}
|
|
88
88
|
break;
|
|
89
89
|
|
|
90
|
+
case "json":
|
|
91
|
+
// A JSON column holds an object/array (or a pre-serialised JSON
|
|
92
|
+
// string); reject a bare scalar. A value that can't be JSON-encoded
|
|
93
|
+
// (a circular reference, a BigInt) fails loud at save time.
|
|
94
|
+
if (value !== null && typeof value !== "object" && typeof value !== "string") {
|
|
95
|
+
errors.push({ field: name, message: "must be a JSON object or array" });
|
|
96
|
+
}
|
|
97
|
+
break;
|
|
98
|
+
|
|
90
99
|
case "foreignKey": {
|
|
91
100
|
// Outlier D: previously there was no foreignKey case, so ANY value
|
|
92
101
|
// passed validation silently. A foreign key references another model's
|
|
@@ -446,6 +446,11 @@ function fieldToSchemaProperty(def: FieldDefinition): Record<string, unknown> {
|
|
|
446
446
|
// and produced an empty {} schema (audit P2).
|
|
447
447
|
prop.type = "integer";
|
|
448
448
|
break;
|
|
449
|
+
case "json":
|
|
450
|
+
// A JSON document column — an object or array. OpenAPI 3.0 can't express
|
|
451
|
+
// "object OR array" in one type, so advertise the common object shape.
|
|
452
|
+
prop.type = "object";
|
|
453
|
+
break;
|
|
449
454
|
default:
|
|
450
455
|
prop.type = "string";
|
|
451
456
|
}
|