├── .gitignore ├── LICENSE ├── README.md ├── example ├── main.go └── views │ ├── _layout.gohtml │ ├── index.gohtml │ └── info │ ├── _layout.gohtml │ └── about.gohtml ├── go.mod └── view.go /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .vscode 3 | .DS_Store 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Aleksandr Baryshnikov 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. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # View [![GoDoc](https://godoc.org/github.com/reddec/view?status.png)](https://godoc.org/github.com/reddec/view) 2 | 3 | The `view` package provides a type-safe and hierarchical (layouts) way to load and render Go HTML templates using the standard library's [`html/template`](https://pkg.go.dev/html/template) package. It supports loading templates from any sources exposed as [`fs.FS`](https://pkg.go.dev/io/fs#FS). The package comes with no external dependencies and is designed for use in web applications. 4 | 5 | This is extremly light library based on gist https://gist.github.com/reddec/312367d75cc03f1ee49bae74c52a6b31 and has zero external dependecies. 6 | 7 | Key points: 8 | 9 | - **Hierarchical**: The templates are loaded in a hierarchical way, allowing you to have a base layout and extend it with partials or views at different levels. Layouts defined in each directory as `_layout.gohtml` file and can be extended. 10 | - **Type-safe**: The package provides a type-safe wrapper around the standard `html/template` library using a custom `View` struct. 11 | 12 | 13 | ## [Example](example/) 14 | 15 | Layout 16 | 17 | ``` 18 | ├── main.go 19 | └── views 20 | ├── _layout.gohtml 21 | ├── index.gohtml 22 | └── info 23 | ├── _layout.gohtml 24 | └── about.gohtml 25 | ``` 26 | 27 | 28 | And the code 29 | 30 | ```go 31 | package main 32 | 33 | import ( 34 | "embed" 35 | "fmt" 36 | "net/http" 37 | 38 | "github.com/reddec/view" 39 | ) 40 | 41 | //go:embed all:views 42 | var views embed.FS 43 | 44 | func main() { 45 | index := view.Must(view.New[string](views, "views/index.gohtml")) 46 | about := view.Must(view.New[string](views, "views/info/about.gohtml")) 47 | http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 48 | index.Render(w, "the index page") 49 | }) 50 | http.HandleFunc("/info/about", func(w http.ResponseWriter, r *http.Request) { 51 | about.Render(w, "made by RedDec") 52 | }) 53 | fmt.Println("ready on :8080") 54 | panic(http.ListenAndServe(":8080", nil)) 55 | } 56 | ``` 57 | 58 | - note: `all:view` - the `all:` prefix is required in order to include files with underscore in name prefix 59 | 60 | ## Installation 61 | 62 | ```bash 63 | go get github.com/reddec/view 64 | ``` -------------------------------------------------------------------------------- /example/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "embed" 5 | "fmt" 6 | "net/http" 7 | 8 | "github.com/reddec/view" 9 | ) 10 | 11 | //go:embed all:views 12 | var views embed.FS 13 | 14 | func main() { 15 | index := view.Must(view.New[string](views, "views/index.gohtml")) 16 | about := view.Must(view.New[string](views, "views/info/about.gohtml")) 17 | http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 18 | index.Render(w, "the index page") 19 | }) 20 | http.HandleFunc("/info/about", func(w http.ResponseWriter, r *http.Request) { 21 | about.Render(w, "made by RedDec") 22 | }) 23 | fmt.Println("ready on :8080") 24 | panic(http.ListenAndServe(":8080", nil)) 25 | } 26 | -------------------------------------------------------------------------------- /example/views/_layout.gohtml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | {{block "title" .}}{{end}} 9 | 10 | 11 | 12 |
13 | 22 | 23 | {{block "main" .}} 24 | Default content 25 | {{end}} 26 |
27 | 28 | 29 | -------------------------------------------------------------------------------- /example/views/index.gohtml: -------------------------------------------------------------------------------- 1 | {{define "title"}}Main page{{end}} 2 | 3 | {{define "main"}} 4 | Hello world! 5 |
6 | Parameter is: {{.}} 7 | {{end}} -------------------------------------------------------------------------------- /example/views/info/_layout.gohtml: -------------------------------------------------------------------------------- 1 | {{define "main"}} 2 |

Details

