diff --git a/TODO.md b/TODO.md index 67de8fd..d284610 100644 --- a/TODO.md +++ b/TODO.md @@ -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 @@ -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 diff --git a/build.go b/build.go index c430ca9..1fc7d73 100644 --- a/build.go +++ b/build.go @@ -26,26 +26,44 @@ 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 { @@ -53,31 +71,32 @@ func Build(config *Config) (CancelHandler, error) { } // 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 @@ -85,18 +104,8 @@ func Build(config *Config) (CancelHandler, error) { // 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, } @@ -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 { @@ -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 { @@ -234,17 +259,17 @@ 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 @@ -252,11 +277,17 @@ func (x *xserver) addStaticFileHandler(path_ string, log *slog.Logger, stats *bu 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. @@ -264,17 +295,17 @@ func (x *xserver) addTemplateHandler(path_ string, log *slog.Logger, stats *buil 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_) @@ -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" { @@ -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 diff --git a/config.go b/config.go index f9179fe..9bfb4b9 100644 --- a/config.go +++ b/config.go @@ -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. diff --git a/go.mod b/go.mod index 969613b..5c44554 100644 --- a/go.mod +++ b/go.mod @@ -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 @@ -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 diff --git a/go.sum b/go.sum index 818cf97..1e5b7a5 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/main.go b/main.go index 295c41b..df32edb 100644 --- a/main.go +++ b/main.go @@ -1,7 +1,6 @@ package xtemplate import ( - "database/sql" "flag" "fmt" "log/slog" @@ -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 } diff --git a/test/exec.sh b/test/exec.sh index 889be9c..eb91715 100755 --- a/test/exec.sh +++ b/test/exec.sh @@ -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 diff --git a/test/tests/routing.hurl b/test/tests/routing.hurl index dda0703..ce984d8 100644 --- a/test/tests/routing.hurl +++ b/test/tests/routing.hurl @@ -3,7 +3,7 @@ GET http://localhost:8080/ HTTP 200 [Asserts] -body contains "

Hello world!

" +body contains "

Hello world!" # subdir GET http://localhost:8080/subdir diff --git a/test/tests/templates.hurl b/test/tests/templates.hurl index 900655c..a58661d 100644 --- a/test/tests/templates.hurl +++ b/test/tests/templates.hurl @@ -3,7 +3,7 @@ GET http://localhost:8080/visible HTTP 200 [Asserts] -body contains "

You can't see me

" +body contains "

You can't see me" # reading files from context @@ -11,7 +11,7 @@ GET http://localhost:8080/context HTTP 200 [Asserts] -body contains "" +body contains "" body contains "bar"