diff --git a/README.md b/README.md index 27674b626..f9488d480 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Datastar helps you build reactive web applications with the simplicity of server-side rendering and the power of a full-stack SPA framework. -Getting started is as easy as adding a single 13.0 KiB script tag to your HTML. +Getting started is as easy as adding a single 13.2 KiB script tag to your HTML. ```html diff --git a/Taskfile.yml b/Taskfile.yml index 4466f4d9e..6f2bdaab3 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -2,7 +2,7 @@ version: "3" -interval: 100ms +interval: 1000ms vars: VERSION: diff --git a/build/consts.go b/build/consts.go index 79ee93f4b..a8bbbe197 100644 --- a/build/consts.go +++ b/build/consts.go @@ -62,6 +62,12 @@ type ConstTemplateData struct { var Consts = &ConstTemplateData{ DoNotEdit: "This is auto-generated by Datastar. DO NOT EDIT.", SDKLanguages: []Language{ + { + FileExtension: "clojure", + Name: "Clojure", + Icon: "vscode-icons:file-type-clojure", + SdkUrl: "https://github.com/starfederation/datastar/tree/main/sdk/clojure", + }, { FileExtension: "dotnet", Name: "Dotnet", @@ -99,7 +105,7 @@ var Consts = &ConstTemplateData{ SdkUrl: "https://github.com/starfederation/datastar/tree/main/sdk/python", }, { - FileExtension: "rs", + FileExtension: "rust", Name: "Rust", Icon: "vscode-icons:file-type-rust", SdkUrl: "https://github.com/starfederation/datastar/tree/main/sdk/rust", diff --git a/build/consts_clojure.qtpl b/build/consts_clojure.qtpl new file mode 100644 index 000000000..0d05902e3 --- /dev/null +++ b/build/consts_clojure.qtpl @@ -0,0 +1,79 @@ +{%- func clojureConsts(data *ConstTemplateData) -%} +;; {%s data.DoNotEdit %} +(ns starfederation.datastar.clojure.consts + (:require + [clojure.string :as string])) + + +(def datastar-key "{%s data.DatastarKey %}") +(def version "{%s data.Version %}") +(def version-client-byte-size {%d data.VersionClientByteSize %}) +(def version-client-byte-size-gzip {%d data.VersionClientByteSizeGzip %}) + + +;; ----------------------------------------------------------------------------- +;; Default durations +;; ----------------------------------------------------------------------------- +{%- for _, d := range data.DefaultDurations -%} +(def default-{%s d.Name.Kebab %} + "{%s= d.Description %}" + {%d durationToMs(d.Duration) %}) + +{%- endfor -%} + +;; ----------------------------------------------------------------------------- +;; Default values +;; ----------------------------------------------------------------------------- +{%- for _, s := range data.DefaultStrings -%} +(def default-{%s s.Name.Kebab %} + "{%s= s.Description %}" + (-> "{%s s.Value %}" + (string/split #" ") + (update 0 keyword) + (->> (apply array-map)))) + +{%- endfor -%} + +;; ----------------------------------------------------------------------------- +;; Dataline literals +;; ----------------------------------------------------------------------------- +{%- for _, literal := range data.DatalineLiterals -%} +(def {%s literal.Kebab %}-dataline-literal "{%s literal.Camel %} ") +{%- endfor -%} + + +;; ----------------------------------------------------------------------------- +;; Default booleans +;; ----------------------------------------------------------------------------- +{%- for _, b := range data.DefaultBools -%} +(def default-{%s b.Name.Kebab %} + "{%s= b.Description %}" + {%v b.Value %}) + +{%- endfor -%} + + +;; ----------------------------------------------------------------------------- +;; Enums +;; ----------------------------------------------------------------------------- +{%- for _, enum := range data.Enums -%} +;; {%s enum.Name.Pascal %} + +{%- for _, entry := range enum.Values -%} +(def {%s enum.Name.Kebab %}-{%s entry.Name.Kebab %} + "{%s= entry.Description %}" + "{%s entry.Value %}") + +{%- endfor -%} + +{%- if enum.Default != nil -%} +(def default-{%s enum.Name.Kebab %} + "Default value for {%s enum.Name.Pascal %}. + {%s= enum.Default.Description %}" + {%s enum.Name.Kebab %}-{%s enum.Default.Name.Kebab %}) +{%- endif -%} + + +{%- endfor -%} + +{%- endfunc -%} diff --git a/build/run.go b/build/run.go index 3a674ae92..1c8c3f0fe 100644 --- a/build/run.go +++ b/build/run.go @@ -131,15 +131,16 @@ func writeOutConsts(version string) error { }) templates := map[string]func(data *ConstTemplateData) string{ - "README.md": datastarREADME, - "library/README.md": datastarREADME, - "library/src/engine/consts.ts": datastarClientConsts, - "library/package.json": datastarClientPackageJSON, - "sdk/go/consts.go": goConsts, - "sdk/dotnet/src/Consts.fs": dotnetConsts, - "sdk/php/src/Consts.php": phpConsts, - "sdk/php/src/enums/EventType.php": phpEventType, - "sdk/php/src/enums/FragmentMergeMode.php": phpFragmentMergeMode, + "README.md": datastarREADME, + "library/README.md": datastarREADME, + "library/src/engine/consts.ts": datastarClientConsts, + "library/package.json": datastarClientPackageJSON, + "sdk/clojure/sdk/src/main/starfederation/datastar/clojure/consts.clj": clojureConsts, + "sdk/go/consts.go": goConsts, + "sdk/dotnet/src/Consts.fs": dotnetConsts, + "sdk/php/src/Consts.php": phpConsts, + "sdk/php/src/enums/EventType.php": phpEventType, + "sdk/php/src/enums/FragmentMergeMode.php": phpFragmentMergeMode, "sdk/java/core/src/main/java/starfederation/datastar/Consts.java": javaConsts, "sdk/java/core/src/main/java/starfederation/datastar/enums/EventType.java": javaEventType, "sdk/java/core/src/main/java/starfederation/datastar/enums/FragmentMergeMode.java": javaFragmentMergeMode, @@ -147,6 +148,7 @@ func writeOutConsts(version string) error { "sdk/typescript/src/consts.ts": typescriptConsts, "sdk/rust/src/consts.rs": rustConsts, "sdk/zig/src/consts.zig": zigConsts, + "examples/clojure/hello-world/resources/public/hello-world.html": helloWorldExample, "examples/dotnet/HelloWorld/wwwroot/hello-world.html": helloWorldExample, "examples/go/hello-world/hello-world.html": helloWorldExample, "examples/php/hello-world/public/hello-world.html": helloWorldExamplePHP, diff --git a/bundles/datastar-core.js b/bundles/datastar-core.js index 9861a1c33..97a88e3a5 100644 --- a/bundles/datastar-core.js +++ b/bundles/datastar-core.js @@ -1,8 +1,8 @@ // Datastar v1.0.0-beta.2 -var U=/🖕JS_DS🚀/.source,D=U.slice(0,5),j=U.slice(4),k="datastar";var _e={Morph:"morph",Inner:"inner",Outer:"outer",Prepend:"prepend",Append:"append",Before:"before",After:"after",UpsertAttributes:"upsertAttributes"},xe=_e.Morph;var y=(s=>(s[s.Attribute=1]="Attribute",s[s.Watcher=2]="Watcher",s[s.Action=3]="Action",s))(y||{});var me="computed",H={type:1,name:me,keyReq:1,valReq:1,onLoad:({key:t,signals:e,genRX:n})=>{let s=n();e.setComputed(t,s)}};var Y=t=>t.replace(/[A-Z]+(?![a-z])|[A-Z]/g,(e,n)=>(n?"-":"")+e.toLowerCase()),X=t=>t.replace(/(?:^\w|[A-Z]|\b\w)/g,(e,n)=>n===0?e.toLowerCase():e.toUpperCase()).replace(/\s+/g,""),Z=t=>new Function(`return Object.assign({}, ${t})`)();var Q={type:1,name:"signals",removeOnLoad:!0,onLoad:t=>{let{key:e,value:n,genRX:s,signals:i,mods:o}=t,r=o.has("ifmissing");if(e!==""&&!r){let l=n===""?n:s()();i.setValue(e,l)}else{let l=Z(t.value);t.value=JSON.stringify(l);let f=s()();i.merge(f,r)}}};var ee={type:1,name:"star",keyReq:2,valReq:2,onLoad:()=>{alert("YOU ARE PROBABLY OVERCOMPLICATING IT")}};var R=class{#e=0;#t;constructor(e=k){this.#t=e}with(e){if(typeof e=="string")for(let n of e.split(""))this.with(n.charCodeAt(0));else this.#e=(this.#e<<5)-this.#e+e;return this}reset(){return this.#e=0,this}get value(){return this.#t+Math.abs(this.#e).toString(36)}};function te(t){if(t.id)return t.id;let e=new R,n=t;for(;n.parentNode;){if(n.id){e.with(n.id);break}if(n===n.ownerDocument.documentElement)e.with(n.tagName);else for(let s=1,i=t;i.previousElementSibling;i=i.previousElementSibling,s++)e.with(s);n=n.parentNode}return e.value}var ye=`${window.location.origin}/errors`;function B(t,e,n={}){let s=new Error;e=e[0].toUpperCase()+e.slice(1),s.name=`${k} ${t} error`;let i=Y(e).replaceAll("-","_"),o=new URLSearchParams({metadata:JSON.stringify(n)}).toString(),r=JSON.stringify(n,null,2);return s.message=`${e} -More info: ${ye}/${t}/${i}?${o} -Context: ${r}`,s}function h(t,e,n={}){return B("internal",e,Object.assign({from:t},n))}function ne(t,e,n={}){let s={plugin:{name:e.plugin.name,type:y[e.plugin.type]}};return B("init",t,Object.assign(s,n))}function m(t,e,n={}){let s={plugin:{name:e.plugin.name,type:y[e.plugin.type]},element:{id:e.el.id,tag:e.el.tagName},expression:{rawKey:e.rawKey,key:e.key,value:e.value,validSignals:e.signals.paths(),fnContent:e.fnContent}};return B("runtime",t,Object.assign(s,n))}var v="preact-signals",ve=Symbol.for("preact-signals"),_=1,T=2,C=4,N=8,P=16,w=32;function q(){M++}function K(){if(M>1){M--;return}let t,e=!1;for(;A!==void 0;){let n=A;for(A=void 0,G++;n!==void 0;){let s=n._nextBatchedEffect;if(n._nextBatchedEffect=void 0,n._flags&=~T,!(n._flags&N)&&ie(n))try{n._callback()}catch(i){e||(t=i,e=!0)}n=s}}if(G=0,M--,e)throw h(v,"BatchError, error",{error:t})}var a;var A,M=0,G=0,V=0;function se(t){if(a===void 0)return;let e=t._node;if(e===void 0||e._target!==a)return e={_version:0,_source:t,_prevSource:a._sources,_nextSource:void 0,_target:a,_prevTarget:void 0,_nextTarget:void 0,_rollbackNode:e},a._sources!==void 0&&(a._sources._nextSource=e),a._sources=e,t._node=e,a._flags&w&&t._subscribe(e),e;if(e._version===-1)return e._version=0,e._nextSource!==void 0&&(e._nextSource._prevSource=e._prevSource,e._prevSource!==void 0&&(e._prevSource._nextSource=e._nextSource),e._prevSource=a._sources,e._nextSource=void 0,a._sources._nextSource=e,a._sources=e),e}function u(t){this._value=t,this._version=0,this._node=void 0,this._targets=void 0}u.prototype.brand=ve;u.prototype._refresh=()=>!0;u.prototype._subscribe=function(t){this._targets!==t&&t._prevTarget===void 0&&(t._nextTarget=this._targets,this._targets!==void 0&&(this._targets._prevTarget=t),this._targets=t)};u.prototype._unsubscribe=function(t){if(this._targets!==void 0){let e=t._prevTarget,n=t._nextTarget;e!==void 0&&(e._nextTarget=n,t._prevTarget=void 0),n!==void 0&&(n._prevTarget=e,t._nextTarget=void 0),t===this._targets&&(this._targets=n)}};u.prototype.subscribe=function(t){return I(()=>{let e=this.value,n=a;a=void 0;try{t(e)}finally{a=n}})};u.prototype.valueOf=function(){return this.value};u.prototype.toString=function(){return`${this.value}`};u.prototype.toJSON=function(){return this.value};u.prototype.peek=function(){let t=a;a=void 0;try{return this.value}finally{a=t}};Object.defineProperty(u.prototype,"value",{get(){let t=se(this);return t!==void 0&&(t._version=this._version),this._value},set(t){if(t!==this._value){if(G>100)throw h(v,"SignalCycleDetected");this._value=t,this._version++,V++,q();try{for(let e=this._targets;e!==void 0;e=e._nextTarget)e._target._notify()}finally{K()}}}});function ie(t){for(let e=t._sources;e!==void 0;e=e._nextSource)if(e._source._version!==e._version||!e._source._refresh()||e._source._version!==e._version)return!0;return!1}function re(t){for(let e=t._sources;e!==void 0;e=e._nextSource){let n=e._source._node;if(n!==void 0&&(e._rollbackNode=n),e._source._node=e,e._version=-1,e._nextSource===void 0){t._sources=e;break}}}function oe(t){let e=t._sources,n;for(;e!==void 0;){let s=e._prevSource;e._version===-1?(e._source._unsubscribe(e),s!==void 0&&(s._nextSource=e._nextSource),e._nextSource!==void 0&&(e._nextSource._prevSource=s)):n=e,e._source._node=e._rollbackNode,e._rollbackNode!==void 0&&(e._rollbackNode=void 0),e=s}t._sources=n}function S(t){u.call(this,void 0),this._fn=t,this._sources=void 0,this._globalVersion=V-1,this._flags=C}S.prototype=new u;S.prototype._refresh=function(){if(this._flags&=~T,this._flags&_)return!1;if((this._flags&(C|w))===w||(this._flags&=~C,this._globalVersion===V))return!0;if(this._globalVersion=V,this._flags|=_,this._version>0&&!ie(this))return this._flags&=~_,!0;let t=a;try{re(this),a=this;let e=this._fn();(this._flags&P||this._value!==e||this._version===0)&&(this._value=e,this._flags&=~P,this._version++)}catch(e){this._value=e,this._flags|=P,this._version++}return a=t,oe(this),this._flags&=~_,!0};S.prototype._subscribe=function(t){if(this._targets===void 0){this._flags|=C|w;for(let e=this._sources;e!==void 0;e=e._nextSource)e._source._subscribe(e)}u.prototype._subscribe.call(this,t)};S.prototype._unsubscribe=function(t){if(this._targets!==void 0&&(u.prototype._unsubscribe.call(this,t),this._targets===void 0)){this._flags&=~w;for(let e=this._sources;e!==void 0;e=e._nextSource)e._source._unsubscribe(e)}};S.prototype._notify=function(){if(!(this._flags&T)){this._flags|=C|T;for(let t=this._targets;t!==void 0;t=t._nextTarget)t._target._notify()}};Object.defineProperty(S.prototype,"value",{get(){if(this._flags&_)throw h(v,"SignalCycleDetected");let t=se(this);if(this._refresh(),t!==void 0&&(t._version=this._version),this._flags&P)throw h(v,"GetComputedError",{value:this._value});return this._value}});function ae(t){return new S(t)}function le(t){let e=t._cleanup;if(t._cleanup=void 0,typeof e=="function"){q();let n=a;a=void 0;try{e()}catch(s){throw t._flags&=~_,t._flags|=N,W(t),h(v,"CleanupEffectError",{error:s})}finally{a=n,K()}}}function W(t){for(let e=t._sources;e!==void 0;e=e._nextSource)e._source._unsubscribe(e);t._fn=void 0,t._sources=void 0,le(t)}function Se(t){if(a!==this)throw h(v,"EndEffectError");oe(this),a=t,this._flags&=~_,this._flags&N&&W(this),K()}function O(t){this._fn=t,this._cleanup=void 0,this._sources=void 0,this._nextBatchedEffect=void 0,this._flags=w}O.prototype._callback=function(){let t=this._start();try{if(this._flags&N||this._fn===void 0)return;let e=this._fn();typeof e=="function"&&(this._cleanup=e)}finally{t()}};O.prototype._start=function(){if(this._flags&_)throw h(v,"SignalCycleDetected");this._flags|=_,this._flags&=~N,le(this),re(this),q();let t=a;return a=this,Se.bind(this,t)};O.prototype._notify=function(){this._flags&T||(this._flags|=T,this._nextBatchedEffect=A,A=this)};O.prototype._dispose=function(){this._flags|=N,this._flags&_||W(this)};function I(t){let e=new O(t);try{e._callback()}catch(n){throw e._dispose(),n}return e._dispose.bind(e)}var ue="namespacedSignals";function ce(t,e=!1){let n={};for(let s in t)if(Object.hasOwn(t,s)){if(e&&s.startsWith("_"))continue;let i=t[s];i instanceof u?n[s]=i.value:n[s]=ce(i)}return n}function fe(t,e,n=!1){for(let s in e)if(Object.hasOwn(e,s)){if(s.match(/\_\_+/))throw h(ue,"InvalidSignalKey",{key:s});let i=e[s];if(i instanceof Object&&!Array.isArray(i))t[s]||(t[s]={}),fe(t[s],i,n);else{if(Object.hasOwn(t,s)){if(n)continue;let r=t[s];if(r instanceof u){r.value=i;continue}}t[s]=new u(i)}}}function de(t,e){for(let n in t)if(Object.hasOwn(t,n)){let s=t[n];s instanceof u?e(n,s):de(s,(i,o)=>{e(`${n}.${i}`,o)})}}function be(t,...e){let n={};for(let s of e){let i=s.split("."),o=t,r=n;for(let p=0;pn());this.setSignal(e,s)}value(e){return this.signal(e)?.value}setValue(e,n){let s=this.upsertIfMissing(e,n);s.value=n}upsertIfMissing(e,n){let s=e.split("."),i=this.#e;for(let p=0;pe.push(n)),e}values(e=!1){return ce(this.#e,e)}JSON(e=!0,n=!1){let s=this.values(n);return e?JSON.stringify(s,null,2):JSON.stringify(s)}toString(){return this.JSON()}};var F=class{#e=new $;#t=[];#s={};#o=[];#n=new Map;get signals(){return this.#e}load(...e){for(let n of e){let s=this,i={get signals(){return s.#e},effect:r=>I(r),actions:this.#s,apply:this.apply.bind(this),cleanup:this.#i.bind(this),plugin:n},o;switch(n.type){case 2:{let r=n;this.#o.push(r),o=r.onGlobalInit;break}case 3:{this.#s[n.name]=n;break}case 1:{let r=n;this.#t.push(r),o=r.onGlobalInit;break}default:throw ne("InvalidPluginType",i)}o&&o(i)}this.#t.sort((n,s)=>{let i=s.name.length-n.name.length;return i!==0?i:n.name.localeCompare(s.name)})}apply(e){this.#r(e,n=>{this.#i(n);for(let s of Object.keys(n.dataset)){let i=this.#t.find(c=>s.startsWith(c.name));if(!i)continue;n.id.length||(n.id=te(n));let[o,...r]=s.slice(i.name.length).split(/\_\_+/),l=o.length>0;if(l){let c=o.slice(1);o=o.startsWith("-")?c:o[0].toLowerCase()+c}let p=`${n.dataset[s]}`||"",f=p.length>0,L=this,g={get signals(){return L.#e},effect:c=>I(c),apply:this.apply.bind(this),cleanup:this.#i.bind(this),actions:this.#s,genRX:()=>this.#a(g,...i.argNames||[]),plugin:i,el:n,rawKey:s,key:o,value:p,mods:new Map},b=i.keyReq||0;if(l){if(b===2)throw m(`${i.name}KeyNotAllowed`,g)}else if(b===1)throw m(`${i.name}KeyRequired`,g);let x=i.valReq||0;if(f){if(x===2)throw m(`${i.name}ValueNotAllowed`,g)}else if(x===1)throw m(`${i.name}ValueRequired`,g);if(b===3||x===3){if(l&&f)throw m(`${i.name}KeyAndValueProvided`,g);if(!l&&!f)throw m(`${i.name}KeyOrValueRequired`,g)}for(let c of r){let[E,...ge]=c.split(".");g.mods.set(X(E),new Set(ge.map(he=>he.toLowerCase())))}let d=i.onLoad(g);d&&(this.#n.has(n)||this.#n.set(n,{id:n.id,fns:[]}),this.#n.get(n)?.fns.push(d)),i?.removeOnLoad&&delete n.dataset[s]}})}#a(e,...n){let s="",i=/(\/(\\\/|[^\/])*\/|"(\\"|[^\"])*"|'(\\'|[^'])*'|`(\\`|[^`])*`|[^;])+/gm,o=e.value.trim().match(i);if(o){let d=o.length-1,c=o[d].trim();c.startsWith("return")||(o[d]=`return (${c});`),s=o.join(`; -`)}let r=new Map,l=new RegExp(`(?:${D})(.*?)(?:${j})`,"gm");for(let d of s.matchAll(l)){let c=d[1],E=new R("dsEscaped").with(c).value;r.set(E,c),s=s.replace(D+c+j,E)}let p=/@(\w*)\(/gm,f=s.matchAll(p),L=new Set;for(let d of f)L.add(d[1]);let g=new RegExp(`@(${Object.keys(this.#s).join("|")})\\(`,"gm");s=s.replaceAll(g,"ctx.actions.$1.fn(ctx,");let b=e.signals.paths();if(b.length){let d=new RegExp(`\\$(${b.join("|")})(\\W|$)`,"gm");s=s.replaceAll(d,"ctx.signals.signal('$1').value$2")}for(let[d,c]of r)s=s.replace(d,c);let x=`return (()=> { -${s} -})()`;e.fnContent=x;try{let d=new Function("ctx",...n,x);return(...c)=>{try{return d(e,...c)}catch(E){throw m("ExecuteExpression",e,{error:E.message})}}}catch(d){throw m("GenerateExpression",e,{error:d.message})}}#r(e,n){if(!e||!(e instanceof HTMLElement||e instanceof SVGElement))return null;let s=e.dataset;if("starIgnore"in s)return null;"starIgnore__self"in s||n(e);let i=e.firstElementChild;for(;i;)this.#r(i,n),i=i.nextElementSibling}#i(e){let n=this.#n.get(e);if(n){for(let s of n.fns)s();this.#n.delete(e)}}};var pe=new F;pe.load(ee,Q,H);var J=pe;J.apply(document.body);var rt=J;export{rt as Datastar}; +var z=/🖕JS_DS🚀/.source,C=z.slice(0,5),j=z.slice(4),A="datastar";var ye={Morph:"morph",Inner:"inner",Outer:"outer",Prepend:"prepend",Append:"append",Before:"before",After:"after",UpsertAttributes:"upsertAttributes"},Ae=ye.Morph;var b=(n=>(n[n.Attribute=1]="Attribute",n[n.Watcher=2]="Watcher",n[n.Action=3]="Action",n))(b||{});var Y=`${A}-signals`;var Se="computed",Z={type:1,name:Se,keyReq:1,valReq:1,onLoad:({key:t,signals:e,genRX:s})=>{let n=s();e.setComputed(t,n)}};var H=t=>t.replace(/[A-Z]+(?![a-z])|[A-Z]/g,(e,s)=>(s?"-":"")+e.toLowerCase()),K=t=>H(t).replace(/-./g,e=>e[1].toUpperCase()),Q=t=>new Function(`return Object.assign({}, ${t})`)();var ee={type:1,name:"signals",removeOnLoad:!0,onLoad:t=>{let{key:e,value:s,genRX:n,signals:r,mods:o}=t,i=o.has("ifmissing");if(e!==""&&!i){let a=s===""?s:n()();r.setValue(e,a)}else{let a=Q(t.value);t.value=JSON.stringify(a);let p=n()();r.merge(p,i)}}};var te={type:1,name:"star",keyReq:2,valReq:2,onLoad:()=>{alert("YOU ARE PROBABLY OVERCOMPLICATING IT")}};var O=class{#e=0;#n;constructor(e=A){this.#n=e}with(e){if(typeof e=="string")for(let s of e.split(""))this.with(s.charCodeAt(0));else this.#e=(this.#e<<5)-this.#e+e;return this}reset(){return this.#e=0,this}get value(){return this.#n+Math.abs(this.#e).toString(36)}};function ne(t){if(t.id)return t.id;let e=new O,s=t;for(;s.parentNode;){if(s.id){e.with(s.id);break}if(s===s.ownerDocument.documentElement)e.with(s.tagName);else for(let n=1,r=t;r.previousElementSibling;r=r.previousElementSibling,n++)e.with(n);s=s.parentNode}return e.value}function se(t,e,s=!1,n=!0){let r=-1,o=()=>r&&clearTimeout(r);return(...i)=>{o(),s&&!r&&t(...i),r=setTimeout(()=>{n&&t(...i),o()},e)}}var be=`${window.location.origin}/errors`;function B(t,e,s={}){let n=new Error;e=e[0].toUpperCase()+e.slice(1),n.name=`${A} ${t} error`;let r=H(e).replaceAll("-","_"),o=new URLSearchParams({metadata:JSON.stringify(s)}).toString(),i=JSON.stringify(s,null,2);return n.message=`${e} +More info: ${be}/${t}/${r}?${o} +Context: ${i}`,n}function _(t,e,s={}){return B("internal",e,Object.assign({from:t},s))}function re(t,e,s={}){let n={plugin:{name:e.plugin.name,type:b[e.plugin.type]}};return B("init",t,Object.assign(n,s))}function S(t,e,s={}){let n={plugin:{name:e.plugin.name,type:b[e.plugin.type]},element:{id:e.el.id,tag:e.el.tagName},expression:{rawKey:e.rawKey,key:e.key,value:e.value,validSignals:e.signals.paths(),fnContent:e.fnContent}};return B("runtime",t,Object.assign(n,s))}var x="preact-signals",xe=Symbol.for("preact-signals"),v=1,N=2,k=4,R=8,V=16,w=32;function q(){P++}function J(){if(P>1){P--;return}let t,e=!1;for(;D!==void 0;){let s=D;for(D=void 0,W++;s!==void 0;){let n=s._nextBatchedEffect;if(s._nextBatchedEffect=void 0,s._flags&=~N,!(s._flags&R)&&oe(s))try{s._callback()}catch(r){e||(t=r,e=!0)}s=n}}if(W=0,P--,e)throw _(x,"BatchError, error",{error:t})}var u;var D,P=0,W=0,$=0;function ie(t){if(u===void 0)return;let e=t._node;if(e===void 0||e._target!==u)return e={_version:0,_source:t,_prevSource:u._sources,_nextSource:void 0,_target:u,_prevTarget:void 0,_nextTarget:void 0,_rollbackNode:e},u._sources!==void 0&&(u._sources._nextSource=e),u._sources=e,t._node=e,u._flags&w&&t._subscribe(e),e;if(e._version===-1)return e._version=0,e._nextSource!==void 0&&(e._nextSource._prevSource=e._prevSource,e._prevSource!==void 0&&(e._prevSource._nextSource=e._nextSource),e._prevSource=u._sources,e._nextSource=void 0,u._sources._nextSource=e,u._sources=e),e}function f(t){this._value=t,this._version=0,this._node=void 0,this._targets=void 0}f.prototype.brand=xe;f.prototype._refresh=()=>!0;f.prototype._subscribe=function(t){this._targets!==t&&t._prevTarget===void 0&&(t._nextTarget=this._targets,this._targets!==void 0&&(this._targets._prevTarget=t),this._targets=t)};f.prototype._unsubscribe=function(t){if(this._targets!==void 0){let e=t._prevTarget,s=t._nextTarget;e!==void 0&&(e._nextTarget=s,t._prevTarget=void 0),s!==void 0&&(s._prevTarget=e,t._nextTarget=void 0),t===this._targets&&(this._targets=s)}};f.prototype.subscribe=function(t){return I(()=>{let e=this.value,s=u;u=void 0;try{t(e)}finally{u=s}})};f.prototype.valueOf=function(){return this.value};f.prototype.toString=function(){return`${this.value}`};f.prototype.toJSON=function(){return this.value};f.prototype.peek=function(){let t=u;u=void 0;try{return this.value}finally{u=t}};Object.defineProperty(f.prototype,"value",{get(){let t=ie(this);return t!==void 0&&(t._version=this._version),this._value},set(t){if(t!==this._value){if(W>100)throw _(x,"SignalCycleDetected");this._value=t,this._version++,$++,q();try{for(let e=this._targets;e!==void 0;e=e._nextTarget)e._target._notify()}finally{J()}}}});function oe(t){for(let e=t._sources;e!==void 0;e=e._nextSource)if(e._source._version!==e._version||!e._source._refresh()||e._source._version!==e._version)return!0;return!1}function ae(t){for(let e=t._sources;e!==void 0;e=e._nextSource){let s=e._source._node;if(s!==void 0&&(e._rollbackNode=s),e._source._node=e,e._version=-1,e._nextSource===void 0){t._sources=e;break}}}function le(t){let e=t._sources,s;for(;e!==void 0;){let n=e._prevSource;e._version===-1?(e._source._unsubscribe(e),n!==void 0&&(n._nextSource=e._nextSource),e._nextSource!==void 0&&(e._nextSource._prevSource=n)):s=e,e._source._node=e._rollbackNode,e._rollbackNode!==void 0&&(e._rollbackNode=void 0),e=n}t._sources=s}function E(t){f.call(this,void 0),this._fn=t,this._sources=void 0,this._globalVersion=$-1,this._flags=k}E.prototype=new f;E.prototype._refresh=function(){if(this._flags&=~N,this._flags&v)return!1;if((this._flags&(k|w))===w||(this._flags&=~k,this._globalVersion===$))return!0;if(this._globalVersion=$,this._flags|=v,this._version>0&&!oe(this))return this._flags&=~v,!0;let t=u;try{ae(this),u=this;let e=this._fn();(this._flags&V||this._value!==e||this._version===0)&&(this._value=e,this._flags&=~V,this._version++)}catch(e){this._value=e,this._flags|=V,this._version++}return u=t,le(this),this._flags&=~v,!0};E.prototype._subscribe=function(t){if(this._targets===void 0){this._flags|=k|w;for(let e=this._sources;e!==void 0;e=e._nextSource)e._source._subscribe(e)}f.prototype._subscribe.call(this,t)};E.prototype._unsubscribe=function(t){if(this._targets!==void 0&&(f.prototype._unsubscribe.call(this,t),this._targets===void 0)){this._flags&=~w;for(let e=this._sources;e!==void 0;e=e._nextSource)e._source._unsubscribe(e)}};E.prototype._notify=function(){if(!(this._flags&N)){this._flags|=k|N;for(let t=this._targets;t!==void 0;t=t._nextTarget)t._target._notify()}};Object.defineProperty(E.prototype,"value",{get(){if(this._flags&v)throw _(x,"SignalCycleDetected");let t=ie(this);if(this._refresh(),t!==void 0&&(t._version=this._version),this._flags&V)throw _(x,"GetComputedError",{value:this._value});return this._value}});function ue(t){return new E(t)}function ce(t){let e=t._cleanup;if(t._cleanup=void 0,typeof e=="function"){q();let s=u;u=void 0;try{e()}catch(n){throw t._flags&=~v,t._flags|=R,U(t),_(x,"CleanupEffectError",{error:n})}finally{u=s,J()}}}function U(t){for(let e=t._sources;e!==void 0;e=e._nextSource)e._source._unsubscribe(e);t._fn=void 0,t._sources=void 0,ce(t)}function Ee(t){if(u!==this)throw _(x,"EndEffectError");le(this),u=t,this._flags&=~v,this._flags&R&&U(this),J()}function M(t){this._fn=t,this._cleanup=void 0,this._sources=void 0,this._nextBatchedEffect=void 0,this._flags=w}M.prototype._callback=function(){let t=this._start();try{if(this._flags&R||this._fn===void 0)return;let e=this._fn();typeof e=="function"&&(this._cleanup=e)}finally{t()}};M.prototype._start=function(){if(this._flags&v)throw _(x,"SignalCycleDetected");this._flags|=v,this._flags&=~R,ce(this),ae(this),q();let t=u;return u=this,Ee.bind(this,t)};M.prototype._notify=function(){this._flags&N||(this._flags|=N,this._nextBatchedEffect=D,D=this)};M.prototype._dispose=function(){this._flags|=R,this._flags&v||U(this)};function I(t){let e=new M(t);try{e._callback()}catch(s){throw e._dispose(),s}return e._dispose.bind(e)}var fe="namespacedSignals",F=t=>{document.dispatchEvent(new CustomEvent(Y,{detail:Object.assign({added:[],removed:[],updated:[]},t)}))};function de(t,e=!1){let s={};for(let n in t)if(Object.hasOwn(t,n)){if(e&&n.startsWith("_"))continue;let r=t[n];r instanceof f?s[n]=r.value:s[n]=de(r)}return s}function pe(t,e,s=!1){let n={added:[],removed:[],updated:[]};for(let r in e)if(Object.hasOwn(e,r)){if(r.match(/\_\_+/))throw _(fe,"InvalidSignalKey",{key:r});let o=e[r];if(o instanceof Object&&!Array.isArray(o)){t[r]||(t[r]={});let i=pe(t[r],o,s);n.added.push(...i.added.map(a=>`${r}.${a}`)),n.removed.push(...i.removed.map(a=>`${r}.${a}`)),n.updated.push(...i.updated.map(a=>`${r}.${a}`))}else{if(Object.hasOwn(t,r)){if(s)continue;let a=t[r];if(a instanceof f){let c=a.value;a.value=o,c!==o&&n.updated.push(r);continue}}t[r]=new f(o),n.added.push(r)}}return n}function ge(t,e){for(let s in t)if(Object.hasOwn(t,s)){let n=t[s];n instanceof f?e(s,n):ge(n,(r,o)=>{e(`${s}.${r}`,o)})}}function Te(t,...e){let s={};for(let n of e){let r=n.split("."),o=t,i=s;for(let c=0;cs());this.setSignal(e,n)}value(e){return this.signal(e)?.value}setValue(e,s){let n=this.upsertIfMissing(e,s),r=n.value;n.value=s,r!==s&&F({updated:[e]})}upsertIfMissing(e,s){let n=e.split("."),r=this.#e;for(let c=0;ce.push(s)),e}values(e=!1){return de(this.#e,e)}JSON(e=!0,s=!1){let n=this.values(s);return e?JSON.stringify(n,null,2):JSON.stringify(n)}toString(){return this.JSON()}};var he=(t,e)=>`${t}${C}${e}`,G=class{#e=new L;#n=[];#s={};#a=[];#t=new Map;constructor(){let e="data-";new MutationObserver(n=>{for(let{target:r,type:o,attributeName:i,oldValue:a,addedNodes:c,removedNodes:p}of n)switch(o){case"childList":{for(let d of p){let g=d,h=this.#t.get(g);if(h){for(let[y,l]of h)l();this.#t.delete(g)}}for(let d of c){let g=d;this.#r(g)}}break;case"attributes":{{if(!i?.startsWith(e))break;let d=r,g=K(i.slice(e.length));if(a!==null&&d.dataset[g]!==a){let h=this.#t.get(d);if(h){let y=he(g,a),l=h.get(y);l&&(l(),h.delete(y))}}this.#i(d,g)}break}}}).observe(document.body,{attributes:!0,attributeOldValue:!0,childList:!0,subtree:!0})}get signals(){return this.#e}load(...e){for(let s of e){let n=this,r={get signals(){return n.#e},effect:i=>I(i),actions:this.#s,plugin:s},o;switch(s.type){case 2:{let i=s;this.#a.push(i),o=i.onGlobalInit;break}case 3:{this.#s[s.name]=s;break}case 1:{let i=s;this.#n.push(i),o=i.onGlobalInit;break}default:throw re("InvalidPluginType",r)}o&&o(r)}this.#n.sort((s,n)=>{let r=n.name.length-s.name.length;return r!==0?r:s.name.localeCompare(n.name)}),this.#l()}#l=se(()=>{this.#r(document.body)},1);#r(e){this.#o(e,s=>{let n=this.#t.get(s);if(n){for(let[,r]of n)r();this.#t.delete(s)}for(let r of Object.keys(s.dataset))this.#i(s,r)})}#i(e,s){let n=this.#n.find(l=>s.startsWith(l.name));if(!n)return;e.id.length||(e.id=ne(e));let[r,...o]=s.slice(n.name.length).split(/\_\_+/),i=r.length>0;if(i){let l=r.slice(1);r=r.startsWith("-")?l:r[0].toLowerCase()+l}let a=e.dataset[s]||"",c=a.length>0,p=this,d={get signals(){return p.#e},effect:l=>I(l),actions:this.#s,genRX:()=>this.#u(d,...n.argNames||[]),plugin:n,el:e,rawKey:s,key:r,value:a,mods:new Map},g=n.keyReq||0;if(i){if(g===2)throw S(`${n.name}KeyNotAllowed`,d)}else if(g===1)throw S(`${n.name}KeyRequired`,d);let h=n.valReq||0;if(c){if(h===2)throw S(`${n.name}ValueNotAllowed`,d)}else if(h===1)throw S(`${n.name}ValueRequired`,d);if(g===3||h===3){if(i&&c)throw S(`${n.name}KeyAndValueProvided`,d);if(!i&&!c)throw S(`${n.name}KeyOrValueRequired`,d)}for(let l of o){let[m,...T]=l.split(".");d.mods.set(K(m),new Set(T.map(ve=>ve.toLowerCase())))}let y=n.onLoad(d);if(y){let l=this.#t.get(e);l||(l=new Map,this.#t.set(e,l)),l.set(he(s,a),y)}n?.removeOnLoad&&delete e.dataset[s]}#u(e,...s){let n="",r=/(\/(\\\/|[^\/])*\/|"(\\"|[^\"])*"|'(\\'|[^'])*'|`(\\`|[^`])*`|[^;])+/gm,o=e.value.trim().match(r);if(o){let l=o.length-1,m=o[l].trim();m.startsWith("return")||(o[l]=`return (${m});`),n=o.join(`; +`)}let i=new Map,a=new RegExp(`(?:${C})(.*?)(?:${j})`,"gm");for(let l of n.matchAll(a)){let m=l[1],T=new O("dsEscaped").with(m).value;i.set(T,m),n=n.replace(C+m+j,T)}let c=/@(\w*)\(/gm,p=n.matchAll(c),d=new Set;for(let l of p)d.add(l[1]);let g=new RegExp(`@(${Object.keys(this.#s).join("|")})\\(`,"gm");n=n.replaceAll(g,"ctx.actions.$1.fn(ctx,");let h=e.signals.paths();if(h.length){let l=new RegExp(`\\$(${h.join("|")})(\\W|$)`,"gm");n=n.replaceAll(l,"ctx.signals.signal('$1').value$2")}for(let[l,m]of i)n=n.replace(l,m);let y=`return (()=> { +${n} +})()`;e.fnContent=y;try{let l=new Function("ctx",...s,y);return(...m)=>{try{return l(e,...m)}catch(T){throw S("ExecuteExpression",e,{error:T.message})}}}catch(l){throw S("GenerateExpression",e,{error:l.message})}}#o(e,s){if(!e||!(e instanceof HTMLElement||e instanceof SVGElement))return null;let n=e.dataset;if("starIgnore"in n)return null;"starIgnore__self"in n||s(e);let r=e.firstElementChild;for(;r;)this.#o(r,s),r=r.nextElementSibling}};var me=new G;me.load(te,ee,Z);var _e=me;var dt=_e;export{dt as Datastar}; //# sourceMappingURL=datastar-core.js.map diff --git a/bundles/datastar-core.js.map b/bundles/datastar-core.js.map index f1e8b5706..43af92ae2 100644 --- a/bundles/datastar-core.js.map +++ b/bundles/datastar-core.js.map @@ -1,7 +1,7 @@ { "version": 3, - "sources": ["../library/src/engine/consts.ts", "../library/src/engine/types.ts", "../library/src/plugins/official/core/attributes/computed.ts", "../library/src/utils/text.ts", "../library/src/plugins/official/core/attributes/signals.ts", "../library/src/plugins/official/core/attributes/star.ts", "../library/src/utils/dom.ts", "../library/src/engine/errors.ts", "../library/src/vendored/preact-core.ts", "../library/src/engine/signals.ts", "../library/src/engine/engine.ts", "../library/src/engine/index.ts", "../library/src/bundles/datastar-core.ts"], - "sourcesContent": ["// This is auto-generated by Datastar. DO NOT EDIT.\nconst lol = /\uD83D\uDD95JS_DS\uD83D\uDE80/.source\nexport const DSP = lol.slice(0, 5)\nexport const DSS = lol.slice(4)\n\nexport const DATASTAR = \"datastar\";\nexport const DATASTAR_EVENT = \"datastar-event\";\nexport const DATASTAR_REQUEST = \"Datastar-Request\";\n\n// #region Defaults\n\n// #region Default durations\n\n// The default duration for settling during fragment merges. Allows for CSS transitions to complete.\nexport const DefaultFragmentsSettleDurationMs = 300;\n// The default duration for retrying SSE on connection reset. This is part of the underlying retry mechanism of SSE.\nexport const DefaultSseRetryDurationMs = 1000;\n\n// #endregion\n\n\n// #region Default strings\n\n// The default attributes for + + + +
+
+

+ Datastar SDK Demo +

+ Rocket +
+

+ SSE events will be streamed from the backend to the frontend. +

+
+ + +
+ +
+
+
Hello, world!
+
+ + \ No newline at end of file diff --git a/examples/clojure/hello-world/src/dev/user.clj b/examples/clojure/hello-world/src/dev/user.clj new file mode 100644 index 000000000..1c0e887a5 --- /dev/null +++ b/examples/clojure/hello-world/src/dev/user.clj @@ -0,0 +1,21 @@ +(ns user + (:require + [clj-reload.core :as reload])) + + +(alter-var-root #'*warn-on-reflection* (constantly true)) + + +(reload/init + {:no-reload ['user]}) + + +(defn reload! [] + (reload/reload)) + + +(comment + (reload!) + *e) + + diff --git a/examples/clojure/hello-world/src/main/example/core.clj b/examples/clojure/hello-world/src/main/example/core.clj new file mode 100644 index 000000000..2f0852ed4 --- /dev/null +++ b/examples/clojure/hello-world/src/main/example/core.clj @@ -0,0 +1,61 @@ +(ns example.core + (:require + [clojure.java.io :as io] + [clojure.string :as string] + [dev.onionpancakes.chassis.compiler :as hc] + [dev.onionpancakes.chassis.core :as h] + [example.utils :as u] + [reitit.ring.middleware.parameters :as rmparams] + [reitit.ring :as rr] + [ring.util.response :as ruresp] + [starfederation.datastar.clojure.api :as d*] + [starfederation.datastar.clojure.adapter.ring-jetty :refer [->sse-response]])) + + +(def home-page + (-> (io/resource "public/hello-world.html") + slurp + (string/split-lines) + (->> (drop 3) + (apply str)))) + + +(defn home [_] + (-> home-page + (ruresp/response) + (ruresp/content-type "text/html"))) + + +(def message "Hello, world!") + +(def msg-count (count message)) + + +(defn ->frag [i] + (h/html + (hc/compile + [:div {:id "message"} + (subs message 0 (inc i))]))) + + + +(defn hello-world [request] + (let [d (-> request u/get-signals (get "delay") int)] + (->sse-response request + {:on-open + (fn [sse] + (d*/with-open-sse sse + (dotimes [i msg-count] + (d*/merge-fragment! sse (->frag i)) + (Thread/sleep d))))}))) + + +(def routes + [["/" {:handler home}] + ["/hello-world" {:handler hello-world + :middleware [rmparams/parameters-middleware]}]]) + +(def router (rr/router routes)) + +(def handler (rr/ring-handler router)) + diff --git a/examples/clojure/hello-world/src/main/example/main.clj b/examples/clojure/hello-world/src/main/example/main.clj new file mode 100644 index 000000000..7f2973a9f --- /dev/null +++ b/examples/clojure/hello-world/src/main/example/main.clj @@ -0,0 +1,8 @@ +(ns example.main + (:require + [example.core :as c] + [example.server :as server])) + + +(defn -main [& _] + (server/start! c/handler {:join? true})) diff --git a/examples/clojure/hello-world/src/main/example/server.clj b/examples/clojure/hello-world/src/main/example/server.clj new file mode 100644 index 000000000..f65c01879 --- /dev/null +++ b/examples/clojure/hello-world/src/main/example/server.clj @@ -0,0 +1,32 @@ +(ns example.server + (:require + [example.core :as c] + [ring.adapter.jetty :as jetty]) + (:import + org.eclipse.jetty.server.Server)) + + + + +(defonce !jetty-server (atom nil)) + + +(defn start! [handler & {:as opts}] + (jetty/run-jetty handler + (merge {:port 8080 :join? false} + opts))) + + +(defn stop! [server] + (.stop ^Server server)) + + +(defn reboot-jetty-server! [handler & {:as opts}] + (swap! !jetty-server + (fn [server] + (when server + (stop! server)) + (start! handler opts)))) + +(comment + (reboot-jetty-server! #'c/handler)) diff --git a/examples/clojure/hello-world/src/main/example/utils.clj b/examples/clojure/hello-world/src/main/example/utils.clj new file mode 100644 index 000000000..09bd8020c --- /dev/null +++ b/examples/clojure/hello-world/src/main/example/utils.clj @@ -0,0 +1,13 @@ +(ns example.utils + (:require + [charred.api :as charred] + [starfederation.datastar.clojure.api :as d*])) + + +(def ^:private bufSize 1024) +(def read-json (charred/parse-json-fn {:async? false :bufsize bufSize})) + +(defn get-signals [req] + (-> req d*/get-signals read-json)) + + diff --git a/examples/go/hello-world/hello-world.html b/examples/go/hello-world/hello-world.html index 6407c0177..ca8a8e4ed 100755 --- a/examples/go/hello-world/hello-world.html +++ b/examples/go/hello-world/hello-world.html @@ -7,13 +7,13 @@ - +

Datastar SDK Demo

- Rocket + Rocket

SSE events will be streamed from the backend to the frontend. @@ -24,11 +24,11 @@

- -
+
Hello, world!
diff --git a/examples/php/hello-world/public/hello-world.html b/examples/php/hello-world/public/hello-world.html index e0329914e..a242bb57a 100644 --- a/examples/php/hello-world/public/hello-world.html +++ b/examples/php/hello-world/public/hello-world.html @@ -7,13 +7,13 @@ - +

Datastar SDK Demo

- Rocket + Rocket

SSE events will be streamed from the backend to the frontend. @@ -24,11 +24,11 @@

-
-
+
Hello, world!
diff --git a/examples/zig/httpz/hello-world/src/hello-world.html b/examples/zig/httpz/hello-world/src/hello-world.html index 6407c0177..ca8a8e4ed 100644 --- a/examples/zig/httpz/hello-world/src/hello-world.html +++ b/examples/zig/httpz/hello-world/src/hello-world.html @@ -7,13 +7,13 @@ - +

Datastar SDK Demo

- Rocket + Rocket

SSE events will be streamed from the backend to the frontend. @@ -24,11 +24,11 @@

-
-
+
Hello, world!
diff --git a/library/README.md b/library/README.md index 27674b626..f9488d480 100644 --- a/library/README.md +++ b/library/README.md @@ -10,7 +10,7 @@ Datastar helps you build reactive web applications with the simplicity of server-side rendering and the power of a full-stack SPA framework. -Getting started is as easy as adding a single 13.0 KiB script tag to your HTML. +Getting started is as easy as adding a single 13.2 KiB script tag to your HTML. ```html diff --git a/library/src/bundles/datastar-core.ts b/library/src/bundles/datastar-core.ts index e14d72ab6..29ba7cb5f 100644 --- a/library/src/bundles/datastar-core.ts +++ b/library/src/bundles/datastar-core.ts @@ -1,4 +1,3 @@ import { Datastar as DS } from '../engine' -DS.apply(document.body) export const Datastar = DS diff --git a/library/src/bundles/datastar.ts b/library/src/bundles/datastar.ts index 262bf506a..11e501c35 100644 --- a/library/src/bundles/datastar.ts +++ b/library/src/bundles/datastar.ts @@ -62,5 +62,4 @@ DS.load( SetAll, ToggleAll, ) -DS.apply(document.body) export const Datastar = DS diff --git a/library/src/engine/engine.ts b/library/src/engine/engine.ts index 954816608..536205990 100644 --- a/library/src/engine/engine.ts +++ b/library/src/engine/engine.ts @@ -1,5 +1,6 @@ import { Hash, elUniqId } from '../utils/dom' import { camelize } from '../utils/text' +import { debounce } from '../utils/timing' import { effect } from '../vendored/preact-core' import { DSP, DSS } from './consts' import { initErr, runtimeErr } from './errors' @@ -14,19 +15,92 @@ import { type InitContext, type OnRemovalFn, PluginType, - type RemovalEntry, Requirement, type RuntimeContext, type RuntimeExpressionFunction, type WatcherPlugin, } from './types' +const removalKey = (k: string, v: string) => `${k}${DSP}${v}` + export class Engine { #signals = new SignalsRoot() #plugins: AttributePlugin[] = [] #actions: ActionPlugins = {} #watchers: WatcherPlugin[] = [] - #removals = new Map() + + // Map of cleanup functions by element, keyed by the raw dataset key and value + #removals = new Map>() + + constructor() { + const dsPrefix = 'data-' + + const ob = new MutationObserver((mutations) => { + for (const { + target, + type, + attributeName, + oldValue, + addedNodes, + removedNodes, + } of mutations) { + switch (type) { + case 'childList': + { + for (const node of removedNodes) { + const el = node as HTMLorSVGElement + const elRemovals = this.#removals.get(el) + if (!elRemovals) continue + + for (const [_, removalFn] of elRemovals) { + removalFn() + } + this.#removals.delete(el) + } + + for (const node of addedNodes) { + const el = node as HTMLorSVGElement + this.#apply(el) + } + } + break + case 'attributes': { + { + if (!attributeName?.startsWith(dsPrefix)) { + break + } + + const el = target as HTMLorSVGElement + const rawKey = camelize(attributeName.slice(dsPrefix.length)) + + // If the value is not null and has changed, cleanup the old value + if (oldValue !== null && el.dataset[rawKey] !== oldValue) { + const elRemovals = this.#removals.get(el) + if (elRemovals) { + const rk = removalKey(rawKey, oldValue) + const removalFn = elRemovals.get(rk) + if (removalFn) { + removalFn() + elRemovals.delete(rk) + } + } + } + + this.#applyAttributePlugin(el, rawKey) + } + break + } + } + } + }) + + ob.observe(document.body, { + attributes: true, + attributeOldValue: true, + childList: true, + subtree: true, + }) + } get signals() { return this.#signals @@ -41,8 +115,6 @@ export class Engine { }, effect: (cb: () => void): OnRemovalFn => effect(cb), actions: this.#actions, - apply: this.apply.bind(this), - cleanup: this.#cleanup.bind(this), plugin, } @@ -79,117 +151,120 @@ export class Engine { if (lenDiff !== 0) return lenDiff return a.name.localeCompare(b.name) }) + + this.#debouncedApply() } + #debouncedApply = debounce(() => { + this.#apply(document.body) + }, 1) + // Apply all plugins to the element and its children - public apply(rootElement: Element) { + #apply(rootElement: Element) { this.#walkDownDOM(rootElement, (el) => { - // Cleanup any previous plugins - this.#cleanup(el) + // Cleanup any existing removal functions + const elRemovals = this.#removals.get(el) + if (elRemovals) { + for (const [, removalFn] of elRemovals) { + removalFn() + } + this.#removals.delete(el) + } // Apply the plugins to the element in order of application // since DOMStringMap is ordered, we can be deterministic for (const rawKey of Object.keys(el.dataset)) { - // Find the plugin that matches, since the plugins are sorted by length descending and alphabetically - // the first match will be the most specific - const plugin = this.#plugins.find((p) => rawKey.startsWith(p.name)) - - // Skip if no plugin is found - if (!plugin) continue - - // Ensure the element has an id - if (!el.id.length) el.id = elUniqId(el) - - // Extract the key and value from the dataset - let [key, ...rawModifiers] = rawKey - .slice(plugin.name.length) - .split(/\_\_+/) - - const hasKey = key.length > 0 - if (hasKey) { - // Keys starting with a dash are not converted to camel case in the dataset - const keySlice1 = key.slice(1) - key = key.startsWith('-') - ? keySlice1 - : key[0].toLowerCase() + keySlice1 - } - const value = `${el.dataset[rawKey]}` || '' - const hasValue = value.length > 0 - - // Create the runtime context - const that = this // I hate javascript - const ctx: RuntimeContext = { - get signals() { - return that.#signals - }, - effect: (cb: () => void): OnRemovalFn => effect(cb), - apply: this.apply.bind(this), - cleanup: this.#cleanup.bind(this), - actions: this.#actions, - genRX: () => this.#genRX(ctx, ...(plugin.argNames || [])), - plugin, - el, - rawKey, - key, - value, - mods: new Map(), - } + this.#applyAttributePlugin(el, rawKey) + } + }) + } - // Check the requirements - const keyReq = plugin.keyReq || Requirement.Allowed - if (hasKey) { - if (keyReq === Requirement.Denied) { - throw runtimeErr(`${plugin.name}KeyNotAllowed`, ctx) - } - } else if (keyReq === Requirement.Must) { - throw runtimeErr(`${plugin.name}KeyRequired`, ctx) - } - const valReq = plugin.valReq || Requirement.Allowed - if (hasValue) { - if (valReq === Requirement.Denied) { - throw runtimeErr(`${plugin.name}ValueNotAllowed`, ctx) - } - } else if (valReq === Requirement.Must) { - throw runtimeErr(`${plugin.name}ValueRequired`, ctx) - } + #applyAttributePlugin(el: HTMLorSVGElement, rawKey: string) { + // Find the plugin that matches, since the plugins are sorted by length descending and alphabetically + // the first match will be the most specific + const plugin = this.#plugins.find((p) => rawKey.startsWith(p.name)) - // Check for exclusive requirements - if ( - keyReq === Requirement.Exclusive || - valReq === Requirement.Exclusive - ) { - if (hasKey && hasValue) { - throw runtimeErr(`${plugin.name}KeyAndValueProvided`, ctx) - } - if (!hasKey && !hasValue) { - throw runtimeErr(`${plugin.name}KeyOrValueRequired`, ctx) - } - } + // Skip if no plugin is found + if (!plugin) return - for (const rawMod of rawModifiers) { - const [label, ...mod] = rawMod.split('.') - ctx.mods.set( - camelize(label), - new Set(mod.map((t) => t.toLowerCase())), - ) - } + // Ensure the element has an id + if (!el.id.length) el.id = elUniqId(el) - // Load the plugin and store any cleanup functions - const removal = plugin.onLoad(ctx) - if (removal) { - if (!this.#removals.has(el)) { - this.#removals.set(el, { - id: el.id, - fns: [], - }) - } - this.#removals.get(el)?.fns.push(removal) - } + // Extract the key and value from the dataset + let [key, ...rawModifiers] = rawKey.slice(plugin.name.length).split(/\_\_+/) + + const hasKey = key.length > 0 + if (hasKey) { + // Keys starting with a dash are not converted to camel case in the dataset + const keySlice1 = key.slice(1) + key = key.startsWith('-') ? keySlice1 : key[0].toLowerCase() + keySlice1 + } + const value = el.dataset[rawKey] || '' + const hasValue = value.length > 0 + + // Create the runtime context + const that = this // I hate javascript + const ctx: RuntimeContext = { + get signals() { + return that.#signals + }, + effect: (cb: () => void): OnRemovalFn => effect(cb), + actions: this.#actions, + genRX: () => this.#genRX(ctx, ...(plugin.argNames || [])), + plugin, + el, + rawKey, + key, + value, + mods: new Map(), + } - // Remove the attribute if required - if (plugin?.removeOnLoad) delete el.dataset[rawKey] + // Check the requirements + const keyReq = plugin.keyReq || Requirement.Allowed + if (hasKey) { + if (keyReq === Requirement.Denied) { + throw runtimeErr(`${plugin.name}KeyNotAllowed`, ctx) } - }) + } else if (keyReq === Requirement.Must) { + throw runtimeErr(`${plugin.name}KeyRequired`, ctx) + } + const valReq = plugin.valReq || Requirement.Allowed + if (hasValue) { + if (valReq === Requirement.Denied) { + throw runtimeErr(`${plugin.name}ValueNotAllowed`, ctx) + } + } else if (valReq === Requirement.Must) { + throw runtimeErr(`${plugin.name}ValueRequired`, ctx) + } + + // Check for exclusive requirements + if (keyReq === Requirement.Exclusive || valReq === Requirement.Exclusive) { + if (hasKey && hasValue) { + throw runtimeErr(`${plugin.name}KeyAndValueProvided`, ctx) + } + if (!hasKey && !hasValue) { + throw runtimeErr(`${plugin.name}KeyOrValueRequired`, ctx) + } + } + + for (const rawMod of rawModifiers) { + const [label, ...mod] = rawMod.split('.') + ctx.mods.set(camelize(label), new Set(mod.map((t) => t.toLowerCase()))) + } + + // Load the plugin and store any cleanup functions + const removalFn = plugin.onLoad(ctx) + if (removalFn) { + let elRemovals = this.#removals.get(el) + if (!elRemovals) { + elRemovals = new Map() + this.#removals.set(el, elRemovals) + } + elRemovals.set(removalKey(rawKey, value), removalFn) + } + + // Remove the attribute if required + if (plugin?.removeOnLoad) delete el.dataset[rawKey] } #genRX( @@ -214,7 +289,8 @@ export class Engine { // // [^;] // - const statementRe = /(\/(\\\/|[^\/])*\/|"(\\"|[^\"])*"|'(\\'|[^'])*'|`(\\`|[^`])*`|[^;])+/gm + const statementRe = + /(\/(\\\/|[^\/])*\/|"(\\"|[^\"])*"|'(\\'|[^'])*'|`(\\`|[^`])*`|[^;])+/gm const statements = ctx.value.trim().match(statementRe) if (statements) { const lastIdx = statements.length - 1 @@ -314,15 +390,4 @@ export class Engine { el = el.nextElementSibling } } - - // Clenup all plugins associated with the element - #cleanup(el: Element) { - const removalSet = this.#removals.get(el) - if (removalSet) { - for (const removal of removalSet.fns) { - removal() - } - this.#removals.delete(el) - } - } } diff --git a/library/src/engine/errors.ts b/library/src/engine/errors.ts index 227f03858..0cee3c234 100644 --- a/library/src/engine/errors.ts +++ b/library/src/engine/errors.ts @@ -2,12 +2,11 @@ import { kebabize } from '../utils/text' import { DATASTAR } from './consts' import { type InitContext, PluginType, type RuntimeContext } from './types' -// const url = 'https://data-star.dev/errors' const url = `${window.location.origin}/errors` interface Metadata { - error?: string; - [key: string]: any; + error?: string + [key: string]: any } function dserr(type: string, reason: string, metadata: Metadata = {}) { @@ -56,4 +55,4 @@ export function runtimeErr(reason: string, ctx: RuntimeContext, metadata = {}) { }, } return dserr('runtime', reason, Object.assign(errCtx, metadata)) -} \ No newline at end of file +} diff --git a/library/src/engine/signals.ts b/library/src/engine/signals.ts index aeb57e03b..a6b881340 100644 --- a/library/src/engine/signals.ts +++ b/library/src/engine/signals.ts @@ -1,9 +1,21 @@ import { type Computed, Signal, computed } from '../vendored/preact-core' import { internalErr } from './errors' -import type { NestedSignal, NestedValues } from './types' +import { + DATASTAR_SIGNAL_EVENT, + type DatastarSignalEvent, + type NestedSignal, + type NestedValues, +} from './types' const from = 'namespacedSignals' +const dispatchSignalEvent = (evt: Partial) => { + document.dispatchEvent( + new CustomEvent(DATASTAR_SIGNAL_EVENT, { + detail: Object.assign({ added: [], removed: [], updated: [] }, evt), + }), + ) +} // If onlyPublic is true, only signals not starting with an underscore are included function nestedValues( signal: NestedSignal, @@ -30,7 +42,12 @@ function mergeNested( target: NestedValues, values: NestedValues, onlyIfMissing = false, -): void { +) { + const evt: DatastarSignalEvent = { + added: [], + removed: [], + updated: [], + } for (const key in values) { if (Object.hasOwn(values, key)) { if (key.match(/\_\_+/)) { @@ -42,25 +59,35 @@ function mergeNested( if (!target[key]) { target[key] = {} } - mergeNested( + const subEvt = mergeNested( target[key] as NestedValues, value as NestedValues, onlyIfMissing, ) + evt.added.push(...subEvt.added.map((k) => `${key}.${k}`)) + evt.removed.push(...subEvt.removed.map((k) => `${key}.${k}`)) + evt.updated.push(...subEvt.updated.map((k) => `${key}.${k}`)) } else { const hasKey = Object.hasOwn(target, key) if (hasKey) { if (onlyIfMissing) continue const t = target[key] if (t instanceof Signal) { + const oldValue = t.value t.value = value + if (oldValue !== value) { + evt.updated.push(key) + } continue } } + target[key] = new Signal(value) + evt.added.push(key) } } } + return evt } function walkNestedSignal( @@ -174,7 +201,11 @@ export class SignalsRoot { setValue(dotDelimitedPath: string, value: T) { const s = this.upsertIfMissing(dotDelimitedPath, value) + const oldValue = s.value s.value = value + if (oldValue !== value) { + dispatchSignalEvent({ updated: [dotDelimitedPath] }) + } } upsertIfMissing(dotDelimitedPath: string, defaultValue: T) { @@ -197,10 +228,17 @@ export class SignalsRoot { const signal = new Signal(defaultValue) subSignals[last] = signal + dispatchSignalEvent({ added: [dotDelimitedPath] }) + return signal } remove(...dotDelimitedPaths: string[]) { + if (!dotDelimitedPaths.length) { + this.#signals = {} + return + } + const removed = Array() for (const path of dotDelimitedPaths) { const parts = path.split('.') let subSignals = this.#signals @@ -213,11 +251,16 @@ export class SignalsRoot { } const last = parts[parts.length - 1] delete subSignals[last] + removed.push(path) } + dispatchSignalEvent({ removed }) } merge(other: NestedValues, onlyIfMissing = false) { - mergeNested(this.#signals, other, onlyIfMissing) + const evt = mergeNested(this.#signals, other, onlyIfMissing) + if (evt.added.length || evt.removed.length || evt.updated.length) { + dispatchSignalEvent(evt) + } } subset(...keys: string[]): NestedValues { diff --git a/library/src/engine/types.ts b/library/src/engine/types.ts index 64f205ac0..a24d56330 100644 --- a/library/src/engine/types.ts +++ b/library/src/engine/types.ts @@ -1,4 +1,5 @@ import type { EffectFn, Signal } from '../vendored/preact-core' +import { DATASTAR } from './consts' import type { SignalsRoot } from './signals' export type OnRemovalFn = () => void @@ -21,6 +22,33 @@ export enum Requirement { Exclusive = 3, } +export interface DatastarSignalEvent { + added: Array + removed: Array + updated: Array +} +export const DATASTAR_SIGNAL_EVENT = `${DATASTAR}-signals` +export interface CustomEventMap { + [DATASTAR_SIGNAL_EVENT]: CustomEvent +} +export type WatcherFn = ( + this: Document, + ev: CustomEventMap[K], +) => void +declare global { + interface Document { + dispatchEvent(ev: CustomEventMap[K]): void + addEventListener( + type: K, + listener: WatcherFn, + ): void + removeEventListener( + type: K, + listener: WatcherFn, + ): void + } +} + // A plugin accesible via a `data-${name}` attribute on an element export interface AttributePlugin extends DatastarPlugin { type: PluginType.Attribute @@ -48,15 +76,13 @@ export interface ActionPlugin extends DatastarPlugin { } export type GlobalInitializer = (ctx: InitContext) => void -export type RemovalEntry = { id: string; fns: Array } +// export type RemovalEntry = { id: string; fns: Array } export type InitContext = { plugin: DatastarPlugin signals: SignalsRoot effect: (fn: EffectFn) => OnRemovalFn actions: Readonly - apply: (target: Element) => void - cleanup: (el: Element) => void } export type HTMLorSVGElement = Element & (HTMLElement | SVGElement) diff --git a/library/src/plugins/official/backend/shared.ts b/library/src/plugins/official/backend/shared.ts index ca51b6fe7..36b4c063f 100644 --- a/library/src/plugins/official/backend/shared.ts +++ b/library/src/plugins/official/backend/shared.ts @@ -13,7 +13,7 @@ export interface DatastarSSEEvent { } export interface CustomEventMap { - 'datastar-sse': CustomEvent + [DATASTAR_SSE_EVENT]: CustomEvent } export type WatcherFn = ( this: Document, diff --git a/library/src/plugins/official/backend/watchers/mergeFragments.ts b/library/src/plugins/official/backend/watchers/mergeFragments.ts index 7ee86c197..d0b2dadbe 100644 --- a/library/src/plugins/official/backend/watchers/mergeFragments.ts +++ b/library/src/plugins/official/backend/watchers/mergeFragments.ts @@ -85,14 +85,7 @@ function applyToTargets( let modifiedTarget = initialTarget switch (mergeMode) { case FragmentMergeModes.Morph: { - const result = idiomorph(modifiedTarget, fragment, { - callbacks: { - beforeNodeRemoved: (oldNode: Element, _: Element) => { - ctx.cleanup(oldNode) - return true - }, - }, - }) + const result = idiomorph(modifiedTarget, fragment) if (!result?.length) { throw initErr('MorphFailed', ctx) } @@ -133,12 +126,11 @@ function applyToTargets( default: throw initErr('InvalidMergeMode', ctx, { mergeMode }) } - ctx.cleanup(modifiedTarget) const cl = modifiedTarget.classList cl.add(SWAPPING_CLASS) - ctx.apply(document.body) + // ctx.apply(document.body) setTimeout(() => { initialTarget.classList.remove(SWAPPING_CLASS) diff --git a/library/src/plugins/official/backend/watchers/mergeSignals.ts b/library/src/plugins/official/backend/watchers/mergeSignals.ts index 7f644241f..7484f5cfe 100644 --- a/library/src/plugins/official/backend/watchers/mergeSignals.ts +++ b/library/src/plugins/official/backend/watchers/mergeSignals.ts @@ -23,7 +23,6 @@ export const MergeSignals: WatcherPlugin = { const { signals } = ctx const onlyIfMissing = isBoolString(onlyIfMissingRaw) signals.merge(jsStrToObject(raw), onlyIfMissing) - ctx.apply(document.body) }, ) }, diff --git a/library/src/plugins/official/backend/watchers/removeSignals.ts b/library/src/plugins/official/backend/watchers/removeSignals.ts index 990246407..d89056624 100644 --- a/library/src/plugins/official/backend/watchers/removeSignals.ts +++ b/library/src/plugins/official/backend/watchers/removeSignals.ts @@ -19,7 +19,6 @@ export const RemoveSignals: WatcherPlugin = { throw initErr('NoPathsProvided', ctx) } ctx.signals.remove(...paths) - ctx.apply(document.body) }, ) }, diff --git a/library/src/plugins/official/dom/attributes/on.ts b/library/src/plugins/official/dom/attributes/on.ts index 0e3dd4691..c725f8746 100644 --- a/library/src/plugins/official/dom/attributes/on.ts +++ b/library/src/plugins/official/dom/attributes/on.ts @@ -5,6 +5,8 @@ import { type AttributePlugin, + DATASTAR_SIGNAL_EVENT, + type DatastarSignalEvent, PluginType, Requirement, } from '../../../../engine/types' @@ -22,7 +24,7 @@ export const On: AttributePlugin = { keyReq: Requirement.Must, valReq: Requirement.Must, argNames: [EVT], - onLoad: ({ el, rawKey, key, value, genRX, mods, signals, effect }) => { + onLoad: ({ el, rawKey, key, value, genRX, mods }) => { const rx = genRX() let target: Element | Window | Document = el if (mods.has('window')) target = window @@ -112,15 +114,15 @@ export const On: AttributePlugin = { onElementRemoved(el, () => { lastSignalsMarshalled.delete(el.id) }) - return effect(() => { - const onlyRemoteSignals = mods.has('remote') - const current = signals.JSON(false, onlyRemoteSignals) - const last = lastSignalsMarshalled.get(el.id) || '' - if (last !== current) { - lastSignalsMarshalled.set(el.id, current) - callback() - } - }) + + callback() + const signalFn = (event: CustomEvent) => { + callback(event) + } + document.addEventListener(DATASTAR_SIGNAL_EVENT, signalFn) + return () => { + document.removeEventListener(DATASTAR_SIGNAL_EVENT, signalFn) + } } default: { diff --git a/library/src/utils/text.ts b/library/src/utils/text.ts index 11e23156e..1d7e7193e 100644 --- a/library/src/utils/text.ts +++ b/library/src/utils/text.ts @@ -7,11 +7,7 @@ export const kebabize = (str: string) => ) export const camelize = (str: string) => - str - .replace(/(?:^\w|[A-Z]|\b\w)/g, (word, index) => - index === 0 ? word.toLowerCase() : word.toUpperCase(), - ) - .replace(/\s+/g, '') + kebabize(str).replace(/-./g, (x) => x[1].toUpperCase()) export const jsStrToObject = (raw: string) => new Function(`return Object.assign({}, ${raw})`)() diff --git a/library/src/vendored/idiomorph.ts b/library/src/vendored/idiomorph.ts index 694f16f3d..b6d27341b 100644 --- a/library/src/vendored/idiomorph.ts +++ b/library/src/vendored/idiomorph.ts @@ -101,27 +101,15 @@ function morphOldNodeTo(oldNode: Element, newContent: Element, ctx: any) { if (ctx.ignoreActive && oldNode === document.activeElement) { // don't morph focused element } else if (newContent == null) { - if (ctx.callbacks.beforeNodeRemoved(oldNode) === false) return - oldNode.remove() - ctx.callbacks.afterNodeRemoved(oldNode) return } else if (!isSoftMatch(oldNode, newContent)) { - if (ctx.callbacks.beforeNodeRemoved(oldNode) === false) return - if (ctx.callbacks.beforeNodeAdded(newContent) === false) return - if (!oldNode.parentElement) { throw internalErr(from, 'NoParentElementFound', { oldNode }) } oldNode.parentElement.replaceChild(newContent, oldNode) - ctx.callbacks.afterNodeAdded(newContent) - ctx.callbacks.afterNodeRemoved(oldNode) return newContent } else { - if (ctx.callbacks.beforeNodeMorphed(oldNode, newContent) === false) { - return - } - if (oldNode instanceof HTMLHeadElement && ctx.head.ignore) { // ignore the head element } else if ( @@ -134,7 +122,6 @@ function morphOldNodeTo(oldNode: Element, newContent: Element, ctx: any) { syncNodeFrom(newContent, oldNode) morphChildren(newContent, oldNode, ctx) } - ctx.callbacks.afterNodeMorphed(oldNode, newContent) return oldNode } } @@ -173,10 +160,7 @@ function morphChildren(newParent: Element, oldParent: Element, ctx: any) { // if we are at the end of the exiting parent's children, just append if (insertionPoint == null) { - if (ctx.callbacks.beforeNodeAdded(newChild) === false) return - oldParent.appendChild(newChild) - ctx.callbacks.afterNodeAdded(newChild) removeIdsFromConsideration(ctx, newChild) continue } @@ -219,10 +203,7 @@ function morphChildren(newParent: Element, oldParent: Element, ctx: any) { // abandon all hope of morphing, just insert the new child before the insertion point // and move on - if (ctx.callbacks.beforeNodeAdded(newChild) === false) return - oldParent.insertBefore(newChild, insertionPoint) - ctx.callbacks.afterNodeAdded(newChild) removeIdsFromConsideration(ctx, newChild) } @@ -384,30 +365,24 @@ function handleHeadElement( if (!newElt) { throw internalErr(from, 'NewElementCouldNotBeCreated', { newNode }) } - if (ctx.callbacks.beforeNodeAdded(newElt)) { - if (newElt.hasAttribute('href') || newElt.hasAttribute('src')) { - let resolver: (value: unknown) => void - const promise = new Promise((resolve) => { - resolver = resolve - }) - newElt.addEventListener('load', () => { - resolver(undefined) - }) - promises.push(promise) - } - currentHead.appendChild(newElt) - ctx.callbacks.afterNodeAdded(newElt) - added.push(newElt) + if (newElt.hasAttribute('href') || newElt.hasAttribute('src')) { + let resolver: (value: unknown) => void + const promise = new Promise((resolve) => { + resolver = resolve + }) + newElt.addEventListener('load', () => { + resolver(undefined) + }) + promises.push(promise) } + currentHead.appendChild(newElt) + added.push(newElt) } // remove all removed elements, after we have appended the new elements to avoid // additional network requests for things like style sheets for (const removedElement of removed) { - if (ctx.callbacks.beforeNodeRemoved(removedElement) !== false) { - currentHead.removeChild(removedElement) - ctx.callbacks.afterNodeRemoved(removedElement) - } + currentHead.removeChild(removedElement) } ctx.head.afterHeadMorphed(currentHead, { @@ -436,17 +411,6 @@ function createMorphContext( ignoreActive: config.ignoreActive, idMap: createIdMap(oldNode, newContent), deadIds: new Set(), - callbacks: Object.assign( - { - beforeNodeAdded: noOp, - afterNodeAdded: noOp, - beforeNodeMorphed: noOp, - afterNodeMorphed: noOp, - beforeNodeRemoved: noOp, - afterNodeRemoved: noOp, - }, - config.callbacks, - ), head: Object.assign( { style: 'merge', @@ -721,10 +685,7 @@ function scoreElement(node1: Element, node2: Element, ctx: any) { function removeNode(tempNode: Element, ctx: any) { removeIdsFromConsideration(ctx, tempNode) - if (ctx.callbacks.beforeNodeRemoved(tempNode) === false) return - tempNode.remove() - ctx.callbacks.afterNodeRemoved(tempNode) } //============================================================================= diff --git a/sdk/clojure/.clj-kondo/config.edn b/sdk/clojure/.clj-kondo/config.edn new file mode 100644 index 000000000..55c6809ae --- /dev/null +++ b/sdk/clojure/.clj-kondo/config.edn @@ -0,0 +1,6 @@ +{:lint-as {fr.jeremyschoffen.datastar.utils/defroutes clojure.core/def + starfederation.datastar.clojure.utils/transient-> clojure.core/->} + :hooks + {:analyze-call + {test.utils/with-server hooks.test-hooks/with-server}}} + diff --git a/sdk/clojure/.clj-kondo/hooks/test_hooks.clj b/sdk/clojure/.clj-kondo/hooks/test_hooks.clj new file mode 100644 index 000000000..871699247 --- /dev/null +++ b/sdk/clojure/.clj-kondo/hooks/test_hooks.clj @@ -0,0 +1,17 @@ +(ns hooks.test-hooks + (:require + [clj-kondo.hooks-api :as api])) + + +(defn with-server [{:keys [node] :as exp}] + (let [[s-name handler opts & body] (-> node :children rest) + underscore (api/token-node '_) + new-children (list* + (api/token-node 'let) + (api/vector-node + [s-name handler + underscore opts]) + body) + new-node (assoc node :children new-children)] + (assoc exp :node new-node))) + diff --git a/sdk/clojure/.gitignore b/sdk/clojure/.gitignore new file mode 100644 index 000000000..d4cc8a969 --- /dev/null +++ b/sdk/clojure/.gitignore @@ -0,0 +1,11 @@ +.cpcache +.nrepl-port +.lsp +.clj-kondo/** +!.clj-kondo/config.edn +!.clj-kondo/hooks** + + + + +test-resources/test.config.edn diff --git a/sdk/clojure/README.md b/sdk/clojure/README.md new file mode 100644 index 000000000..50da0cb5a --- /dev/null +++ b/sdk/clojure/README.md @@ -0,0 +1,178 @@ +# Datastar Clojure SDK + +We provide several libraries for working with [Datastar](https://data-star.dev/): + +- A generic SDK to generate and send Datastar event using a SSE generator + abstraction defined by the `starfederation.datastar.clojure.protocols/SSEGenerator` + protocol. This gives us a common API working for each implementation of the protocol. +- Libraries containing implementations of the `SSEGenerator` protocol that work + with specific ring adapters. +- A library containing [malli schemas](https://github.com/metosin/malli) for the SDK. + +There currently are adapter implementations for: + +- [ring jetty](https://github.com/ring-clojure/ring) +- [http-kit](https://github.com/http-kit/http-kit) + +If you want to roll your own adapter implementation, see +[implementing-datapters](/sdk/clojure/doc/implementing-adapters.md). + +## Installation + +For now the libraries are distributed as git dependencies. You need to add a dependency +for each library you use. + +> [!important] +> This project is new and there isn't a release process yet other than using git shas. +> Replace `LATEST_SHA` in the git coordinates below by the actual latest commit sha of this repository. + +To your `deps.edn` file you can add the following coordinates: + +- SDK + +```clojure +datastar/sdk {:git/url "https://github.com/starfederation/datastar/tree/develop" + :git/sha "LATEST_SHA" + :deps/root "sdk/clojure/sdk"} +``` + +- ring jetty implementation + +```clojure +datastar/ring-jetty {:git/url "https://github.com/starfederation/datastar/tree/develop" + :git/sha "LATEST_SHA" + :deps/root "sdk/clojure/adapter-jetty"}} +``` + +- http-kit implementation + +```clojure +datastar/ring-http-kit {:git/url "https://github.com/starfederation/datastar/tree/develop" + :git/sha "LATEST_SHA" + :deps/root "sdk/clojure/adapter-jetty"}} +``` + +- Malli schemas: + +```clojure +datastar/malli-schemas {:git/url "https://github.com/starfederation/datastar/tree/develop" + :git/sha "LATEST_SHA" + :deps/root "sdk/clojure/malli-schemas"}} +``` + +## Usage + +### Concepts + +By convention adapters provide a single `->sse-responce` function. This +function returns a valid ring response tailored to work with the used ring +adapter. This function takes callbacks that receive an implementation of the +SSE generator the only parameter. + +You then use the Datastar SDK functions with the SSE generator. + +### Short example + +Start by requiring the api and an adapter. With HTTP-Kit for instance: + +```clojure +(require '[starfederation.datastar.clojure.api :as d*]) + '[starfederation.datastar.clojure.adapter.http-kit :as hk-gen]) + +``` + +Using the adapter you create ring responses for your handlers: + +```clojure +(defn sse-handler [request] + (hk-gen/->sse-response request + {:on-open + (fn [sse-gen] + (d*/merge-fragment! sse-gen "
test
") + (d*/close-sse! sse-gen))})) + +``` + +In the callback we use the SSE generator `sse-gen` with the Datastar SDK functions. + +Depending on the adapter you use, you can keep the SSE generator open, store it +somewhere and use it later: + +```clojure +(def !connections (atom #{})) + + +(defn sse-handler [request] + (hk-gen/->sse-response request + {:on-open + (fn [sse-gen] + (swap! !connections conj sse-gen)) + :on-close + (fn [sse-gen] + (swap! !connections disj sse-gen))})) + + +(defn broadcast-fragment! [fragment] + (doseq [c @!connections] + (d*/merge-fragment! c fragment))) + +``` + +> [!important] +> Check doctrings / Readmes for the specific adapter you use. + +> [!note] +> Check the docstrings in the `starfederation.datastar.clojure.api` namespace for +> more details on the SDK functions. + +## Adapter differences: + +Ring adapters are not made equals. Here are some the differences between the adapters: + +| Adapter | return values from the SDK event sending functions | +| ---------- | -------------------------------------------------- | +| ring-jetty | irrelevant | +| http-kit | boolean, from `org.http-kit.server/send!` | + +> [!note] +> The SDK's event sending functions return whatever the adapter's implementation of +> `starfederation.datastar.clojure.protocols/send-event!` returns. + +| Adapter | connection lifetime in ring sync mode | +| ---------- | ---------------------------------------------------------------------- | +| ring-jetty | same as the thread creating the sse response | +| http-kit | alive until the client or the server explicitely closes the connection | + +> [!note] +> You may keep the connection open in ring-jetty sync mode by somehow blocking the thread +> handling the request. + +| Adapter | connection lifetime in ring async mode | +| ---------- | ---------------------------------------------------------------------- | +| ring-jetty | alive until the client or the server explicitely closes the connection | +| http-kit | alive until the client or the server explicitely closes the connection | + +| Adapter | sending an event on closed connection | +| ---------- | -------------------------------------------------- | +| ring-jetty | exception thrown | +| http-kit | fn returns false, from `org.http-kit.server/send!` | + +> [!note] +> This is the way to detect the connection has been closed by the client. + +> [!important] +> At the moment, the ring-jetty adapter needs to send 2 small messages or 1 big +> message to detect a closed connection. There must be some buffering happening +> independent of our implementation. + +| Adapter | `:on-close` callback parameters | +| ---------- | ------------------------------- | +| ring-jetty | `[sse-gen]` | +| http-kit | `[sse-gen status-code]` | + +## TODO: + +- Streamlined release process (cutting releases and publish jar to a maven repo) +- consider using a byteArrayBuilder (may require adding a dependency) +- consider uniformizing the adapters behavior on connection closing (throwing in all adapters?) +- add license files ? diff --git a/sdk/clojure/adapter-http-kit/README.md b/sdk/clojure/adapter-http-kit/README.md new file mode 100644 index 000000000..363168720 --- /dev/null +++ b/sdk/clojure/adapter-http-kit/README.md @@ -0,0 +1,18 @@ +# Datastar http-kit adapter + +## Installation + +For now the SDK and adapters are distributed as git dependencies using a `deps.edn` file. + +```clojure +{datastar/sdk {:git/url "https://github.com/starfederation/datastar/tree/develop" + :git/sha "LATEST SHA" + :deps/root "sdk/clojure/sdk"} + + datastar/http-kit {:git/url "https://github.com/starfederation/datastar/tree/develop" + :git/sha "LATEST SHA" + :deps/root "sdk/clojure/adapter-http-kit"}} +``` + +> [!important] +> Replace `LATEST_SHA` in the git coordinates below by the actual latest commit sha of the repository. diff --git a/sdk/clojure/adapter-http-kit/deps.edn b/sdk/clojure/adapter-http-kit/deps.edn new file mode 100644 index 000000000..25e6606b7 --- /dev/null +++ b/sdk/clojure/adapter-http-kit/deps.edn @@ -0,0 +1,3 @@ +{:paths ["src/main"] + :deps {http-kit/http-kit {:git/url "https://github.com/http-kit/http-kit" :git/sha "76b869fc34536ad0c43afa9a98d971a0fc32c644"}}} + diff --git a/sdk/clojure/adapter-http-kit/src/main/starfederation/datastar/clojure/adapter/http_kit.clj b/sdk/clojure/adapter-http-kit/src/main/starfederation/datastar/clojure/adapter/http_kit.clj new file mode 100644 index 000000000..cf5a48d21 --- /dev/null +++ b/sdk/clojure/adapter-http-kit/src/main/starfederation/datastar/clojure/adapter/http_kit.clj @@ -0,0 +1,36 @@ +(ns starfederation.datastar.clojure.adapter.http-kit + (:require + [org.httpkit.server :as hk-server] + [starfederation.datastar.clojure.adapter.http-kit.impl :as impl])) + +(defn ->sse-response + "Make a Ring like response that works with HTTP-Kit. + + An empty response containing a 200 status code, the + `:headers`, and the SSE specific headers are sent + automatically before `on-open` is called. + + Note that the SSE connection stays opened util you close it. + + Opts: + - `:headers`: Ring headers map to add to the response. + - `:on-open`: Mandatory callback (fn [sse-gen] ...) called when the + generator is ready to send. + - `:on-close`: callback (fn [sse-gen status-code]) + + The callback are based on the HTTP-Kit channel ones, adding the sse + generator as the second parameter." + [ring-request {:keys [headers on-open on-close]}] + (let [future-gen (promise) + response (hk-server/as-channel ring-request + {:on-open (fn [ch] + (impl/send-base-sse-response! ch ring-request headers) + (let [sse-gen (impl/->sse-gen ch)] + (deliver future-gen sse-gen) + (on-open sse-gen))) + :on-close (fn [_ status-code] + (when on-close + (on-close (deref future-gen 0 nil) + status-code)))})] + response)) + diff --git a/sdk/clojure/adapter-http-kit/src/main/starfederation/datastar/clojure/adapter/http_kit/impl.clj b/sdk/clojure/adapter-http-kit/src/main/starfederation/datastar/clojure/adapter/http_kit/impl.clj new file mode 100644 index 000000000..eed4ed3c8 --- /dev/null +++ b/sdk/clojure/adapter-http-kit/src/main/starfederation/datastar/clojure/adapter/http_kit/impl.clj @@ -0,0 +1,44 @@ +(ns starfederation.datastar.clojure.adapter.http-kit.impl + (:require + [starfederation.datastar.clojure.api.sse :as sse] + [starfederation.datastar.clojure.protocols :as p] + [starfederation.datastar.clojure.utils :as u] + [org.httpkit.server :as hk-server]) + (:import + [java.util.concurrent.locks ReentrantLock] + [java.io Closeable])) + + +;; ----------------------------------------------------------------------------- +;; HTTP-KIT sse gen +;; ----------------------------------------------------------------------------- +(defrecord HK-gen [ch lock] + p/SSEGenerator + (send-event! [_ event-type data-lines opts] + (let [event-str (-> (StringBuilder.) + (sse/write-event! event-type data-lines opts) + str)] + (u/lock! lock + (hk-server/send! ch event-str false)))) + + (close-sse! [_] + (u/lock! lock + (hk-server/close ch))) + + Closeable + (close [this] + (p/close-sse! this))) + + +(defn ->sse-gen [ch] + (HK-gen. ch (ReentrantLock.))) + + +(defn send-base-sse-response! [ch req headers] + (let [sse-headers (sse/headers req)] + (hk-server/send! ch + {:status 200 + :headers (merge headers sse-headers)} + false))) + + diff --git a/sdk/clojure/adapter-jetty/README.md b/sdk/clojure/adapter-jetty/README.md new file mode 100644 index 000000000..f4da17be6 --- /dev/null +++ b/sdk/clojure/adapter-jetty/README.md @@ -0,0 +1,18 @@ +# Dataster ring jetty adapter + +## Installation + +For now the SDK and adapters are distributed as git dependencies using a `deps.edn` file. + +```clojure +{datastar/sdk {:git/url "https://github.com/starfederation/datastar/tree/develop" + :git/sha "LATEST SHA" + :deps/root "sdk/clojure/sdk"} + + datastar/ring-jetty {:git/url "https://github.com/starfederation/datastar/tree/develop" + :git/sha "LATEST SHA" + :deps/root "sdk/clojure/adapter-jetty"}} +``` + +> [!important] +> Replace `LATEST_SHA` in the git coordinates below by the actual latest commit sha of the repository. diff --git a/sdk/clojure/adapter-jetty/deps.edn b/sdk/clojure/adapter-jetty/deps.edn new file mode 100644 index 000000000..db5085cc7 --- /dev/null +++ b/sdk/clojure/adapter-jetty/deps.edn @@ -0,0 +1,2 @@ +{:paths ["src/main"] + :deps {ring/ring-jetty-adapter {:mvn/version "1.13.0"}}} diff --git a/sdk/clojure/adapter-jetty/src/main/starfederation/datastar/clojure/adapter/ring_jetty.clj b/sdk/clojure/adapter-jetty/src/main/starfederation/datastar/clojure/adapter/ring_jetty.clj new file mode 100644 index 000000000..1df0387a0 --- /dev/null +++ b/sdk/clojure/adapter-jetty/src/main/starfederation/datastar/clojure/adapter/ring_jetty.clj @@ -0,0 +1,28 @@ +(ns starfederation.datastar.clojure.adapter.ring-jetty + (:require + [starfederation.datastar.clojure.adapter.ring-jetty.impl :as impl] + [starfederation.datastar.clojure.api.sse :as sse])) + + +(defn ->sse-response + "Returns a ring response with status 200, specific SSE headers merged + with the provided ones and the body is a sse generator implementing + `ring.core.protocols/StreamableResponseBody`. + + Note that the SSE connections stays opened until you close it in async mode. + In sync mode, the connection is closed automatically when the handler is + done running. + + Opts: + - `:headers`: Ring headers map to add to the response + - `:on-open`: Mandatory callback (fn [sse-gen] ...) called when the generator + is ready to send. + - `:on-close`: callback (fn [sse-gen] ...) called right after the generator + has closed it's connection." + [ring-request {:keys [headers on-open on-close]}] + {:pre [(identity on-open)]} + (let [sse-gen (impl/->sse-gen on-open on-close)] + {:status 200 + :headers (merge headers (sse/headers ring-request)) + :body sse-gen})) + diff --git a/sdk/clojure/adapter-jetty/src/main/starfederation/datastar/clojure/adapter/ring_jetty/impl.clj b/sdk/clojure/adapter-jetty/src/main/starfederation/datastar/clojure/adapter/ring_jetty/impl.clj new file mode 100644 index 000000000..5ad10fc03 --- /dev/null +++ b/sdk/clojure/adapter-jetty/src/main/starfederation/datastar/clojure/adapter/ring_jetty/impl.clj @@ -0,0 +1,59 @@ +(ns starfederation.datastar.clojure.adapter.ring-jetty.impl + (:require + [clojure.java.io :as io] + [starfederation.datastar.clojure.api.sse :as sse] + [starfederation.datastar.clojure.protocols :as p] + [starfederation.datastar.clojure.utils :as u] + [ring.core.protocols :as rp]) + (:import + java.io.Closeable + java.io.OutputStream + java.io.BufferedWriter + java.util.concurrent.locks.ReentrantLock)) + + +(defrecord SSE-gen [writer lock on-open on-close] + rp/StreamableResponseBody + (write-body-to-stream [this _ output-stream] + (u/lock! lock + (when (deref writer) + (throw (ex-info "Reused SSE-gen as several ring responses body. Don't do this." {}))) + (.flush ^OutputStream output-stream) + (vreset! writer (io/writer output-stream))) + (on-open this)) + + + p/SSEGenerator + (send-event! [this event-type data-lines opts] + (u/lock! lock + (try + (doto ^BufferedWriter @writer + (sse/write-event! event-type data-lines opts) + (.flush)) + (catch Exception e + (throw (ex-info "Error sending SSE event" + {:sse-gen this + :event-type event-type + :data-lines data-lines + :opts opts} + e)))))) + + (close-sse! [this] + (u/lock! lock + (when-let [^BufferedWriter w @writer] + (.close w) + (when on-close + (on-close this))))) + + Closeable + (close [this] + (p/close-sse! this))) + + +(defn ->sse-gen [on-open on-close] + (SSE-gen. (volatile! nil) + (ReentrantLock.) + on-open + on-close)) + + diff --git a/sdk/clojure/bb.edn b/sdk/clojure/bb.edn new file mode 100644 index 000000000..cb47f0c01 --- /dev/null +++ b/sdk/clojure/bb.edn @@ -0,0 +1,28 @@ +{:paths ["src/main" "src/bb"] + :tasks + {:requires ([tasks :as t]) + + -prep (t/prep-libs) + + dev {:task (do (println "Starting Dev repl") + (t/dev))} + + + test:all (t/lazytest [:http-kit + :ring-jetty + :malli-schemas] + [:test.paths/core-sdk + :test.paths/malli-schemas + :test.paths/adapter-http-kit + :test.paths/adapter-ring-jetty]) + + + test:all-w (t/lazytest [:http-kit + :ring-jetty + :malli-schemas] + [:test.paths/core-sdk + :test.paths/malli-schemas + :test.paths/adapter-http-kit + :test.paths/adapter-ring-jetty] + "--watch" + "--delay 1000")}} diff --git a/sdk/clojure/deps.edn b/sdk/clojure/deps.edn new file mode 100644 index 000000000..11e3b54ae --- /dev/null +++ b/sdk/clojure/deps.edn @@ -0,0 +1,38 @@ +{:paths ["sdk/src/main"] + + :deps {io.github.paintparty/fireworks {:mvn/version "0.10.4"}} + + :aliases + {:repl {:extra-paths ["src/dev"] + :extra-deps {org.clojure/clojure {:mvn/version "1.12.0"} + nrepl/nrepl {:mvn/version "1.3.0"} + cider/cider-nrepl {:mvn/version "0.50.2"} + io.github.tonsky/clj-reload {:mvn/version "0.7.1"}}} + + + :test {:extra-paths ["test-resources/" + :test.paths/core-sdk + :test.paths/malli-schemas + :test.paths/adapter-common + :test.paths/adapter-http-kit + :test.paths/adapter-ring-jetty] + + + :extra-deps {io.github.noahtheduke/lazytest {:mvn/version "1.5.0"} + metosin/reitit {:mvn/version "0.7.2"} + etaoin/etaoin {:mvn/version "1.1.42"} + com.cnuernber/charred {:mvn/version "1.034"} + dev.onionpancakes/chassis {:mvn/version "1.0.365"}}} + + + :http-kit {:extra-deps {sdk/adapter-http-kit {:local/root "./adapter-http-kit"}}} + :ring-jetty {:extra-deps {sdk/adapter-jetty {:local/root "./adapter-jetty"}}} + :malli-schemas {:extra-deps {sdk/malli {:local/root "./malli-schemas"}}} + + + :test.paths/core-sdk ["src/test/core-sdk"] + :test.paths/malli-schemas ["src/test/malli-schemas"] + :test.paths/adapter-common ["src/test/adapter-common"] + :test.paths/adapter-http-kit ["src/test/adapter-http-kit"] + :test.paths/adapter-ring-jetty ["src/test/adapter-ring-jetty"]}} + diff --git a/sdk/clojure/doc/implementing-adapters.md b/sdk/clojure/doc/implementing-adapters.md new file mode 100644 index 000000000..cd7f59f5a --- /dev/null +++ b/sdk/clojure/doc/implementing-adapters.md @@ -0,0 +1,110 @@ +# Implementing adapters + +If you are using a ring adapter not supported by this library or if you want to +roll your own there are helpers to facilitate making one. At minimum you need to +implement 1 protocol. If you wanna be more in line with the provided adapters +there are more conventions to follow. + +Also, for the library as a whole we try to stay close to the +[SDK's design document](/sdk/README.md) used for all SDKs. + +## Implementing the `SSEGenerator` protocol + +An SSE generator is made by implementing the +`starfederation.datastar.clojure.protocols/SSEGenerator` protocol. + +There are 2 functions to implement: + +- `(send-event! [this event-type data-lines opts] )` + This function must contain the logic to actually send a SSE event. +- `(close-sse! [this] "Close connection.")` + This function must close the connection use by the `SSEGenerator`. + +### Implementing `send-event!` + +To help implement this function you should use the +`starfederation.datastar.clojure.api.sse/write-event!` function. + +It take 4 arguments: + +- `buffer`: A `java.lang.Appendable` +- `event-type`: a string representing a Datastar event type +- `data-lines`: a seq of data lines constituting the 'body' of the event +- `opts`: a map of SSE Options. + +You actually don't need to care about anything other than the `buffer` with this function, +the generic SDK will provide the value for the other arguments. + +For instance implementing a test generator that return the event's text instead +of sending it looks like: + +```clojure +(deftype ReturnMsgGen [] + p/SSEGenerator + (send-event! [_ event-type data-lines opts] + (-> (StringBuilder.) + (sse/write-event! event-type data-lines opts) ; just pass the parameters down + str)) ; we return the event string instead of sending it + + (close-sse! [_])) + + +(defn ->sse-gen [& _] + (->ReturnMsgGen)) + +``` + +As per the design doc that all Datastar SDKs follow, we use a lock in this +function (`java.util.concurrent.locks.ReentrantLock`) to protect +from several threads concurrently writing any underlying buffer before flushing. + +See `starfederation.datastar.clojure.utils/lock!` for a helper with the +Reentrant locks. + +> [!note] +> The lock is not needed in this example, since the buffer is created for each call. +> However it is necessary when the buffer is shared. + +### Implementing `close-sse!` + +Just close whatever constitutes a connection for your particular adapter. + +If you follow the conventions detailed below, you must call an `on-close` +callback in this function. + +## Conventions followed in the provided adapters + +The provided adapters follow some conventions beyond the `SSEGenerator` protocol. +You can take a look at how they are implemented and replicate the API. + +### The `->sse-response` function + +Provided adapters have a single `->sse-response` function for an API. + +This function takes 2 arguments: + +- the ring request +- a map whose keys are: + - `:on-open` a mandatory callback that must be called when the SSE connection is opened. + It has 1 argument, the SSE Generator. + - `:on-close` A callback called when the SSE connection is closed. + Each adapter may have a different parameters list for this callback, depending on what + is relevant. Still the first parameter should be the SSE generator. + - `:headers` a map of `str -> str`, HTTP headers to add to the response. + +It has 2 responsibilities: + +- This function creates the SSE generator, gives the callbacks to it. +- It must create a valid ring response with the correct HTTP SSE headers and + merge the headers provided with `:headers`. + See `starfederation.datastar.clojure.api.sse/headers`. + +### `SSEGenerator` additional logic + +The implementation must call the `on-open` callback when the underlying connection is opened. + +### The `close-sse!` function + +This function must call the `on-close` callback provided when using the `->sse-response` +function. Here the lock used when writing an event is reused when closing the underlying +connection. diff --git a/sdk/clojure/doc/maintainers-guide.md b/sdk/clojure/doc/maintainers-guide.md new file mode 100644 index 000000000..6ca7afdd3 --- /dev/null +++ b/sdk/clojure/doc/maintainers-guide.md @@ -0,0 +1,22 @@ +# Notes to self and potential maintainers + +## Directory structure + +- sdk: the source folder for the main sdk +- adapter-\*: source folders for adapter specific code +- malli-schemas: self explanatory... +- src/bb: tasks used run a repl, tests... +- src/dev: dev utils, examples +- src/test: centralized tests for all the libraries +- test-resources: self explanatory + +## bb tasks + +- `bb run tasks`: start a repl with all the sub projects in the classpath +- `bb run test:all`: run all test for sdk, adapters... + +## Test + +- Tests resources contains a test.config.edn file. It contains a map whose keys are: + - `:drivers`: [etaoin](https://github.com/clj-commons/etaoin) webdriver types to run + - `:webdriver-opts`: a map of webdriver type to webriver specific options diff --git a/sdk/clojure/malli-schemas/README.md b/sdk/clojure/malli-schemas/README.md new file mode 100644 index 000000000..24b4212d2 --- /dev/null +++ b/sdk/clojure/malli-schemas/README.md @@ -0,0 +1,14 @@ +# Malli schemas for the SDK + +## Installation + +For now the SDK and adapters are distributed as git dependencies using a `deps.edn` file. + +```clojure +{datastar/malli-schemas {:git/url "https://github.com/starfederation/datastar/tree/develop" + :git/sha "LATEST SHA" + :deps/root "sdk/clojure/malli-schemas"}} +``` + +> [!important] +> Replace `LATEST_SHA` in the git coordinates below by the actual latest commit sha of the repository. diff --git a/sdk/clojure/malli-schemas/deps.edn b/sdk/clojure/malli-schemas/deps.edn new file mode 100644 index 000000000..029431a8e --- /dev/null +++ b/sdk/clojure/malli-schemas/deps.edn @@ -0,0 +1,2 @@ +{:paths ["src/main"] + :deps {metosin/malli {:mvn/version "0.17.0"}}} diff --git a/sdk/clojure/malli-schemas/src/main/starfederation/datastar/clojure/api/common_schemas.clj b/sdk/clojure/malli-schemas/src/main/starfederation/datastar/clojure/api/common_schemas.clj new file mode 100644 index 000000000..b33b961f2 --- /dev/null +++ b/sdk/clojure/malli-schemas/src/main/starfederation/datastar/clojure/api/common_schemas.clj @@ -0,0 +1,111 @@ +(ns starfederation.datastar.clojure.api.common-schemas + (:require + [malli.core :as m] + [malli.util :as mu] + [starfederation.datastar.clojure.api.common :as common] + [starfederation.datastar.clojure.consts :as consts] + [starfederation.datastar.clojure.protocols :as p])) + +(def sse-gen-schema [:fn #(satisfies? p/SSEGenerator %)]) + +(def event-type-schema + [:enum + consts/event-type-merge-fragments + consts/event-type-remove-fragments + consts/event-type-merge-signals + consts/event-type-remove-signals + consts/event-type-execute-script]) + +(def data-lines-schema [:seqable :string]) + +(def sse-options-schema + (mu/optional-keys + [:map + [common/id :string] + [common/retry-duration number?]])) + + +(comment + (m/validate sse-options-schema {common/id "1"}) + (m/validate sse-options-schema {common/id 1})) + +;; ----------------------------------------------------------------------------- +(def fragment-schema :string) +(def fragments-schema [:seqable :string]) + + +(def merge-modes-schema + [:enum + consts/fragment-merge-mode-morph + consts/fragment-merge-mode-inner + consts/fragment-merge-mode-outer + consts/fragment-merge-mode-prepend + consts/fragment-merge-mode-append + consts/fragment-merge-mode-before + consts/fragment-merge-mode-after + consts/fragment-merge-mode-upsert-attributes]) + +(comment + (m/validate merge-modes-schema consts/fragment-merge-mode-after) + (m/validate merge-modes-schema "toto")) + + +(def merge-fragment-options-schemas + (mu/merge + sse-options-schema + (mu/optional-keys + [:map + [common/selector :string] + [common/merge-mode merge-modes-schema] + [common/settle-duration number?] + [common/use-view-transition :boolean]]))) + +;; ----------------------------------------------------------------------------- +(def selector-schema :string) + +(def remove-fragments-options-schemas + (mu/merge + sse-options-schema + (mu/optional-keys + [:map + [common/settle-duration number?] + [common/use-view-transition :boolean]]))) + + +;; ----------------------------------------------------------------------------- +(def signals-schema [:seqable :string]) + +(def merge-signals-options-schemas + (mu/merge + sse-options-schema + (mu/optional-keys + [:map + [common/only-if-missing :boolean]]))) + + +;; ----------------------------------------------------------------------------- +(def signal-paths-schema :string) + +(def remove-signals-options-schemas sse-options-schema) + +;; ----------------------------------------------------------------------------- +(def script-content-schema :string) + +(def execute-script-options-schemas + (mu/merge + sse-options-schema + (mu/optional-keys + [:map + [common/auto-remove :boolean] + [common/attributes [:map-of [:or :string :keyword] :any]]]))) + + +(comment + (m/validate execute-script-options-schemas {common/auto-remove true}) + (m/validate execute-script-options-schemas {common/auto-remove "1"}) + (m/validate execute-script-options-schemas {common/attributes {:t1 1}}) + (m/validate execute-script-options-schemas {common/attributes {"t1" 1}}) + (m/validate execute-script-options-schemas {common/attributes {1 1}}) + (m/validate execute-script-options-schemas {common/attributes :t1})) + + diff --git a/sdk/clojure/malli-schemas/src/main/starfederation/datastar/clojure/api/fragments_schemas.clj b/sdk/clojure/malli-schemas/src/main/starfederation/datastar/clojure/api/fragments_schemas.clj new file mode 100644 index 000000000..22b11edd5 --- /dev/null +++ b/sdk/clojure/malli-schemas/src/main/starfederation/datastar/clojure/api/fragments_schemas.clj @@ -0,0 +1,17 @@ +(ns starfederation.datastar.clojure.api.fragments-schemas + (:require + [malli.core :as m] + [starfederation.datastar.clojure.api.common-schemas :as cs] + [starfederation.datastar.clojure.api.fragments])) + + +(m/=> starfederation.datastar.clojure.api.fragments/->merge-fragment + [:-> cs/fragment-schema cs/merge-fragment-options-schemas cs/data-lines-schema]) + + +(m/=> starfederation.datastar.clojure.api.fragments/->merge-fragments + [:-> cs/fragments-schema cs/merge-fragment-options-schemas cs/data-lines-schema]) + + +(m/=> starfederation.datastar.clojure.api.fragments/remove-fragment! + [:-> cs/selector-schema cs/remove-fragments-options-schemas cs/data-lines-schema]) diff --git a/sdk/clojure/malli-schemas/src/main/starfederation/datastar/clojure/api/scripts_schemas.clj b/sdk/clojure/malli-schemas/src/main/starfederation/datastar/clojure/api/scripts_schemas.clj new file mode 100644 index 000000000..83563c60e --- /dev/null +++ b/sdk/clojure/malli-schemas/src/main/starfederation/datastar/clojure/api/scripts_schemas.clj @@ -0,0 +1,9 @@ +(ns starfederation.datastar.clojure.api.scripts-schemas + (:require + [malli.core :as m] + [starfederation.datastar.clojure.api.common-schemas :as cs] + [starfederation.datastar.clojure.api.scripts])) + +(m/=> starfederation.datastar.clojure.api.scripts/->script + [:-> cs/script-content-schema cs/data-lines-schema]) + diff --git a/sdk/clojure/malli-schemas/src/main/starfederation/datastar/clojure/api/signals_schemas.clj b/sdk/clojure/malli-schemas/src/main/starfederation/datastar/clojure/api/signals_schemas.clj new file mode 100644 index 000000000..40047105f --- /dev/null +++ b/sdk/clojure/malli-schemas/src/main/starfederation/datastar/clojure/api/signals_schemas.clj @@ -0,0 +1,13 @@ +(ns starfederation.datastar.clojure.api.signals-schemas + (:require + [malli.core :as m] + [starfederation.datastar.clojure.api.common-schemas :as cs] + [starfederation.datastar.clojure.api.signals])) + + +(m/=> starfederation.datastar.clojure.api.signals/->merge-signals + [:-> cs/signals-schema cs/merge-signals-options-schemas cs/data-lines-schema]) + + +(m/=> starfederation.datastar.clojure.api.signals/->remove-signals + [:-> cs/signal-paths-schema cs/data-lines-schema]) diff --git a/sdk/clojure/malli-schemas/src/main/starfederation/datastar/clojure/api/sse_schemas.clj b/sdk/clojure/malli-schemas/src/main/starfederation/datastar/clojure/api/sse_schemas.clj new file mode 100644 index 000000000..290137129 --- /dev/null +++ b/sdk/clojure/malli-schemas/src/main/starfederation/datastar/clojure/api/sse_schemas.clj @@ -0,0 +1,11 @@ +(ns starfederation.datastar.clojure.api.sse-schemas + (:require + [malli.core :as m] + [starfederation.datastar.clojure.api.common-schemas :as cs] + [starfederation.datastar.clojure.api.sse])) + + +(m/=> starfederation.datastar.clojure.api.sse/send-event! + [:function + [:-> cs/sse-gen-schema cs/event-type-schema cs/data-lines-schema :any] + [:-> cs/sse-gen-schema cs/event-type-schema cs/data-lines-schema cs/sse-options-schema :any]]) diff --git a/sdk/clojure/malli-schemas/src/main/starfederation/datastar/clojure/api_schemas.clj b/sdk/clojure/malli-schemas/src/main/starfederation/datastar/clojure/api_schemas.clj new file mode 100644 index 000000000..73e97049e --- /dev/null +++ b/sdk/clojure/malli-schemas/src/main/starfederation/datastar/clojure/api_schemas.clj @@ -0,0 +1,92 @@ +(ns starfederation.datastar.clojure.api-schemas + (:require + [malli.core :as m] + [starfederation.datastar.clojure.api] + [starfederation.datastar.clojure.api.common-schemas :as cs])) + + +(m/=> starfederation.datastar.clojure.api/close-sse! + [:-> cs/sse-gen-schema :any]) + + +(m/=> starfederation.datastar.clojure.api/merge-fragment! + [:function + [:-> cs/sse-gen-schema cs/fragment-schema :any] + [:-> cs/sse-gen-schema cs/fragment-schema cs/merge-fragment-options-schemas :any]]) + + +(m/=> starfederation.datastar.clojure.api/merge-fragments! + [:function + [:-> cs/sse-gen-schema cs/fragments-schema :any] + [:-> cs/sse-gen-schema cs/fragments-schema cs/merge-fragment-options-schemas :any]]) + + +(m/=> starfederation.datastar.clojure.api/remove-fragment! + [:function + [:-> cs/sse-gen-schema cs/selector-schema :any] + [:-> cs/sse-gen-schema cs/selector-schema cs/remove-fragments-options-schemas :any]]) + + +(m/=> starfederation.datastar.clojure.api/merge-signals! + [:function + [:-> cs/sse-gen-schema cs/signals-schema :any] + [:-> cs/sse-gen-schema cs/signals-schema cs/merge-signals-options-schemas :any]]) + + +(m/=> starfederation.datastar.clojure.api/remove-signals! + [:function + [:-> cs/sse-gen-schema cs/signal-paths-schema :any] + [:-> cs/sse-gen-schema cs/signal-paths-schema cs/remove-signals-options-schemas :any]]) + + +(m/=> starfederation.datastar.clojure.api/execute-script! + [:function + [:-> cs/sse-gen-schema cs/script-content-schema :any] + [:-> cs/sse-gen-schema cs/script-content-schema cs/execute-script-options-schemas :any]]) + + +(m/=> starfederation.datastar.clojure.api/sse-get + [:function + [:-> :string :string] + [:-> :string :string :string]]) + +(m/=> starfederation.datastar.clojure.api/sse-post + [:function + [:-> :string :string] + [:-> :string :string :string]]) + + +(m/=> starfederation.datastar.clojure.api/sse-put + [:function + [:-> :string :string] + [:-> :string :string :string]]) + +(m/=> starfederation.datastar.clojure.api/sse-patch + [:function + [:-> :string :string] + [:-> :string :string :string]]) + +(m/=> starfederation.datastar.clojure.api/sse-delete + [:function + [:-> :string :string] + [:-> :string :string :string]]) + + +(m/=> starfederation.datastar.clojure.api/console-log! + [:function + [:-> cs/sse-gen-schema :string :any] + [:-> cs/sse-gen-schema :string cs/execute-script-options-schemas :any]]) + + +(m/=> starfederation.datastar.clojure.api/console-error! + [:function + [:-> cs/sse-gen-schema :string :any] + [:-> cs/sse-gen-schema :string cs/execute-script-options-schemas :any]]) + + +(m/=> starfederation.datastar.clojure.api/redirect! + [:function + [:-> cs/sse-gen-schema :string :any] + [:-> cs/sse-gen-schema :string cs/execute-script-options-schemas :any]]) + + diff --git a/sdk/clojure/sdk-tests/README.md b/sdk/clojure/sdk-tests/README.md new file mode 100644 index 000000000..a77e473cb --- /dev/null +++ b/sdk/clojure/sdk-tests/README.md @@ -0,0 +1,17 @@ +# SDK tests + +This is where the code for the [generic tests](/sdk/test) lives. + +## Running the test app + +- repl: + +``` +clojure -M:repl -m nrepl.cmdline --middleware "[cider.nrepl/cider-middleware]" +``` + +- main: + +``` +clojure -M -m starfederation.datastar.clojure.sdk-test.main +``` diff --git a/sdk/clojure/sdk-tests/deps.edn b/sdk/clojure/sdk-tests/deps.edn new file mode 100644 index 000000000..f5d2545d8 --- /dev/null +++ b/sdk/clojure/sdk-tests/deps.edn @@ -0,0 +1,16 @@ +{:paths ["src/main"] + + :deps {datastar/sdk {:local/root "../sdk"} + datastar/ring-jetty {:local/root "../adapter-jetty"} + metosin/reitit {:mvn/version "0.7.2"} + com.cnuernber/charred {:mvn/version "1.034"} + dev.onionpancakes/chassis {:mvn/version "1.0.365"}} + + + :aliases + {:repl {:extra-paths ["src/dev"] + :extra-deps {org.clojure/clojure {:mvn/version "1.12.0"} + nrepl/nrepl {:mvn/version "1.3.0"} + cider/cider-nrepl {:mvn/version "0.50.2"} + io.github.tonsky/clj-reload {:mvn/version "0.7.1"}}}}} + diff --git a/sdk/clojure/sdk-tests/src/dev/user.clj b/sdk/clojure/sdk-tests/src/dev/user.clj new file mode 100644 index 000000000..6ddb9a8ae --- /dev/null +++ b/sdk/clojure/sdk-tests/src/dev/user.clj @@ -0,0 +1,26 @@ +(ns user + (:require + [clj-reload.core :as reload])) + + +(alter-var-root #'*warn-on-reflection* (constantly true)) + +;(rcf/enable!) + + +(reload/init + {:no-reload ['user]}) + + +(defn reload! [] + (reload/reload)) + + + + +(defn clear-terminal! [] + (binding [*out* (java.io.PrintWriter. System/out)] + (print "\033c") + (flush))) + + diff --git a/sdk/clojure/sdk-tests/src/main/starfederation/datastar/clojure/sdk_test/core.clj b/sdk/clojure/sdk-tests/src/main/starfederation/datastar/clojure/sdk_test/core.clj new file mode 100644 index 000000000..672f9acb8 --- /dev/null +++ b/sdk/clojure/sdk-tests/src/main/starfederation/datastar/clojure/sdk_test/core.clj @@ -0,0 +1,144 @@ +(ns starfederation.datastar.clojure.sdk-test.core + (:require + [charred.api :as charred] + [clojure.set :as set] + [reitit.ring.middleware.parameters :as rrm-params] + [reitit.ring.middleware.multipart :as rrm-multi-params] + [reitit.ring :as rr] + [starfederation.datastar.clojure.adapter.ring-jetty :refer [->sse-response]] + [starfederation.datastar.clojure.api :as d*])) + +;; ----------------------------------------------------------------------------- +;; JSON / Datastar signals utils +;; ----------------------------------------------------------------------------- +(def ^:private bufSize 1024) +(def read-json (charred/parse-json-fn {:async? false :bufsize bufSize})) + +(defn get-signals [req] + (-> req d*/get-signals read-json)) + + +;; ----------------------------------------------------------------------------- +;; Parsing / reformatting received events +;; ----------------------------------------------------------------------------- +(def events-json-key "events") + +(defn get-events [signals] + (get signals events-json-key)) + + +(def event-type-json-key "type") + + +(defn get-event-type [event] + (get event event-type-json-key)) + + + +(def str->datastar-opt + {"eventId" d*/id + "retryDuration" d*/retry-duration + + "selector" d*/selector + "mergeMode" d*/merge-mode + "useViewTransition" d*/use-view-transition + "settleDuration" d*/settle-duration + + "onlyIfMissing" d*/only-if-missing + "autoRemove" d*/auto-remove + + "attributes" d*/attributes}) + +(def options (set (keys str->datastar-opt))) + +;; ----------------------------------------------------------------------------- +;; Testing code: We want to send back event received as datastar signal values +;; ----------------------------------------------------------------------------- +(defn merge-fragments! [sse event] + (let [frags (get event "fragments") + opts (-> event + (select-keys options) + (set/rename-keys str->datastar-opt))] + (d*/merge-fragment! sse frags opts))) + + +(defn remove-fragments! [sse event] + (let [selector (get event "selector") + opts (-> event + (select-keys options) + (set/rename-keys str->datastar-opt))] + (d*/remove-fragment! sse selector opts))) + + +(defn merge-signals! [sse event] + (let [signals (-> (get event "signals") + (->> (into (sorted-map))) ;; for the purpose of the test, keys need to be ordered + (charred/write-json-str)) + opts (-> event + (select-keys options) + (set/rename-keys str->datastar-opt))] + (d*/merge-signals! sse signals opts))) + + +(defn remove-signals! [sse event] + (let [paths (get event "paths") + opts (-> event + (select-keys options) + (set/rename-keys str->datastar-opt))] + (d*/remove-signals! sse paths opts))) + + +(defn execute-script! [sse event] + (let [script (get event "script") + opts (-> event + (select-keys options) + (set/rename-keys str->datastar-opt) + (update d*/attributes update-keys keyword))] + (d*/execute-script! sse script opts))) + + + +(def dispatch + {"mergeFragments" merge-fragments! + "removeFragments" remove-fragments! + "mergeSignals" merge-signals! + "removeSignals" remove-signals! + "executeScript" execute-script!}) + + +(defn send-event-back! [sse event] + (let [type (get-event-type event)] + ((dispatch type) sse event))) + + +(defn send-events-back! [sse req] + (let [signals (get-signals req)] + (doseq [event (get-events signals)] + (send-event-back! sse event)))) + + +;; ----------------------------------------------------------------------------- +;; Setting up the endpoint for the shell tests +;; ----------------------------------------------------------------------------- +(defn test-handler [req] + (->sse-response req + {:on-open + (fn [sse] + (d*/with-open-sse sse + (send-events-back! sse req)))})) + + +(def routes + [["/test" {:handler test-handler + :parameters {:multipart true} + :middleware [rrm-multi-params/multipart-middleware]}]]) + + +(def router (rr/router routes)) + + +(def handler + (rr/ring-handler router + (rr/create-default-handler) + {:middleware [rrm-params/parameters-middleware]})) + diff --git a/sdk/clojure/sdk-tests/src/main/starfederation/datastar/clojure/sdk_test/main.clj b/sdk/clojure/sdk-tests/src/main/starfederation/datastar/clojure/sdk_test/main.clj new file mode 100644 index 000000000..82f4c1ceb --- /dev/null +++ b/sdk/clojure/sdk-tests/src/main/starfederation/datastar/clojure/sdk_test/main.clj @@ -0,0 +1,38 @@ +(ns starfederation.datastar.clojure.sdk-test.main + (:require + [ring.adapter.jetty :as jetty] + [starfederation.datastar.clojure.sdk-test.core :as c]) + (:import + org.eclipse.jetty.server.Server)) + + + +(defonce !jetty-server (atom nil)) + + +(def default-server-opts {:port 8080 + :join? false}) + +(defn start! [handler & {:as opts}] + (let [opts (merge default-server-opts opts)] + (println "Starting server port" (:port opts)) + (jetty/run-jetty handler opts))) + + +(defn stop! [server] + (.stop ^Server server)) + + +(defn reboot-jetty-server! [handler & {:as opts}] + (swap! !jetty-server + (fn [server] + (when server + (stop! server)) + (start! handler opts)))) + + +(comment + (reboot-jetty-server! #'c/handler)) + +(defn -main [& _] + (start! c/handler {:join? true})) diff --git a/sdk/clojure/sdk/README.md b/sdk/clojure/sdk/README.md new file mode 100644 index 000000000..1d922dd02 --- /dev/null +++ b/sdk/clojure/sdk/README.md @@ -0,0 +1,18 @@ +# Generic Clojure SDK for Datastar + +This is where the code for the Generic SDK lives. + +## Instalation + +For now the SDK and adapters are distributed as git dependencies using a `deps.edn` file. +If you roll your own adapter you only need: + +```clojure +{datastar/sdk {:git/url "https://github.com/starfederation/datastar/tree/develop" + :git/sha "LATEST SHA" + :deps/root "sdk/clojure/sdk"}} +``` + +> [!important] +> This project is new and there isn't a release process yet other than using git shas. +> Replace `LATEST_SHA` in the git coordinates below by the actual latest commit sha of the repository. diff --git a/sdk/clojure/sdk/deps.edn b/sdk/clojure/sdk/deps.edn new file mode 100644 index 000000000..8c75a4415 --- /dev/null +++ b/sdk/clojure/sdk/deps.edn @@ -0,0 +1,2 @@ +{:paths ["src/main"]} + diff --git a/sdk/clojure/sdk/src/main/starfederation/datastar/clojure/adapter/test.clj b/sdk/clojure/sdk/src/main/starfederation/datastar/clojure/adapter/test.clj new file mode 100644 index 000000000..4b93d72eb --- /dev/null +++ b/sdk/clojure/sdk/src/main/starfederation/datastar/clojure/adapter/test.clj @@ -0,0 +1,20 @@ +(ns starfederation.datastar.clojure.adapter.test + (:require + [starfederation.datastar.clojure.api.sse :as sse] + [starfederation.datastar.clojure.protocols :as p])) + + +(deftype ReturnMsgGen [] + p/SSEGenerator + (send-event! [_ event-type data-lines opts] + (-> (StringBuilder.) + (sse/write-event! event-type data-lines opts) + str)) + + (close-sse! [_])) + + + +(defn ->sse-gen [& _] + (->ReturnMsgGen)) + diff --git a/sdk/clojure/sdk/src/main/starfederation/datastar/clojure/api.clj b/sdk/clojure/sdk/src/main/starfederation/datastar/clojure/api.clj new file mode 100644 index 000000000..0e418c23d --- /dev/null +++ b/sdk/clojure/sdk/src/main/starfederation/datastar/clojure/api.clj @@ -0,0 +1,418 @@ +(ns + ^{:doc "Public api for the Datastar SDK. + + The api consists of 5 main functions that operate on SSE generators, see: + - [[merge-fragment!]] + - [[remove-fragment!]] + - [[merge-signals!]] + - [[remove-signals!]] + - [[execute-script!]] + + These function take options map whose keys are: + - [[id]] + - [[retry-duration]] + - [[selector]] + - [[merge-mode]] + - [[settle-duration]] + - [[use-view-transition]] + - [[only-if-missing]] + - [[auto-remove]] + - [[attributes]] + + To help manage SSE generators's underlying connection there is: + - [[close-sse!]] + - [[with-open-sse]] + + Some common utilities for HTTP are also provided: + - [[sse-get]] + - [[sse-post]] + - [[sse-put]] + - [[sse-patch]] + - [[sse-delete]] + + Some scripts are provided: + - [[console-log!]] + - [[console-error!]] + - [[redirect!]] + "} + starfederation.datastar.clojure.api + (:require + [starfederation.datastar.clojure.api.common :as common] + [starfederation.datastar.clojure.api.fragments :as fragments] + [starfederation.datastar.clojure.api.signals :as signals] + [starfederation.datastar.clojure.api.scripts :as scripts] + [starfederation.datastar.clojure.consts :as consts] + [starfederation.datastar.clojure.protocols :as p])) + + +;; ----------------------------------------------------------------------------- +;; SSE generator management +;; ----------------------------------------------------------------------------- +(defn close-sse! + "Close the connection of a sse generator." + [sse-gen] + (p/close-sse! sse-gen)) + + +(defmacro with-open-sse + "Macro functioning similarly to [[clojure.core/with-open]]. It evalutes the + `body` inside a try expression and closes the `sse-gen` at the end using + [[close-see!]] in a finally clause. + + Ex: + ``` + (with-open-sse sse-gen + (d*/merge-fragment! sse-gen frag1) + (d*/merge-signals! sse-gen signals)) + ``` + " + [sse-gen & body] + `(try + ~@body + (finally + (close-sse! ~sse-gen)))) + +(comment + (macroexpand-1 + '(with-open-sse toto + (do-stuff) + (do-stuff)))) + + +;; ----------------------------------------------------------------------------- +;; Option names +;; ----------------------------------------------------------------------------- +(def id + "SSE option use in all event functions, string: + + Each event may include an eventId. This can be used by + the backend to replay events. This is part of the SSE spec and is used to + tell the browser how to handle the event. For more details see + https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events#id" + common/id) + +(def retry-duration + "SSE option used in all event functions, number: + + Each event may include a retryDuration value. If one is + not provided the SDK must default to 1000 milliseconds. This is part of the + SSE spec and is used to tell the browser how long to wait before reconnecting + if the connection is lost. For more details see + https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events#retry" + common/retry-duration) + +;; Merge fragment opts +(def selector + "[[merge-fragment!]] option, string: + + The CSS selector to use to insert the fragments. If not + provided or empty, Datastar will default to using the id attribute of the + fragment." + common/selector) + +(def merge-mode + "[[merge-fragment!]] option, string: + + The mode to use when merging fragments into the DOM. + If not provided the Datastar client side will default to morph. + + The set of valid values is: + - [[mm-morph]] + - [[mm-inner]] + - [[mm-outer]] + - [[mm-prepend]] + - [[mm-append]] + - [[mm-before]] + - [[mm-after]] + - [[mm-upsert-attributes]] + " + common/merge-mode) + +(def settle-duration + "[[merge-fragment!]] / [[remove-fragment!]] option, number: + + Used to control the amount of time that a fragment + should take before removing any CSS related to settling. It is used to allow + for animations in the browser via the Datastar client. If provided the value must + be a positive integer of the number of milliseconds to allow for settling. If none + is provided, the default value of 300 milliseconds will be used." + common/settle-duration) + +(def use-view-transition + "[[merge-fragment!]] / [[remove-fragment!] option, boolean: + + Whether to use view transitions, if not provided the + Datastar client side will default to false." + common/use-view-transition) + +;;Signals opts +(def only-if-missing + "[[merge-signals!]] option, boolean: + + Whether to merge the signal only if it does not already + exist. If not provided, the Datastar client side will default to false, which + will cause the data to be merged into the signals." + common/only-if-missing) + +;; Script opts +(def auto-remove + "[[execute-script!]] option, boolean: + + Whether to remove the script after execution, if not + provided the Datastar client side will default to true." + common/auto-remove) + +(def attributes + "[[execute-script!]] option, map: + + A map of attributes to add to the script element, + if not provided the Datastar client side will default to + `{:type \"module\"}`." + common/attributes) + + +;; ----------------------------------------------------------------------------- +;; Data-star base api +;; ----------------------------------------------------------------------------- +(def mm-morph + "Merge mode: morphs the fragment into the existing element using idiomorph." + consts/fragment-merge-mode-morph) + +(def mm-inner + "Merge mode: replaces the inner HTML of the existing element." + consts/fragment-merge-mode-inner) + +(def mm-outer + "Merge mode: replaces the outer HTML of the existing element." + consts/fragment-merge-mode-outer) + +(def mm-prepend + "Merge mode: prepends the fragment to the existing element." + consts/fragment-merge-mode-prepend) + +(def mm-append + "Merge mode: appends the fragment to the existing element." + consts/fragment-merge-mode-append) + +(def mm-before + "Merge mode: inserts the fragment before the existing element." + consts/fragment-merge-mode-before) + +(def mm-after + "Merge mode: inserts the fragment after the existing element." + consts/fragment-merge-mode-after) + +(def mm-upsert-attributes + "Merge mode: upserts the attributes of the existing element." + consts/fragment-merge-mode-upsert-attributes) + + +(defn merge-fragment! + "Send HTML fragments to the browser to be merged into the DOM. + + Args: + - `sse-gen`: the sse generator to send from + - `fragments`: A string of HTML fragments. + - `opts`: An options map + + Options keys: + - [[id]] + - [[retry-duration]] + - [[selector]] + - [[merge-mode]] + - [[settle-duration]] + - [[use-view-transition]] + " + ([sse-gen fragments] + (merge-fragment! sse-gen fragments {})) + ([sse-gen fragments opts] + (fragments/merge-fragment! sse-gen fragments opts))) + + +(defn merge-fragments! + "Same as [[merge-fragment!]] except that it takes a seq of fragments. + " + ([sse-gen fragments] + (merge-fragments! sse-gen fragments {})) + ([sse-gen fragments opts] + (fragments/merge-fragments! sse-gen fragments opts))) + + + +(defn remove-fragment! + "Send a selector to the browser to remove HTML fragments from the DOM. + + Args: + - `sse-gen`: the sse generator to send from + - `selector`: string, CSS selector that represents the fragments to be + removed from the DOM. + - `opts`: options map + + Options keys: + - [[id]] + - [[retry-duration]] + - [[settle-duration]] + - [[use-view-transition]] + " + ([sse-gen selector] + (remove-fragment! sse-gen selector {})) + ([sse-gen selector opts] + (fragments/remove-fragment! sse-gen selector opts))) + + +(defn merge-signals! + "Send one or more signals to the browser to be merged into the signals. + + Args: + - `sse-gen`: the sse generator to send from + - `signals-content`: a JavaScript object or JSON string that will be sent to + the browser to update signals in the signals. The data must evaluate to a + valid JavaScript. It will be converted to signals by the Datastar client + side. + - `opts`: An options map + + Options keys: + - [[id]] + - [[retry-duration]] + - [[only-if-missing]] + " + ([sse-gen signals-content] + (merge-signals! sse-gen signals-content {})) + ([sse-gen signals-content opts] + (signals/merge-signals! sse-gen signals-content opts))) + + +(defn remove-signals! + "Send signals to the browser to be removed from the signals. + + Args: + - `sse-gen`: the sse generator to send from + - `paths`: seq of strings that represent the signal paths to be removed from + the signals. The paths must be valid `.` delimited paths to signals within the + signals. The Datastar client side will use these paths to remove the data + from the signals. + + Options keys: + - [[id]] + - [[retry-duration]] + - [[only-if-missing]] + " + ([sse-gen paths] + (signals/remove-signals! sse-gen paths {})) + ([sse-gen paths opts] + (signals/remove-signals! sse-gen paths opts))) + + +(defn get-signals + "Returns the signals json string from a ring request map. + (Bring your own json parsing)" + [ring-request] + (signals/get-signals ring-request)) + + +(defn execute-script! + " + Send an execute script event to the client. + + Args: + - `sse-gen`: the sse generator to send from + - `script-content`: string that represents the JavaScript to be executed + by the browser. + - `opts`: An options map + + Options keys: + - [[id]] + - [[retry-duration]] + - [[auto-remove]] + - [[attributes]] + " + ([sse-gen script-content] + (scripts/execute-script! sse-gen script-content {})) + ([sse-gen script-content opts] + (scripts/execute-script! sse-gen script-content opts))) + + +;; ----------------------------------------------------------------------------- +;; SSE helpers +;; ----------------------------------------------------------------------------- +(defn- sse + ([method url] + (str "@" method "('" url "')")) + ([method url opts-string] + (str "@" method "('" url "', " opts-string ")"))) + + +(defn sse-get + "Helper making a @get(...) action." + ([url] + (sse "get" url)) + ([url opts-string] + (sse "get" url opts-string))) + + +(defn sse-post + "Helper making a @post(...) action." + ([url] + (sse "post" url)) + ([url opts-string] + (sse "post" url opts-string))) + + +(defn sse-put + "Helper making a @put(...) action." + ([url] + (sse "put" url)) + ([url opts-string] + (sse "put" url opts-string))) + + +(defn sse-patch + "Helper making a @patch(...) action." + ([url] + (sse "patch" url)) + ([url opts-string] + (sse "patch" url opts-string))) + + +(defn sse-delete + "Helper making a @delete(...) action." + ([url] + (sse "delete" url)) + ([url opts-string] + (sse "delete" url opts-string))) + + +(comment + (sse-get "/a/b") + := "@get('/a/b')" + + (sse-put "/a/b" "{includeLocal: true}") + := "@put('/a/b', {includeLocal: true})") + + +;; ----------------------------------------------------------------------------- +;; Scripts common +;; ----------------------------------------------------------------------------- +(defn console-log! + "Log msg in the browser console." + ([sse-gen msg] + (console-log! sse-gen msg {})) + ([sse-gen msg opts] + (execute-script! sse-gen (str "console.log(\"" msg "\")") opts))) + + +(defn console-error! + "Log error msg in the browser console." + ([sse-gen msg] + (console-error! sse-gen msg {})) + ([sse-gen msg opts] + (execute-script! sse-gen (str "console.error(\"" msg "\")") opts))) + + +(defn redirect! + "Redirect a page using a script." + ([sse-gen url] + (redirect! sse-gen url {})) + ([sse-gen url opts] + (execute-script! sse-gen (str "window.location.href = \""url"\";") opts))) + diff --git a/sdk/clojure/sdk/src/main/starfederation/datastar/clojure/api/common.clj b/sdk/clojure/sdk/src/main/starfederation/datastar/clojure/api/common.clj new file mode 100644 index 000000000..6f50d9f12 --- /dev/null +++ b/sdk/clojure/sdk/src/main/starfederation/datastar/clojure/api/common.clj @@ -0,0 +1,67 @@ +(ns starfederation.datastar.clojure.api.common) + +;; ----------------------------------------------------------------------------- +;; Option names +;; ----------------------------------------------------------------------------- + +;; SSE Options +(def id :d*.sse/id) +(def retry-duration :d*.sse/retry-duration) + +;; Merge fragment opts +(def selector :d*.fragments/selector) +(def merge-mode :d*.fragments/merge-mode) +(def settle-duration :d*.fragments/settle-duration) +(def use-view-transition :d*.fragments/use-view-transition) + +;;Signals opts +(def only-if-missing :d*.signals/only-if-missing) + +;; Script opts +(def auto-remove :d*.scripts/auto-remove) +(def attributes :d*.scripts/attributes) + + + +;; ----------------------------------------------------------------------------- +;; Data lines construction helpers +;; ----------------------------------------------------------------------------- +(defn add-opt-line! + "Add an option `v` line to the transient `data-lines!` vector. + + Args: + - `data-lines`: a transient vector of data-lines that will be written in a sse + event + - `prefix`: The Datastar specific preffix for that line + - `test`: function applied to `v` if the result is true the data-line is + added, it is elided otherwise. If not test is provided, the data-line will + be added. + - `v`: the value for that line + " + ([data-lines! prefix v] + (conj! data-lines! (str prefix v))) + ([data-lines! test prefix v] + (cond-> data-lines! + (test v) (conj! (str prefix v))))) + + +(defn add-data-lines! + "Add several data-lines to the `data-lines!` transient vector." + [data-lines! prefix lines] + (reduce + (fn [acc part] + (conj! acc (str prefix part))) + data-lines! + lines)) + + +(defn add-boolean-option? + "Utility used to test whether an boolean option should result in a sse event + data-line. Returns true if `val` a boolean and isn't the `default-val`." + [default-val val] + (and + (boolean? val) + (not= val default-val))) + + + diff --git a/sdk/clojure/sdk/src/main/starfederation/datastar/clojure/api/fragments.clj b/sdk/clojure/sdk/src/main/starfederation/datastar/clojure/api/fragments.clj new file mode 100644 index 000000000..bd0aeb3de --- /dev/null +++ b/sdk/clojure/sdk/src/main/starfederation/datastar/clojure/api/fragments.clj @@ -0,0 +1,202 @@ +(ns starfederation.datastar.clojure.api.fragments + (:require + [clojure.string :as string] + [starfederation.datastar.clojure.api.common :as common] + [starfederation.datastar.clojure.api.sse :as sse] + [starfederation.datastar.clojure.consts :as consts] + [starfederation.datastar.clojure.utils :as u])) + + + +;; ----------------------------------------------------------------------------- +;; Selector helpers +(def ^:private valid-selector? u/not-empty-string?) + +(defn- add-selector! [data-lines! selector] + (common/add-opt-line! data-lines! + consts/selector-dataline-literal + selector)) + +(defn- add-selector?! [data-lines! selector] + (common/add-opt-line! + data-lines! + valid-selector? + consts/selector-dataline-literal + selector)) + + +;; ----------------------------------------------------------------------------- +;; Fragment merge mode helpers +(defn- add-fmm? [fmm] + (and fmm (not= fmm consts/fragment-merge-mode-morph))) + + +(defn- add-fragment-merge-mode?! [data-lines! fmm] + (common/add-opt-line! + data-lines! + add-fmm? + consts/merge-mode-dataline-literal + fmm)) + + +;; ----------------------------------------------------------------------------- +;; Settle duration helpers +(defn- add-settle-duration? [d] + (and d (> d consts/default-fragments-settle-duration))) + + +(defn- add-settle-duration?! [data-lines! d] + (common/add-opt-line! + data-lines! + add-settle-duration? + consts/settle-duration-dataline-literal + d)) + +;; ----------------------------------------------------------------------------- +;; View transition helpers +(defn add-view-transition? [val] + (common/add-boolean-option? consts/default-fragments-use-view-transitions + val)) + +(defn- add-view-transition?! [data-lines! uvt] + (common/add-opt-line! + data-lines! + add-view-transition? + consts/use-view-transition-dataline-literal + uvt)) + + +;; ----------------------------------------------------------------------------- +;; Fragment -> data lines +(defn- add-merge-fragment! [data-lines! fragment] + (cond-> data-lines! + (u/not-empty-string? fragment) + (common/add-data-lines! consts/fragments-dataline-literal + (string/split-lines fragment)))) + +;; ----------------------------------------------------------------------------- +;; Merge fragment +;; ----------------------------------------------------------------------------- +(defn ->merge-fragment [fragment opts] + (u/transient-> [] + (add-selector?! (common/selector opts)) + (add-fragment-merge-mode?! (common/merge-mode opts)) + (add-settle-duration?! (common/settle-duration opts)) + (add-view-transition?! (common/use-view-transition opts)) + (add-merge-fragment! fragment))) + + +(comment + (->merge-fragment "
hello
" {}) + := ["fragments
hello
"] + + (->merge-fragment "
hello
\n
world!!!
" + {common/selector "#toto" + common/merge-mode consts/fragment-merge-mode-after + common/settle-duration 500 + common/use-view-transition :toto}) + := ["selector #toto" + "mergeMode after" + "settleDuration 500" + "useViewTransition true" + "fragments
hello
" + "fragments
world!!!
"]) + + +(defn merge-fragment! [sse-gen fragment opts] + (try + (sse/send-event! sse-gen + consts/event-type-merge-fragments + (->merge-fragment fragment opts) + opts) + (catch Exception e + (throw (ex-info "Failed to send fragment." + {:fragment fragment} + e))))) + +;; ----------------------------------------------------------------------------- +;; Merge fragments +;; ----------------------------------------------------------------------------- +(defn- add-merge-fragments! [data-lines! fragments] + (cond-> data-lines! + (seq fragments) + (common/add-data-lines! consts/fragments-dataline-literal + (eduction + (comp (mapcat string/split-lines) + (remove string/blank?)) + fragments)))) + + +(defn ->merge-fragments [fragments opts] + (u/transient-> [] + (add-selector?! (common/selector opts)) + (add-fragment-merge-mode?! (common/merge-mode opts)) + (add-settle-duration?! (common/settle-duration opts)) + (add-view-transition?! (common/use-view-transition opts)) + (add-merge-fragments! fragments))) + + +(defn merge-fragments! [sse-gen fragments opts] + (try + (sse/send-event! sse-gen + consts/event-type-merge-fragments + (->merge-fragments fragments opts) + opts) + (catch Exception e + (throw (ex-info "Failed to send fragment." + {:fragments fragments} + e))))) + + +(comment + (->merge-fragments ["
hello
" " " "
\nworld\n
"] {}) + := ["fragments
hello
" + "fragments
" + "fragments world" + "fragments
"] + + (->merge-fragments ["
hello
\n
world!!!
" "
world!!!
"] + {common/selector "#toto" + common/merge-mode consts/fragment-merge-mode-after + common/settle-duration 500 + common/use-view-transition true}) + := ["selector #toto" + "mergeMode after" + "settleDuration 500" + "useViewTransition true" + "fragments
hello
" + "fragments
world!!!
" + "fragments
world!!!
"]) + + +;; ----------------------------------------------------------------------------- +;; Remove fragment +;; ----------------------------------------------------------------------------- +(defn ->remove-fragment [selector opts] + (u/transient-> [] + (add-settle-duration?! (common/settle-duration opts)) + (add-view-transition?! (common/use-view-transition opts)) + (add-selector! selector))) + + +(comment + (->remove-fragment "#titi" + {common/settle-duration 500 + common/use-view-transition true}) + := ["selector #titi" "settleDuration 500" "useViewTransition true"]) + + +(defn remove-fragment! [sse-gen selector opts] + (when-not (valid-selector? selector) + (throw (ex-info "Invalid selector" {:selector selector}))) + + (try + (sse/send-event! sse-gen + consts/event-type-remove-fragments + (->remove-fragment selector opts) + opts) + (catch Exception e + (throw (ex-info "Failed to send remove." + {:selector selector} + e))))) + diff --git a/sdk/clojure/sdk/src/main/starfederation/datastar/clojure/api/scripts.clj b/sdk/clojure/sdk/src/main/starfederation/datastar/clojure/api/scripts.clj new file mode 100644 index 000000000..b3bf649a1 --- /dev/null +++ b/sdk/clojure/sdk/src/main/starfederation/datastar/clojure/api/scripts.clj @@ -0,0 +1,92 @@ +(ns starfederation.datastar.clojure.api.scripts + (:require + [clojure.string :as string] + [starfederation.datastar.clojure.api.common :as common] + [starfederation.datastar.clojure.api.sse :as sse] + [starfederation.datastar.clojure.consts :as consts] + [starfederation.datastar.clojure.utils :as u])) + + +(defn add-auto-remove? [val] + (common/add-boolean-option? consts/default-execute-script-auto-remove + val)) + +(defn- add-auto-remove?! [data-lines! ar] + (common/add-opt-line! + data-lines! + add-auto-remove? + consts/auto-remove-dataline-literal + ar)) + + +(defn- attributes->lines [m] + (persistent! + (reduce-kv + (fn [acc k v] + (conj! acc (str (name k) \space v))) + (transient []) + m))) + + +(defn- add-attributes? [attributes] + (and attributes + (not= attributes consts/default-execute-script-attributes))) + +(defn- add-attributes! [data-lines! attributes] + (cond-> data-lines! + (add-attributes? attributes) + (common/add-data-lines! consts/attributes-dataline-literal + (attributes->lines attributes)))) + + + +(defn- add-script! [data-lines! script-content] + (cond-> data-lines! + (u/not-empty-string? script-content) + (common/add-data-lines! consts/script-dataline-literal + (string/split-lines script-content)))) + + +(defn ->script [script-content opts] + (u/transient-> [] + (add-attributes! (common/attributes opts)) + (add-auto-remove?! (common/auto-remove opts)) + (add-script! script-content))) + + +(comment + (->script "console.log('hello')" {}) + := ["script console.log('hello')"] + + (->script "console.log('hello')" + {common/auto-remove false}) + := ["autoRemove false" "script console.log('hello')"] + + + + (->script "console.log('hello')" + {common/auto-remove false + common/attributes {:type "module"}}) + := ["autoRemove false" "script console.log('hello')"] + + + (->script "console.log('hello')\nconsole.log('world!!!')" + {common/attributes {:type "module" :data-something 1}}) + := ["attributes type module" + "attributes data-something 1" + "script console.log('hello')" + "script console.log('world!!!')"]) + + +(defn execute-script! [sse-gen script-content opts] + (try + (sse/send-event! sse-gen + consts/event-type-execute-script + (->script script-content opts) + opts) + (catch Exception e + (throw (ex-info "Failed to send script" + {:script script-content} + e))))) + + diff --git a/sdk/clojure/sdk/src/main/starfederation/datastar/clojure/api/signals.clj b/sdk/clojure/sdk/src/main/starfederation/datastar/clojure/api/signals.clj new file mode 100644 index 000000000..656cd2377 --- /dev/null +++ b/sdk/clojure/sdk/src/main/starfederation/datastar/clojure/api/signals.clj @@ -0,0 +1,119 @@ +(ns starfederation.datastar.clojure.api.signals + (:require + [clojure.string :as string] + [starfederation.datastar.clojure.api.common :as common] + [starfederation.datastar.clojure.api.sse :as sse] + [starfederation.datastar.clojure.consts :as consts] + [starfederation.datastar.clojure.utils :as u])) + + +;; ----------------------------------------------------------------------------- +;; Merge signal +;; ----------------------------------------------------------------------------- +(defn add-only-if-missing? [val] + (common/add-boolean-option? consts/default-merge-signals-only-if-missing + val)) + +(defn- add-only-if-missing?! [data-lines! only-if-missing] + (common/add-opt-line! + data-lines! + add-only-if-missing? + consts/only-if-missing-dataline-literal + only-if-missing)) + + +(defn- add-merge-signals! [data-lines! signals] + (cond-> data-lines! + (u/not-empty-string? signals) + (common/add-data-lines! consts/signals-dataline-literal + (string/split-lines signals)))) + + +(defn ->merge-signals [signals opts] + (u/transient-> [] + (add-only-if-missing?! (common/only-if-missing opts)) + (add-merge-signals! signals))) + + +(comment + (->merge-signals "{some json}\n{some other json}" {}) + := ["signals {some json}" + "signals {some other json}"] + + (->merge-signals "{some json}\n{some other json}" + {common/only-if-missing :toto}) + := ["onlyIfMissing true" + "signals {some json}" + "signals {some other json}"]) + + + +(defn merge-signals! [sse-gen signals-content opts] + (try + (sse/send-event! sse-gen + consts/event-type-merge-signals + (->merge-signals signals-content opts) + opts) + (catch Exception e + (throw (ex-info "Failed to send merge signals" + {:signals signals-content} + e))))) + + +;; ----------------------------------------------------------------------------- +;; Remove signals +;; ----------------------------------------------------------------------------- +(defn add-remove-signals-paths! [data-lines! paths] + (common/add-data-lines! + data-lines! + consts/paths-dataline-literal + paths)) + + +(defn ->remove-signals [paths] + (u/transient-> [] + (add-remove-signals-paths! paths))) + + +(comment + (->remove-signals ["foo.bar" "foo.baz" "bar"]) + := ["paths foo.bar" + "paths foo.baz" + "paths bar"]) + + +(defn remove-signals! [sse-gen paths opts] + (when-not (seq paths) + (throw (ex-info "Invalid signal paths to remove." + {:paths paths}))) + + (try + (sse/send-event! sse-gen + consts/event-type-remove-signals + (->remove-signals paths) + opts) + (catch Exception e + (throw (ex-info "Failed to send remove signals" + {:signals paths} + e))))) + + +;; ----------------------------------------------------------------------------- +;; Read signals +;; ----------------------------------------------------------------------------- +(defn datastar-request? [request] + (= "true" (get-in request [:headers "datastar-request"]))) + + +(defn get-signals + "Returns the signals json string. You need to use some middleware + that adds the :query-params key to the request for this function + to work properly. + + (Bring your own json parsing)" + [request] + (if (= :get (:request-method request)) + (get-in request [:query-params consts/datastar-key]) + (:body request))) + + diff --git a/sdk/clojure/sdk/src/main/starfederation/datastar/clojure/api/sse.clj b/sdk/clojure/sdk/src/main/starfederation/datastar/clojure/api/sse.clj new file mode 100644 index 000000000..c6d578e8e --- /dev/null +++ b/sdk/clojure/sdk/src/main/starfederation/datastar/clojure/api/sse.clj @@ -0,0 +1,140 @@ +(ns starfederation.datastar.clojure.api.sse + (:require + [starfederation.datastar.clojure.api.common :as common] + [starfederation.datastar.clojure.consts :as consts] + [starfederation.datastar.clojure.protocols :as p] + [starfederation.datastar.clojure.utils :as u]) + (:import + java.lang.Appendable)) + + +(def SSE-headers-1 + {"Cache-Control" "nocache" + "Connection" "keep-alive" + "Content-Type" "text/event-stream"}) + + +(def SSE-headers-2+ + {"Cache-Control" "nocache" + "Content-Type" "text/event-stream"}) + + +(defn headers + "Returns sse headers given a `ring-request`, more specificaly given the + `:protocol` key from that request." + [ring-request] + (let [protocol (:protocol ring-request)] + (if (or + (nil? protocol) + (= "HTTP/1.1" protocol)) + SSE-headers-1 + SSE-headers-2+))) + + + +;; ----------------------------------------------------------------------------- +;; SSE prefixes and constants +;; ----------------------------------------------------------------------------- +(def ^:private event-line-prefix "event: ") +(def ^:private id-line-prefix "id: ") +(def ^:private retry-line-prefix "retry: ") +(def ^:private data-line-prefix "data: ") + +(def ^:private new-line "\n") +(def ^:private end-event (str new-line new-line)) + + +;; ----------------------------------------------------------------------------- +;; Appending to a buffer +;; ----------------------------------------------------------------------------- +(defn- append! [^Appendable a v] + (.append a (str v))) + + +(defn- append-line! [buffer prefix line error-msg error-key] + (try + (doto buffer + (append! prefix) + (append! line) + (append! new-line)) + (catch Exception e + (throw (ex-info error-msg + {error-key line} e))))) + + +;; ----------------------------------------------------------------------------- +;; Appending event type +;; ----------------------------------------------------------------------------- +(defn- append-event-type! [buffer event-type] + (append-line! buffer event-line-prefix event-type + "Failed to write event type." :event-type)) + +;; ----------------------------------------------------------------------------- +;; Appending event opts +;; ----------------------------------------------------------------------------- +(def ^:private add-event-id? u/not-empty-string?) + +(defn- add-retry-duration? [d] + (and d (> d 0))) + + +(defn- append-opts! [buffer {event-id common/id retry-duration common/retry-duration}] + (when (add-event-id? event-id) + (append-line! buffer id-line-prefix event-id + "Failed to write event id" common/id)) + + (when (add-retry-duration? retry-duration) + (append-line! buffer retry-line-prefix retry-duration + "Failed to write retry" common/retry-duration))) + + +;; ----------------------------------------------------------------------------- +;; Appending event data +;; ----------------------------------------------------------------------------- +(defn- append-data-lines! [buffer data-lines] + (doseq [l data-lines] + (append-line! buffer data-line-prefix l "Failed to write data." :data-line))) + + +;; ----------------------------------------------------------------------------- +;; Append end event +;; ----------------------------------------------------------------------------- +(defn- append-end-event! [buffer] + (try + (append! buffer end-event) + (catch Exception e + (throw (ex-info "Failed to write new lines." + {} + e))))) + + +;; ----------------------------------------------------------------------------- +;; Public api +;; ----------------------------------------------------------------------------- +(defn write-event! + "Appends and event to an java.lang.Appendable buffer." + [buffer event-type data-lines opts] + (doto buffer + (append-event-type! event-type) + (append-opts! opts) + (append-data-lines! data-lines) + (append-end-event!))) + + + +(defn send-event! + "Wrapper around the [p/send-event!] function. + It provides multiple arities and defaults options." + ([sse-gen event-type data-lines] + (send-event! sse-gen event-type data-lines {})) + ([sse-gen event-type data-lines opts] + (let [id (common/id opts "") + retry-duration (common/retry-duration opts consts/default-sse-retry-duration)] + (u/assert (string? id)) + (u/assert (number? retry-duration)) + (p/send-event! sse-gen + event-type + data-lines + {common/id id + common/retry-duration retry-duration})))) + diff --git a/sdk/clojure/sdk/src/main/starfederation/datastar/clojure/consts.clj b/sdk/clojure/sdk/src/main/starfederation/datastar/clojure/consts.clj new file mode 100644 index 000000000..a3f805e53 --- /dev/null +++ b/sdk/clojure/sdk/src/main/starfederation/datastar/clojure/consts.clj @@ -0,0 +1,133 @@ +;; This is auto-generated by Datastar. DO NOT EDIT. +(ns starfederation.datastar.clojure.consts + (:require + [clojure.string :as string])) + + +(def datastar-key "datastar") +(def version "1.0.0-beta.2") +(def version-client-byte-size 36675) +(def version-client-byte-size-gzip 13579) + + +;; ----------------------------------------------------------------------------- +;; Default durations +;; ----------------------------------------------------------------------------- +(def default-fragments-settle-duration + "The default duration for settling during fragment merges. Allows for CSS transitions to complete." + 300) + +(def default-sse-retry-duration + "The default duration for retrying SSE on connection reset. This is part of the underlying retry mechanism of SSE." + 1000) + + +;; ----------------------------------------------------------------------------- +;; Default values +;; ----------------------------------------------------------------------------- +(def default-execute-script-attributes + "The default attributes for