Skip to content

Commit

Permalink
Support minifying html templates at load time
Browse files Browse the repository at this point in the history
  • Loading branch information
infogulch committed Mar 6, 2024
1 parent 73a6df9 commit a54a404
Show file tree
Hide file tree
Showing 9 changed files with 136 additions and 54 deletions.
4 changes: 2 additions & 2 deletions TODO.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
# TODO

- [ ] Support minifying templates as they're loaded. https://github.com/tdewolff/minify
- [ ] Add command to pre-compress all static files
- Support SSE
- [ ] Integrate nats subscription
Expand All @@ -27,12 +26,13 @@

# DONE

## v0.3.2 - Mar 2024
## v0.3 - Mar 2024

- [x] Refactor watch to be easier to use from both Main() and xtemplate-caddy.
- [x] Use LogAttrs in hot paths
- [x] Simplify handlers
- [x] Use github.com/felixge/httpsnoop to capture response metrics
- [x] Support minifying templates as they're loaded. https://github.com/tdewolff/minify

## v0.3 - Feb 2024

Expand Down
115 changes: 73 additions & 42 deletions build.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,77 +26,86 @@ import (
"github.com/Masterminds/sprig/v3"
"github.com/andybalholm/brotli"
"github.com/klauspost/compress/zstd"
"github.com/tdewolff/minify/v2"
"github.com/tdewolff/minify/v2/css"
"github.com/tdewolff/minify/v2/html"
"github.com/tdewolff/minify/v2/js"
"github.com/tdewolff/minify/v2/svg"
)

type xbuilder struct {
*xserver

log *slog.Logger
minify *minify.M
stats struct {
Routes int
TemplateFiles int
TemplateDefinitions int
TemplateInitializers int
StaticFiles int
StaticFileEncodings int
}
}

