tenseleyflow/shithub / c197c28

Browse files

S39: renderer — recursive partial walk + fail-loud on undefined template refs

Authored by espadonne
SHA
c197c289ffdfa35bcf6e42319edb71ca38d2f40a
Parents
24a480c
Tree
4f249a2

2 changed files

StatusFile+-
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 (
1414
 	"io/fs"
1515
 	"net/http"
1616
 	"path"
17
+	"sort"
1718
 	"strings"
19
+	"text/template/parse"
1820
 	"time"
1921
 
2022
 	"github.com/tenseleyFlow/shithub/internal/web/middleware"
@@ -36,39 +38,31 @@ type Options struct {
3638
 	Octicons OcticonResolver
3739
 }
3840
 
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.
4361
 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
-
4962
 	var (
50
-		partialNames []string
51
-		pageNames    []string
52
-		errorPages   []string
63
+		partialPaths []string
64
+		pagePaths    []string
5365
 	)
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.
7266
 	if err := fs.WalkDir(tmplFS, ".", func(p string, d fs.DirEntry, walkErr error) error {
7367
 		if walkErr != nil {
7468
 			return walkErr
@@ -76,43 +70,100 @@ func New(tmplFS fs.FS, opts Options) (*Renderer, error) {
7670
 		if d.IsDir() || !strings.HasSuffix(p, ".html") {
7771
 			return nil
7872
 		}
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)
8177
 		}
82
-		errorPages = append(errorPages, p)
8378
 		return nil
8479
 	}); err != nil {
8580
 		return nil, fmt.Errorf("walk templates: %w", err)
8681
 	}
82
+	sort.Strings(partialPaths)
83
+	sort.Strings(pagePaths)
8784
 
8885
 	r := &Renderer{
89
-		pages:   make(map[string]*template.Template, len(pageNames)+len(errorPages)),
86
+		pages:   make(map[string]*template.Template, len(pagePaths)),
9087
 		octicon: opts.Octicons,
9188
 	}
9289
 
93
-	parse := func(displayName string, primary string) error {
90
+	parsePage := func(displayName, primary string) error {
9491
 		t := template.New(path.Base(primary)).Funcs(funcMap(r.octicon))
95
-		all := append([]string{}, partialNames...)
92
+		all := append([]string{}, partialPaths...)
9693
 		all = append(all, primary)
9794
 		parsed, err := t.ParseFS(tmplFS, all...)
9895
 		if err != nil {
9996
 			return fmt.Errorf("parse %s: %w", displayName, err)
10097
 		}
98
+		if missing := undefinedTemplateRefs(parsed); len(missing) > 0 {
99
+			return fmt.Errorf("page %q references undefined template(s): %s", displayName, strings.Join(missing, ", "))
100
+		}
101101
 		r.pages[displayName] = parsed
102102
 		return nil
103103
 	}
104104
 
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 {
107107
 			return nil, err
108108
 		}
109109
 	}
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
113130
 		}
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)
114166
 	}
115
-	return r, nil
116167
 }
117168
 
118169
 // 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
+}