From f5158b866ce093e624d7b256f34fe373e287f483 Mon Sep 17 00:00:00 2001 From: Ryan Hamamura <58859899+ryanhamamura@users.noreply.github.com> Date: Fri, 6 Feb 2026 13:22:00 -1000 Subject: [PATCH] feat: add Static and StaticFS helpers for serving static files One-liner static file serving: v.Static("/assets/", "./public") for filesystem directories and v.StaticFS("/assets/", fsys) for embed.FS. Both auto-normalize the URL prefix and disable directory listings. --- static_test.go | 143 +++++++++++++++++++++++++++++++++++++++++++++++++ via.go | 43 ++++++++++++++- 2 files changed, 185 insertions(+), 1 deletion(-) create mode 100644 static_test.go diff --git a/static_test.go b/static_test.go new file mode 100644 index 0000000..1edec65 --- /dev/null +++ b/static_test.go @@ -0,0 +1,143 @@ +package via + +import ( + "io/fs" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "testing/fstest" + + "github.com/stretchr/testify/assert" +) + +func TestStatic(t *testing.T) { + dir := t.TempDir() + os.MkdirAll(filepath.Join(dir, "sub"), 0755) + os.WriteFile(filepath.Join(dir, "hello.txt"), []byte("hello world"), 0644) + os.WriteFile(filepath.Join(dir, "sub", "nested.txt"), []byte("nested"), 0644) + + v := New() + v.Static("/assets/", dir) + + t.Run("serves file", func(t *testing.T) { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/assets/hello.txt", nil) + v.mux.ServeHTTP(w, r) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "hello world", w.Body.String()) + }) + + t.Run("serves nested file", func(t *testing.T) { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/assets/sub/nested.txt", nil) + v.mux.ServeHTTP(w, r) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "nested", w.Body.String()) + }) + + t.Run("directory listing returns 404", func(t *testing.T) { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/assets/", nil) + v.mux.ServeHTTP(w, r) + + assert.Equal(t, http.StatusNotFound, w.Code) + }) + + t.Run("subdirectory listing returns 404", func(t *testing.T) { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/assets/sub/", nil) + v.mux.ServeHTTP(w, r) + + assert.Equal(t, http.StatusNotFound, w.Code) + }) + + t.Run("missing file returns 404", func(t *testing.T) { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/assets/nope.txt", nil) + v.mux.ServeHTTP(w, r) + + assert.Equal(t, http.StatusNotFound, w.Code) + }) +} + +func TestStaticAutoSlash(t *testing.T) { + dir := t.TempDir() + os.WriteFile(filepath.Join(dir, "ok.txt"), []byte("ok"), 0644) + + v := New() + v.Static("/files", dir) // no trailing slash + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/files/ok.txt", nil) + v.mux.ServeHTTP(w, r) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "ok", w.Body.String()) +} + +func TestStaticFS(t *testing.T) { + fsys := fstest.MapFS{ + "style.css": {Data: []byte("body{}")}, + "js/app.js": {Data: []byte("console.log('hi')")}, + } + + v := New() + v.StaticFS("/static/", fsys) + + t.Run("serves file", func(t *testing.T) { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/static/style.css", nil) + v.mux.ServeHTTP(w, r) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "body{}", w.Body.String()) + }) + + t.Run("serves nested file", func(t *testing.T) { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/static/js/app.js", nil) + v.mux.ServeHTTP(w, r) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "console.log('hi')", w.Body.String()) + }) + + t.Run("directory listing returns 404", func(t *testing.T) { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/static/", nil) + v.mux.ServeHTTP(w, r) + + assert.Equal(t, http.StatusNotFound, w.Code) + }) + + t.Run("missing file returns 404", func(t *testing.T) { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/static/nope.css", nil) + v.mux.ServeHTTP(w, r) + + assert.Equal(t, http.StatusNotFound, w.Code) + }) +} + +func TestStaticFSAutoSlash(t *testing.T) { + fsys := fstest.MapFS{ + "ok.txt": {Data: []byte("ok")}, + } + + v := New() + v.StaticFS("/embed", fsys) // no trailing slash + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/embed/ok.txt", nil) + v.mux.ServeHTTP(w, r) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "ok", w.Body.String()) +} + +// Verify StaticFS accepts the fs.FS interface (compile-time check). +var _ fs.FS = fstest.MapFS{} diff --git a/via.go b/via.go index c327d64..bb52ab5 100644 --- a/via.go +++ b/via.go @@ -9,12 +9,13 @@ package via import ( "context" "crypto/rand" - _ "embed" "crypto/subtle" + _ "embed" "encoding/hex" "encoding/json" "fmt" "io" + "io/fs" "net/http" "net/url" "os" @@ -412,6 +413,46 @@ func (v *V) HTTPServeMux() *http.ServeMux { return v.mux } +// Static serves files from a filesystem directory at the given URL prefix. +// +// Example: +// +// v.Static("/assets/", "./public") +func (v *V) Static(urlPrefix, dir string) { + if !strings.HasSuffix(urlPrefix, "/") { + urlPrefix += "/" + } + fileServer := http.StripPrefix(urlPrefix, http.FileServer(http.Dir(dir))) + v.mux.Handle("GET "+urlPrefix, noDirListing(fileServer)) +} + +// StaticFS serves files from an [fs.FS] at the given URL prefix. +// This is useful with //go:embed filesystems. +// +// Example: +// +// //go:embed static +// var staticFiles embed.FS +// v.StaticFS("/assets/", staticFiles) +func (v *V) StaticFS(urlPrefix string, fsys fs.FS) { + if !strings.HasSuffix(urlPrefix, "/") { + urlPrefix += "/" + } + fileServer := http.StripPrefix(urlPrefix, http.FileServerFS(fsys)) + v.mux.Handle("GET "+urlPrefix, noDirListing(fileServer)) +} + +// noDirListing wraps a file server handler to return 404 for directory requests. +func noDirListing(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/") { + http.NotFound(w, r) + return + } + next.ServeHTTP(w, r) + }) +} + func (v *V) ensureDatastarHandler() { v.datastarOnce.Do(func() { v.mux.HandleFunc("GET "+v.datastarPath, func(w http.ResponseWriter, r *http.Request) {