Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4b831492e | ||
|
|
ea7b9ad4a1 | ||
|
|
03b6d7453a |
@@ -37,4 +37,12 @@ type Options struct {
|
|||||||
// with scs LoadAndSave middleware. Configure the session manager before
|
// with scs LoadAndSave middleware. Configure the session manager before
|
||||||
// passing it (lifetime, cookie settings, store, etc).
|
// passing it (lifetime, cookie settings, store, etc).
|
||||||
SessionManager *scs.SessionManager
|
SessionManager *scs.SessionManager
|
||||||
|
|
||||||
|
// DatastarContent is the Datastar.js script content.
|
||||||
|
// If nil, the embedded default is used.
|
||||||
|
DatastarContent []byte
|
||||||
|
|
||||||
|
// DatastarPath is the URL path where the script is served.
|
||||||
|
// Defaults to "/_datastar.js" if empty.
|
||||||
|
DatastarPath string
|
||||||
}
|
}
|
||||||
|
|||||||
31
context.go
31
context.go
@@ -318,6 +318,37 @@ func (c *Context) ExecScript(s string) {
|
|||||||
c.sendPatch(patch{patchTypeScript, s})
|
c.sendPatch(patch{patchTypeScript, s})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Redirect navigates the browser to the given URL.
|
||||||
|
// This triggers a full page navigation - the current context will be disposed
|
||||||
|
// and a new context created at the destination URL.
|
||||||
|
func (c *Context) Redirect(url string) {
|
||||||
|
if url == "" {
|
||||||
|
c.app.logWarn(c, "redirect failed: empty url")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.sendPatch(patch{patchTypeRedirect, url})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redirectf navigates the browser to a URL constructed from the format string and arguments.
|
||||||
|
func (c *Context) Redirectf(format string, a ...any) {
|
||||||
|
c.Redirect(fmt.Sprintf(format, a...))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReplaceURL updates the browser's URL and history without triggering navigation.
|
||||||
|
// Useful for updating query params or path to reflect UI state changes.
|
||||||
|
func (c *Context) ReplaceURL(url string) {
|
||||||
|
if url == "" {
|
||||||
|
c.app.logWarn(c, "replace url failed: empty url")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.sendPatch(patch{patchTypeReplaceURL, url})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReplaceURLf updates the browser's URL using a format string.
|
||||||
|
func (c *Context) ReplaceURLf(format string, a ...any) {
|
||||||
|
c.ReplaceURL(fmt.Sprintf(format, a...))
|
||||||
|
}
|
||||||
|
|
||||||
// stopAllRoutines stops all go routines tied to this Context preventing goroutine leaks.
|
// stopAllRoutines stops all go routines tied to this Context preventing goroutine leaks.
|
||||||
func (c *Context) stopAllRoutines() {
|
func (c *Context) stopAllRoutines() {
|
||||||
select {
|
select {
|
||||||
|
|||||||
1
h/h.go
1
h/h.go
@@ -79,7 +79,6 @@ func HTML5(p HTML5Props) H {
|
|||||||
Body: retype(p.Body),
|
Body: retype(p.Body),
|
||||||
HTMLAttrs: retype(p.HTMLAttrs),
|
HTMLAttrs: retype(p.HTMLAttrs),
|
||||||
}
|
}
|
||||||
gp.Head = append(gp.Head, Script(Type("module"), Src("/_datastar.js")))
|
|
||||||
return gc.HTML5(gp)
|
return gc.HTML5(gp)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,11 +7,11 @@ import (
|
|||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
v := via.New()
|
v := via.New()
|
||||||
|
v.Config(via.Options{ServerAddress: ":7331"})
|
||||||
|
|
||||||
v.Page("/", func(c *via.Context) {
|
// Login page
|
||||||
username := c.Session().GetString("username")
|
v.Page("/login", func(c *via.Context) {
|
||||||
flash := c.Session().PopString("flash")
|
flash := c.Session().PopString("flash")
|
||||||
|
|
||||||
usernameInput := c.Signal("")
|
usernameInput := c.Signal("")
|
||||||
|
|
||||||
login := c.Action(func() {
|
login := c.Action(func() {
|
||||||
@@ -20,38 +20,69 @@ func main() {
|
|||||||
c.Session().Set("username", name)
|
c.Session().Set("username", name)
|
||||||
c.Session().Set("flash", "Welcome, "+name+"!")
|
c.Session().Set("flash", "Welcome, "+name+"!")
|
||||||
c.Session().RenewToken()
|
c.Session().RenewToken()
|
||||||
|
c.Redirect("/dashboard")
|
||||||
}
|
}
|
||||||
c.Sync()
|
|
||||||
})
|
|
||||||
|
|
||||||
logout := c.Action(func() {
|
|
||||||
c.Session().Set("flash", "Goodbye!")
|
|
||||||
c.Session().Delete("username")
|
|
||||||
c.Sync()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
c.View(func() h.H {
|
c.View(func() h.H {
|
||||||
|
// Already logged in? Redirect to dashboard
|
||||||
|
if c.Session().GetString("username") != "" {
|
||||||
|
c.Redirect("/dashboard")
|
||||||
|
return h.Div()
|
||||||
|
}
|
||||||
|
|
||||||
var flashMsg h.H
|
var flashMsg h.H
|
||||||
if flash != "" {
|
if flash != "" {
|
||||||
flashMsg = h.P(h.Text(flash), h.Style("color: green"))
|
flashMsg = h.P(h.Text(flash), h.Style("color: green"))
|
||||||
}
|
}
|
||||||
|
|
||||||
if username == "" {
|
|
||||||
return h.Div(
|
return h.Div(
|
||||||
flashMsg,
|
flashMsg,
|
||||||
h.H1(h.Text("Login")),
|
h.H1(h.Text("Login")),
|
||||||
h.Input(h.Type("text"), h.Placeholder("Username"), usernameInput.Bind()),
|
h.Input(h.Type("text"), h.Placeholder("Username"), usernameInput.Bind()),
|
||||||
h.Button(h.Text("Login"), login.OnClick()),
|
h.Button(h.Text("Login"), login.OnClick()),
|
||||||
)
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Dashboard page (protected)
|
||||||
|
v.Page("/dashboard", func(c *via.Context) {
|
||||||
|
logout := c.Action(func() {
|
||||||
|
c.Session().Set("flash", "Goodbye!")
|
||||||
|
c.Session().Delete("username")
|
||||||
|
c.Redirect("/login")
|
||||||
|
})
|
||||||
|
|
||||||
|
c.View(func() h.H {
|
||||||
|
username := c.Session().GetString("username")
|
||||||
|
|
||||||
|
// Not logged in? Redirect to login
|
||||||
|
if username == "" {
|
||||||
|
c.Session().Set("flash", "Please log in first")
|
||||||
|
c.Redirect("/login")
|
||||||
|
return h.Div()
|
||||||
|
}
|
||||||
|
|
||||||
|
flash := c.Session().PopString("flash")
|
||||||
|
var flashMsg h.H
|
||||||
|
if flash != "" {
|
||||||
|
flashMsg = h.P(h.Text(flash), h.Style("color: green"))
|
||||||
}
|
}
|
||||||
return h.Div(
|
return h.Div(
|
||||||
flashMsg,
|
flashMsg,
|
||||||
h.H1(h.Textf("Hello, %s!", username)),
|
h.H1(h.Textf("Dashboard - Hello, %s!", username)),
|
||||||
h.P(h.Text("Your session persists across page refreshes.")),
|
h.P(h.Text("Your session persists across page refreshes.")),
|
||||||
h.Button(h.Text("Logout"), logout.OnClick()),
|
h.Button(h.Text("Logout"), logout.OnClick()),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Redirect root to login
|
||||||
|
v.Page("/", func(c *via.Context) {
|
||||||
|
c.View(func() h.H {
|
||||||
|
c.Redirect("/login")
|
||||||
|
return h.Div()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
v.Start()
|
v.Start()
|
||||||
}
|
}
|
||||||
|
|||||||
46
via.go
46
via.go
@@ -15,6 +15,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -39,6 +40,9 @@ type V struct {
|
|||||||
documentFootIncludes []h.H
|
documentFootIncludes []h.H
|
||||||
devModePageInitFnMap map[string]func(*Context)
|
devModePageInitFnMap map[string]func(*Context)
|
||||||
sessionManager *scs.SessionManager
|
sessionManager *scs.SessionManager
|
||||||
|
datastarPath string
|
||||||
|
datastarContent []byte
|
||||||
|
datastarOnce sync.Once
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *V) logFatal(format string, a ...any) {
|
func (v *V) logFatal(format string, a ...any) {
|
||||||
@@ -107,6 +111,12 @@ func (v *V) Config(cfg Options) {
|
|||||||
if cfg.SessionManager != nil {
|
if cfg.SessionManager != nil {
|
||||||
v.sessionManager = cfg.SessionManager
|
v.sessionManager = cfg.SessionManager
|
||||||
}
|
}
|
||||||
|
if cfg.DatastarContent != nil {
|
||||||
|
v.datastarContent = cfg.DatastarContent
|
||||||
|
}
|
||||||
|
if cfg.DatastarPath != "" {
|
||||||
|
v.datastarPath = cfg.DatastarPath
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// AppendToHead appends the given h.H nodes to the head of the base HTML document.
|
// AppendToHead appends the given h.H nodes to the head of the base HTML document.
|
||||||
@@ -140,6 +150,7 @@ func (v *V) AppendToFoot(elements ...h.H) {
|
|||||||
// })
|
// })
|
||||||
// })
|
// })
|
||||||
func (v *V) Page(route string, initContextFn func(c *Context)) {
|
func (v *V) Page(route string, initContextFn func(c *Context)) {
|
||||||
|
v.ensureDatastarHandler()
|
||||||
// check for panics
|
// check for panics
|
||||||
func() {
|
func() {
|
||||||
defer func() {
|
defer func() {
|
||||||
@@ -175,7 +186,7 @@ func (v *V) Page(route string, initContextFn func(c *Context)) {
|
|||||||
if v.cfg.DevMode {
|
if v.cfg.DevMode {
|
||||||
v.devModePersist(c)
|
v.devModePersist(c)
|
||||||
}
|
}
|
||||||
headElements := []h.H{}
|
headElements := []h.H{h.Script(h.Type("module"), h.Src(v.datastarPath))}
|
||||||
headElements = append(headElements, v.documentHeadIncludes...)
|
headElements = append(headElements, v.documentHeadIncludes...)
|
||||||
headElements = append(headElements,
|
headElements = append(headElements,
|
||||||
h.Meta(h.Data("signals", fmt.Sprintf("{'via-ctx':'%s'}", id))),
|
h.Meta(h.Data("signals", fmt.Sprintf("{'via-ctx':'%s'}", id))),
|
||||||
@@ -257,6 +268,15 @@ func (v *V) HTTPServeMux() *http.ServeMux {
|
|||||||
return v.mux
|
return v.mux
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (v *V) ensureDatastarHandler() {
|
||||||
|
v.datastarOnce.Do(func() {
|
||||||
|
v.mux.HandleFunc("GET "+v.datastarPath, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/javascript")
|
||||||
|
_, _ = w.Write(v.datastarContent)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (v *V) devModePersist(c *Context) {
|
func (v *V) devModePersist(c *Context) {
|
||||||
p := filepath.Join(".via", "devmode", "ctx.json")
|
p := filepath.Join(".via", "devmode", "ctx.json")
|
||||||
if err := os.MkdirAll(filepath.Dir(p), 0755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(p), 0755); err != nil {
|
||||||
@@ -359,6 +379,8 @@ const (
|
|||||||
patchTypeElements = iota
|
patchTypeElements = iota
|
||||||
patchTypeSignals
|
patchTypeSignals
|
||||||
patchTypeScript
|
patchTypeScript
|
||||||
|
patchTypeRedirect
|
||||||
|
patchTypeReplaceURL
|
||||||
)
|
)
|
||||||
|
|
||||||
type patch struct {
|
type patch struct {
|
||||||
@@ -375,6 +397,8 @@ func New() *V {
|
|||||||
contextRegistry: make(map[string]*Context),
|
contextRegistry: make(map[string]*Context),
|
||||||
devModePageInitFnMap: make(map[string]func(*Context)),
|
devModePageInitFnMap: make(map[string]func(*Context)),
|
||||||
sessionManager: scs.New(),
|
sessionManager: scs.New(),
|
||||||
|
datastarPath: "/_datastar.js",
|
||||||
|
datastarContent: datastarJS,
|
||||||
cfg: Options{
|
cfg: Options{
|
||||||
DevMode: false,
|
DevMode: false,
|
||||||
ServerAddress: ":3000",
|
ServerAddress: ":3000",
|
||||||
@@ -383,11 +407,6 @@ func New() *V {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
v.mux.HandleFunc("GET /_datastar.js", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.Header().Set("Content-Type", "application/javascript")
|
|
||||||
_, _ = w.Write(datastarJS)
|
|
||||||
})
|
|
||||||
|
|
||||||
v.mux.HandleFunc("GET /_sse", func(w http.ResponseWriter, r *http.Request) {
|
v.mux.HandleFunc("GET /_sse", func(w http.ResponseWriter, r *http.Request) {
|
||||||
isReconnect := false
|
isReconnect := false
|
||||||
if r.Header.Get("last-event-id") == "via" {
|
if r.Header.Get("last-event-id") == "via" {
|
||||||
@@ -453,6 +472,21 @@ func New() *V {
|
|||||||
v.logErr(c, "ExecuteScript failed: %v", err)
|
v.logErr(c, "ExecuteScript failed: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
case patchTypeRedirect:
|
||||||
|
if err := sse.Redirect(patch.content); err != nil {
|
||||||
|
if sse.Context().Err() == nil {
|
||||||
|
v.logErr(c, "Redirect failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case patchTypeReplaceURL:
|
||||||
|
parsedURL, err := url.Parse(patch.content)
|
||||||
|
if err != nil {
|
||||||
|
v.logErr(c, "ReplaceURL failed to parse URL: %v", err)
|
||||||
|
} else if err := sse.ReplaceURL(*parsedURL); err != nil {
|
||||||
|
if sse.Context().Err() == nil {
|
||||||
|
v.logErr(c, "ReplaceURL failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
48
via_test.go
48
via_test.go
@@ -28,6 +28,10 @@ func TestPageRoute(t *testing.T) {
|
|||||||
|
|
||||||
func TestDatastarJS(t *testing.T) {
|
func TestDatastarJS(t *testing.T) {
|
||||||
v := New()
|
v := New()
|
||||||
|
v.Page("/", func(c *Context) {
|
||||||
|
c.View(func() h.H { return h.Div() })
|
||||||
|
})
|
||||||
|
|
||||||
req := httptest.NewRequest("GET", "/_datastar.js", nil)
|
req := httptest.NewRequest("GET", "/_datastar.js", nil)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
v.mux.ServeHTTP(w, req)
|
v.mux.ServeHTTP(w, req)
|
||||||
@@ -37,6 +41,50 @@ func TestDatastarJS(t *testing.T) {
|
|||||||
assert.Contains(t, w.Body.String(), "🖕JS_DS🚀")
|
assert.Contains(t, w.Body.String(), "🖕JS_DS🚀")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCustomDatastarContent(t *testing.T) {
|
||||||
|
customScript := []byte("// Custom Datastar Script")
|
||||||
|
v := New()
|
||||||
|
v.Config(Options{
|
||||||
|
DatastarContent: customScript,
|
||||||
|
})
|
||||||
|
v.Page("/", func(c *Context) {
|
||||||
|
c.View(func() h.H { return h.Div() })
|
||||||
|
})
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/_datastar.js", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
v.mux.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, w.Code)
|
||||||
|
assert.Equal(t, "application/javascript", w.Header().Get("Content-Type"))
|
||||||
|
assert.Contains(t, w.Body.String(), "Custom Datastar Script")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCustomDatastarPath(t *testing.T) {
|
||||||
|
v := New()
|
||||||
|
v.Config(Options{
|
||||||
|
DatastarPath: "/assets/datastar.js",
|
||||||
|
})
|
||||||
|
v.Page("/test", func(c *Context) {
|
||||||
|
c.View(func() h.H { return h.Div() })
|
||||||
|
})
|
||||||
|
|
||||||
|
// Custom path should serve the script
|
||||||
|
req := httptest.NewRequest("GET", "/assets/datastar.js", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
v.mux.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, w.Code)
|
||||||
|
assert.Equal(t, "application/javascript", w.Header().Get("Content-Type"))
|
||||||
|
assert.Contains(t, w.Body.String(), "🖕JS_DS🚀")
|
||||||
|
|
||||||
|
// Page should reference the custom path in script tag
|
||||||
|
req2 := httptest.NewRequest("GET", "/test", nil)
|
||||||
|
w2 := httptest.NewRecorder()
|
||||||
|
v.mux.ServeHTTP(w2, req2)
|
||||||
|
assert.Contains(t, w2.Body.String(), `src="/assets/datastar.js"`)
|
||||||
|
}
|
||||||
|
|
||||||
func TestSignal(t *testing.T) {
|
func TestSignal(t *testing.T) {
|
||||||
var sig *signal
|
var sig *signal
|
||||||
v := New()
|
v := New()
|
||||||
|
|||||||
Reference in New Issue
Block a user