From 13445115dae6e62c81141466a63f4c9c7d5a93b6 Mon Sep 17 00:00:00 2001 From: Drew Bednar Date: Sun, 26 Jan 2025 09:44:54 -0500 Subject: [PATCH] Templates complete --- README.md | 3 +- cmd/ratchetd/main.go | 4 +- internal/server/handlers.go | 71 ++++++++++++++++------------ internal/server/routes.go | 7 +-- internal/server/server.go | 9 ++-- internal/server/templates.go | 90 +++++++++++++++++++++++++++++++++++- ui/html/base.go.tmpl | 2 +- ui/html/pages/home.go.tmpl | 21 ++++++++- ui/html/pages/view.go.tmpl | 12 +++-- 9 files changed, 173 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index e058b5b..8fd3129 100644 --- a/README.md +++ b/README.md @@ -126,7 +126,8 @@ The [official template docs](https://pkg.go.dev/text/template#hdr-Functions) can - If the type that you’re yielding between `{{ }}` tags has methods defined against it, you can call these methods (so long as they are exported and they return only a single value — or a single value and an error). Example `.Snippet.Created` struct field has the underlying type `time.Time` so `{{.Snippet.Created.Weekday}}`. You can also pass parameters `{{.Snippet.Created.AddDate 0 6 0}}` - The `html/template` package always strips out any HTML comments you include in your templates which also help avoid XSS attacks when rendering dynamic content. - Variable names must be prefixed by a dollar sign and can contain alphanumeric characters only. Ex: `{{$bar := len .Foo}}` - +- You can combine multiple template functions with `()`. Ex: `{{if (gt (len .Foo) 99)}} C1 {{end}}` +- Within a `{{range}}` action you can use the `{{break}}` command to end the loop early, and `{{continue}}` to immediately start the next loop iteration. ### Template Actions diff --git a/cmd/ratchetd/main.go b/cmd/ratchetd/main.go index d9ccf1b..524e2d9 100644 --- a/cmd/ratchetd/main.go +++ b/cmd/ratchetd/main.go @@ -42,10 +42,12 @@ func main() { // Close db connection before exiting main. defer db.Close() + tc, err := server.InitTemplateCache() + // Propagate build information to root package to share globally // ratchet.Version = strings.TrimPrefix(version, "") // ratchet.Commit = commit - server := server.NewRatchetServer(logger, db) + server := server.NewRatchetServer(logger, tc, db) // START SERVING REQUESTS slog.Debug("Herp dirp!") diff --git a/internal/server/handlers.go b/internal/server/handlers.go index bb295b0..cceeda2 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -11,7 +11,7 @@ import ( ) // TODO function should accept and a pointer to an interface allowing for mocking in tests. -func handleHome(logger *slog.Logger, snippetService *model.SnippetService) http.Handler { +func handleHome(logger *slog.Logger, tc *TemplateCache, snippetService *model.SnippetService) http.Handler { return http.HandlerFunc( func(w http.ResponseWriter, r *http.Request) { logger.Info("request received", "method", "GET", "path", "/") @@ -26,9 +26,15 @@ func handleHome(logger *slog.Logger, snippetService *model.SnippetService) http. logger.Debug(fmt.Sprintf("%d snippets retrieved", len(snippets))) - for _, snippet := range snippets { - fmt.Fprintf(w, "%+v\n", snippet) - } + // Old way. We want default data so + // data := templateData{ + // Snippets: snippets, + // } + data := newTemplateData() + data.Snippets = snippets + + renderTemplate(w, r, tc, http.StatusOK, "home.go.tmpl", data) + // // Initialize a slice containing the paths to the two files. It's important // // to note that the file containing our base template must be the *first* // // file in the slice. @@ -38,24 +44,25 @@ func handleHome(logger *slog.Logger, snippetService *model.SnippetService) http. // "./ui/html/pages/home.go.tmpl", // } - // // read template file into template set. + // read template file into template set. // ts, err := template.ParseFiles(files...) + // ts, err := parseTemplateFiles(files...) // if err != nil { - // rs.serverError(w, r, err) + // serverError(w, r, err) // return // } // // Write template content to response body - // err = ts.ExecuteTemplate(w, "base", nil) + // err = ts.ExecuteTemplate(w, "base", data) // if err != nil { // // This is the older more verbose way of doing what RatchetServer.serverError does // // rs.logger.Error(err.Error()) // // http.Error(w, "Internal Server Error", http.StatusInternalServerError) - // rs.serverError(w, r, err) + // serverError(w, r, err) // } }) } -func handleSnippetView(logger *slog.Logger, snippetService *model.SnippetService) http.Handler { +func handleSnippetView(logger *slog.Logger, tc *TemplateCache, snippetService *model.SnippetService) http.Handler { return http.HandlerFunc( func(w http.ResponseWriter, r *http.Request) { id, err := strconv.Atoi(r.PathValue("id")) @@ -78,24 +85,24 @@ func handleSnippetView(logger *slog.Logger, snippetService *model.SnippetService return } - files := []string{ - "./ui/html/base.go.tmpl", - "./ui/html/partials/nav.go.tmpl", - "./ui/html/pages/view.go.tmpl", - } + // files := []string{ + // "./ui/html/base.go.tmpl", + // "./ui/html/partials/nav.go.tmpl", + // "./ui/html/pages/view.go.tmpl", + // } - //ts, err := template.ParseFiles(files...) - ts, err := parseTemplateFiles(files...) - if err != nil { - serverError(w, r, err) - return - } + // //ts, err := template.ParseFiles(files...) + // ts, err := parseTemplateFiles(files...) + // if err != nil { + // serverError(w, r, err) + // return + // } - data := templateData{ - Snippet: snippet, - } + // data := templateData{ + // Snippet: snippet, + // } - logger.Debug(fmt.Sprintf("created template: %s", ts.Name())) + // logger.Debug(fmt.Sprintf("created template: %s", ts.Name())) // Any data that you pass as the final parameter to ts.ExecuteTemplate() // is represented within your HTML templates by the . character (referred to as dot). @@ -103,10 +110,16 @@ func handleSnippetView(logger *slog.Logger, snippetService *model.SnippetService // When the underlying type of dot is a struct, you can render (or yield) the value // of any exported field in your templates by postfixing dot with the field name // field, we could yield the snippet title by writing {{.Title}} in our templates. - err = ts.ExecuteTemplate(w, "base", data) - if err != nil { - serverError(w, r, err) - } + // err = ts.ExecuteTemplate(w, "base", data) + // if err != nil { + // serverError(w, r, err) + // } + // data := templateData{ + // Snippet: snippet, + // } + data := newTemplateData() + data.Snippet = snippet + renderTemplate(w, r, tc, http.StatusOK, "view.go.tmpl", data) }) } @@ -119,7 +132,7 @@ func handleSnippetCreateGet() http.Handler { // snippetCreate handles display of the form used to create snippets // // curl -iL -d "" http://localhost:5001/snippet/create -func handleSnippetCreatePost(logger *slog.Logger, snippetService *model.SnippetService) http.Handler { +func handleSnippetCreatePost(logger *slog.Logger, tc *TemplateCache, snippetService *model.SnippetService) http.Handler { return http.HandlerFunc( func(w http.ResponseWriter, r *http.Request) { // example of a custom header. Must be done before calling WriteHeader diff --git a/internal/server/routes.go b/internal/server/routes.go index b472bf1..5563ebe 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -10,6 +10,7 @@ import ( func addRoutes(mux *http.ServeMux, logger *slog.Logger, + tc *TemplateCache, db *sql.DB, snippetService *model.SnippetService) http.Handler { @@ -17,10 +18,10 @@ func addRoutes(mux *http.ServeMux, // resulting in this route requiring an exact match on "/" only // You can only include one HTTP method in a route pattern if you choose // GET will match GET & HEAD http request methods - mux.Handle("GET /{$}", handleHome(logger, snippetService)) - mux.Handle("GET /snippet/view/{id}", handleSnippetView(logger, snippetService)) + mux.Handle("GET /{$}", handleHome(logger, tc, snippetService)) + mux.Handle("GET /snippet/view/{id}", handleSnippetView(logger, tc, snippetService)) mux.Handle("GET /snippet/create", handleSnippetCreateGet()) - mux.Handle("POST /snippet/create", handleSnippetCreatePost(logger, snippetService)) + mux.Handle("POST /snippet/create", handleSnippetCreatePost(logger, tc, snippetService)) // mux.Handle("/something", handleSomething(logger, config)) // mux.Handle("/healthz", handleHealthzPlease(logger)) // mux.Handle("/", http.NotFoundHandler()) diff --git a/internal/server/server.go b/internal/server/server.go index 2f5f312..9f1ac50 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -11,16 +11,19 @@ import ( type RatchetServer struct { http.Handler - logger *slog.Logger + logger *slog.Logger + templateCache *TemplateCache //Services used by HTTP routes snippetService *model.SnippetService UserService model.UserService } -func NewRatchetServer(logger *slog.Logger, db *sql.DB) *RatchetServer { +func NewRatchetServer(logger *slog.Logger, tc *TemplateCache, db *sql.DB) *RatchetServer { rs := new(RatchetServer) rs.logger = logger rs.snippetService = &model.SnippetService{DB: db} + + rs.templateCache = tc // TODO implement middleware that disables directory listings fileServer := http.FileServer(http.Dir("./ui/static/")) router := http.NewServeMux() @@ -42,6 +45,6 @@ func NewRatchetServer(logger *slog.Logger, db *sql.DB) *RatchetServer { // Mux Router implements the Handler interface. AKA it has a ServeHTTP receiver. // SEE we can really clean things up by moving this into routes.go and handlers.go - rs.Handler = addRoutes(router, rs.logger, db, rs.snippetService) + rs.Handler = addRoutes(router, rs.logger, rs.templateCache, db, rs.snippetService) return rs } diff --git a/internal/server/templates.go b/internal/server/templates.go index 193cd42..2909eaa 100644 --- a/internal/server/templates.go +++ b/internal/server/templates.go @@ -1,8 +1,13 @@ package server import ( + "bytes" "database/sql" + "fmt" "html/template" + "net/http" + "path/filepath" + "time" "git.runcible.io/learning/ratchet/internal/model" ) @@ -12,7 +17,25 @@ import ( // At the moment it only contains one field, but we'll add more // to it as the build progresses. type templateData struct { - Snippet model.Snippet + CurrentYear int + Snippet model.Snippet + Snippets []model.Snippet +} + +// newTemplateData is useful to inject default values. Example CSRF tokens for forms. +func newTemplateData() templateData { + return templateData{CurrentYear: time.Now().Year()} +} + +// TEMPLATE FUNCTIONS + +// Custom template functions can accept as many parameters as they need to, but they must return one value only. +// The only exception to this is if you want to return an error as the second value, in which case that’s OK too. + +// Create a humanDate function which returns a nicely formatted string +// representation of a time.Time object. +func humanDate(t time.Time) string { + return t.Format("02 Jan 2006 at 15:04") } // TEMPLATE FILTERS @@ -25,6 +48,7 @@ var templateFuncMap = template.FuncMap{ } return "" }, + "humanDate": humanDate, } // parseTemplateFiles parses the provided template files and extends the resulting @@ -48,3 +72,67 @@ func parseTemplateFiles(files ...string) (*template.Template, error) { } return tmpl, nil } + +// TODO use an go:embed FS instead of file paths + +type TemplateCache map[string]*template.Template + +func InitTemplateCache() (*TemplateCache, error) { + cache := TemplateCache{} + + pages, err := filepath.Glob("./ui/html/pages/*.tmpl") + if err != nil { + return nil, err + } + + for _, page := range pages { + name := filepath.Base(page) + + tmpl := template.New(name).Funcs(templateFuncMap) + // Parse the base template file into a template set. + tmpl, err = tmpl.ParseFiles("./ui/html/base.go.tmpl") + if err != nil { + return nil, err + } + // Call ParseGlob() *on this template set* to add any partials. + tmpl, err = tmpl.ParseGlob("./ui/html/partials/*.tmpl") + if err != nil { + return nil, err + } + + // Call ParseFiles() *on this template set* to add the page template. + tmpl, err = tmpl.ParseFiles(page) + if err != nil { + return nil, err + } + + cache[name] = tmpl + + } + return &cache, nil +} + +func renderTemplate(w http.ResponseWriter, r *http.Request, tc *TemplateCache, status int, page string, data templateData) { + cache := *tc + ts, ok := cache[page] + if !ok { + err := fmt.Errorf("the template %s does not exist", page) + serverError(w, r, err) + return + } + + // Write the template results to a buffer to capture templating errors before writing + // to ResponseWriter + buf := new(bytes.Buffer) + + err := ts.ExecuteTemplate(buf, "base", data) + if err != nil { + serverError(w, r, err) + return + } + + w.WriteHeader(status) + + buf.WriteTo(w) + +} diff --git a/ui/html/base.go.tmpl b/ui/html/base.go.tmpl index 09d86e4..d9f03b1 100644 --- a/ui/html/base.go.tmpl +++ b/ui/html/base.go.tmpl @@ -17,7 +17,7 @@
{{template "main" .}}
- + diff --git a/ui/html/pages/home.go.tmpl b/ui/html/pages/home.go.tmpl index 4925fb7..ddda5d0 100644 --- a/ui/html/pages/home.go.tmpl +++ b/ui/html/pages/home.go.tmpl @@ -1,6 +1,23 @@ {{define "title"}}Home{{end}} {{define "main" -}} -

Latest Snippets

-

There's nothing to see yet!

+ {{ if .Snippets}} + + + + + + + {{range .Snippets}} + + + + + + + {{end}} +
TitleCreatedID
{{.Title.String}}{{.CreatedAt | humanDate }}#{{.ID}}
+ {{else}} +

There's nothing to see yet!

+ {{end}} {{- end -}} diff --git a/ui/html/pages/view.go.tmpl b/ui/html/pages/view.go.tmpl index b888e5c..c1001ae 100644 --- a/ui/html/pages/view.go.tmpl +++ b/ui/html/pages/view.go.tmpl @@ -1,17 +1,19 @@ {{define "title"}}Snippet #{{.Snippet.ID}}{{end}} {{define "main"}} + {{ with .Snippet }}
-
{{.Snippet.Content.String }}
+
{{.Content.String }}
+ {{ end }} {{end}} \ No newline at end of file