// Build creates a new xtemplate server instance, a `CancelHandler`, from an xtemplate.Config.
func Build(config *Config) (CancelHandler, error) {
server, err := newServer(config)
builder, err := newBuilder(config)
if err != nil {
return nil, err
}
log := server.Logger.WithGroup("build")
stats := &buildStats{}

// Recursively scan and process all files in Template.FS.
if err := fs.WalkDir(server.Template.FS, ".", func(path string, d fs.DirEntry, err error) error {
if err := fs.WalkDir(builder.Template.FS, ".", func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return err
}
if ext := filepath.Ext(path); ext == server.Template.TemplateExtension {
err = server.addTemplateHandler(path, log, stats)
if ext := filepath.Ext(path); ext == builder.Template.TemplateExtension {
err = builder.addTemplateHandler(path)
} else {
err = server.addStaticFileHandler(path, log, stats)
err = builder.addStaticFileHandler(path)
}
return err
}); err != nil {
return nil, fmt.Errorf("error scanning files: %v", err)
}

// Invoke all initilization templates, aka any template whose name starts with "INIT ".
for _, tmpl := range server.templates.Templates() {
for _, tmpl := range builder.templates.Templates() {
if strings.HasPrefix(tmpl.Name(), "INIT ") {
context := &struct {
baseContext
fsContext
}{
baseContext{
server: server,
log: log,
server: builder.xserver,
log: builder.log,
},
fsContext{
fs: server.Context.FS,
fs: builder.Context.FS,
},
}
err := tmpl.Execute(io.Discard, context)
if err = context.resolvePendingTx(err); err != nil {
return nil, fmt.Errorf("template initializer '%s' failed: %w", tmpl.Name(), err)
}
stats.TemplateInitializers += 1
builder.stats.TemplateInitializers += 1
}
}

log.Info("xtemplate instance initialized", slog.Any("stats", stats))
log.Debug("xtemplate instance details", slog.Any("xtemplate", server))
return server, nil
builder.log.Info("xtemplate instance initialized", slog.Any("stats", builder.stats))
builder.log.Debug("xtemplate instance details", slog.Any("xtemplate", builder.xserver))

return builder.xserver, nil
}

// Counter to assign a unique id to each instance of xtemplate created when
// calling Build(). This is intended to help distinguish logs from multiple
// instances in a single process.
var nextInstanceIdentity int64

// Counts of various objects created during Build.
type buildStats struct {
Routes int
TemplateFiles int
TemplateDefinitions int
TemplateInitializers int
StaticFiles int
StaticFileEncodings int
}

// newServer creates an empty xserver with all data structures initalized using the provided config.
func newServer(config *Config) (*xserver, error) {
// newBuilder creates an empty xserver with all data structures initalized using the provided config.
func newBuilder(config *Config) (*xbuilder, error) {
server := &xserver{
Config: *config,
}
Expand Down Expand Up @@ -153,7 +162,23 @@ func newServer(config *Config) (*xserver, error) {
server.templates = template.New(".").Delims(server.Template.Delimiters.Left, server.Template.Delimiters.Right).Funcs(server.funcs)
server.associatedTemplate = make(map[string]*template.Template)

return server, nil
builder := &xbuilder{
xserver: server,
log: server.Logger.WithGroup("build"),
}

if config.Template.Minify {
m := minify.New()
m.Add("text/css", &css.Minifier{})
m.Add("image/svg+xml", &svg.Minifier{})
m.Add("text/html", &html.Minifier{
TemplateDelims: [...]string{server.Template.Delimiters.Left, server.Template.Delimiters.Right},
})
m.AddRegexp(regexp.MustCompile("^(application|text)/(x-)?(java|ecma)script$"), &js.Minifier{})
builder.minify = m
}

return builder, nil
}

type fileInfo struct {
Expand All @@ -167,7 +192,7 @@ type encodingInfo struct {
modtime time.Time
}

func (x *xserver) addStaticFileHandler(path_ string, log *slog.Logger, stats *buildStats) error {
func (x *xbuilder) addStaticFileHandler(path_ string) error {
// Open and stat the file
fsfile, err := x.Template.FS.Open(path_)
if err != nil {
Expand Down Expand Up @@ -234,47 +259,53 @@ func (x *xserver) addStaticFileHandler(path_ string, log *slog.Logger, stats *bu
}
file.encodings = []encodingInfo{{encoding: encoding, path: path_, size: size, modtime: stat.ModTime()}}
x.router.HandleFunc("GET "+basepath, staticFileHandler)
stats.StaticFiles += 1
stats.Routes += 1
log.Debug("added static file handler", slog.String("path", basepath), slog.String("filepath", path_), slog.String("contenttype", file.contentType), slog.Int64("size", size), slog.Time("modtime", stat.ModTime()), slog.String("hash", sri))
x.stats.StaticFiles += 1
x.stats.Routes += 1
x.log.Debug("added static file handler", slog.String("path", basepath), slog.String("filepath", path_), slog.String("contenttype", file.contentType), slog.Int64("size", size), slog.Time("modtime", stat.ModTime()), slog.String("hash", sri))
} else {
if file.hash != sri {
return fmt.Errorf("encoded file contents did not match original file '%s': expected %s, got %s", path_, file.hash, sri)
}
file.encodings = append(file.encodings, encodingInfo{encoding: encoding, path: path_, size: size, modtime: stat.ModTime()})
sort.Slice(file.encodings, func(i, j int) bool { return file.encodings[i].size < file.encodings[j].size })
stats.StaticFileEncodings += 1
log.Debug("added static file encoding", slog.String("path", basepath), slog.String("filepath", path_), slog.String("encoding", encoding), slog.Int64("size", size), slog.Time("modtime", stat.ModTime()))
x.stats.StaticFileEncodings += 1
x.log.Debug("added static file encoding", slog.String("path", basepath), slog.String("filepath", path_), slog.String("encoding", encoding), slog.Int64("size", size), slog.Time("modtime", stat.ModTime()))
}
x.files[basepath] = file
return nil
}

var routeMatcher *regexp.Regexp = regexp.MustCompile("^(GET|POST|PUT|PATCH|DELETE|SSE) (.*)$")

func (x *xserver) addTemplateHandler(path_ string, log *slog.Logger, stats *buildStats) error {
func (x *xbuilder) addTemplateHandler(path_ string) error {
content, err := fs.ReadFile(x.Template.FS, path_)
if err != nil {
return fmt.Errorf("could not read template file '%s': %v", path_, err)
}
if x.Template.Minify {
content, err = x.minify.Bytes("text/html", content)
if err != nil {
return fmt.Errorf("could not minify template file '%s': %v", path_, err)
}
}
path_ = path.Clean("/" + path_)
// parse each template file manually to have more control over its final
// names in the template namespace.
newtemplates, err := parse.Parse(path_, string(content), x.Template.Delimiters.Left, x.Template.Delimiters.Right, x.funcs, buliltinsSkeleton)
if err != nil {
return fmt.Errorf("could not parse template file '%s': %v", path_, err)
}
stats.TemplateFiles += 1
x.stats.TemplateFiles += 1
// add all templates
for name, tree := range newtemplates {
if x.templates.Lookup(name) != nil {
log.Debug("overriding named template '%s' with definition from file: %s", name, path_)
x.log.Debug("overriding named template '%s' with definition from file: %s", name, path_)
}
tmpl, err := x.templates.AddParseTree(name, tree)
if err != nil {
return fmt.Errorf("could not add template '%s' from '%s': %v", name, path_, err)
}
stats.TemplateDefinitions += 1
x.stats.TemplateDefinitions += 1
if name == path_ {
// don't register routes to hidden files
_, file := filepath.Split(path_)
Expand All @@ -292,8 +323,8 @@ func (x *xserver) addTemplateHandler(path_ string, log *slog.Logger, stats *buil
}
x.associatedTemplate["GET "+routePath] = tmpl
x.router.HandleFunc("GET "+routePath, bufferingTemplateHandler)
stats.Routes += 1
log.Debug("added path template handler", "method", "GET", "path", routePath, "template_path", path_)
x.stats.Routes += 1
x.log.Debug("added path template handler", "method", "GET", "path", routePath, "template_path", path_)
} else if matches := routeMatcher.FindStringSubmatch(name); len(matches) == 3 {
method, path_ := matches[1], matches[2]
if method == "SSE" {
Expand All @@ -305,8 +336,8 @@ func (x *xserver) addTemplateHandler(path_ string, log *slog.Logger, stats *buil
x.associatedTemplate[pattern] = tmpl
x.router.HandleFunc(pattern, bufferingTemplateHandler)
}
stats.Routes += 1
log.Debug("added named template handler", "method", method, "path", path_, "template_name", name, "template_path", path_)
x.stats.Routes += 1
x.log.Debug("added named template handler", "method", method, "path", path_, "template_name", name, "template_path", path_)
}
}
return nil
Expand Down
7 changes: 7 additions & 0 deletions config.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,13 @@ type Config struct {
Left string `json:"left,omitempty"`
Right string `json:"right,omitempty"`
} `json:"delimiters,omitempty"`

// Minify html templates as they're loaded.
//
// > Minification is the process of removing bytes from a file (such as
// whitespace) without changing its output and therefore shrinking its
// size and speeding up transmission over the internet
Minify bool `json:"minify"`
} `json:"template,omitempty"`

// Control where the templates may have dynamic access the filesystem.
Expand Down
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ require (
github.com/klauspost/compress v1.17.7
github.com/microcosm-cc/bluemonday v1.0.26
github.com/segmentio/ksuid v1.0.4
github.com/tdewolff/minify/v2 v2.20.18
github.com/yuin/goldmark v1.7.0
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
golang.org/x/exp v0.0.0-20240222234643-814bf88cf225
Expand All @@ -35,6 +36,7 @@ require (
github.com/shopspring/decimal v1.3.1 // indirect
github.com/spf13/cast v1.6.0 // indirect
github.com/stretchr/testify v1.8.4 // indirect
github.com/tdewolff/parse/v2 v2.7.12 // indirect
golang.org/x/crypto v0.19.0 // indirect
golang.org/x/net v0.21.0 // indirect
golang.org/x/sys v0.18.0 // indirect
Expand Down
7 changes: 7 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,13 @@ github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/tdewolff/minify/v2 v2.20.18 h1:y+s6OzlZwFqApgNXWNtaMuEMEPbHT72zrCyb9Az35Xo=
github.com/tdewolff/minify/v2 v2.20.18/go.mod h1:ulkFoeAVWMLEyjuDz1ZIWOA31g5aWOawCFRp9R/MudM=
github.com/tdewolff/parse/v2 v2.7.12 h1:tgavkHc2ZDEQVKy1oWxwIyh5bP4F5fEh/JmBwPP/3LQ=
github.com/tdewolff/parse/v2 v2.7.12/go.mod h1:3FbJWZp3XT9OWVN3Hmfp0p/a08v4h8J9W1aghka0soA=
github.com/tdewolff/test v1.0.11-0.20231101010635-f1265d231d52/go.mod h1:6DAvZliBAAnD7rhVgwaM7DE5/d9NMOAJ09SqYqeK4QE=
github.com/tdewolff/test v1.0.11-0.20240106005702-7de5f7df4739 h1:IkjBCtQOOjIn03u/dMQK9g+Iw9ewps4mCl1nB8Sscbo=
github.com/tdewolff/test v1.0.11-0.20240106005702-7de5f7df4739/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.7.0 h1:EfOIvIMZIzHdB/R/zVrikYLPPwJlfMcNczJFMs1m6sA=
Expand Down
47 changes: 41 additions & 6 deletions main.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package xtemplate

import (
"database/sql"
"flag"
"fmt"
"log/slog"
Expand All @@ -20,29 +19,65 @@ type flags struct {
watch_context_path bool
}

var helptext = `xtemplate is a hypertext preprocessor and html templating http server
-listen string
Listen address (default "0.0.0.0:8080")
-template-path string
Directory where templates are loaded from (default "templates")
-watch-template
Watch the template directory and reload if changed (default true)
-template-extension
File extension to look for to identify templates (default ".html")
-minify
Preprocess the template files to minimize their size at load time (default false)
-ldelim string
Left template delimiter (default "{{")
-rdelim string
Right template delimiter (default "}}")
-context-path string
Directory that template definitions are given direct access to. No access is given if empty (default "")
-watch-context
Watch the context directory and reload if changed (default false)
-db-driver string
Name of the database driver registered as a Go 'sql.Driver'. Not available if empty. (default "")
-db-connstr string
Database connection string
-c string
Config values, in the form 'x=y'. This arg can be specified multiple times
-log int
Log level, DEBUG=-4, INFO=0, WARN=4, ERROR=8
-help
Display help
`

func parseflags() (f flags) {
flag.StringVar(&f.listen_addr, "listen", "0.0.0.0:8080", "Listen address")
flag.StringVar(&f.config.Template.Path, "template-path", "templates", "Directory where templates are loaded from")
flag.BoolVar(&f.watch_template_path, "watch-template", true, "Watch the template directory and reload if changed")
flag.StringVar(&f.config.Template.TemplateExtension, "template-extension", ".html", "File extension to look for to identify templates")
flag.BoolVar(&f.config.Template.Minify, "minify", false, "Preprocess the template files to minimize their size at load time")
flag.StringVar(&f.config.Template.Delimiters.Left, "ldelim", "{{", "Left template delimiter")
flag.StringVar(&f.config.Template.Delimiters.Right, "rdelim", "}}", "Right template delimiter")

flag.StringVar(&f.config.Context.Path, "context-path", "", "Directory that template definitions are given direct access to. No access is given if empty (default \"\")")
flag.BoolVar(&f.watch_context_path, "watch-context", false, "Watch the context directory and reload if changed (default false)")

flag.StringVar(&f.config.Database.Driver, "db-driver", "", "Name of the database driver registered as a Go `sql.Driver`. Not available if empty. (default \"\")")
flag.StringVar(&f.config.Database.Driver, "db-driver", "", "Name of the database driver registered as a Go 'sql.Driver'. Not available if empty. (default \"\")")
flag.StringVar(&f.config.Database.Connstr, "db-connstr", "", "Database connection string")

flag.Var(&f.config.UserConfig, "c", "Config values, in the form `x=y`, can be specified multiple times")
flag.Var(&f.config.UserConfig, "c", "Config values, in the form 'x=y', can be specified multiple times")

flag.IntVar(&f.config.LogLevel, "log", 0, "Log level, DEBUG=-4, INFO=0, WARN=4, ERROR=8")
flag.Parse()
flag.Usage = func() {
fmt.Fprintf(flag.CommandLine.Output(), "xtemplate is a hypertext preprocessor and http templating web server.\nUsage of %s:\n", os.Args[0])
flag.PrintDefaults()
fmt.Fprintf(flag.CommandLine.Output(), "Usage of %s:\n%s\n", os.Args[0], helptext)
}
sql.Drivers()
return
}

Expand Down
2 changes: 1 addition & 1 deletion test/exec.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
# Set up xtemplate server and cleanup
echo "Running xtemplate..."
pushd `dirname "$(readlink -f "$0")"` > /dev/null # cd to the directory where this script is
go run ../cmd --log -4 --context-path context > xtemplate.log & # run xtemplate cmd in the background
go run ../cmd --log -4 --context-path context -minify > xtemplate.log & # run xtemplate cmd in the background
PID=$! # grab the pid
exit() {
sleep 0.1s # wait for stdout to flush
Expand Down
2 changes: 1 addition & 1 deletion test/tests/routing.hurl
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ GET http://localhost:8080/

HTTP 200
[Asserts]
body contains "<p>Hello world!</p>"
body contains "<p>Hello world!"

# subdir
GET http://localhost:8080/subdir
Expand Down
Loading

0 comments on commit a54a404

Please sign in to comment.