diff --git a/context.go b/context.go new file mode 100644 index 0000000..47372bc --- /dev/null +++ b/context.go @@ -0,0 +1,224 @@ +package via + +import ( + "bytes" + "fmt" + "log" + "reflect" + "sync" + "time" + + "github.com/go-via/via/h" + "github.com/starfederation/datastar-go/datastar" +) + +// Context is the living bridge between Go and the browser. +// +// It binds user state and actions, manages reactive signals, and defines UI through View. +type Context struct { + id string + route string + app *via + view func() h.H + sse *datastar.ServerSentEventGenerator + actionRegistry map[string]func() + signals map[string]*signal + signalsMux sync.Mutex + createdAt time.Time +} + +// View defines the UI rendered by this context. +// The function should return an h.H element (from via/h). +// +// Changes to signals or state can be pushed live with Sync(). +func (c *Context) View(f func() h.H) { + if f == nil { + c.app.logErr(c, "failed to bind view to context: nil func") + } + c.view = func() h.H { return h.Div(h.ID(c.id), f()) } +} + +type actionTrigger struct { + id string +} + +func (a *actionTrigger) OnClick() h.H { + return h.Data("on:click", fmt.Sprintf("@get('/_action/%s')", a.id)) +} + +// Action registers a named event handler callable from the browser. +// +// Use h.OnClick("actionName") or similar event bindings to trigger actions. +// Signal updates from the browser are automatically injected in the context before the +// handler function executes. +func (c *Context) Action(f func()) *actionTrigger { + // if id == "" { + // c.app.logErr(c, "failed to bind action to context: id is ''") + // } + id := genRandID() + if f == nil { + c.app.logErr(c, "failed to bind action '%s' to context: nil func", id) + return nil + } + c.actionRegistry[id] = f + return &actionTrigger{id} +} + +func (c *Context) getActionFn(id string) (func(), error) { + if f, ok := c.actionRegistry[id]; ok { + return f, nil + } + return nil, fmt.Errorf("action '%s' not found", id) +} + +func (c *Context) Signals() map[string]*signal { + if c.signals == nil { + c.app.logErr(c, "failed to get signal: nil signals in ctx") + return make(map[string]*signal) + } + return c.signals +} + +// Signal creates a reactive signal and initializes it with a value. +// Use Bind() to link the value of input elements to the signal and Text() to +// display the signal value and watch the UI update live as the input changes. +// +// Example: +// +// h.Div( +// h.P(h.Span(h.Text("Hello, ")), h.Span(mysignal.Text())), +// h.Input(h.Value("World"), mysignal.Bind()), +// ) +// +// Signals are 'alive' only in the browser, but Via always injects their values into +// the Context before each action call. +// If any signal value is updated by the server the update is automatically sent to the +// browser when using Sync() or SyncSignsls(). +func (c *Context) Signal(v any) *signal { + sigID := genRandID() + if v == nil { + c.app.logErr(c, "failed to bind signal: nil signal value") + dummy := "Error" + return &signal{ + id: sigID, + v: reflect.ValueOf(dummy), + t: reflect.TypeOf(dummy), + err: fmt.Errorf("context '%s' failed to bind signal '%s': nil signal value", c.id, sigID), + } + } + sig := &signal{ + id: sigID, + v: reflect.ValueOf(v), + t: reflect.TypeOf(v), + changed: true, + } + c.signals[sigID] = sig + return sig + +} + +func (c *Context) injectSignals(sigs map[string]any) { + if sigs == nil { + c.app.logErr(c, "signal injection failed: nil signals in ctx") + return + } + for k, v := range sigs { + if _, ok := c.signals[k]; !ok { + continue + } + c.signals[k].v = reflect.ValueOf(v) + c.signals[k].changed = false + } +} + +// Sync pushes the current view state and signal changes to the browser immediately +// over the live SSE connection. +func (c *Context) Sync() { + if c.sse == nil { + c.app.logErr(c, "sync view failed: no sse connection") + } + elemsPatch := bytes.NewBuffer(make([]byte, 0)) + if err := c.view().Render(elemsPatch); err != nil { + c.app.logErr(c, "sync view failed: %v", err) + return + } + _ = c.sse.PatchElements(elemsPatch.String()) + updatedSigs := make(map[string]any) + for id, sig := range c.signals { + if sig.err != nil { + c.app.logWarn(c, "failed to sync signal '%s': %v", sig.id, sig.err) + } + if sig.changed && sig.err == nil { + updatedSigs[id] = fmt.Sprintf("%v", sig.v) + } + } + if len(updatedSigs) != 0 { + _ = c.sse.MarshalAndPatchSignals(updatedSigs) + } +} + +// SyncElements pushes an immediate html patch to the browser that merges DOM +// +// For the merge to occur, the top level element in the patch needs to have +// an ID that matches the ID of an element that already sits in the view. +// +// Example: +// +// If the view already contains the element: +// +// h.Div( +// h.ID("my-element"), +// h.P(h.Text("Hello from Via!")) +// ) +// +// Then, the merge will only occur if the ID of the top level element mattches 'my-element'. +func (c *Context) SyncElements(elem h.H) { + if c.sse == nil { + c.app.logErr(c, "sync element failed: no sse connection") + } + if c.view == nil { + c.app.logErr(c, "sync element failed: viewfn is nil") + return + } + if elem == nil { + c.app.logErr(c, "sync element failed: view func is nil") + return + } + b := bytes.NewBuffer(make([]byte, 0)) + _ = elem.Render(b) + c.sse.PatchElements(b.String()) +} + +// SyncSignals pushes the current signal changes to the browser immediately +// over the live SSE connection. +func (c *Context) SyncSignals() { + if c.sse == nil { + c.app.logErr(c, "sync signals failed: sse connection not found") + } + updatedSigs := make(map[string]any) + for id, sig := range c.signals { + if sig.err != nil { + c.app.logWarn(c, "signal out of sync'%s': %v", sig.id, sig.err) + } + if sig.changed && sig.err == nil { + updatedSigs[id] = fmt.Sprintf("%v", sig.v) + } + } + if len(updatedSigs) != 0 { + _ = c.sse.MarshalAndPatchSignals(updatedSigs) + } +} + +func newContext(id string, a *via) *Context { + if a == nil { + log.Fatalf("create context failed: app pointer is nil") + } + + return &Context{ + id: id, + app: a, + actionRegistry: make(map[string]func()), + signals: make(map[string]*signal), + createdAt: time.Now(), + } +} diff --git a/datastar.js b/datastar.js new file mode 100644 index 0000000..be57e9d --- /dev/null +++ b/datastar.js @@ -0,0 +1,9 @@ +// Datastar v1.0.0-RC.6 +var nt=/🖕JS_DS🚀/.source,De=nt.slice(0,5),Ve=nt.slice(4),q="datastar-fetch",z="datastar-signal-patch";var de=e=>e.replace(/([a-z0-9])([A-Z])/g,"$1-$2").replace(/([a-z])([0-9]+)/gi,"$1-$2").replace(/([0-9]+)([a-z])/gi,"$1-$2").toLowerCase();var rt=e=>de(e).replace(/-/g,"_");var re=e=>{try{return JSON.parse(e)}catch{return Function(`return (${e})`)()}},st={camel:e=>e.replace(/-[a-z]/g,t=>t[1].toUpperCase()),snake:e=>e.replace(/-/g,"_"),pascal:e=>e[0].toUpperCase()+st.camel(e.slice(1))},R=(e,t,n="camel")=>{for(let r of t.get("case")||[n])e=st[r]?.(e)||e;return e},Q=e=>`data-${e}`;var _=Object.hasOwn??Object.prototype.hasOwnProperty.call;var se=e=>e!==null&&typeof e=="object"&&(Object.getPrototypeOf(e)===Object.prototype||Object.getPrototypeOf(e)===null),it=e=>{for(let t in e)if(_(e,t))return!1;return!0},Z=(e,t)=>{for(let n in e){let r=e[n];se(r)||Array.isArray(r)?Z(r,t):e[n]=t(r)}},Te=e=>{let t={};for(let[n,r]of e){let s=n.split("."),i=s.pop(),a=s.reduce((o,c)=>o[c]??={},t);a[i]=r}return t};var Ae=[],Ie=[],Fe=0,Re=0,$e=0,qe,G,we=0,w=()=>{Fe++},M=()=>{--Fe||(ct(),Y())},V=e=>{qe=G,G=e},I=()=>{G=qe,qe=void 0},me=e=>It.bind(0,{previousValue:e,t:e,e:1}),Ge=Symbol("computed"),Ne=e=>{let t=$t.bind(0,{e:17,getter:e});return t[Ge]=1,t},E=e=>{let t={d:e,e:2};G&&Be(t,G),V(t),w();try{t.d()}finally{M(),I()}return dt.bind(0,t)},ct=()=>{for(;Re<$e;){let e=Ie[Re];Ie[Re++]=void 0,ut(e,e.e&=-65)}Re=0,$e=0},ot=e=>"getter"in e?lt(e):ft(e,e.t),lt=e=>{V(e),mt(e);try{let t=e.t;return t!==(e.t=e.getter(t))}finally{I(),pt(e)}},ft=(e,t)=>(e.e=1,e.previousValue!==(e.previousValue=t)),je=e=>{let t=e.e;if(!(t&64)){e.e=t|64;let n=e.r;n?je(n.o):Ie[$e++]=e}},ut=(e,t)=>{if(t&16||t&32&>(e.s,e)){V(e),mt(e),w();try{e.d()}finally{M(),I(),pt(e)}return}t&32&&(e.e=t&-33);let n=e.s;for(;n;){let r=n.c,s=r.e;s&64&&ut(r,r.e=s&-65),n=n.i}},It=(e,...t)=>{if(t.length){if(e.t!==(e.t=t[0])){e.e=17;let r=e.r;return r&&(qt(r),Fe||ct()),!0}return!1}let n=e.t;if(e.e&16&&ft(e,n)){let r=e.r;r&&Le(r)}return G&&Be(e,G),n},$t=e=>{let t=e.e;if(t&16||t&32&>(e.s,e)){if(lt(e)){let n=e.r;n&&Le(n)}}else t&32&&(e.e=t&-33);return G&&Be(e,G),e.t},dt=e=>{let t=e.s;for(;t;)t=xe(t,e);let n=e.r;n&&xe(n),e.e=0},Be=(e,t)=>{let n=t.a;if(n&&n.c===e)return;let r=n?n.i:t.s;if(r&&r.c===e){r.m=we,t.a=r;return}let s=e.p;if(s&&s.m===we&&s.o===t)return;let i=t.a=e.p={m:we,c:e,o:t,l:n,i:r,f:s};r&&(r.l=i),n?n.i=i:t.s=i,s?s.n=i:e.r=i},xe=(e,t=e.o)=>{let n=e.c,r=e.l,s=e.i,i=e.n,a=e.f;if(s?s.l=r:t.a=r,r?r.i=s:t.s=s,i?i.f=a:n.p=a,a)a.n=i;else if(!(n.r=i))if("getter"in n){let o=n.s;if(o){n.e=17;do o=xe(o,n);while(o)}}else"previousValue"in n||dt(n);return s},qt=e=>{let t=e.n,n;e:for(;;){let r=e.o,s=r.e;if(s&60?s&12?s&4?!(s&48)&&Gt(e,r)?(r.e=s|40,s&=1):s=0:r.e=s&-9|32:s=0:r.e=s|32,s&2&&je(r),s&1){let i=r.r;if(i){let a=(e=i).n;a&&(n={t,u:n},t=a);continue}}if(e=t){t=e.n;continue}for(;n;)if(e=n.t,n=n.u,e){t=e.n;continue e}break}},mt=e=>{we++,e.a=void 0,e.e=e.e&-57|4},pt=e=>{let t=e.a,n=t?t.i:e.s;for(;n;)n=xe(n,e);e.e&=-5},gt=(e,t)=>{let n,r=0,s=!1;e:for(;;){let i=e.c,a=i.e;if(t.e&16)s=!0;else if((a&17)===17){if(ot(i)){let o=i.r;o.n&&Le(o),s=!0}}else if((a&33)===33){(e.n||e.f)&&(n={t:e,u:n}),e=i.s,t=i,++r;continue}if(!s){let o=e.i;if(o){e=o;continue}}for(;r--;){let o=t.r,c=o.n;if(c?(e=n.t,n=n.u):e=o,s){if(ot(t)){c&&Le(o),t=e.o;continue}s=!1}else t.e&=-33;if(t=e.o,e.i){e=e.i;continue e}}return s}},Le=e=>{do{let t=e.o,n=t.e;(n&48)===32&&(t.e=n|16,n&2&&je(t))}while(e=e.n)},Gt=(e,t)=>{let n=t.a;for(;n;){if(n===e)return!0;n=n.l}return!1},ie=e=>{let t=X,n=e.split(".");for(let r of n){if(t==null||!_(t,r))return;t=t[r]}return t},Me=(e,t="")=>{let n=Array.isArray(e);if(n||se(e)){let r=n?[]:{};for(let i in e)r[i]=me(Me(e[i],`${t+i}.`));let s=me(0);return new Proxy(r,{get(i,a){if(!(a==="toJSON"&&!_(r,a)))return n&&a in Array.prototype?(s(),r[a]):typeof a=="symbol"?r[a]:((!_(r,a)||r[a]()==null)&&(r[a]=me(""),Y(t+a,""),s(s()+1)),r[a]())},set(i,a,o){let c=t+a;if(n&&a==="length"){let l=r[a]-o;if(r[a]=o,l>0){let f={};for(let d=o;d{if(e!==void 0&&t!==void 0&&Ae.push([e,t]),!Fe&&Ae.length){let n=Te(Ae);Ae.length=0,document.dispatchEvent(new CustomEvent(z,{detail:n}))}},C=(e,{ifMissing:t}={})=>{w();for(let n in e)e[n]==null?t||delete X[n]:ht(e[n],n,X,"",t);M()},S=(e,t)=>C(Te(e),t),ht=(e,t,n,r,s)=>{if(se(e)){_(n,t)&&(se(n[t])||Array.isArray(n[t]))||(n[t]={});for(let i in e)e[i]==null?s||delete n[t][i]:ht(e[i],i,n[t],`${r+t}.`,s)}else s&&_(n,t)||(n[t]=e)},at=e=>typeof e=="string"?RegExp(e.replace(/^\/|\/$/g,"")):e,D=({include:e=/.*/,exclude:t=/(?!)/}={},n=X)=>{let r=at(e),s=at(t),i=[],a=[[n,""]];for(;a.length;){let[o,c]=a.pop();for(let l in o){let f=c+l;se(o[l])?a.push([o[l],`${f}.`]):r.test(f)&&!s.test(f)&&i.push([f,ie(f)])}}return Te(i)},X=Me({});var W=e=>e instanceof HTMLElement||e instanceof SVGElement||e instanceof MathMLElement;var jt="https://data-star.dev/errors",pe=(e,t,n={})=>{Object.assign(n,e);let r=new Error,s=rt(t),i=new URLSearchParams({metadata:JSON.stringify(n)}).toString(),a=JSON.stringify(n,null,2);return r.message=`${t} +More info: ${jt}/${s}?${i} +Context: ${a}`,r},Oe=new Map,vt=new Map,bt=new Map,Et=new Proxy({},{get:(e,t)=>Oe.get(t)?.apply,has:(e,t)=>Oe.has(t),ownKeys:()=>Reflect.ownKeys(Oe),set:()=>!1,deleteProperty:()=>!1}),ge=new Map,Ce=[],We=new Set,p=e=>{Ce.push(e),Ce.length===1&&setTimeout(()=>{for(let t of Ce)We.add(t.name),vt.set(t.name,t);Ce.length=0,Jt(),We.clear()})},O=e=>{Oe.set(e.name,e)};document.addEventListener(q,e=>{let t=bt.get(e.detail.type);t&&t.apply({error:pe.bind(0,{plugin:{type:"watcher",name:t.name},element:{id:e.target.id,tag:e.target.tagName}})},e.detail.argsRaw)});var he=e=>{bt.set(e.name,e)},yt=e=>{for(let t of e){let n=ge.get(t);if(ge.delete(t)){for(let r of n.values())r();n.clear()}}},St=Q("ignore"),Bt=`[${St}]`,Tt=e=>e.hasAttribute(`${St}__self`)||!!e.closest(Bt),Pe=(e,t)=>{for(let n of e)if(!Tt(n))for(let r in n.dataset)At(n,r.replace(/[A-Z]/g,"-$&").toLowerCase(),n.dataset[r],t)},Wt=e=>{for(let{target:t,type:n,attributeName:r,addedNodes:s,removedNodes:i}of e)if(n==="childList"){for(let a of i)W(a)&&(yt([a]),yt(a.querySelectorAll("*")));for(let a of s)W(a)&&(Pe([a]),Pe(a.querySelectorAll("*")))}else if(n==="attributes"&&r.startsWith("data-")&&W(t)&&!Tt(t)){let a=r.slice(5),o=t.getAttribute(r);if(o===null){let c=ge.get(t);c&&(c.get(a)?.(),c.delete(a))}else At(t,a,o)}},Ut=new MutationObserver(Wt),Jt=(e=document.documentElement)=>{W(e)&&Pe([e],!0),Pe(e.querySelectorAll("*"),!0),Ut.observe(e,{subtree:!0,childList:!0,attributes:!0})},At=(e,t,n,r)=>{{let s=t,[i,...a]=s.split("__"),[o,c]=i.split(/:(.+)/),l=vt.get(o);if((!r||We.has(o))&&l){let f={el:e,rawKey:s,mods:new Map,error:pe.bind(0,{plugin:{type:"attribute",name:l.name},element:{id:e.id,tag:e.tagName},expression:{rawKey:s,key:c,value:n}}),key:c,value:n,rx:void 0},d=l.requirement&&(typeof l.requirement=="string"?l.requirement:l.requirement.key)||"allowed",x=l.requirement&&(typeof l.requirement=="string"?l.requirement:l.requirement.value)||"allowed";if(c){if(d==="denied")throw f.error("KeyNotAllowed")}else if(d==="must")throw f.error("KeyRequired");if(n){if(x==="denied")throw f.error("ValueNotAllowed")}else if(x==="must")throw f.error("ValueRequired");if(d==="exclusive"||x==="exclusive"){if(c&&n)throw f.error("KeyAndValueProvided");if(!c&&!n)throw f.error("KeyOrValueRequired")}if(n){let m;f.rx=(...h)=>(m||(m=Kt(n,{returnsValue:l.returnsValue,argNames:l.argNames})),m(e,...h))}for(let m of a){let[h,...v]=m.split(".");f.mods.set(h,new Set(v))}let u=l.apply(f);if(u){let m=ge.get(e);m?m.get(s)?.():(m=new Map,ge.set(e,m)),m.set(s,u)}}}},Kt=(e,{returnsValue:t=!1,argNames:n=[]}={})=>{let r="";if(t){let o=/(\/(\\\/|[^/])*\/|"(\\"|[^"])*"|'(\\'|[^'])*'|`(\\`|[^`])*`|\(\s*((function)\s*\(\s*\)|(\(\s*\))\s*=>)\s*(?:\{[\s\S]*?\}|[^;){]*)\s*\)\s*\(\s*\)|[^;])+/gm,c=e.trim().match(o);if(c){let l=c.length-1,f=c[l].trim();f.startsWith("return")||(c[l]=`return (${f});`),r=c.join(`; +`)}}else r=e.trim();let s=new Map,i=RegExp(`(?:${De})(.*?)(?:${Ve})`,"gm"),a=0;for(let o of r.matchAll(i)){let c=o[1],l=`__escaped${a++}`;s.set(l,c),r=r.replace(De+c+Ve,l)}r=r.replace(/\$\['([a-zA-Z_$\d][\w$]*)'\]/g,"$$$1").replace(/\$([a-zA-Z_\d]\w*(?:[.-]\w+)*)/g,(o,c)=>c.split(".").reduce((l,f)=>`${l}['${f}']`,"$")).replace(/\[(\$[a-zA-Z_\d]\w*)\]/g,(o,c)=>`[$['${c.slice(1)}']]`),r=r.replaceAll(/@(\w+)\(/g,'__action("$1",evt,');for(let[o,c]of s)r=r.replace(o,c);try{let o=Function("el","$","__action","evt",...n,r);return(c,...l)=>{let f=(d,x,...u)=>{let m=pe.bind(0,{plugin:{type:"action",name:d},element:{id:c.id,tag:c.tagName},expression:{fnContent:r,value:e}}),h=Et[d];if(h)return h({el:c,evt:x,error:m},...u);throw m("UndefinedAction")};try{return o(c,X,f,void 0,...l)}catch(d){throw console.error(d),pe({element:{id:c.id,tag:c.tagName},expression:{fnContent:r,value:e},error:d.message},"ExecuteExpression")}}}catch(o){throw console.error(o),pe({expression:{fnContent:r,value:e},error:o.message},"GenerateExpression")}};var P=new Map,ae=new Set,oe=new Map,ye=new Set,ce=document.createElement("div");ce.hidden=!0;var ve=Q("ignore-morph"),zt=`[${ve}]`,Ke=(e,t,n="outer")=>{if(W(e)&&W(t)&&e.hasAttribute(ve)&&t.hasAttribute(ve)||e.parentElement?.closest(zt))return;let r=document.createElement("div");r.append(t),document.body.insertAdjacentElement("afterend",ce);let s=e.querySelectorAll("[id]");for(let{id:o,tagName:c}of s)oe.has(o)?ye.add(o):oe.set(o,c);e instanceof Element&&e.id&&(oe.has(e.id)?ye.add(e.id):oe.set(e.id,e.tagName)),ae.clear();let i=r.querySelectorAll("[id]");for(let{id:o,tagName:c}of i)ae.has(o)?ye.add(o):oe.get(o)===c&&ae.add(o);for(let o of ye)ae.delete(o);oe.clear(),ye.clear(),P.clear();let a=n==="outer"?e.parentElement:e;wt(a,s),wt(r,i),Mt(a,r,n==="outer"?e:null,e.nextSibling),ce.remove()},Mt=(e,t,n=null,r=null)=>{e instanceof HTMLTemplateElement&&t instanceof HTMLTemplateElement&&(e=e.content,t=t.content),n??=e.firstChild;for(let s of t.childNodes){if(n&&n!==r){let i=Qt(s,n,r);if(i){if(i!==n){let a=n;for(;a&&a!==i;){let o=a;a=a.nextSibling,Je(o)}}Ue(i,s),n=i.nextSibling;continue}}if(s instanceof Element&&ae.has(s.id)){let i=document.getElementById(s.id),a=i;for(;a=a.parentNode;){let o=P.get(a);o&&(o.delete(s.id),o.size||P.delete(a))}xt(e,i,n),Ue(i,s),n=i.nextSibling;continue}if(P.has(s)){let i=document.createElement(s.tagName);e.insertBefore(i,n),Ue(i,s),n=i.nextSibling}else{let i=document.importNode(s,!0);e.insertBefore(i,n),n=i.nextSibling}}for(;n&&n!==r;){let s=n;n=n.nextSibling,Je(s)}},Qt=(e,t,n)=>{let r=null,s=e.nextSibling,i=0,a=0,o=P.get(e)?.size||0,c=t;for(;c&&c!==n;){if(Rt(c,e)){let l=!1,f=P.get(c),d=P.get(e);if(d&&f){for(let x of f)if(d.has(x)){l=!0;break}}if(l)return c;if(!r&&!P.has(c)){if(!o)return c;r=c}}if(a+=P.get(c)?.size||0,a>o)break;r===null&&s&&Rt(c,s)&&(i++,s=s.nextSibling,i>=2&&(r=void 0)),c=c.nextSibling}return r||null},Rt=(e,t)=>e.nodeType===t.nodeType&&e.tagName===t.tagName&&(!e.id||e.id===t.id),Je=e=>{P.has(e)?xt(ce,e,null):e.parentNode?.removeChild(e)},xt=Je.call.bind(ce.moveBefore??ce.insertBefore),Zt=Q("preserve-attr"),Ue=(e,t)=>{let n=t.nodeType;if(n===1){let r=e,s=t;if(r.hasAttribute(ve)&&s.hasAttribute(ve))return e;r instanceof HTMLInputElement&&s instanceof HTMLInputElement&&s.type!=="file"?s.getAttribute("value")!==r.getAttribute("value")&&(r.value=s.getAttribute("value")??""):r instanceof HTMLTextAreaElement&&s instanceof HTMLTextAreaElement&&(s.value!==r.value&&(r.value=s.value),r.firstChild&&r.firstChild.nodeValue!==s.value&&(r.firstChild.nodeValue=s.value));let i=(t.getAttribute(Zt)??"").split(" ");for(let{name:a,value:o}of s.attributes)r.getAttribute(a)!==o&&!i.includes(a)&&r.setAttribute(a,o);for(let a=r.attributes.length-1;a>=0;a--){let{name:o}=r.attributes[a];!s.hasAttribute(o)&&!i.includes(o)&&r.removeAttribute(o)}r.isEqualNode(s)||Mt(r,s)}return(n===8||n===3)&&e.nodeValue!==t.nodeValue&&(e.nodeValue=t.nodeValue),e},wt=(e,t)=>{for(let n of t)if(ae.has(n.id)){let r=n;for(;r&&r!==e;){let s=P.get(r);s||(s=new Set,P.set(r,s)),s.add(n.id),r=r.parentElement}}};O({name:"peek",apply(e,t){V();try{return t()}finally{I()}}});O({name:"setAll",apply(e,t,n){V();let r=D(n);Z(r,()=>t),C(r),I()}});O({name:"toggleAll",apply(e,t){V();let n=D(t);Z(n,r=>!r),C(n),I()}});var He=new WeakMap,be=(e,t)=>O({name:e,apply:async({el:n,evt:r,error:s},i,{selector:a,headers:o,contentType:c="json",filterSignals:{include:l=/.*/,exclude:f=/(^|\.)_/}={},openWhenHidden:d=!1,retryInterval:x=1e3,retryScaler:u=2,retryMaxWaitMs:m=3e4,retryMaxCount:h=10,requestCancellation:v="auto"}={})=>{let L=v instanceof AbortController?v:new AbortController,A=v==="disabled";if(!A){let T=He.get(n);T&&(T.abort(),await Promise.resolve())}!A&&!(v instanceof AbortController)&&He.set(n,L);try{let T=new MutationObserver(j=>{for(let H of j)for(let B of H.removedNodes)B===n&&(L.abort(),K())});n.parentNode&&T.observe(n.parentNode,{childList:!0});let K=()=>{T.disconnect()};try{if(!i?.length)throw s("FetchNoUrlProvided",{action:O});let j={Accept:"text/event-stream, text/html, application/json","Datastar-Request":!0};c==="json"&&(j["Content-Type"]="application/json");let H=Object.assign({},j,o),B={method:t,headers:H,openWhenHidden:d,retryInterval:x,retryScaler:u,retryMaxWaitMs:m,retryMaxCount:h,signal:L.signal,onopen:async g=>{g.status>=400&&ee(Yt,n,{status:g.status.toString()})},onmessage:g=>{if(!g.event.startsWith("datastar"))return;let $=g.event,b={};for(let N of g.data.split(` +`)){let y=N.indexOf(" "),k=N.slice(0,y),ue=N.slice(y+1);(b[k]||=[]).push(ue)}let F=Object.fromEntries(Object.entries(b).map(([N,y])=>[N,y.join(` +`)]));ee($,n,F)},onerror:g=>{if(Lt(g))throw g("FetchExpectedTextEventStream",{url:i});g&&(console.error(g.message),ee(Xt,n,{message:g.message}))}},fe=new URL(i,document.baseURI),ne=new URLSearchParams(fe.search);if(c==="json"){let g=JSON.stringify(D({include:l,exclude:f}));t==="GET"?ne.set("datastar",g):B.body=g}else if(c==="form"){let g=a?document.querySelector(a):n.closest("form");if(!g)throw s("FetchFormNotFound",{action:O,selector:a});if(!g.checkValidity()){g.reportValidity(),K();return}let $=new FormData(g),b=n;if(n===g&&r instanceof SubmitEvent)b=r.submitter;else{let y=k=>k.preventDefault();g.addEventListener("submit",y),K=()=>{g.removeEventListener("submit",y),T.disconnect()}}if(b instanceof HTMLButtonElement){let y=b.getAttribute("name");y&&$.append(y,b.value)}let F=g.getAttribute("enctype")==="multipart/form-data";F||(H["Content-Type"]="application/x-www-form-urlencoded");let N=new URLSearchParams($);if(t==="GET")for(let[y,k]of N)ne.append(y,k);else F?B.body=$:B.body=N}else throw s("FetchInvalidContentType",{action:O,contentType:c});ee(ze,n,{}),fe.search=ne.toString();try{await on(fe.toString(),n,B)}catch(g){if(!Lt(g))throw s("FetchFailed",{method:t,url:i,error:g.message})}}finally{ee(Qe,n,{}),K()}}finally{He.get(n)===L&&He.delete(n)}}});be("delete","DELETE");be("get","GET");be("patch","PATCH");be("post","POST");be("put","PUT");var ze="started",Qe="finished",Yt="error",Xt="retrying",en="retries-failed",ee=(e,t,n)=>document.dispatchEvent(new CustomEvent(q,{detail:{type:e,el:t,argsRaw:n}})),Lt=e=>`${e}`.includes("text/event-stream"),tn=async(e,t)=>{let n=e.getReader(),r=await n.read();for(;!r.done;)t(r.value),r=await n.read()},nn=e=>{let t,n,r,s=!1;return i=>{t?t=sn(t,i):(t=i,n=0,r=-1);let a=t.length,o=0;for(;n{let r=Ft(),s=new TextDecoder;return(i,a)=>{if(!i.length)n?.(r),r=Ft();else if(a>0){let o=s.decode(i.subarray(0,a)),c=a+(i[a+1]===32?2:1),l=s.decode(i.subarray(c));switch(o){case"data":r.data=r.data?`${r.data} +${l}`:l;break;case"event":r.event=l;break;case"id":e(r.id=l);break;case"retry":{let f=+l;Number.isNaN(f)||t(r.retry=f);break}}}}},sn=(e,t)=>{let n=new Uint8Array(e.length+t.length);return n.set(e),n.set(t,e.length),n},Ft=()=>({data:"",event:"",id:"",retry:void 0}),on=(e,t,{signal:n,headers:r,onopen:s,onmessage:i,onclose:a,onerror:o,openWhenHidden:c,fetch:l,retryInterval:f=1e3,retryScaler:d=2,retryMaxWaitMs:x=3e4,retryMaxCount:u=10,overrides:m,...h})=>new Promise((v,L)=>{let A={...r},T,K=()=>{T.abort(),document.hidden||$()};c||document.addEventListener("visibilitychange",K);let j=0,H=()=>{document.removeEventListener("visibilitychange",K),clearTimeout(j),T.abort()};n?.addEventListener("abort",()=>{H(),v()});let B=l||window.fetch,fe=s||(()=>{}),ne=0,g=f,$=async()=>{T=new AbortController;try{let b=await B(e,{...h,headers:A,signal:T.signal});ne=0,f=g,await fe(b);let F=async(y,k,ue,Ee,...Vt)=>{let tt={[ue]:await k.text()};for(let ke of Vt){let _e=k.headers.get(`datastar-${de(ke)}`);if(Ee){let Se=Ee[ke];Se&&(_e=typeof Se=="string"?Se:JSON.stringify(Se))}_e&&(tt[ke]=_e)}ee(y,t,tt),H(),v()},N=b.headers.get("Content-Type");if(N?.includes("text/html"))return await F("datastar-patch-elements",b,"elements",m,"selector","mode","useViewTransition");if(N?.includes("application/json"))return await F("datastar-patch-signals",b,"signals",m,"onlyIfMissing");if(N?.includes("text/javascript")){let y=document.createElement("script"),k=b.headers.get("datastar-script-attributes");if(k)for(let[ue,Ee]of Object.entries(JSON.parse(k)))y.setAttribute(ue,Ee);y.textContent=await b.text(),document.head.appendChild(y),H();return}await tn(b.body,nn(rn(y=>{y?A["last-event-id"]=y:delete A["last-event-id"]},y=>{g=f=y},i))),a?.(),H(),v()}catch(b){if(!T.signal.aborted)try{let F=o?.(b)||f;clearTimeout(j),j=setTimeout($,F),f=Math.min(f*d,x),++ne>=u?(ee(en,t,{}),H(),L("Max retries reached.")):console.error(`Datastar failed to reach ${e.toString()} retrying in ${F}ms.`)}catch(F){H(),L(F)}}};$()});p({name:"attr",requirement:{value:"must"},returnsValue:!0,apply({el:e,key:t,rx:n}){let r=(o,c)=>{c===""||c===!0?e.setAttribute(o,""):c===!1||c==null?e.removeAttribute(o):typeof c=="string"?e.setAttribute(o,c):e.setAttribute(o,JSON.stringify(c))},s=t?()=>{i.disconnect();let o=n();r(t,o),i.observe(e,{attributeFilter:[t]})}:()=>{i.disconnect();let o=n(),c=Object.keys(o);for(let l of c)r(l,o[l]);i.observe(e,{attributeFilter:c})},i=new MutationObserver(s),a=E(s);return()=>{i.disconnect(),a()}}});var an=/^data:(?[^;]+);base64,(?.*)$/,Nt=Symbol("empty"),Ct=Q("bind");p({name:"bind",requirement:"exclusive",apply({el:e,key:t,mods:n,value:r,error:s}){let i=t!=null?R(t,n):r,a=(u,m)=>m==="number"?+u.value:u.value,o=u=>{e.value=`${u}`};if(e instanceof HTMLInputElement)switch(e.type){case"range":case"number":a=(u,m)=>m==="string"?u.value:+u.value;break;case"checkbox":a=(u,m)=>u.value!=="on"?m==="boolean"?u.checked:u.checked?u.value:"":m==="string"?u.checked?u.value:"":u.checked,o=u=>{e.checked=typeof u=="string"?u===e.value:u};break;case"radio":e.getAttribute("name")?.length||e.setAttribute("name",i),a=(u,m)=>u.checked?m==="number"?+u.value:u.value:Nt,o=u=>{e.checked=u===(typeof u=="number"?+e.value:e.value)};break;case"file":{let u=()=>{let m=[...e.files||[]],h=[];Promise.all(m.map(v=>new Promise(L=>{let A=new FileReader;A.onload=()=>{if(typeof A.result!="string")throw s("InvalidFileResultType",{resultType:typeof A.result});let T=A.result.match(an);if(!T?.groups)throw s("InvalidDataUri",{result:A.result});h.push({name:v.name,contents:T.groups.contents,mime:T.groups.mime})},A.onloadend=()=>L(),A.readAsDataURL(v)}))).then(()=>{S([[i,h]])})};return e.addEventListener("change",u),e.addEventListener("input",u),()=>{e.removeEventListener("change",u),e.removeEventListener("input",u)}}}else if(e instanceof HTMLSelectElement){if(e.multiple){let u=new Map;a=m=>[...m.selectedOptions].map(h=>{let v=u.get(h.value);return v==="string"||v==null?h.value:+h.value}),o=m=>{for(let h of e.options)m.includes(h.value)?(u.set(h.value,"string"),h.selected=!0):m.includes(+h.value)?(u.set(h.value,"number"),h.selected=!0):h.selected=!1}}}else e instanceof HTMLTextAreaElement||(a=u=>"value"in u?u.value:u.getAttribute("value"),o=u=>{"value"in e?e.value=u:e.setAttribute("value",u)});let c=ie(i),l=typeof c,f=i;if(Array.isArray(c)&&!(e instanceof HTMLSelectElement&&e.multiple)){let u=t||r,m=document.querySelectorAll(`[${Ct}\\:${CSS.escape(u)}],[${Ct}="${CSS.escape(u)}"]`),h=[],v=0;for(let L of m){if(h.push([`${f}.${v}`,a(L,"none")]),e===L)break;v++}S(h,{ifMissing:!0}),f=`${f}.${v}`}else S([[f,a(e,l)]],{ifMissing:!0});let d=()=>{let u=ie(f);if(u!=null){let m=a(e,typeof u);m!==Nt&&S([[f,m]])}};e.addEventListener("input",d),e.addEventListener("change",d);let x=E(()=>{o(ie(f))});return()=>{x(),e.removeEventListener("input",d),e.removeEventListener("change",d)}}});p({name:"class",requirement:{value:"must"},returnsValue:!0,apply({key:e,el:t,mods:n,rx:r}){e&&(e=R(e,n,"kebab"));let s=()=>{i.disconnect();let o=e?{[e]:r()}:r();for(let c in o){let l=c.split(/\s+/).filter(f=>f.length>0);if(o[c])for(let f of l)t.classList.contains(f)||t.classList.add(f);else for(let f of l)t.classList.contains(f)&&t.classList.remove(f)}i.observe(t,{attributeFilter:["class"]})},i=new MutationObserver(s),a=E(s);return()=>{i.disconnect(),a();let o=e?{[e]:r()}:r();for(let c in o){let l=c.split(/\s+/).filter(f=>f.length>0);for(let f of l)t.classList.remove(f)}}}});p({name:"computed",requirement:{value:"must"},returnsValue:!0,apply({key:e,mods:t,rx:n,error:r}){if(e)S([[R(e,t),Ne(n)]]);else{let s=Object.assign({},n());Z(s,i=>{if(typeof i=="function")return Ne(i);throw r("ComputedExpectedFunction")}),C(s)}}});p({name:"effect",requirement:{key:"denied",value:"must"},apply:({rx:e})=>E(e)});p({name:"indicator",requirement:"exclusive",apply({el:e,key:t,mods:n,value:r}){let s=t!=null?R(t,n):r;S([[s,!1]]);let i=a=>{let{type:o,el:c}=a.detail;if(c===e)switch(o){case ze:S([[s,!0]]);break;case Qe:S([[s,!1]]);break}};return document.addEventListener(q,i),()=>{S([[s,!1]]),document.removeEventListener(q,i)}}});p({name:"json-signals",requirement:{key:"denied"},apply({el:e,value:t,mods:n}){let r=n.has("terse")?0:2,s={};t&&(s=re(t));let i=()=>{a.disconnect(),e.textContent=JSON.stringify(D(s),null,r),a.observe(e,{childList:!0,characterData:!0,subtree:!0})},a=new MutationObserver(i),o=E(i);return()=>{a.disconnect(),o()}}});var U=e=>{if(!e||e.size<=0)return 0;for(let t of e){if(t.endsWith("ms"))return+t.replace("ms","");if(t.endsWith("s"))return+t.replace("s","")*1e3;try{return Number.parseFloat(t)}catch{}}return 0},te=(e,t,n=!1)=>e?e.has(t.toLowerCase()):n;var Ze=(e,t)=>(...n)=>{setTimeout(()=>{e(...n)},t)},cn=(e,t,n=!1,r=!0)=>{let s=0;return(...i)=>{s&&clearTimeout(s),n&&!s&&e(...i),s=setTimeout(()=>{r&&e(...i),s&&clearTimeout(s),s=0},t)}},ln=(e,t,n=!0,r=!1)=>{let s=!1;return(...i)=>{s||(n&&e(...i),s=!0,setTimeout(()=>{r&&e(...i),s=!1},t))}},le=(e,t)=>{let n=t.get("delay");if(n){let i=U(n);e=Ze(e,i)}let r=t.get("debounce");if(r){let i=U(r),a=te(r,"leading",!1),o=!te(r,"notrailing",!1);e=cn(e,i,a,o)}let s=t.get("throttle");if(s){let i=U(s),a=!te(s,"noleading",!1),o=te(s,"trailing",!1);e=ln(e,i,a,o)}return e};var Ye=!!document.startViewTransition,J=(e,t)=>{if(t.has("viewtransition")&&Ye){let n=e;e=(...r)=>document.startViewTransition(()=>n(...r))}return e};p({name:"on",requirement:"must",argNames:["evt"],apply({el:e,key:t,mods:n,rx:r}){let s=e;n.has("window")&&(s=window);let i=c=>{c&&(n.has("prevent")&&c.preventDefault(),n.has("stop")&&c.stopPropagation()),w(),r(c),M()};i=J(i,n),i=le(i,n);let a={capture:n.has("capture"),passive:n.has("passive"),once:n.has("once")};if(n.has("outside")){s=document;let c=i;i=l=>{e.contains(l?.target)||c(l)}}let o=R(t,n,"kebab");if((o===q||o===z)&&(s=document),e instanceof HTMLFormElement&&o==="submit"){let c=i;i=l=>{l?.preventDefault(),c(l)}}return s.addEventListener(o,i,a),()=>{s.removeEventListener(o,i)}}});var Xe=new WeakSet;p({name:"on-intersect",requirement:{key:"denied",value:"must"},apply({el:e,mods:t,rx:n}){let r=()=>{w(),n(),M()};r=J(r,t),r=le(r,t);let s={threshold:0};t.has("full")?s.threshold=1:t.has("half")&&(s.threshold=.5);let i=new IntersectionObserver(a=>{for(let o of a)o.isIntersecting&&(r(),i&&Xe.has(e)&&i.disconnect())},s);return i.observe(e),t.has("once")&&Xe.add(e),()=>{t.has("once")||Xe.delete(e),i&&(i.disconnect(),i=null)}}});p({name:"on-interval",requirement:{key:"denied",value:"must"},apply({mods:e,rx:t}){let n=()=>{w(),t(),M()};n=J(n,e);let r=1e3,s=e.get("duration");s&&(r=U(s),te(s,"leading",!1)&&n());let i=setInterval(n,r);return()=>{clearInterval(i)}}});p({name:"init",requirement:{key:"denied",value:"must"},apply({rx:e,mods:t}){let n=()=>{w(),e(),M()};n=J(n,t);let r=0,s=t.get("delay");s&&(r=U(s),r>0&&(n=Ze(n,r))),n()}});p({name:"on-signal-patch",requirement:{value:"must"},argNames:["patch"],returnsValue:!0,apply({el:e,key:t,mods:n,rx:r,error:s}){if(t&&t!=="filter")throw s("KeyNotAllowed");let i=e.getAttribute("data-on-signal-patch-filter"),a={};i&&(a=re(i));let o=le(c=>{let l=D(a,c.detail);it(l)||(w(),r(l),M())},n);return document.addEventListener(z,o),()=>{document.removeEventListener(z,o)}}});p({name:"ref",requirement:"exclusive",apply({el:e,key:t,mods:n,value:r}){let s=t!=null?R(t,n):r;S([[s,e]])}});var Ot="none",Pt="display";p({name:"show",requirement:{key:"denied",value:"must"},returnsValue:!0,apply({el:e,rx:t}){let n=()=>{r.disconnect(),t()?e.style.display===Ot&&e.style.removeProperty(Pt):e.style.setProperty(Pt,Ot),r.observe(e,{attributeFilter:["style"]})},r=new MutationObserver(n),s=E(n);return()=>{r.disconnect(),s()}}});p({name:"signals",returnsValue:!0,apply({key:e,mods:t,rx:n}){let r=t.has("ifmissing");if(e)e=R(e,t),S([[e,n?.()]],{ifMissing:r});else{let s=Object.assign({},n?.());C(s,{ifMissing:r})}}});p({name:"style",requirement:{value:"must"},returnsValue:!0,apply({key:e,el:t,rx:n}){let{style:r}=t,s=new Map,i=(l,f)=>{let d=s.get(l);!f&&f!==0?d!==void 0&&(d?r.setProperty(l,d):r.removeProperty(l)):(d===void 0&&s.set(l,r.getPropertyValue(l)),r.setProperty(l,String(f)))},a=()=>{if(o.disconnect(),e)i(e,n());else{let l=n();for(let[f,d]of s)f in l||(d?r.setProperty(f,d):r.removeProperty(f));for(let f in l)i(de(f),l[f])}o.observe(t,{attributeFilter:["style"]})},o=new MutationObserver(a),c=E(a);return()=>{o.disconnect(),c();for(let[l,f]of s)f?r.setProperty(l,f):r.removeProperty(l)}}});p({name:"text",requirement:{key:"denied",value:"must"},returnsValue:!0,apply({el:e,rx:t}){let n=()=>{r.disconnect(),e.textContent=`${t()}`,r.observe(e,{childList:!0,characterData:!0,subtree:!0})},r=new MutationObserver(n),s=E(n);return()=>{r.disconnect(),s()}}});he({name:"datastar-patch-elements",apply(e,{elements:t="",selector:n="",mode:r="outer",useViewTransition:s}){switch(r){case"remove":case"outer":case"inner":case"replace":case"prepend":case"append":case"before":case"after":break;default:throw e.error("PatchElementsInvalidMode",{mode:r})}if(!n&&r!=="outer"&&r!=="replace")throw e.error("PatchElementsExpectedSelector");let i={mode:r,selector:n,elements:t,useViewTransition:s?.trim()==="true"};Ye&&s?document.startViewTransition(()=>Ht(e,i)):Ht(e,i)}});var Ht=({error:e},{elements:t,selector:n,mode:r})=>{let s=t.replace(/]*>|>)([\s\S]*?)<\/svg>/gim,""),i=/<\/html>/.test(s),a=/<\/head>/.test(s),o=/<\/body>/.test(s),c=new DOMParser().parseFromString(i||a||o?t:``,"text/html"),l=document.createDocumentFragment();if(i?l.appendChild(c.documentElement):a&&o?(l.appendChild(c.head),l.appendChild(c.body)):a?l.appendChild(c.head):o?l.appendChild(c.body):l=c.querySelector("template").content,!n&&(r==="outer"||r==="replace"))for(let f of l.children){let d;if(f instanceof HTMLHtmlElement)d=document.documentElement;else if(f instanceof HTMLBodyElement)d=document.body;else if(f instanceof HTMLHeadElement)d=document.head;else if(d=document.getElementById(f.id),!d){console.warn(e("PatchElementsNoTargetsFound"),{element:{id:f.id}});continue}_t(r,f,[d])}else{let f=document.querySelectorAll(n);if(!f.length){console.warn(e("PatchElementsNoTargetsFound"),{selector:n});return}_t(r,l,f)}},et=new WeakSet;for(let e of document.querySelectorAll("script"))et.add(e);var Dt=e=>{let t=e instanceof HTMLScriptElement?[e]:e.querySelectorAll("script");for(let n of t)if(!et.has(n)){let r=document.createElement("script");for(let{name:s,value:i}of n.attributes)r.setAttribute(s,i);r.text=n.text,n.replaceWith(r),et.add(r)}},kt=(e,t,n)=>{for(let r of e){let s=t.cloneNode(!0);Dt(s),r[n](s)}},_t=(e,t,n)=>{switch(e){case"remove":for(let r of n)r.remove();break;case"outer":case"inner":for(let r of n)Ke(r,t.cloneNode(!0),e),Dt(r);break;case"replace":kt(n,t,"replaceWith");break;case"prepend":case"append":case"before":case"after":kt(n,t,e)}};he({name:"datastar-patch-signals",apply({error:e},{signals:t,onlyIfMissing:n}){if(t){let r=n?.trim()==="true";C(re(t),{ifMissing:r})}else throw e("PatchSignalsExpectedSignals")}});export{O as action,Et as actions,p as attribute,w as beginBatch,Ne as computed,E as effect,M as endBatch,D as filtered,ie as getPath,C as mergePatch,S as mergePaths,Ke as morph,X as root,me as signal,V as startPeeking,I as stopPeeking,he as watcher}; +//# sourceMappingURL=datastar.js.map diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..13613bf --- /dev/null +++ b/go.mod @@ -0,0 +1,14 @@ +module github.com/go-via/via + +go 1.25.3 + +require maragu.dev/gomponents v1.2.0 + +require github.com/starfederation/datastar-go v1.0.3 + +require ( + github.com/CAFxX/httpcompression v0.0.9 // indirect + github.com/andybalholm/brotli v1.2.0 // indirect + github.com/klauspost/compress v1.18.0 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..dd39832 --- /dev/null +++ b/go.sum @@ -0,0 +1,43 @@ +github.com/CAFxX/httpcompression v0.0.9 h1:0ue2X8dOLEpxTm8tt+OdHcgA+gbDge0OqFQWGKSqgrg= +github.com/CAFxX/httpcompression v0.0.9/go.mod h1:XX8oPZA+4IDcfZ0A71Hz0mZsv/YJOgYygkFhizVPilM= +github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= +github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= +github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/brotli/go/cbrotli v0.0.0-20230829110029-ed738e842d2f h1:jopqB+UTSdJGEJT8tEqYyE29zN91fi2827oLET8tl7k= +github.com/google/brotli/go/cbrotli v0.0.0-20230829110029-ed738e842d2f/go.mod h1:nOPhAkwVliJdNTkj3gXpljmWhjc4wCaVqbMJcPKWP4s= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs= +github.com/pierrec/lz4/v4 v4.1.18 h1:xaKrnTkyoqfh1YItXl56+6KJNVYWlEEPuAQW9xsplYQ= +github.com/pierrec/lz4/v4 v4.1.18/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/starfederation/datastar-go v1.0.3 h1:DnzgsJ6tDHDM6y5Nxsk0AGW/m8SyKch2vQg3P1xGTcU= +github.com/starfederation/datastar-go v1.0.3/go.mod h1:stm83LQkhZkwa5GzzdPEN6dLuu8FVwxIv0w1DYkbD3w= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/ulikunitz/xz v0.5.11/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/gozstd v1.20.1 h1:xPnnnvjmaDDitMFfDxmQ4vpx0+3CdTg2o3lALvXTU/g= +github.com/valyala/gozstd v1.20.1/go.mod h1:y5Ew47GLlP37EkTB+B4s7r6A5rdaeB7ftbl9zoYiIPQ= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +maragu.dev/gomponents v1.2.0 h1:H7/N5htz1GCnhu0HB1GasluWeU2rJZOYztVEyN61iTc= +maragu.dev/gomponents v1.2.0/go.mod h1:oEDahza2gZoXDoDHhw8jBNgH+3UR5ni7Ur648HORydM= diff --git a/h/attributes.go b/h/attributes.go new file mode 100644 index 0000000..bc2b8dd --- /dev/null +++ b/h/attributes.go @@ -0,0 +1,32 @@ +package h + +import gh "maragu.dev/gomponents/html" + +func Href(v string) H { + return gh.Href(v) +} + +func Type(v string) H { + return gh.Type(v) +} + +func Src(v string) H { + return gh.Src(v) +} + +func ID(v string) H { + return gh.ID(v) +} + +func Value(v string) H { + return gh.Value(v) +} + +func Placeholder(v string) H { + return gh.Placeholder(v) +} + +// Data attributes automatically have their name prefixed with "data-". +func Data(name, v string) H { + return gh.Data(name, v) +} diff --git a/h/datastar.go b/h/datastar.go new file mode 100644 index 0000000..d50de92 --- /dev/null +++ b/h/datastar.go @@ -0,0 +1,9 @@ +package h + +import "fmt" + +type OnClickOpts string + +func OnClick(actionid string, opt ...OnClickOpts) H { + return Data("on:click", fmt.Sprintf("@get('/_action/%s')", actionid)) +} diff --git a/h/elements.go b/h/elements.go new file mode 100644 index 0000000..06f9bfd --- /dev/null +++ b/h/elements.go @@ -0,0 +1,401 @@ +package h + +import ( + gh "maragu.dev/gomponents/html" +) + +func A(children ...H) H { + return gh.A(retype(children)...) +} + +func Abbr(children ...H) H { + return gh.Abbr(retype(children)...) +} + +func Address(children ...H) H { + return gh.Address(retype(children)...) +} + +func Area(children ...H) H { + return gh.Area(retype(children)...) +} + +func Article(children ...H) H { + return gh.Article(retype(children)...) +} + +func Aside(children ...H) H { + return gh.Aside(retype(children)...) +} + +func Audio(children ...H) H { + return gh.Audio(retype(children)...) +} + +func B(children ...H) H { + return gh.B(retype(children)...) +} + +func Base(children ...H) H { + return gh.Base(retype(children)...) +} + +func BlockQuote(children ...H) H { + return gh.BlockQuote(retype(children)...) +} + +func Body(children ...H) H { + return gh.Body(retype(children)...) +} + +func Br(children ...H) H { + return gh.Br(retype(children)...) +} + +func Button(children ...H) H { + return gh.Button(retype(children)...) +} + +func Canvas(children ...H) H { + return gh.Canvas(retype(children)...) +} + +func Caption(children ...H) H { + return gh.Caption(retype(children)...) +} + +func Cite(children ...H) H { + return gh.Cite(retype(children)...) +} + +func Code(children ...H) H { + return gh.Code(retype(children)...) +} + +func Col(children ...H) H { + return gh.Col(retype(children)...) +} + +func ColGroup(children ...H) H { + return gh.ColGroup(retype(children)...) +} + +func DataList(children ...H) H { + return gh.DataList(retype(children)...) +} + +func Dd(children ...H) H { + return gh.Dd(retype(children)...) +} + +func Del(children ...H) H { + return gh.Del(retype(children)...) +} + +func Details(children ...H) H { + return gh.Details(retype(children)...) +} + +func Dfn(children ...H) H { + return gh.Dfn(retype(children)...) +} + +func Dialog(children ...H) H { + return gh.Dialog(retype(children)...) +} + +func Div(children ...H) H { + return gh.Div(retype(children)...) +} + +func Dl(children ...H) H { + return gh.Dl(retype(children)...) +} + +func Dt(children ...H) H { + return gh.Dt(retype(children)...) +} + +func Em(children ...H) H { + return gh.Em(retype(children)...) +} + +func Embed(children ...H) H { + return gh.Embed(retype(children)...) +} + +func FieldSet(children ...H) H { + return gh.FieldSet(retype(children)...) +} + +func FigCaption(children ...H) H { + return gh.FigCaption(retype(children)...) +} + +func Figure(children ...H) H { + return gh.Figure(retype(children)...) +} + +func Footer(children ...H) H { + return gh.Footer(retype(children)...) +} + +func Form(children ...H) H { + return gh.Form(retype(children)...) +} + +func H1(children ...H) H { + return gh.H1(retype(children)...) +} + +func H2(children ...H) H { + return gh.H2(retype(children)...) +} + +func H3(children ...H) H { + return gh.H3(retype(children)...) +} + +func H4(children ...H) H { + return gh.H4(retype(children)...) +} + +func H5(children ...H) H { + return gh.H5(retype(children)...) +} + +func H6(children ...H) H { + return gh.H6(retype(children)...) +} + +func Head(children ...H) H { + return gh.Head(retype(children)...) +} + +func Header(children ...H) H { + return gh.Header(retype(children)...) +} + +func Hr(children ...H) H { + return gh.Hr(retype(children)...) +} + +func HTML(children ...H) H { + return gh.HTML(retype(children)...) +} + +func I(children ...H) H { + return gh.I(retype(children)...) +} + +func IFrame(children ...H) H { + return gh.IFrame(retype(children)...) +} + +func Img(children ...H) H { + return gh.Img(retype(children)...) +} + +func Input(children ...H) H { + return gh.Input(retype(children)...) +} + +func Ins(children ...H) H { + return gh.Ins(retype(children)...) +} + +func Kbd(children ...H) H { + return gh.Kbd(retype(children)...) +} + +func Label(children ...H) H { + return gh.Label(retype(children)...) +} + +func Legend(children ...H) H { + return gh.Legend(retype(children)...) +} + +func Li(children ...H) H { + return gh.Li(retype(children)...) +} + +func Link(children ...H) H { + return gh.Link(retype(children)...) +} + +func Main(children ...H) H { + return gh.Main(retype(children)...) +} + +func Mark(children ...H) H { + return gh.Mark(retype(children)...) +} + +func Meta(children ...H) H { + return gh.Meta(retype(children)...) +} + +func Meter(children ...H) H { + return gh.Meter(retype(children)...) +} + +func Nav(children ...H) H { + return gh.Nav(retype(children)...) +} + +func NoScript(children ...H) H { + return gh.NoScript(retype(children)...) +} + +func Object(children ...H) H { + return gh.Object(retype(children)...) +} + +func Ol(children ...H) H { + return gh.Ol(retype(children)...) +} + +func OptGroup(children ...H) H { + return gh.OptGroup(retype(children)...) +} + +func Option(children ...H) H { + return gh.Option(retype(children)...) +} + +func P(children ...H) H { + return gh.P(retype(children)...) +} + +func Picture(children ...H) H { + return gh.Picture(retype(children)...) +} + +func Pre(children ...H) H { + return gh.Pre(retype(children)...) +} + +func Progress(children ...H) H { + return gh.Progress(retype(children)...) +} + +func Q(children ...H) H { + return gh.Q(retype(children)...) +} + +func S(children ...H) H { + return gh.S(retype(children)...) +} + +func Samp(children ...H) H { + return gh.Samp(retype(children)...) +} + +func Script(children ...H) H { + return gh.Script(retype(children)...) +} + +func Section(children ...H) H { + return gh.Section(retype(children)...) +} + +func Select(children ...H) H { + return gh.Select(retype(children)...) +} + +func Small(children ...H) H { + return gh.Small(retype(children)...) +} + +func Source(children ...H) H { + return gh.Source(retype(children)...) +} + +func Span(children ...H) H { + return gh.Span(retype(children)...) +} + +func Strong(children ...H) H { + return gh.Strong(retype(children)...) +} + +func Style(v string) H { + return gh.Style(v) +} + +func Sub(children ...H) H { + return gh.Sub(retype(children)...) +} + +func Summary(children ...H) H { + return gh.Summary(retype(children)...) +} + +func Sup(children ...H) H { + return gh.Sup(retype(children)...) +} + +func Table(children ...H) H { + return gh.Table(retype(children)...) +} + +func TBody(children ...H) H { + return gh.TBody(retype(children)...) +} + +func Td(children ...H) H { + return gh.Td(retype(children)...) +} + +func Template(children ...H) H { + return gh.Template(retype(children)...) +} + +func Textarea(children ...H) H { + return gh.Textarea(retype(children)...) +} + +func TFoot(children ...H) H { + return gh.TFoot(retype(children)...) +} + +func Th(children ...H) H { + return gh.Th(retype(children)...) +} + +func THead(children ...H) H { + return gh.THead(retype(children)...) +} + +func Time(children ...H) H { + return gh.Time(retype(children)...) +} + +func Title(v string) H { + return gh.Title(v) +} + +func Tr(children ...H) H { + return gh.Tr(retype(children)...) +} + +func U(children ...H) H { + return gh.U(retype(children)...) +} + +func Ul(children ...H) H { + return gh.Ul(retype(children)...) +} + +func Var(children ...H) H { + return gh.Var(retype(children)...) +} + +func Video(children ...H) H { + return gh.Video(retype(children)...) +} + +func Wbr(children ...H) H { + return gh.Wbr(retype(children)...) +} diff --git a/h/h.go b/h/h.go new file mode 100644 index 0000000..1190a48 --- /dev/null +++ b/h/h.go @@ -0,0 +1,78 @@ +// Package h provides a Go-native DSL for HTML composition. +// Every element, attribute, and text node is constructed as a function that returns a [h.H] DOM node. +// +// Example: +// +// h.Div( +// h.H1(h.Text("Hello, Via")), +// h.P(h.Text("Pure Go. No tmplates.")), +// ) +package h + +import ( + "io" + g "maragu.dev/gomponents" + gc "maragu.dev/gomponents/components" +) + +// H represents a DOM node. +type H interface { + Render(w io.Writer) error +} + +// Text creates a text DOM node that Renders the escaped string t. +func Text(t string) H { + return g.Text(t) +} + +// Textf creates a text DOM node that Renders the interpolated and escaped string format. +func Textf(format string, a ...any) H { + return g.Textf(format, a...) +} + +// / Raw creates a text DOM [Node] that just Renders the unescaped string t. +func Raw(s string) H { + return g.Raw(s) +} + +// Attr creates an attribute DOM [Node] with a name and optional value. +// If only a name is passed, it's a name-only (boolean) attribute (like "required"). +// If a name and value are passed, it's a name-value attribute (like `class="header"`). +// More than one value make [Attr] panic. +// Use this if no convenience creator exists in the h package. +func Attr(name string, value ...string) H { + return g.Attr(name, value...) +} + +// HTML5Props defines properties for HTML5 pages. Title is set always set, Description +// and Language elements only if the strings are non-empty. +type HTML5Props struct { + Title string + Description string + Language string + Head []H + Body []H + HTMLAttrs []H +} + +// HTML5 document template. +func HTML5(p HTML5Props) H { + gp := gc.HTML5Props{ + Title: p.Title, + Description: p.Description, + Language: p.Language, + Head: retype(p.Head), + Body: retype(p.Body), + HTMLAttrs: retype(p.HTMLAttrs), + } + gp.Head = append(gp.Head, Script(Type("module"), Src("/_datastar.js"))) + return gc.HTML5(gp) +} + +// JoinAttrs with the given name only on the first level of the given nodes. This means that +// attributes on non-direct descendants are ignored. Attribute values are joined by spaces. +// +// Note that this renders all first-level attributes to check whether they should be processed. +func JoinAttrs(name string, children ...H) H { + return gc.JoinAttrs(name, retype(children)...) +} diff --git a/h/util.go b/h/util.go new file mode 100644 index 0000000..61b5803 --- /dev/null +++ b/h/util.go @@ -0,0 +1,13 @@ +package h + +import ( + g "maragu.dev/gomponents" +) + +func retype(nodes []H) []g.Node { + list := make([]g.Node, len(nodes)) + for i, node := range nodes { + list[i] = node.(g.Node) + } + return list +} diff --git a/internal/examples/counter/main.go b/internal/examples/counter/main.go new file mode 100644 index 0000000..e225511 --- /dev/null +++ b/internal/examples/counter/main.go @@ -0,0 +1,96 @@ +package main + +import ( + "github.com/go-via/via" + "github.com/go-via/via/h" +) + +type CounterState struct{ Count int } + +func main() { + v := via.New() + + v.Page("/", func(c *via.Context) { + + s := CounterState{Count: 0} + + step := c.Signal(1) + greeting := c.Signal("Hello...") + + increment := c.Action(func() { + s.Count += step.Int() + c.Sync() + }) + greetBob := c.Action(func() { + greeting.SetValue("Hello Bob!") + c.SyncSignals() + }) + greetAlice := c.Action(func() { + greeting.SetValue("Hello Alice!") + c.SyncSignals() + }) + + c.View(func() h.H { + return h.Div( + h.P(h.Textf("Count: %d", s.Count)), + h.P(h.Span(h.Text("Step: ")), h.Span(step.Text())), + h.Label( + h.Text("Update Step: "), + h.Input(h.Type("number"), step.Bind()), + ), + h.Button(h.Text("Increment"), increment.OnClick()), + + h.P(h.Span(h.Text("Greeting: ")), h.Span(greeting.Text())), + h.Button(h.Text("Greet Alice"), greetBob.OnClick()), + h.Button(h.Text("Greet Alice"), greetAlice.OnClick()), + ) + }) + }) + + v.Start(":3000") +} + +// +// c.View(func() h.H { +// return Layout( +// h.Div( +// h.Meta(h.Data("init", "@get('/_sse')")), +// h.P(h.Data("text", "$via-ctx")), +// h.Div( +// counter(), +// h.Data("signals:step", "1"), +// h.Label(h.Text("Step")), +// h.Input(h.Data("bind", "step")), +// h.Button( +// h.Text("Trigger foo"), +// h.Data("on:click", "@get('/_action/foo')"), +// ), +// ), +// ), +// ) +// }) + +// conterComponent := c.Component("counter1", CounterComponent) +// +// in c.View of page add CounterComponent +// +// func CounterComponent(c *via.Context){ +// s := CounterState{ Count: 1 } +// step := c.Signal(1) +// +// c.View(func() h.H { +// return h.Div( +// h.P(h.Textf("Count: %d", s.Count)), +// h.Label( +// h.Text("Step"), +// h.Input(h.Type("number"), step.Bind()), +// ), +// h.Button(h.Text("Increment"), h.OnClick("inc")), +// ) +// }) +// +// c.Action("inc", func() { +// s.Count += step +// c.Sync() +// }) +// } diff --git a/internal/examples/countercomp/main.go b/internal/examples/countercomp/main.go new file mode 100644 index 0000000..06ab7d0 --- /dev/null +++ b/internal/examples/countercomp/main.go @@ -0,0 +1 @@ +package main diff --git a/signal.go b/signal.go new file mode 100644 index 0000000..541370e --- /dev/null +++ b/signal.go @@ -0,0 +1,186 @@ +package via + +import ( + "fmt" + "reflect" + "strconv" + "strings" + + "github.com/go-via/via/h" +) + +// Signal represents a value that is reactive in the browser. Signals +// are synct with the server right before an action triggers. +// +// Use Bind() to connect a signal to an input and Text() to display it +// reactively on an html element. +type signal struct { + id string + v reflect.Value + t reflect.Type + changed bool + err error +} + +// ID returns the signal ID +func (s *signal) ID() string { + return s.id +} + +// Err returns a signal error or nil if it contains no error. +// +// It is useful to check for errors after updating signals with +// dinamic values. +func (s *signal) Err() error { + return s.err +} + +// Bind binds this signal to an imput element. When the imput changes +// its value the signal updates in real-time in the browser. +// +// Example: +// +// h.Input(h.Type("number"), mysignal.Bind()) +func (s *signal) Bind() h.H { + return h.Data("bind", s.id) +} + +// Text binds the signal value to an html element as text. +// +// Example: +// +// h.Div(h.Text("x: "), mysignal.Text()) +func (s *signal) Text() h.H { + return h.Data("text", "$"+s.id) +} + +// SetValue updates the signal’s value and marks it for synchronization with the browser. +// The change will be propagated to the browser using *Context.Sync() or *Context.SyncSignals(). +func (s *signal) SetValue(v any) { + val := reflect.ValueOf(v) + typ := reflect.TypeOf(v) + if typ != s.t { + s.err = fmt.Errorf("expected type '%s', got '%s'", s.t.String(), typ.String()) + return + } + s.v = val + s.changed = true + s.err = nil +} + +// String return the signal value as a string. +func (s *signal) String() string { + return fmt.Sprintf("%v", s.v) +} + +// Bool tries to read the signal value as a bool. +// Returns the value or false on failure. +func (s *signal) Bool() bool { + switch s.v.Kind() { + case reflect.Bool: + return s.v.Bool() + case reflect.String: + val := strings.ToLower(s.v.String()) + return val == "true" || val == "1" || val == "yes" || val == "on" + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return s.v.Int() != 0 + case reflect.Float32, reflect.Float64: + return s.v.Float() != 0 + default: + return false + } +} + +// Int tries to read the signal value as an int. +// Returns the value or 0 on failure. +func (s *signal) Int() int { + if n, err := strconv.Atoi(s.v.String()); err == nil { + return n + } + if s.v.CanInt() { + return int(s.v.Int()) + } + if s.v.CanFloat() { + return int(s.v.Float()) + } + return 0 +} + +// Int64 tries to read the signal value as an int64. +// Returns the value or 0 on failure. +func (s *signal) Int64() int64 { + if n, err := strconv.ParseInt(s.v.String(), 10, 64); err == nil { + return n + } + if s.v.CanInt() { + return s.v.Int() + } + if s.v.CanFloat() { + return int64(s.v.Float()) + } + return 0 +} + +// Uint64 tries to read the signal value as an uint64. +// Returns the value or 0 on failure. +func (s *signal) Uint64() uint64 { + if n, err := strconv.ParseUint(s.v.String(), 10, 64); err == nil { + return n + } + if s.v.CanUint() { + return s.v.Uint() + } + if s.v.CanFloat() { + return uint64(s.v.Float()) + } + return 0 +} + +// Float64 tries to read the signal value as a float64. +// Returns the value or 0.0 on failure. +func (s *signal) Float64() float64 { + if n, err := strconv.ParseFloat(s.v.String(), 64); err == nil { + return n + } + if s.v.CanFloat() { + return s.v.Float() + } + if s.v.CanInt() { + return float64(s.v.Int()) + } + return 0.0 +} + +// Complex128 tries to read the signal value as a complex128. +// Returns the value or 0 on failure. +func (s *signal) Complex128() complex128 { + if s.v.Kind() == reflect.Complex128 { + return s.v.Complex() + } + if s.v.Kind() == reflect.String { + if n, err := strconv.ParseComplex(s.v.String(), 128); err == nil { + return n + } + } + if s.v.CanFloat() { + return complex(s.v.Float(), 0) + } + if s.v.CanInt() { + return complex(float64(s.v.Int()), 0) + } + return complex(0, 0) +} + +// Bytes tries to read the signal value as a []byte +// Returns the value or an empty []byte on failure. +func (s *signal) Bytes() []byte { + switch s.v.Kind() { + case reflect.Slice: + if s.v.Type().Elem().Kind() == reflect.Uint8 { + return s.v.Bytes() + } + case reflect.String: + return []byte(s.v.String()) + } + return make([]byte, 0) +} diff --git a/via.go b/via.go new file mode 100644 index 0000000..75dfd0d --- /dev/null +++ b/via.go @@ -0,0 +1,224 @@ +// Package via provides a reactive web framework for Go. +// It lets you build live, type-safe web interfaces without JavaScript. +// +// Via unifies routing, state, and UI reactivity through a simple mental model: +// Go on the server — HTML in the browser — updated in real time via Datastar. +package via + +import ( + "crypto/rand" + _ "embed" + "encoding/hex" + "fmt" + "log" + "net/http" + "sync" + + "github.com/go-via/via/h" + "github.com/starfederation/datastar-go/datastar" +) + +//go:embed datastar.js +var datastarJS []byte + +type config struct { + logLvl LogLevel +} + +type LogLevel int + +const ( + LogLevelError LogLevel = iota + LogLevelWarn + LogLevelInfo + LogLevelDebug +) + +// via is the root application. +// It manages page routing, user sessions, and SSE connections for live updates. +type via struct { + cfg config + mux *http.ServeMux + contextRegistry map[string]*Context + contextRegistryMutex sync.RWMutex + baseLayout func(h.HTML5Props) h.H +} + +func (v *via) logErr(c *Context, format string, a ...any) { + cRef := "" + if c != nil && c.id != "" { + cRef = fmt.Sprintf("via-ctx=%q ", c.id) + } + log.Printf("[error] %smsg=%q", cRef, fmt.Sprintf(format, a...)) +} + +func (v *via) logWarn(c *Context, format string, a ...any) { + cRef := "" + if c != nil && c.id != "" { + cRef = fmt.Sprintf("via-ctx=%q ", c.id) + } + if v.cfg.logLvl <= LogLevelWarn { + log.Printf("[warn] %smsg=%q", cRef, fmt.Sprintf(format, a...)) + } +} + +func (v *via) logInfo(c *Context, format string, a ...any) { + cRef := "" + if c != nil && c.id != "" { + cRef = fmt.Sprintf("via-ctx=%q ", c.id) + } + if v.cfg.logLvl >= LogLevelInfo { + log.Printf("[info] %smsg=%q", cRef, fmt.Sprintf(format, a...)) + } +} + +func (v *via) logDebug(c *Context, format string, a ...any) { + cRef := "" + if c != nil && c.id != "" { + cRef = fmt.Sprintf("via-ctx=%q ", c.id) + } + if v.cfg.logLvl == LogLevelDebug { + log.Printf("[debug] %smsg=%q", cRef, fmt.Sprintf(format, a...)) + } +} + +// Page registers a route and its associated page handler. +// The handler receives a *Context to define UI, signals, and actions. +// +// Example: +// +// v.Page("/", func(c *via.Context) { +// c.View(func() h.H { +// return h.H1(h.Text("Hello, Via!")) +// }) +// }) +func (v *via) Page(route string, composeContext func(c *Context)) { + v.mux.HandleFunc("GET "+route, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + id := fmt.Sprintf("%s_/%s", route, genRandID()) + c := newContext(id, v) + v.logDebug(c, "GET %s", route) + composeContext(c) + v.registerCtx(c.id, c) + // viewFn := c.view + // viewFnWithID := func() h.H { + // return h.Div(h.ID(c.id), viewFn()) + // } + // c.view = viewFnWithID + view := v.baseLayout(h.HTML5Props{ + Head: []h.H{ + h.Meta(h.Data("signals", fmt.Sprintf("{'via-ctx':'%s'}", id))), + h.Meta(h.Data("init", "@get('/_sse')")), + }, + Body: []h.H{h.Div(h.ID(c.id))}, + }) + _ = view.Render(w) + })) +} + +func (v *via) registerCtx(id string, c *Context) { + v.contextRegistryMutex.Lock() + defer v.contextRegistryMutex.Unlock() + v.contextRegistry[id] = c +} + +// func (a *App) unregisterCtx(id string) { +// if _, ok := a.contextRegistry[id]; ok { +// a.contextRegistryMutex.Lock() +// defer a.contextRegistryMutex.Unlock() +// delete(a.contextRegistry, id) +// } +// } + +func (v *via) getCtx(id string) (*Context, error) { + if c, ok := v.contextRegistry[id]; ok { + return c, nil + } + return nil, fmt.Errorf("ctx '%s' not found", id) +} + +// Start starts the Via HTTP server on the given address. +func (v *via) Start(addr string) { + v.logInfo(nil, "via started") + log.Fatalf("via failed: %v", http.ListenAndServe(addr, v.mux)) +} + +// New creates a new Via application with default configuration. +func New() *via { + mux := http.NewServeMux() + app := &via{ + mux: mux, + contextRegistry: make(map[string]*Context), + cfg: config{logLvl: LogLevelDebug}, + baseLayout: h.HTML5, + } + + app.mux.HandleFunc("GET /favicon.ico", func(w http.ResponseWriter, r *http.Request) {}) + + app.mux.HandleFunc("GET /_datastar.js", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/javascript") + _, _ = w.Write(datastarJS) + }) + + app.mux.HandleFunc("GET /_sse", func(w http.ResponseWriter, r *http.Request) { + var sigs map[string]any + _ = datastar.ReadSignals(r, &sigs) + cID, _ := sigs["via-ctx"].(string) + c, err := app.getCtx(cID) + if err != nil { + app.logErr(nil, "failed to render page: %v", err) + return + } + c.sse = datastar.NewSSE(w, r) + app.logDebug(c, "SSE connection established") + c.Sync() + <-c.sse.Context().Done() + c.sse = nil + app.logDebug(c, "SSE connection closed") + }) + app.mux.HandleFunc("GET /_action/{id}", func(w http.ResponseWriter, r *http.Request) { + actionID := r.PathValue("id") + var sigs map[string]any + _ = datastar.ReadSignals(r, &sigs) + cID, _ := sigs["via-ctx"].(string) + app.logDebug(nil, "GET /_action/%s via-ctx=%s", actionID, cID) + active_ctx_count := 0 + inactive_ctx_count := 0 + for _, c := range app.contextRegistry { + if c.sse != nil { + active_ctx_count++ + continue + } + inactive_ctx_count++ + } + app.logDebug(nil, "active_ctx_count=%d inactive_ctx_count=%d", active_ctx_count, inactive_ctx_count) + c, err := app.getCtx(cID) + if err != nil { + app.logErr(nil, "action '%s' failed: %v", actionID, err) + return + } + actionFn, err := c.getActionFn(actionID) + if err != nil { + app.logDebug(c, "action '%s' failed: %v", actionID, err) + return + } + // log err if actionFn panics + defer func() { + if r := recover(); r != nil { + app.logErr(c, "action '%s' failed: %v", actionID, r) + } + }() + c.signalsMux.Lock() + defer c.signalsMux.Unlock() + app.logDebug(c, "signals=%v", sigs) + c.injectSignals(sigs) + actionFn() + + }) + return app +} + +func genRandID() string { + b := make([]byte, 16) + rand.Read(b) + return hex.EncodeToString(b)[:8] +}