├── go.mod
├── go.sum
├── templates
└── index.html
├── README.md
├── vii_test.go
├── LICENSE
└── vii.go
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/Phillip-England/vii
2 |
3 | go 1.23.3
4 |
5 | require github.com/a-h/templ v0.3.857
6 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | github.com/a-h/templ v0.3.857 h1:6EqcJuGZW4OL+2iZ3MD+NnIcG7nGkaQeF2Zq5kf9ZGg=
2 | github.com/a-h/templ v0.3.857/go.mod h1:qhrhAkRFubE7khxLZHsBFHfX+gWwVNKbzKeF9GlPV4M=
3 |
--------------------------------------------------------------------------------
/templates/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Document
7 |
8 |
9 |
10 |
11 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # vii
2 | Minimal servers in Go
3 |
4 | ## Installation
5 | ```bash
6 | go get github.com/Phillip-England/vii
7 | ```
8 |
9 | ## Hello, World
10 | 1. Create a new `go` project
11 | 2. Create a `./templates` dir
12 | 3. Create a `./static` dir
13 |
14 | Basic Server:
15 |
16 | ```go
17 | package main
18 |
19 | import (
20 | "github.com/Phillip-England/vii"
21 | )
22 |
23 | func main() {
24 | app := vii.NewApp()
25 | app.Use(vii.MwLogger, vii.MwTimeout(10))
26 | app.Static("./static")
27 | app.Favicon()
28 | err := app.Templates("./templates", nil)
29 | if err != nil {
30 | panic(err)
31 | }
32 | app.At("GET /", func(w http.ResponseWriter, r *http.Request) {
33 | vii.ExecuteTemplate(w, r, "index.html", nil)
34 | })
35 | app.Serve("8080")
36 | }
37 | ```
38 |
--------------------------------------------------------------------------------
/vii_test.go:
--------------------------------------------------------------------------------
1 | package vii
2 |
3 | import (
4 | "net/http"
5 | "testing"
6 | )
7 |
8 | func TestVii(t *testing.T) {
9 |
10 | app := NewApp()
11 |
12 | err := app.Templates("./templates", nil)
13 | if err != nil {
14 | panic(err)
15 | }
16 |
17 | app.Use(MwLogger)
18 |
19 | app.Static("./static")
20 | app.Favicon()
21 |
22 | app.At("GET /", func(w http.ResponseWriter, r *http.Request) {
23 | ExecuteTemplate(w, r, "index.html", nil)
24 | }, MwLogger, MwTimeout(10))
25 |
26 | apiGroup := app.Group("/api")
27 | apiGroup.Use(MwLogger)
28 |
29 | apiGroup.At("GET /", func(w http.ResponseWriter, r *http.Request) {
30 | app.JSON(w, 200, map[string]interface{}{
31 | "message": "Hello, World!",
32 | })
33 | }, MwLogger, MwTimeout(10))
34 |
35 | app.Serve("8080")
36 |
37 | }
38 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2025 Phillip England
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
--------------------------------------------------------------------------------
/vii.go:
--------------------------------------------------------------------------------
1 | package vii
2 |
3 | import (
4 | "context"
5 | "encoding/json"
6 | "fmt"
7 | "html/template"
8 | "io/fs" // [NEW] Required for handling embedded filesystems
9 | "net/http"
10 | "os"
11 | "path/filepath"
12 | "strings"
13 | "time"
14 |
15 | "github.com/a-h/templ"
16 | )
17 |
18 | type Group struct {
19 | parent *App
20 | prefix string
21 | middleware []func(http.Handler) http.Handler
22 | }
23 |
24 | func (app *App) Group(prefix string) *Group {
25 | return &Group{
26 | parent: app,
27 | prefix: strings.TrimRight(prefix, "/"),
28 | middleware: []func(http.Handler) http.Handler{},
29 | }
30 | }
31 |
32 | func (g *Group) Use(middleware ...func(http.Handler) http.Handler) {
33 | g.middleware = append(g.middleware, middleware...)
34 | }
35 |
36 | func (g *Group) At(path string, handler http.HandlerFunc, middleware ...func(http.Handler) http.Handler) {
37 | resolvedPath := g.prefix + strings.TrimRight(strings.Split(path, " ")[1], "/")
38 | method := strings.Split(path, " ")[0]
39 | // Only apply Group + Local middleware here
40 | allMiddleware := append(g.middleware, middleware...)
41 | g.parent.Mux.HandleFunc(method+" "+resolvedPath, func(w http.ResponseWriter, r *http.Request) {
42 | r = SetContext("GLOBAL", g.parent.GlobalContext, r)
43 | chain(handler, allMiddleware...).ServeHTTP(w, r)
44 | })
45 | }
46 |
47 | //=====================================
48 | // app
49 | //=====================================
50 |
51 | const VII_CONTEXT = "VII_CONTEXT"
52 |
53 | type App struct {
54 | Mux *http.ServeMux
55 | GlobalContext map[string]any
56 | GlobalMiddleware []func(http.Handler) http.Handler
57 | }
58 |
59 | func NewApp() App {
60 | mux := http.NewServeMux()
61 | app := App{
62 | Mux: mux,
63 | GlobalContext: make(map[string]any),
64 | GlobalMiddleware: []func(http.Handler) http.Handler{},
65 | }
66 | return app
67 | }
68 |
69 | func (app *App) Use(middleware ...func(http.Handler) http.Handler) {
70 | app.GlobalMiddleware = append(app.GlobalMiddleware, middleware...)
71 | }
72 |
73 | func (app *App) SetContext(key string, value any) {
74 | app.GlobalContext[key] = value
75 | }
76 |
77 | func (app *App) At(path string, handler http.HandlerFunc, middleware ...func(http.Handler) http.Handler) {
78 | app.Mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) {
79 | r = SetContext("GLOBAL", app.GlobalContext, r)
80 | // Only apply Local middleware here
81 | chain(handler, middleware...).ServeHTTP(w, r)
82 | })
83 | }
84 |
85 | // Templates loads templates from disk (Legacy)
86 | func (app *App) Templates(path string, funcMap template.FuncMap) error {
87 | strEquals := func(input string, value string) bool {
88 | return input == value
89 | }
90 | vbfFuncMap := template.FuncMap{
91 | "strEquals": strEquals,
92 | }
93 | for k, v := range funcMap {
94 | vbfFuncMap[k] = v
95 | }
96 | templates := template.New("").Funcs(vbfFuncMap)
97 | err := filepath.Walk(path, func(path string, info os.FileInfo, err error) error {
98 | if err != nil {
99 | return err
100 | }
101 | if !info.IsDir() && filepath.Ext(path) == ".html" {
102 | _, err := templates.ParseFiles(path)
103 | if err != nil {
104 | return err
105 | }
106 | }
107 | return nil
108 | })
109 | if err != nil {
110 | return err
111 | }
112 | app.SetContext(VII_CONTEXT, templates)
113 | return nil
114 | }
115 |
116 | // TemplatesFS loads templates from an embedded filesystem (NEW)
117 | // patterns example: "templates/*.html" or "templates/**/*.html"
118 | func (app *App) TemplatesFS(fileSystem fs.FS, patterns string, funcMap template.FuncMap) error {
119 | strEquals := func(input string, value string) bool {
120 | return input == value
121 | }
122 | vbfFuncMap := template.FuncMap{
123 | "strEquals": strEquals,
124 | }
125 | for k, v := range funcMap {
126 | vbfFuncMap[k] = v
127 | }
128 |
129 | // ParseFS handles the walking and matching of patterns natively
130 | templates, err := template.New("").Funcs(vbfFuncMap).ParseFS(fileSystem, patterns)
131 | if err != nil {
132 | return err
133 | }
134 |
135 | app.SetContext(VII_CONTEXT, templates)
136 | return nil
137 | }
138 |
139 | func (app *App) Favicon(middleware ...func(http.Handler) http.Handler) {
140 | app.Mux.HandleFunc("GET /favicon.ico", func(w http.ResponseWriter, r *http.Request) {
141 | chain(func(w http.ResponseWriter, r *http.Request) {
142 | filePath := "favicon.ico"
143 | fullPath := filepath.Join(".", ".", filePath)
144 | http.ServeFile(w, r, fullPath)
145 | }, middleware...).ServeHTTP(w, r)
146 | })
147 | }
148 |
149 | // Static serves files from disk (Legacy)
150 | func (app *App) Static(path string, middleware ...func(http.Handler) http.Handler) {
151 | staticPath := strings.TrimRight(path, "/")
152 | fileServer := http.FileServer(http.Dir(staticPath))
153 | stripHandler := http.StripPrefix("/"+filepath.Base(staticPath)+"/", fileServer)
154 | var handler http.Handler = stripHandler
155 | if len(middleware) > 0 {
156 | handler = chain(stripHandler.ServeHTTP, middleware...)
157 | }
158 | app.Mux.Handle("GET /"+filepath.Base(staticPath)+"/", handler)
159 | }
160 |
161 | // StaticEmbed serves files from an embedded filesystem (NEW)
162 | // urlPrefix: the URL path to serve from (e.g., "/static")
163 | // fileSystem: the embedded FS (e.g., staticFS)
164 | func (app *App) StaticEmbed(urlPrefix string, fileSystem fs.FS, middleware ...func(http.Handler) http.Handler) {
165 | // Ensure the prefix is clean
166 | urlPrefix = "/" + strings.Trim(urlPrefix, "/") + "/"
167 |
168 | // Convert fs.FS to http.FileSystem
169 | fileServer := http.FileServer(http.FS(fileSystem))
170 |
171 | // Strip the prefix from the request URL so the file server sees the relative path
172 | stripHandler := http.StripPrefix(urlPrefix, fileServer)
173 |
174 | var handler http.Handler = stripHandler
175 | if len(middleware) > 0 {
176 | handler = chain(stripHandler.ServeHTTP, middleware...)
177 | }
178 |
179 | app.Mux.Handle("GET "+urlPrefix, handler)
180 | }
181 |
182 | func (app *App) JSON(w http.ResponseWriter, status int, data interface{}) error {
183 | w.Header().Set("Content-Type", "application/json")
184 | w.WriteHeader(status)
185 | jsonData, err := json.Marshal(data)
186 | if err != nil {
187 | return err
188 | }
189 | _, err = w.Write(jsonData)
190 | return err
191 | }
192 |
193 | func (app *App) HTML(w http.ResponseWriter, status int, html string) error {
194 | w.Header().Set("Content-Type", "text/html; charset=utf-8")
195 | w.WriteHeader(status)
196 | _, err := w.Write([]byte(html))
197 | return err
198 | }
199 |
200 | func (app *App) Text(w http.ResponseWriter, status int, text string) error {
201 | w.Header().Set("Content-Type", "text/plain; charset=utf-8")
202 | w.WriteHeader(status)
203 | _, err := w.Write([]byte(text))
204 | return err
205 | }
206 |
207 | func (app *App) Serve(port string) error {
208 | fmt.Println("starting server on port " + port + " 🚀")
209 |
210 | finalHandler := chain(app.Mux.ServeHTTP, app.GlobalMiddleware...)
211 |
212 | err := http.ListenAndServe(":"+port, finalHandler)
213 | if err != nil {
214 | return err
215 | }
216 | return nil
217 | }
218 |
219 | //=====================================
220 | // middleware
221 | //=====================================
222 |
223 | func chain(h http.HandlerFunc, middleware ...func(http.Handler) http.Handler) http.Handler {
224 | finalHandler := http.Handler(h)
225 | for _, m := range middleware {
226 | finalHandler = m(finalHandler)
227 | }
228 | return finalHandler
229 | }
230 |
231 | func MwTimeout(seconds int) func(http.Handler) http.Handler {
232 | return func(next http.Handler) http.Handler {
233 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
234 | done := make(chan bool)
235 | ctx, cancel := context.WithTimeout(r.Context(), time.Duration(seconds)*time.Second)
236 | defer cancel()
237 | r = r.WithContext(ctx)
238 | go func() {
239 | next.ServeHTTP(w, r)
240 | select {
241 | case <-ctx.Done():
242 | return
243 | case done <- true:
244 | }
245 | }()
246 | select {
247 | case <-done:
248 | return
249 | case <-ctx.Done():
250 | http.Error(w, "Request timed out", http.StatusGatewayTimeout)
251 | return
252 | }
253 | })
254 | }
255 | }
256 |
257 | func MwLogger(next http.Handler) http.Handler {
258 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
259 | startTime := time.Now()
260 | next.ServeHTTP(w, r)
261 | endTime := time.Since(startTime)
262 | fmt.Printf("[%s][%s][%s]\n", r.Method, r.URL.Path, endTime)
263 | })
264 | }
265 |
266 | func MwCORS(next http.Handler) http.Handler {
267 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
268 | origin := r.Header.Get("Origin")
269 | if origin != "" {
270 | w.Header().Set("Access-Control-Allow-Origin", origin)
271 | } else {
272 | w.Header().Set("Access-Control-Allow-Origin", "*")
273 | }
274 | w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
275 | w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
276 | w.Header().Set("Access-Control-Allow-Credentials", "true")
277 | if r.Method == "OPTIONS" {
278 | w.WriteHeader(http.StatusOK)
279 | return
280 | }
281 | next.ServeHTTP(w, r)
282 | })
283 | }
284 |
285 | //=====================================
286 | // context
287 | //=====================================
288 |
289 | type ContextKey string
290 |
291 | func SetContext(key string, val any, r *http.Request) *http.Request {
292 | ctx := context.WithValue(r.Context(), ContextKey(key), val)
293 | r = r.WithContext(ctx)
294 | return r
295 | }
296 |
297 | func GetContext(key string, r *http.Request) any {
298 | ctxMap, ok := r.Context().Value(ContextKey("GLOBAL")).(map[string]any)
299 | if ok {
300 | mapVal := ctxMap[key]
301 | if mapVal != nil {
302 | return mapVal
303 | }
304 | }
305 | val := r.Context().Value(ContextKey(key))
306 | return val
307 | }
308 |
309 | //=====================================
310 | // templating
311 | //=====================================
312 |
313 | func getTemplates(r *http.Request) *template.Template {
314 | templates, _ := GetContext(VII_CONTEXT, r).(*template.Template)
315 | return templates
316 | }
317 |
318 | func ExecuteTemplate(w http.ResponseWriter, r *http.Request, filepath string, data any) error {
319 | w.Header().Add("Content-Type", "text/html")
320 | templates := getTemplates(r)
321 | err := templates.ExecuteTemplate(w, filepath, data)
322 | if err != nil {
323 | return err
324 | }
325 | return nil
326 | }
327 |
328 | //=====================================
329 | // request / response helpers
330 | //=====================================
331 |
332 | func Param(r *http.Request, paramName string) string {
333 | return r.URL.Query().Get(paramName)
334 | }
335 |
336 | func ParamIs(r *http.Request, paramName string, valueToCheck string) bool {
337 | return r.URL.Query().Get(paramName) == valueToCheck
338 | }
339 |
340 | func WriteHTML(w http.ResponseWriter, status int, content string) {
341 | w.Header().Set("Content-Type", "text/html")
342 | w.WriteHeader(status)
343 | w.Write([]byte(content))
344 | }
345 |
346 | func WriteString(w http.ResponseWriter, status int, content string) {
347 | w.Header().Set("Content-Type", "text/plain")
348 | w.WriteHeader(status)
349 | w.Write([]byte(content))
350 | }
351 |
352 | func WriteJSON(w http.ResponseWriter, statusCode int, data interface{}) error {
353 | w.Header().Set("Content-Type", "application/json")
354 | w.WriteHeader(statusCode)
355 |
356 | err := json.NewEncoder(w).Encode(data)
357 | if err != nil {
358 | http.Error(w, err.Error(), http.StatusInternalServerError)
359 | return err
360 | }
361 | return nil
362 | }
363 |
364 | func WriteTempl(w http.ResponseWriter, r *http.Request, component templ.Component) error {
365 | w.Header().Add("Content-Type", "text/html")
366 | err := component.Render(r.Context(), w)
367 | if err != nil {
368 | return err
369 | }
370 | return nil
371 | }
372 |
--------------------------------------------------------------------------------