Commit 8b9b635d authored by Kyle Clarke's avatar Kyle Clarke

Stencil - A Templating package for Go

A package designed to take a lot of the hurt out of dealing with a web
app and it's templates. Especially locale specifics. With flash messages
built in too. I hope you enjoy.
parent 7f21f359
.idea
.ds_store
.vscode
image: golang:latest
variables:
REPO_NAME: gitlab.com/kylehqcom/stencil
# The problem is that to be able to use go get, one needs to put
# the repository in the $GOPATH. So for example if your gitlab domain
# is gitlab.com, and that your repository is namespace/project, and
# the default GOPATH being /go, then you'd need to have your
# repository in /go/src/gitlab.com/namespace/project
# Thus, making a symbolic link corrects this.
before_script:
- mkdir -p $GOPATH/src/$REPO_NAME
- ln -svf $CI_PROJECT_DIR/* $GOPATH/src/$REPO_NAME
- cd $GOPATH/src/$REPO_NAME
- go get -t ./...
stages:
- test
format:
stage: test
script:
- go fmt $(go list ./... | grep -v /vendor/)
- go vet -composites=false $(go list ./... | grep -v /vendor/)
test:
stage: test
script:
- go test -race $(go list ./... | grep -v /vendor/) -v -coverprofile .testCoverage.txt
The MIT License (MIT)
Copyright (c) 2019 Kyle Clarke - www.kylehq.com
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
This diff is collapsed.
<div class="flash {{.FlashType}}">
<p>{{.Message}}</p>
</div>
\ No newline at end of file
<p>Footer</p>
\ No newline at end of file
Navigation
\ No newline at end of file
Stencil Index EN
\ No newline at end of file
Stencil Error Fallback Error: {{.error}}
\ No newline at end of file
Stencil Error ES Error: {{.error}}
\ No newline at end of file
Stencil Índice ES
\ No newline at end of file
<html>
<head>
<style>
.alert {
color: orange;
}
.error {
color: red;
}
.info {
color: blue;
}
.success {
color: green;
}
</style>
</head>
<h1>Base Layout</h1>
{{template "../_templates/_nav.html" .}}
{{if .flashes}}
<h3>Flashes</h3>
{{range .flashes}}
{{template "../_templates/_flash.html" .}}
{{end}}
{{end}}
{{render}}
{{template "../_templates/_footer.html" .}}
</html>
\ No newline at end of file
package main
import (
"html/template"
"log"
"net/http"
"gitlab.com/kylehqcom/stencil"
"gitlab.com/kylehqcom/stencil/decorator"
"gitlab.com/kylehqcom/stencil/executor"
"gitlab.com/kylehqcom/stencil/matcher"
)
type mapLoader struct {
col map[string]*template.Template
}
func (m mapLoader) Load(name, text string) error {
t := template.New(name)
m.col[name] = template.Must(t.Parse(text))
return nil
}
func (m mapLoader) RegisterFuncs(template.FuncMap) {
}
func (m mapLoader) Yield() *stencil.Collection {
c := &stencil.Collection{}
t := template.New("")
for n, l := range m.col {
t.AddParseTree(n, l.Tree)
}
c.T = t
return c
}
func main() {
// Use this custom map loader to simulate a non fileloader, eg perhaps on each web request
// you need to load your template from a datastore.
l := mapLoader{}
l.col = make(map[string]*template.Template)
l.Load("a", "A is the content")
l.Load("b", "So much B!!!")
l.Load("c", "Curiosity killed the cat...")
d := decorator.NewRequestDecorator(
l.Yield(),
matcher.NewFilepathMatcher(),
executor.NewTemplateExecutor(),
)
indexHandler := func(w http.ResponseWriter, r *http.Request) {
// Request ?l=a or ?l=b or ?l=c
vals := r.URL.Query()
load := vals.Get("l")
if load != "b" && load != "c" {
load = "a"
}
d.Decorate(w, r, load, nil)
}
http.HandleFunc("/", indexHandler)
log.Println("Enjoy using Stencil")
log.Println("Example now running on Port :8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}
package main
import (
"fmt"
"html/template"
"log"
"net/http"
"time"
"gitlab.com/kylehqcom/stencil"
"gitlab.com/kylehqcom/stencil/decorator"
"gitlab.com/kylehqcom/stencil/executor"
"gitlab.com/kylehqcom/stencil/loader"
)
type maintMatcher struct{}
func (m maintMatcher) Match(c *stencil.Collection, pattern string) (*template.Template, error) {
t := template.New("")
return template.Must(t.Parse(fmt.Sprintf("Custom matcher that returns the maintenance template on every match. Pattern: %s", pattern))), nil
}
func main() {
l := loader.NewFilepathLoader(loader.WithMustParse(true))
l.RegisterFuncs(template.FuncMap{"render": func() string {
return "Content from render func"
}})
l.LoadFromFilepath("../_templates/*.html")
d := decorator.NewRequestDecorator(
l.Yield(),
maintMatcher{},
executor.NewTemplateExecutor(),
)
indexHandler := func(w http.ResponseWriter, r *http.Request) {
// Using time to prove that any template name will render the maint matcher template.
d.Decorate(w, r, time.Now().String(), nil)
}
http.HandleFunc("/", indexHandler)
log.Println("Enjoy using Stencil")
log.Println("Example now running on Port :8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}
package main
import (
"log"
"net/http"
"gitlab.com/kylehqcom/stencil/decorator"
"gitlab.com/kylehqcom/stencil/executor"
"gitlab.com/kylehqcom/stencil/loader"
"gitlab.com/kylehqcom/stencil/matcher"
)
func main() {
l := loader.NewFilepathLoader(loader.WithMustParse(true))
l.Load("hello_world", "Hello world and welcome to using Stencil!!!")
d := decorator.NewRequestDecorator(
l.Yield(),
matcher.NewFilepathMatcher(),
executor.NewTemplateExecutor(),
)
helloWorldHandler := func(w http.ResponseWriter, r *http.Request) {
d.Decorate(w, r, "hello_world", nil)
}
http.HandleFunc("/", helloWorldHandler)
log.Println("Enjoy using Stencil")
log.Println("Example now running on Port :8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}
package main
import (
"html/template"
"log"
"net/http"
"gitlab.com/kylehqcom/stencil/decorator"
"gitlab.com/kylehqcom/stencil/executor"
"gitlab.com/kylehqcom/stencil/loader"
"gitlab.com/kylehqcom/stencil/matcher"
)
//
// Example outputs
//
// Will render the EN index.html
// http://localhost:8080
// http://localhost:8080/?locale=en
// http://localhost:8080/?locale=nosuchlocale
//
// Will render the ES index.html
// http://localhost:8080/?locale=es
//
// Will render the ES error template
// http://localhost:8080/?locale=es&error=1
// Will render the fallback error en template
// http://localhost:8080/?error=1
// http://localhost:8080/?locale=en&error=1
// http://localhost:8080/?locale=nosuchlocale&error=1
var l = loader.NewFilepathLoader(loader.WithMustParse(true))
func main() {
l.RegisterFuncs(template.FuncMap{"render": func() string {
return "Render called unexpectedly"
}})
l.LoadFromFilepath("../_templates/*.html")
l.LoadFromFilepath("../_templates/*/*.html")
indexHandler := func(w http.ResponseWriter, r *http.Request) {
render(w, r, "index.html", nil)
}
http.HandleFunc("/", indexHandler)
log.Println("Enjoy using Stencil")
log.Println("Example now running on Port :8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}
func render(w http.ResponseWriter, r *http.Request, name string, data map[string]interface{}, opts ...decorator.Option) {
vals := r.URL.Query()
loc := vals.Get("locale")
if loc != "es" {
loc = "en"
}
if "" != vals.Get("error") {
name = "not a template that can be matched so will render error"
}
d := decorator.NewRequestDecorator(
l.Yield(),
matcher.NewFilepathMatcher(
matcher.WithPathRoot("../_templates"),
matcher.WithFallbackLocale("en"),
matcher.WithLocale(loc),
),
executor.NewTemplateExecutor(),
decorator.WithBaseTemplate("layout.html"), decorator.WithErrorTemplate("error.html"),
)
d.Decorate(w, r, name, data, opts...)
}
package main
import (
"html/template"
"log"
"net/http"
"gitlab.com/kylehqcom/stencil"
"gitlab.com/kylehqcom/stencil/decorator"
"gitlab.com/kylehqcom/stencil/executor"
"gitlab.com/kylehqcom/stencil/loader"
"gitlab.com/kylehqcom/stencil/matcher"
)
func main() {
l := loader.NewFilepathLoader(loader.WithMustParse(true))
l.RegisterFuncs(template.FuncMap{"render": func() string {
return "Content from render func"
}})
l.LoadFromFilepath("../_templates/*.html")
d := decorator.NewRequestDecorator(
l.Yield(),
matcher.NewFilepathMatcher(matcher.WithPathRoot("../_templates")),
executor.NewTemplateExecutor(),
)
indexHandler := func(w http.ResponseWriter, r *http.Request) {
r = stencil.AddFlashAlert(r, "Flash alert", nil)
r = stencil.AddFlashError(r, "Flash error", nil)
r = stencil.AddFlashInfo(r, "Flash info", nil)
r = stencil.AddFlashSuccess(r, "Flash success", nil)
w.Header().Set("Content-Type", "text/html")
d.Decorate(w, r, "layout.html", nil)
}
http.HandleFunc("/", indexHandler)
log.Println("Enjoy using Stencil")
log.Println("Example now running on Port :8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}
package decorator
// DefaultFlashDataKey is used as the default key to bind the template data map[string]interface
// if flash messages are present
const DefaultFlashDataKey string = "flashes"
type (
// Option defines the behaviours for decorating
Option func(*Options)
// Options are all defined behaviours for decorating
Options struct {
BaseTemplate string
ErrorTemplate string
FlashDataKey string
}
)
// NewOptions will return a new RenderOptions instance
func NewOptions() *Options {
return &Options{
FlashDataKey: DefaultFlashDataKey,
}
}
// WithBaseTemplate will define the template that all other template matches will inherit or embed from
func WithBaseTemplate(name string) Option {
return func(opts *Options) {
opts.BaseTemplate = name
}
}
// WithErrorTemplate will use the name provided to match if any errors are returned on render
func WithErrorTemplate(name string) Option {
return func(opts *Options) {
opts.ErrorTemplate = name
}
}
// WithFlashDataKey will assign your preferred string key name for flash messages bound to the template data
func WithFlashDataKey(key string) Option {
return func(opts *Options) {
opts.FlashDataKey = key
}
}
package decorator
import (
"bytes"
"fmt"
"html/template"
"net/http"
"gitlab.com/kylehqcom/stencil"
)
type (
// RequestDecorator is the default interface to decorate templates for your web requests
RequestDecorator interface {
// Decorate a response, using the current request and matching a template name
Decorate(w http.ResponseWriter, r *http.Request, name string, data map[string]interface{}, opts ...Option)
}
// Request is the struct used for the decorator to render templates from requests
Request struct {
c *stencil.Collection
m stencil.Matcher
e stencil.Executor
Options *Options
}
)
// NewRequestDecorator will return a new Request pointer instance
func NewRequestDecorator(c *stencil.Collection, m stencil.Matcher, e stencil.Executor, opts ...Option) *Request {
r := &Request{c: c, m: m, e: e, Options: NewOptions()}
for _, opt := range opts {
opt(r.Options)
}
return r
}
// Decorate takes attempts to match and render a template for the http response
func (d *Request) Decorate(w http.ResponseWriter, r *http.Request, name string, data map[string]interface{}, opts ...Option) {
var (
baseTemplate *template.Template
contentTemplate *template.Template
errorTemplate *template.Template
)
// Passed in Options take precedence but we respect previous
// default values by copying the values, not the pointer reference.
options := *d.Options
for _, opt := range opts {
opt(&options)
}
// On any decorate errors call this return func to render the error
errorReturn := func(templateName string, templateError error) {
w.WriteHeader(http.StatusInternalServerError)
if errorTemplate != nil {
err := d.e.Execute(w, errorTemplate, map[string]error{"error": templateError})
if err == nil {
return
}
// We have an error rendering the default error template so
// assign the vars to render an error string instead.
templateError = err
templateName = errorTemplate.Name()
}
fmt.Fprintf(w, "Unexpected error. Template Name: %s Error: %s", templateName, templateError.Error())
}
// Locate the default Error template if defined
if options.ErrorTemplate != "" {
errTpl, err := d.m.Match(d.c, options.ErrorTemplate)
if err != nil {
errorReturn(options.ErrorTemplate, err)
return
}
errorTemplate = errTpl
}
// Locate the default base template if defined
if options.BaseTemplate != "" {
baseTpl, err := d.m.Match(d.c, options.BaseTemplate)
if err != nil {
errorReturn(options.BaseTemplate, err)
return
}
baseTemplate = baseTpl
}
// Now locate the template to decorate by name
t, err := d.m.Match(d.c, name)
if err != nil {
errorReturn(name, err)
return
}
if data == nil {
data = make(map[string]interface{})
}
// Add any flash messages from the request
flshKey := DefaultFlashDataKey
if "" != options.FlashDataKey {
flshKey = options.FlashDataKey
}
data[flshKey] = stencil.GetFlashes(r)
if baseTemplate != nil {
baseTemplate.Funcs(template.FuncMap{
"render": func() (template.HTML, error) {
buf := bytes.NewBuffer(nil)
err = d.e.Execute(buf, t, data)
return template.HTML(buf.String()), err
},
})
contentTemplate = baseTemplate
} else {
contentTemplate = t
}
// Execute the template
err = d.e.Execute(w, contentTemplate, data)
if err != nil {
errorReturn(contentTemplate.Name(), err)
}
}
package decorator_test
import (
"fmt"
"html/template"
"io/ioutil"
"net/http"
"net/http/httptest"
"testing"
"gitlab.com/kylehqcom/stencil"
"gitlab.com/kylehqcom/stencil/decorator"
"gitlab.com/kylehqcom/stencil/executor"
"gitlab.com/kylehqcom/stencil/loader"
"gitlab.com/kylehqcom/stencil/matcher"
)
func TestNewRequestDecorator(t *testing.T) {
d := decorator.NewRequestDecorator(nil, nil, nil)
if fmt.Sprintf("%T", d) != "*decorator.Request" {
t.Error("Expected a Request pointer instance")
}
}
func TestDecorate(t *testing.T) {
l := loader.NewFilepathLoader(loader.WithMustParse(true))
l.RegisterFuncs(template.FuncMap{
"render": func() string { return "" },
})
l.Load("error.html", `Base Error Template: {{.error}}`)
l.Load("layout.html", `Base Layout - Locale content: {{render}}{{if .flashes}} Flashes:{{range .flashes}} Msg: {{.Message}}{{end}}{{end}}`)
l.Load("en/home.html", `Home - English content`)
l.Load("en/index.html", `Index - English content`)
l.Load("es/error.html", `Problematic - {{.error}}`)
l.Load("es/index.html", `Índice - Contenido en Español`)
m := matcher.NewFilepathMatcher(
matcher.WithFallbackLocale("en"),
matcher.WithLocale("es"), // Note the default locale is es
)
e := executor.NewTemplateExecutor()
d := decorator.NewRequestDecorator(
l.Yield(),
m,
e,
decorator.WithBaseTemplate("layout.html"), decorator.WithErrorTemplate("error.html"),
)
// ES index.html
r, _ := http.NewRequest("GET", "http://kylehq.com", nil)
w := httptest.NewRecorder()
d.Decorate(w, r, "index.html", nil)
resp := w.Result()
body, _ := ioutil.ReadAll(resp.Body)
esIndexExpect := `Base Layout - Locale content: Índice - Contenido en Español`
if esIndexExpect != string(body) {
t.Error(fmt.Sprintf("Expected: %s Got: %s", esIndexExpect, string(body)))
}
// Check the Locale fallback of EN as no ES home.html
w = httptest.NewRecorder()
d.Decorate(w, r, "home.html", nil)
resp = w.Result()
body, _ = ioutil.ReadAll(resp.Body)
enHomeExpect := `Base Layout - Locale content: Home - English content`
if enHomeExpect != string(body) {
t.Error(fmt.Sprintf("Expected: %s Got: %s", enHomeExpect, string(body)))
}
// No template found, return ES locale error
w = httptest.NewRecorder()
d.Decorate(w, r, "no-such-file-so-es-error.html", nil)
resp = w.Result()
body, _ = ioutil.ReadAll(resp.Body)
esErrorExpect := `Problematic - No match`
if esErrorExpect != string(body) {
t.Error(fmt.Sprintf("Expected: %s Got: %s", esErrorExpect, string(body)))
}
// Also decorate with an unknown default error template to ensure we error out correctly
w = httptest.NewRecorder()
d.Decorate(w, r, "no-such-file-so-es-error.html", nil, decorator.WithErrorTemplate("no-such-error-template"))
resp = w.Result()
body, _ = ioutil.ReadAll(resp.Body)
noErrorTemplateExpect := `Unexpected error. Template Name: no-such-error-template Error: No match`
if noErrorTemplateExpect != string(body) {
t.Error(fmt.Sprintf("Expected: %s Got: %s", esErrorExpect, string(body)))
}
// Also decorate with an unknown base template to ensure we error out correctly and in locale ES
w = httptest.NewRecorder()
d.Decorate(w, r, "index.html", nil, decorator.WithBaseTemplate("no-such-base-template"))
resp = w.Result()
body, _ = ioutil.ReadAll(resp.Body)
noBaseTemplateExpect := `Problematic - No match`
if noBaseTemplateExpect != string(body) {
t.Error(fmt.Sprintf("Expected: %s Got: %s", esErrorExpect, string(body)))
}
// Now set the matcher locale to en
m = matcher.NewFilepathMatcher(
matcher.WithLocale("en"),
)
d = decorator.NewRequestDecorator(
l.Yield(),
m,
e,
decorator.WithBaseTemplate("layout.html"), decorator.WithErrorTemplate("error.html"),
)
// No template found, should fallback to the pathroot/error.html which has no locale
w = httptest.NewRecorder()
d.Decorate(w, r, "no-such-file-so-fallback-error.html", nil)
resp = w.Result()
body, _ = ioutil.ReadAll(resp.Body)
fallbackErrorExpect := `Base Error Template: No match`
if fallbackErrorExpect != string(body) {
t.Error(fmt.Sprintf("Expected: %s Got: %s", fallbackErrorExpect, string(body)))
}
// Add some flashes and check output
w = httptest.NewRecorder()
r = stencil.AddFlashAlert(r, "alert msg", nil)
r = stencil.AddFlashError(r, "error msg", nil)
r = stencil.AddFlashInfo(r, "info msg", nil)
r = stencil.AddFlashSuccess(r, "success msg", nil)
d.Decorate(w, r, "home.html", nil)
resp = w.Result()
body, _ = ioutil.ReadAll(resp.Body)
flashExpect := `Base Layout - Locale content: Home - English content Flashes: Msg: alert msg Msg: error msg Msg: info msg Msg: success msg`
if flashExpect != string(body) {
t.Error(fmt.Sprintf("Expected: %s Got: %s", flashExpect, string(body)))
}
}
package executor
type (
// Option defines the behaviours for executingg
Option func(*Options)
// Options holds options to alter this executors behaviour. Currently a placeholder.
Options struct {
}
)
// NewOptions will return a new ExecutorOptions instance with sensible defaults
func NewOptions() *Options {
return &Options{}
}
package executor
import (
"html/template"
"io"
)
type (
// TemplateExecutor is this packages default executor and implements the Executor interface
TemplateExecutor struct {
Options *Options