3 |
4 | {{block "details" .}}{{end}} 5 |
6 | {{end}} 7 | -------------------------------------------------------------------------------- /example/views/info/about.gohtml: -------------------------------------------------------------------------------- 1 | {{define "title"}}About{{end}} 2 | 3 | {{define "details"}} 4 | {{.}} 5 | {{end}} -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/reddec/view 2 | 3 | go 1.21.3 4 | -------------------------------------------------------------------------------- /view.go: -------------------------------------------------------------------------------- 1 | package view 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "fmt" 7 | "html/template" 8 | "io" 9 | "io/fs" 10 | "net/http" 11 | "os" 12 | "path" 13 | "strings" 14 | ) 15 | 16 | // Layout base file name. 17 | const layoutName = "_layout.gohtml" 18 | 19 | // Load is the same as LoadTemplate but using new empty template as root. 20 | func Load(store fs.FS, view string) (*template.Template, error) { 21 | return LoadTemplate(template.New(""), store, view) 22 | } 23 | 24 | // Load a single template (view) and all associated layouts (_layout.gohtml), starting from the top directory up to the current one. 25 | // 26 | // When using embedded FS (//go:embed), remember to use all suffixes (all:) to ensure that files with underscores 27 | // get embedded by the Go compiler. See https://github.com/golang/go/commit/36dbf7f7e63f3738795bb04593c3c011e987d1f3 28 | func LoadTemplate(root *template.Template, store fs.FS, view string) (*template.Template, error) { 29 | dirs := strings.Split(strings.Trim(view, "/"), "/") 30 | dirs = dirs[:len(dirs)-1] // last segment is view itself 31 | // parse layouts from all dirs starting from the top and until the current dir 32 | for i := range dirs { 33 | fpath := path.Join(path.Join(dirs[:i+1]...), layoutName) 34 | content, err := fs.ReadFile(store, fpath) 35 | if errors.Is(err, os.ErrNotExist) || errors.Is(err, fs.ErrNotExist) { 36 | continue // layout does not exists - skipping 37 | } 38 | if err != nil { 39 | return nil, fmt.Errorf("read layouad %q: %w", fpath, err) 40 | } 41 | 42 | child, err := root.Parse(string(content)) 43 | if err != nil { 44 | return nil, fmt.Errorf("parse %q: %w", fpath, err) 45 | } 46 | root = child 47 | } 48 | // parse view it self 49 | content, err := fs.ReadFile(store, view) 50 | if err != nil { 51 | return nil, fmt.Errorf("parse view %q: %w", view, err) 52 | } 53 | return root.Parse(string(content)) 54 | } 55 | 56 | // NewTemplate creates new [View] with provided root template and type-safe parameter. 57 | func NewTemplate[T any](root *template.Template, store fs.FS, view string) (*View[T], error) { 58 | t, err := LoadTemplate(root, store, view) 59 | if err != nil { 60 | return nil, err 61 | } 62 | return &View[T]{ 63 | parsed: t, 64 | }, nil 65 | } 66 | 67 | // New creates new [View] with empty root template and type-safe parameter. 68 | func New[T any](store fs.FS, view string) (*View[T], error) { 69 | return NewTemplate[T](template.New(""), store, view) 70 | } 71 | 72 | // Must is convinient helper for wrapping around New* constructors. 73 | // The function will panic if error parameter is not nil. 74 | func Must[T any](v *View[T], e error) *View[T] { 75 | if e != nil { 76 | panic(e) 77 | } 78 | return v 79 | } 80 | 81 | // View is type-safe tiny wrapper around standard template. 82 | type View[T any] struct { 83 | parsed *template.Template 84 | } 85 | 86 | // Render template as web page and content-type to text/html. 87 | // It doesn't change response code. In case of error, some part of template 88 | // could be sent to the client. 89 | func (v *View[T]) Render(writer http.ResponseWriter, value T) error { 90 | writer.Header().Set("Content-Type", "text/html") 91 | return v.Execute(writer, value) 92 | } 93 | 94 | // Execute template and render content to the writer. It's just a type-safe wrapper around template.Execute. 95 | func (v *View[T]) Execute(writer io.Writer, value T) error { 96 | return v.parsed.Execute(writer, value) 97 | } 98 | 99 | // Bytes result of template execution. 100 | func (v *View[T]) Bytes(value T) ([]byte, error) { 101 | var buf bytes.Buffer 102 | err := v.Execute(&buf, value) 103 | return buf.Bytes(), err 104 | } 105 | --------------------------------------------------------------------------------