S39: renderer — recursive partial walk + fail-loud on undefined template refs
- SHA
c197c289ffdfa35bcf6e42319edb71ca38d2f40a- Parents
-
24a480c - Tree
4f249a2
c197c28
c197c289ffdfa35bcf6e42319edb71ca38d2f40a24a480c
4f249a2| Status | File | + | - |
|---|---|---|---|
| M |
internal/web/render/render.go
|
93 | 42 |
| A |
internal/web/render/render_test.go
|
164 | 0 |
internal/web/render/render.gomodified@@ -14,7 +14,9 @@ import ( | ||
| 14 | 14 | "io/fs" |
| 15 | 15 | "net/http" |
| 16 | 16 | "path" |
| 17 | + "sort" | |
| 17 | 18 | "strings" |
| 19 | + "text/template/parse" | |
| 18 | 20 | "time" |
| 19 | 21 | |
| 20 | 22 | "github.com/tenseleyFlow/shithub/internal/web/middleware" |
@@ -36,39 +38,31 @@ type Options struct { | ||
| 36 | 38 | Octicons OcticonResolver |
| 37 | 39 | } |
| 38 | 40 | |
| 39 | -// New parses every page template under tmplFS. A "page template" is any file | |
| 40 | -// at the root of tmplFS that does NOT begin with an underscore. Files that | |
| 41 | -// begin with an underscore (e.g. "_layout.html") are partials, parsed once | |
| 42 | -// into every page. | |
| 41 | +// New parses every page template under tmplFS. | |
| 42 | +// | |
| 43 | +// Naming contract — read this before adding files to internal/web/templates/: | |
| 44 | +// | |
| 45 | +// - **Pages** are .html files whose basename does NOT begin with an | |
| 46 | +// underscore. A page at `repo/tree.html` is registered under the | |
| 47 | +// lookup name `repo/tree`. Render that name from a handler. | |
| 48 | +// - **Partials** are .html files whose basename begins with an | |
| 49 | +// underscore (`_layout.html`, `profile/_tabs.html`). Partials are | |
| 50 | +// parsed into *every* page so the `{{ define "name" }}` blocks | |
| 51 | +// they declare are resolvable from any page template. | |
| 52 | +// | |
| 53 | +// Both pages and partials are picked up recursively. Earlier versions | |
| 54 | +// of this loader walked only the root for partials, which caused a | |
| 55 | +// page that referenced a subdir partial (`profile/_tabs.html`'s | |
| 56 | +// `{{ define "tabs" }}`) to render blank — html/template silently | |
| 57 | +// ignored the missing-template ref at exec time. We now also validate | |
| 58 | +// that every `{{ template "name" }}` action in every parsed page | |
| 59 | +// resolves; an undefined ref fails loud at startup with the offending | |
| 60 | +// page + the missing name. | |
| 43 | 61 | func New(tmplFS fs.FS, opts Options) (*Renderer, error) { |
| 44 | - entries, err := fs.ReadDir(tmplFS, ".") | |
| 45 | - if err != nil { | |
| 46 | - return nil, fmt.Errorf("read template root: %w", err) | |
| 47 | - } | |
| 48 | - | |
| 49 | 62 | var ( |
| 50 | - partialNames []string | |
| 51 | - pageNames []string | |
| 52 | - errorPages []string | |
| 63 | + partialPaths []string | |
| 64 | + pagePaths []string | |
| 53 | 65 | ) |
| 54 | - for _, e := range entries { | |
| 55 | - if e.IsDir() { | |
| 56 | - continue | |
| 57 | - } | |
| 58 | - name := e.Name() | |
| 59 | - if !strings.HasSuffix(name, ".html") { | |
| 60 | - continue | |
| 61 | - } | |
| 62 | - if strings.HasPrefix(name, "_") { | |
| 63 | - partialNames = append(partialNames, name) | |
| 64 | - } else { | |
| 65 | - pageNames = append(pageNames, name) | |
| 66 | - } | |
| 67 | - } | |
| 68 | - | |
| 69 | - // Recursively pick up files in subdirectories like errors/. | |
| 70 | - // Each subdirectory file is registered as `<dir>/<name>` (without | |
| 71 | - // suffix) for Render lookups. | |
| 72 | 66 | if err := fs.WalkDir(tmplFS, ".", func(p string, d fs.DirEntry, walkErr error) error { |
| 73 | 67 | if walkErr != nil { |
| 74 | 68 | return walkErr |
@@ -76,43 +70,100 @@ func New(tmplFS fs.FS, opts Options) (*Renderer, error) { | ||
| 76 | 70 | if d.IsDir() || !strings.HasSuffix(p, ".html") { |
| 77 | 71 | return nil |
| 78 | 72 | } |
| 79 | - if !strings.Contains(p, "/") { | |
| 80 | - return nil | |
| 73 | + if strings.HasPrefix(path.Base(p), "_") { | |
| 74 | + partialPaths = append(partialPaths, p) | |
| 75 | + } else { | |
| 76 | + pagePaths = append(pagePaths, p) | |
| 81 | 77 | } |
| 82 | - errorPages = append(errorPages, p) | |
| 83 | 78 | return nil |
| 84 | 79 | }); err != nil { |
| 85 | 80 | return nil, fmt.Errorf("walk templates: %w", err) |
| 86 | 81 | } |
| 82 | + sort.Strings(partialPaths) | |
| 83 | + sort.Strings(pagePaths) | |
| 87 | 84 | |
| 88 | 85 | r := &Renderer{ |
| 89 | - pages: make(map[string]*template.Template, len(pageNames)+len(errorPages)), | |
| 86 | + pages: make(map[string]*template.Template, len(pagePaths)), | |
| 90 | 87 | octicon: opts.Octicons, |
| 91 | 88 | } |
| 92 | 89 | |
| 93 | - parse := func(displayName string, primary string) error { | |
| 90 | + parsePage := func(displayName, primary string) error { | |
| 94 | 91 | t := template.New(path.Base(primary)).Funcs(funcMap(r.octicon)) |
| 95 | - all := append([]string{}, partialNames...) | |
| 92 | + all := append([]string{}, partialPaths...) | |
| 96 | 93 | all = append(all, primary) |
| 97 | 94 | parsed, err := t.ParseFS(tmplFS, all...) |
| 98 | 95 | if err != nil { |
| 99 | 96 | return fmt.Errorf("parse %s: %w", displayName, err) |
| 100 | 97 | } |
| 98 | + if missing := undefinedTemplateRefs(parsed); len(missing) > 0 { | |
| 99 | + return fmt.Errorf("page %q references undefined template(s): %s", displayName, strings.Join(missing, ", ")) | |
| 100 | + } | |
| 101 | 101 | r.pages[displayName] = parsed |
| 102 | 102 | return nil |
| 103 | 103 | } |
| 104 | 104 | |
| 105 | - for _, page := range pageNames { | |
| 106 | - if err := parse(strings.TrimSuffix(page, ".html"), page); err != nil { | |
| 105 | + for _, page := range pagePaths { | |
| 106 | + if err := parsePage(strings.TrimSuffix(page, ".html"), page); err != nil { | |
| 107 | 107 | return nil, err |
| 108 | 108 | } |
| 109 | 109 | } |
| 110 | - for _, page := range errorPages { | |
| 111 | - if err := parse(strings.TrimSuffix(page, ".html"), page); err != nil { | |
| 112 | - return nil, err | |
| 110 | + return r, nil | |
| 111 | +} | |
| 112 | + | |
| 113 | +// undefinedTemplateRefs returns the names of every `{{ template "name" }}` | |
| 114 | +// action in any parsed sub-template that does not resolve to a defined | |
| 115 | +// template within `t`. Empty slice means every reference is satisfied. | |
| 116 | +// | |
| 117 | +// The standard library does not validate this at parse time — html/template | |
| 118 | +// happily parses a page with a dangling `{{ template "missing" }}` and | |
| 119 | +// silently emits nothing at exec time. This helper closes that hole. | |
| 120 | +func undefinedTemplateRefs(t *template.Template) []string { | |
| 121 | + defined := map[string]bool{} | |
| 122 | + for _, child := range t.Templates() { | |
| 123 | + defined[child.Name()] = true | |
| 124 | + } | |
| 125 | + seen := map[string]bool{} | |
| 126 | + var missing []string | |
| 127 | + for _, child := range t.Templates() { | |
| 128 | + if child.Tree == nil { | |
| 129 | + continue | |
| 113 | 130 | } |
| 131 | + walkTemplateRefs(child.Tree.Root, func(name string) { | |
| 132 | + if defined[name] || seen[name] { | |
| 133 | + return | |
| 134 | + } | |
| 135 | + seen[name] = true | |
| 136 | + missing = append(missing, name) | |
| 137 | + }) | |
| 138 | + } | |
| 139 | + sort.Strings(missing) | |
| 140 | + return missing | |
| 141 | +} | |
| 142 | + | |
| 143 | +func walkTemplateRefs(n parse.Node, visit func(name string)) { | |
| 144 | + if n == nil { | |
| 145 | + return | |
| 146 | + } | |
| 147 | + switch x := n.(type) { | |
| 148 | + case *parse.ListNode: | |
| 149 | + if x == nil { | |
| 150 | + return | |
| 151 | + } | |
| 152 | + for _, c := range x.Nodes { | |
| 153 | + walkTemplateRefs(c, visit) | |
| 154 | + } | |
| 155 | + case *parse.IfNode: | |
| 156 | + walkTemplateRefs(x.List, visit) | |
| 157 | + walkTemplateRefs(x.ElseList, visit) | |
| 158 | + case *parse.RangeNode: | |
| 159 | + walkTemplateRefs(x.List, visit) | |
| 160 | + walkTemplateRefs(x.ElseList, visit) | |
| 161 | + case *parse.WithNode: | |
| 162 | + walkTemplateRefs(x.List, visit) | |
| 163 | + walkTemplateRefs(x.ElseList, visit) | |
| 164 | + case *parse.TemplateNode: | |
| 165 | + visit(x.Name) | |
| 114 | 166 | } |
| 115 | - return r, nil | |
| 116 | 167 | } |
| 117 | 168 | |
| 118 | 169 | // Render writes the named page to w using data as the template root context. |
internal/web/render/render_test.goadded@@ -0,0 +1,164 @@ | ||
| 1 | +// SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | |
| 3 | +package render | |
| 4 | + | |
| 5 | +import ( | |
| 6 | + "bytes" | |
| 7 | + "net/http/httptest" | |
| 8 | + "strings" | |
| 9 | + "testing" | |
| 10 | + "testing/fstest" | |
| 11 | +) | |
| 12 | + | |
| 13 | +// Each test builds a tiny in-memory template tree to exercise one | |
| 14 | +// invariant of New(). Keep these focused; broader render flows are | |
| 15 | +// covered by handler-level tests. | |
| 16 | + | |
| 17 | +func TestNew_RegistersRootPages(t *testing.T) { | |
| 18 | + t.Parallel() | |
| 19 | + fsys := fstest.MapFS{ | |
| 20 | + "_layout.html": &fstest.MapFile{Data: []byte(`{{ define "layout" }}<html>{{ template "body" . }}</html>{{ end }}`)}, | |
| 21 | + "home.html": &fstest.MapFile{Data: []byte(`{{ define "body" }}home page{{ end }}`)}, | |
| 22 | + } | |
| 23 | + r, err := New(fsys, Options{}) | |
| 24 | + if err != nil { | |
| 25 | + t.Fatalf("New: %v", err) | |
| 26 | + } | |
| 27 | + var buf bytes.Buffer | |
| 28 | + if err := r.Render(&buf, "home", nil); err != nil { | |
| 29 | + t.Fatalf("render: %v", err) | |
| 30 | + } | |
| 31 | + if !strings.Contains(buf.String(), "home page") { | |
| 32 | + t.Errorf("rendered output missing body: %q", buf.String()) | |
| 33 | + } | |
| 34 | +} | |
| 35 | + | |
| 36 | +func TestNew_RegistersSubdirPages(t *testing.T) { | |
| 37 | + t.Parallel() | |
| 38 | + fsys := fstest.MapFS{ | |
| 39 | + "_layout.html": &fstest.MapFile{Data: []byte(`{{ define "layout" }}<html>{{ template "body" . }}</html>{{ end }}`)}, | |
| 40 | + "errors/404.html": &fstest.MapFile{Data: []byte(`{{ define "body" }}not found{{ end }}`)}, | |
| 41 | + } | |
| 42 | + r, err := New(fsys, Options{}) | |
| 43 | + if err != nil { | |
| 44 | + t.Fatalf("New: %v", err) | |
| 45 | + } | |
| 46 | + var buf bytes.Buffer | |
| 47 | + if err := r.Render(&buf, "errors/404", nil); err != nil { | |
| 48 | + t.Fatalf("render: %v", err) | |
| 49 | + } | |
| 50 | + if !strings.Contains(buf.String(), "not found") { | |
| 51 | + t.Errorf("rendered output missing body: %q", buf.String()) | |
| 52 | + } | |
| 53 | +} | |
| 54 | + | |
| 55 | +// Regression test for the inbound deferral from S30 dogfood: a partial | |
| 56 | +// at `profile/_tabs.html` that defines `{{ define "tabs" }}` was | |
| 57 | +// silently registered as an unparsed page. A page that called | |
| 58 | +// `{{ template "tabs" . }}` then rendered blank. | |
| 59 | +func TestNew_LoadsSubdirPartials(t *testing.T) { | |
| 60 | + t.Parallel() | |
| 61 | + fsys := fstest.MapFS{ | |
| 62 | + "_layout.html": &fstest.MapFile{Data: []byte( | |
| 63 | + `{{ define "layout" }}<html>{{ template "body" . }}</html>{{ end }}`, | |
| 64 | + )}, | |
| 65 | + "profile/_tabs.html": &fstest.MapFile{Data: []byte( | |
| 66 | + `{{ define "tabs" }}TAB CONTENT{{ end }}`, | |
| 67 | + )}, | |
| 68 | + "profile.html": &fstest.MapFile{Data: []byte( | |
| 69 | + `{{ define "body" }}{{ template "tabs" . }}{{ end }}`, | |
| 70 | + )}, | |
| 71 | + } | |
| 72 | + r, err := New(fsys, Options{}) | |
| 73 | + if err != nil { | |
| 74 | + t.Fatalf("New: %v", err) | |
| 75 | + } | |
| 76 | + var buf bytes.Buffer | |
| 77 | + if err := r.Render(&buf, "profile", nil); err != nil { | |
| 78 | + t.Fatalf("render: %v", err) | |
| 79 | + } | |
| 80 | + if !strings.Contains(buf.String(), "TAB CONTENT") { | |
| 81 | + t.Errorf("subdir partial not loaded — body was %q", buf.String()) | |
| 82 | + } | |
| 83 | +} | |
| 84 | + | |
| 85 | +// A page that references a template name nothing defines should fail | |
| 86 | +// LOUDLY at New() time, not silently render blank at exec time. | |
| 87 | +func TestNew_FailsOnUndefinedTemplateRef(t *testing.T) { | |
| 88 | + t.Parallel() | |
| 89 | + fsys := fstest.MapFS{ | |
| 90 | + "_layout.html": &fstest.MapFile{Data: []byte( | |
| 91 | + `{{ define "layout" }}<html>{{ template "body" . }}</html>{{ end }}`, | |
| 92 | + )}, | |
| 93 | + "broken.html": &fstest.MapFile{Data: []byte( | |
| 94 | + `{{ define "body" }}{{ template "does-not-exist" . }}{{ end }}`, | |
| 95 | + )}, | |
| 96 | + } | |
| 97 | + _, err := New(fsys, Options{}) | |
| 98 | + if err == nil { | |
| 99 | + t.Fatal("New: expected error for undefined template ref, got nil") | |
| 100 | + } | |
| 101 | + if !strings.Contains(err.Error(), "does-not-exist") { | |
| 102 | + t.Errorf("error should name the missing template; got %v", err) | |
| 103 | + } | |
| 104 | + if !strings.Contains(err.Error(), "broken") { | |
| 105 | + t.Errorf("error should name the offending page; got %v", err) | |
| 106 | + } | |
| 107 | +} | |
| 108 | + | |
| 109 | +// Sanity: refs into partials still resolve (not flagged as undefined). | |
| 110 | +func TestNew_AcceptsRefsResolvedByPartials(t *testing.T) { | |
| 111 | + t.Parallel() | |
| 112 | + fsys := fstest.MapFS{ | |
| 113 | + "_layout.html": &fstest.MapFile{Data: []byte( | |
| 114 | + `{{ define "layout" }}{{ template "header" . }}{{ template "body" . }}{{ end }}`, | |
| 115 | + )}, | |
| 116 | + "_header.html": &fstest.MapFile{Data: []byte( | |
| 117 | + `{{ define "header" }}HDR{{ end }}`, | |
| 118 | + )}, | |
| 119 | + "page.html": &fstest.MapFile{Data: []byte( | |
| 120 | + `{{ define "body" }}body{{ end }}`, | |
| 121 | + )}, | |
| 122 | + } | |
| 123 | + r, err := New(fsys, Options{}) | |
| 124 | + if err != nil { | |
| 125 | + t.Fatalf("New: %v", err) | |
| 126 | + } | |
| 127 | + var buf bytes.Buffer | |
| 128 | + if err := r.Render(&buf, "page", nil); err != nil { | |
| 129 | + t.Fatalf("render: %v", err) | |
| 130 | + } | |
| 131 | + got := buf.String() | |
| 132 | + if !strings.Contains(got, "HDR") || !strings.Contains(got, "body") { | |
| 133 | + t.Errorf("missing partial or page output: %q", got) | |
| 134 | + } | |
| 135 | +} | |
| 136 | + | |
| 137 | +// RenderPage preserves any Viewer / CSRFToken the handler set itself, | |
| 138 | +// rather than overwriting them. The auto-inject is for handlers that | |
| 139 | +// hand RenderPage an empty map; explicit handlers stay in control. | |
| 140 | +func TestRenderPage_PreservesExplicitViewer(t *testing.T) { | |
| 141 | + t.Parallel() | |
| 142 | + fsys := fstest.MapFS{ | |
| 143 | + "_layout.html": &fstest.MapFile{Data: []byte( | |
| 144 | + `{{ define "layout" }}{{ template "body" . }}{{ end }}`, | |
| 145 | + )}, | |
| 146 | + "page.html": &fstest.MapFile{Data: []byte( | |
| 147 | + `{{ define "body" }}viewer={{ .Viewer }}{{ end }}`, | |
| 148 | + )}, | |
| 149 | + } | |
| 150 | + r, err := New(fsys, Options{}) | |
| 151 | + if err != nil { | |
| 152 | + t.Fatalf("New: %v", err) | |
| 153 | + } | |
| 154 | + req := httptest.NewRequest("GET", "/", nil) | |
| 155 | + rw := httptest.NewRecorder() | |
| 156 | + if err := r.RenderPage(rw, req, "page", map[string]any{ | |
| 157 | + "Viewer": "explicit", | |
| 158 | + }); err != nil { | |
| 159 | + t.Fatalf("render: %v", err) | |
| 160 | + } | |
| 161 | + if !strings.Contains(rw.Body.String(), "viewer=explicit") { | |
| 162 | + t.Errorf("explicit Viewer was overwritten; body=%q", rw.Body.String()) | |
| 163 | + } | |
| 164 | +} | |