Skip to content

Commit 2edc60a

Browse files
committed
Add kit package
1 parent 2304836 commit 2edc60a

25 files changed

Lines changed: 4055 additions & 0 deletions

docs/features.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
| `middleware` | Request ID, role-based access, rate limiting, locale detection, static cache |
1818
| `render` | Template FuncMap utilities, i18n integration |
1919
| `render/ui` | UI components (Badge, Chip, Price, Stat, Button, Link, Form, Input, Alert, Toast) |
20+
| `kit` | UI kit with dependency injection, HTMX-first components, emoji support, CSRF integration |
2021
| `modal` | Modal dialog configuration |
2122
| `pagination` | Generic pagination (`Result[T]`) |
2223
| `i18n` | Internationalization with YAML translation files |

kit/assets.go

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
package kit
2+
3+
import (
4+
"embed"
5+
"io"
6+
"io/fs"
7+
"net/http"
8+
"path"
9+
"strings"
10+
)
11+
12+
// Assets manages static assets with overlay support.
13+
type Assets struct {
14+
embeds embed.FS
15+
overlay fs.FS
16+
prefix string
17+
}
18+
19+
// NewAssets creates a new asset manager.
20+
func NewAssets(embeds embed.FS) *Assets {
21+
return &Assets{
22+
embeds: embeds,
23+
prefix: "/static",
24+
}
25+
}
26+
27+
// WithOverlay sets a filesystem overlay for asset customization.
28+
func (a *Assets) WithOverlay(overlay fs.FS) *Assets {
29+
a.overlay = overlay
30+
return a
31+
}
32+
33+
// WithPrefix sets the URL prefix.
34+
func (a *Assets) WithPrefix(prefix string) *Assets {
35+
a.prefix = prefix
36+
return a
37+
}
38+
39+
// Prefix returns the URL prefix.
40+
func (a *Assets) Prefix() string {
41+
return a.prefix
42+
}
43+
44+
// URL returns the full URL for an asset.
45+
func (a *Assets) URL(assetPath string) string {
46+
if !strings.HasPrefix(assetPath, "/") {
47+
assetPath = "/" + assetPath
48+
}
49+
return a.prefix + assetPath
50+
}
51+
52+
// Open opens a file, checking overlay first.
53+
func (a *Assets) Open(name string) (fs.File, error) {
54+
name = strings.TrimPrefix(name, "/")
55+
56+
if a.overlay != nil {
57+
if f, err := a.overlay.Open(name); err == nil {
58+
return f, nil
59+
}
60+
}
61+
62+
return a.embeds.Open(name)
63+
}
64+
65+
// Read reads a file completely.
66+
func (a *Assets) Read(name string) ([]byte, error) {
67+
f, err := a.Open(name)
68+
if err != nil {
69+
return nil, err
70+
}
71+
defer f.Close()
72+
73+
return io.ReadAll(f)
74+
}
75+
76+
// Handler returns an http.Handler for serving assets.
77+
func (a *Assets) Handler() http.Handler {
78+
return http.StripPrefix(a.prefix, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
79+
name := strings.TrimPrefix(r.URL.Path, "/")
80+
if name == "" {
81+
http.NotFound(w, r)
82+
return
83+
}
84+
85+
f, err := a.Open(name)
86+
if err != nil {
87+
http.NotFound(w, r)
88+
return
89+
}
90+
defer f.Close()
91+
92+
stat, err := f.Stat()
93+
if err != nil {
94+
http.NotFound(w, r)
95+
return
96+
}
97+
98+
if stat.IsDir() {
99+
http.NotFound(w, r)
100+
return
101+
}
102+
103+
content, err := io.ReadAll(f)
104+
if err != nil {
105+
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
106+
return
107+
}
108+
109+
contentType := getContentType(name)
110+
w.Header().Set("Content-Type", contentType)
111+
w.Write(content)
112+
}))
113+
}
114+
115+
func getContentType(name string) string {
116+
ext := strings.ToLower(path.Ext(name))
117+
switch ext {
118+
case ".css":
119+
return "text/css; charset=utf-8"
120+
case ".js":
121+
return "application/javascript; charset=utf-8"
122+
case ".json":
123+
return "application/json; charset=utf-8"
124+
case ".html", ".htm":
125+
return "text/html; charset=utf-8"
126+
case ".svg":
127+
return "image/svg+xml"
128+
case ".png":
129+
return "image/png"
130+
case ".jpg", ".jpeg":
131+
return "image/jpeg"
132+
case ".gif":
133+
return "image/gif"
134+
case ".ico":
135+
return "image/x-icon"
136+
case ".woff":
137+
return "font/woff"
138+
case ".woff2":
139+
return "font/woff2"
140+
case ".ttf":
141+
return "font/ttf"
142+
default:
143+
return "application/octet-stream"
144+
}
145+
}
146+
147+
// Assets returns an asset manager for the kit.
148+
func (k *Kit) Assets() *Assets {
149+
assets := NewAssets(k.embeds)
150+
if k.overlay != nil {
151+
assets.WithOverlay(k.overlay)
152+
}
153+
return assets
154+
}

