feat: add working prototype for via

This commit is contained in:
Joao Goncalves
2025-10-31 00:58:53 -01:00
parent d15af60a1d
commit eb20a2a0a9
13 changed files with 1330 additions and 0 deletions

224
context.go Normal file
View File

@@ -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(),
}
}

9
datastar.js Normal file

File diff suppressed because one or more lines are too long

14
go.mod Normal file
View File

@@ -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
)

43
go.sum Normal file
View File

@@ -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=

32
h/attributes.go Normal file
View File

@@ -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)
}

9
h/datastar.go Normal file
View File

@@ -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))
}

401
h/elements.go Normal file
View File

@@ -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)...)
}

78
h/h.go Normal file
View File

@@ -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)...)
}

13
h/util.go Normal file
View File

@@ -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
}

View File

@@ -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()
// })
// }

View File

@@ -0,0 +1 @@
package main

186
signal.go Normal file
View File

@@ -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 signals 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)
}

224
via.go Normal file
View File

@@ -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]
}