diff --git a/README.md b/README.md index 1a4ca45..e058b5b 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ Warning: http.ServeFile() does not automatically sanitize the file path. If you ## Databases -The Let's-go book calls for MySQL. We use [go-sqlite3](ggithub.com/mattn/go-sqlite3) and [go-migrate]() tool to manage migrations instead. Use `make check-system-deps` to validate all tools for this repository are installed. +The Let's-go book calls for MySQL. We use [go-sqlite3](https://github.com/mattn/go-sqlite3) and [go-migrate]() tool to manage migrations instead. Use `make check-system-deps` to validate all tools for this repository are installed. Packages to consider adopting include sqlx or https://github.com/blockloop/scan. Both have the potential to reduce the verbosity that comes will using the standard database/sql package. @@ -115,4 +115,50 @@ To remove the package you can use `go mod tidy` if all references have been remo ``` go get github.com/foo/bar@none -``` \ No newline at end of file +``` + +## Templates + +The [official template docs](https://pkg.go.dev/text/template#hdr-Functions) can be confusing to sort out. If you are new to templates check this out first: https://www.evandemond.com/kb/go-templates + +- Always use `html/templates` because it escapes characters, preventing XSS attacks. +- With Nested templates you must explicitly pass or pipeline your template data. This occurs in `{{template}}` or `{{block}}` and appears like `{{template "main" .}}` or `{{block "sidebar" .}}{{end}}` +- 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}}` + + +### Template Actions + +| Action | Description | +|-----------------------------------------------|-----------------------------------------------------------------------------------------------| +| `{{ define }}` | Defines a reusable named template. The content inside `{{ define "name" }} ... {{ end }}` can be invoked using `{{ template "name" }}`. Nested `define` calls are not allowed. | +| `{{ template }}` | Executes a template defined by `{{ define "name" }}`. Can optionally pass data into the template as `dot` (e.g., `{{ template "name" .Data }}`). | +| `{{ block }}` | Defines a named template block, similar to `define`, but allows the content to be overridden when embedding templates. Often used in base templates to create extendable sections. | +| `{{/* a comment */}}` | A comment that is ignored by the template engine. Use it to add notes or disable parts of the template without affecting the rendered output. | + +### Template Functions + +- For all actions below the `{{else}}` clause is optional. +- The _empty_ values are false, 0, any nil pointer or interface value, and any array, slice, map, or string of length zero. +- the `with` and `range` actions change the value of dot. Once you start using them, what dot represents can be different depending on where you are in the template and what you’re doing. +- `-}}` and `{{-` trims white space. `"{{23 -}} < {{- 45}}"` becomes `"23<45"` + +| Function | Description | +|-----------------------------------------------|-------------------------------------------------------------------------------------| +| `{{ if .Foo }} C1 {{ else }} C2 {{ end }}` | If `.Foo` is not empty then render the content C1, otherwise render the content C2. | +| `{{ with .Foo }} C1 {{ else }} C2 {{ end }}` | If `.Foo` is not empty, then set `dot` to the value of `.Foo` and render the content C1, otherwise render the content C2.| +| `{{ range .Foo }} C1 {{ else }} C2 {{ end }}` | If the length of `.Foo` is greater than zero then loop over each element, setting dot to the value of each element and rendering the content C1. If the length of `.Foo` is zero then render the content C2. The underlying type of `.Foo` must be an `array`, `slice`, `map`, or `channel`.| +| `{{ define }}` | | +| `{{ define }}` | | + +| Action | Description | +|---------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------| +| `{{ eq .Foo .Bar }}` | Yields `true` if `.Foo` is equal to `.Bar`. | +| `{{ ne .Foo .Bar }}` | Yields `true` if `.Foo` is not equal to `.Bar`. | +| `{{ not .Foo }}` | Yields the boolean negation of `.Foo`. | +| `{{ or .Foo .Bar }}` | Yields `.Foo` if `.Foo` is not empty; otherwise yields `.Bar`. | +| `{{ index .Foo i }}` | Yields the value of `.Foo` at index `i`. The underlying type of `.Foo` must be a map, slice, or array, and `i` must be an integer value. | +| `{{ printf "%s-%s" .Foo .Bar }}`| Yields a formatted string containing the `.Foo` and `.Bar` values. Works in the same way as `fmt.Sprintf()`. | +| `{{ len .Foo }}` | Yields the length of `.Foo` as an integer. | +| `{{$bar := len .Foo}}` | Assigns the length of `.Foo` to the template variable `$bar`. | diff --git a/internal/server/handlers.go b/internal/server/handlers.go index 3bca7a3..bb295b0 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -78,17 +78,38 @@ func handleSnippetView(logger *slog.Logger, snippetService *model.SnippetService return } - // Write the snippet data as a plain-text HTTP response body. - fmt.Fprintf(w, "%+v", snippet) + 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 + } + + data := templateData{ + Snippet: snippet, + } + + 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). + // In this specific case, the underlying type of dot will be a models.Snippet struct. + // 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) + } }) } -// // snippetCreate handles display of the form used to create snippets -// func (rs *RatchetServer) snippetCreate(w http.ResponseWriter, r *http.Request) { -// w.Write([]byte("Create snippet form...")) -// } - func handleSnippetCreateGet() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("Create snippet form..")) @@ -119,22 +140,3 @@ func handleSnippetCreatePost(logger *slog.Logger, snippetService *model.SnippetS http.Redirect(w, r, fmt.Sprintf("/snippet/view/%d", id), http.StatusSeeOther) }) } - -// func (w http.ResponseWriter, r *http.Request) { -// // example of a custom header. Must be done before calling WriteHeader -// // or they will fail to take effect. -// w.Header().Add("Server", "Dirp") -// // Create some variables holding dummy data. We'll remove these later on -// // during the build. -// title := "O snail" -// content := "O snail\nClimb Mount Fuji,\nBut slowly, slowly!\n\n– Kobayashi Issa" -// expires := 7 - -// id, err := rs.snippetService.Insert(title, content, expires) - -// if err != nil { -// rs.serverError(w, r, err) -// } - -// http.Redirect(w, r, fmt.Sprintf("/snippet/view/%d", id), http.StatusSeeOther) -// } diff --git a/internal/server/templates.go b/internal/server/templates.go new file mode 100644 index 0000000..193cd42 --- /dev/null +++ b/internal/server/templates.go @@ -0,0 +1,50 @@ +package server + +import ( + "database/sql" + "html/template" + + "git.runcible.io/learning/ratchet/internal/model" +) + +// Define a templateData type to act as the holding structure for +// any dynamic data that we want to pass to our HTML templates. +// 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 +} + +// TEMPLATE FILTERS + +var templateFuncMap = template.FuncMap{ + // This is a trivial example because you can simply use {{ .Content.String }} to call the sql.NullString.String() function. + "nullStringToStr": func(ns sql.NullString) string { + if ns.Valid { + return ns.String + } + return "" + }, +} + +// parseTemplateFiles parses the provided template files and extends the resulting +// template with additional custom template functions (filters) defined in the +// templateFuncMap. +// +// This function serves as a wrapper around template.ParseFiles, allowing the +// inclusion of reusable template functions to enhance the template's capabilities. +// +// Parameters: +// - files: A variadic list of file paths to the template files to be parsed. +// +// Returns: +// - (*template.Template): The parsed template with custom functions injected. +// - (error): An error if the template files cannot be parsed. +func parseTemplateFiles(files ...string) (*template.Template, error) { + tmpl := template.New("").Funcs(templateFuncMap) + tmpl, err := tmpl.ParseFiles(files...) + if err != nil { + return nil, err + } + return tmpl, nil +} diff --git a/ui/html/pages/view.go.tmpl b/ui/html/pages/view.go.tmpl new file mode 100644 index 0000000..b888e5c --- /dev/null +++ b/ui/html/pages/view.go.tmpl @@ -0,0 +1,17 @@ +{{define "title"}}Snippet #{{.Snippet.ID}}{{end}} + +{{define "main"}} + +
{{.Snippet.Content.String }}
+
+