kit/assets_test.go

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
package kit
2+
3+
import (
4+
"embed"
5+
"io/fs"
6+
"net/http"
7+
"net/http/httptest"
8+
"testing"
9+
"testing/fstest"
10+
)
11+
12+
//go:embed testdata/*
13+
var testEmbeds embed.FS
14+
15+
func TestAssetsURL(t *testing.T) {
16+
assets := NewAssets(testEmbeds)
17+
18+
tests := []struct {
19+
path string
20+
want string
21+
}{
22+
{"style.css", "/static/style.css"},
23+
{"/style.css", "/static/style.css"},
24+
{"js/app.js", "/static/js/app.js"},
25+
}
26+
27+
for _, tt := range tests {
28+
got := assets.URL(tt.path)
29+
if got != tt.want {
30+
t.Errorf("URL(%q) = %q, want %q", tt.path, got, tt.want)
31+
}
32+
}
33+
}
34+
35+
func TestAssetsPrefix(t *testing.T) {
36+
assets := NewAssets(testEmbeds).WithPrefix("/assets")
37+
38+
if assets.Prefix() != "/assets" {
39+
t.Errorf("Prefix() = %q, want %q", assets.Prefix(), "/assets")
40+
}
41+
42+
url := assets.URL("style.css")
43+
if url != "/assets/style.css" {
44+
t.Errorf("URL() = %q, want %q", url, "/assets/style.css")
45+
}
46+
}
47+
48+
func TestAssetsOverlay(t *testing.T) {
49+
overlay := fstest.MapFS{
50+
"custom.css": &fstest.MapFile{Data: []byte("/* custom */")},
51+
}
52+
53+
assets := NewAssets(testEmbeds).WithOverlay(overlay)
54+
55+
content, err := assets.Read("custom.css")
56+
if err != nil {
57+
t.Fatalf("Read() error = %v", err)
58+
}
59+
60+
if string(content) != "/* custom */" {
61+
t.Errorf("Read() = %q, want %q", string(content), "/* custom */")
62+
}
63+
}
64+
65+
func TestAssetsOpenNotFound(t *testing.T) {
66+
assets := NewAssets(testEmbeds)
67+
68+
_, err := assets.Open("nonexistent.css")
69+
if err == nil {
70+
t.Error("Open() expected error for nonexistent file")
71+
}
72+
}
73+
74+
func TestAssetsHandler(t *testing.T) {
75+
overlay := fstest.MapFS{
76+
"test.css": &fstest.MapFile{Data: []byte("body { color: red; }")},
77+
}
78+
79+
assets := NewAssets(testEmbeds).WithOverlay(overlay)
80+
handler := assets.Handler()
81+
82+
tests := []struct {
83+
path string
84+
wantStatus int
85+
wantType string
86+
}{
87+
{"/static/test.css", http.StatusOK, "text/css; charset=utf-8"},
88+
{"/static/notfound.css", http.StatusNotFound, ""},
89+
{"/static/", http.StatusNotFound, ""},
90+
}
91+
92+
for _, tt := range tests {
93+
req := httptest.NewRequest(http.MethodGet, tt.path, nil)
94+
w := httptest.NewRecorder()
95+
96+
handler.ServeHTTP(w, req)
97+
98+
if w.Code != tt.wantStatus {
99+
t.Errorf("Handler(%q) status = %d, want %d", tt.path, w.Code, tt.wantStatus)
100+
}
101+
102+
if tt.wantType != "" && w.Header().Get("Content-Type") != tt.wantType {
103+
t.Errorf("Handler(%q) content-type = %q, want %q",
104+
tt.path, w.Header().Get("Content-Type"), tt.wantType)
105+
}
106+
}
107+
}
108+
109+
func TestGetContentType(t *testing.T) {
110+
tests := []struct {
111+
name string
112+
want string
113+
}{
114+
{"style.css", "text/css; charset=utf-8"},
115+
{"app.js", "application/javascript; charset=utf-8"},
116+
{"data.json", "application/json; charset=utf-8"},
117+
{"page.html", "text/html; charset=utf-8"},
118+
{"icon.svg", "image/svg+xml"},
119+
{"photo.png", "image/png"},
120+
{"photo.jpg", "image/jpeg"},
121+
{"photo.jpeg", "image/jpeg"},
122+
{"anim.gif", "image/gif"},
123+
{"favicon.ico", "image/x-icon"},
124+
{"font.woff", "font/woff"},
125+
{"font.woff2", "font/woff2"},
126+
{"font.ttf", "font/ttf"},
127+
{"unknown.xyz", "application/octet-stream"},
128+
}
129+
130+
for _, tt := range tests {
131+
got := getContentType(tt.name)
132+
if got != tt.want {
133+
t.Errorf("getContentType(%q) = %q, want %q", tt.name, got, tt.want)
134+
}
135+
}
136+
}
137+
138+
func TestAssetsHandlerDirectory(t *testing.T) {
139+
overlay := fstest.MapFS{
140+
"subdir/file.txt": &fstest.MapFile{Data: []byte("test")},
141+
}
142+
143+
assets := NewAssets(testEmbeds).WithOverlay(overlay)
144+
handler := assets.Handler()
145+
146+
req := httptest.NewRequest(http.MethodGet, "/subdir", nil)
147+
w := httptest.NewRecorder()
148+
149+
handler.ServeHTTP(w, req)
150+
151+
if w.Code != http.StatusNotFound {
152+
t.Errorf("Handler for directory status = %d, want %d", w.Code, http.StatusNotFound)
153+
}
154+
}
155+
156+
type errorFS struct{}
157+
158+
func (errorFS) Open(name string) (fs.File, error) {
159+
return nil, fs.ErrNotExist
160+
}
161+
162+
func TestAssetsOverlayFallback(t *testing.T) {
163+
assets := NewAssets(testEmbeds).WithOverlay(errorFS{})
164+
165+
_, err := assets.Open("testdata/sample.txt")
166+
if err != nil {
167+
t.Errorf("Open() should fall back to embeds, got error: %v", err)
168+
}
169+
}

0 commit comments

Comments
 (0)