internal/frontend: add a test that checks templates

Call a "static" template checker on our templates after we parse
them. It can catch errors in a test, instead of during serving.
For example, it will find misspelled field names.

Change-Id: I8eb352b5f1c584957e8c0cf6f30b24f44e2b5743
Reviewed-on: https://go-review.googlesource.com/c/pkgsite/+/275972
Trust: Jonathan Amsterdam <jba@google.com>
Trust: Julie Qiu <julie@golang.org>
Run-TryBot: Jonathan Amsterdam <jba@google.com>
Run-TryBot: Julie Qiu <julie@golang.org>
Reviewed-by: Julie Qiu <julie@golang.org>
TryBot-Result: kokoro <noreply+kokoro@google.com>
diff --git a/go.mod b/go.mod
index 0c1128a..f9829e5 100644
--- a/go.mod
+++ b/go.mod
@@ -27,9 +27,10 @@
 	github.com/google/go-cmp v0.5.2
 	github.com/google/go-replayers/httpreplay v0.1.0
 	github.com/google/licensecheck v0.0.0-20200805042302-c54f297c3b57
-	github.com/google/safehtml v0.0.1
+	github.com/google/safehtml v0.0.2
 	github.com/jackc/pgconn v1.7.2
 	github.com/jackc/pgx/v4 v4.9.2
+	github.com/jba/templatecheck v0.2.0
 	github.com/lib/pq v1.3.0
 	github.com/microcosm-cc/bluemonday v1.0.2
 	github.com/russross/blackfriday/v2 v2.0.1
@@ -42,6 +43,7 @@
 	golang.org/x/net v0.0.0-20200904194848-62affa334b73
 	golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208
 	golang.org/x/sys v0.0.0-20200922070232-aee5d888a860 // indirect
+	golang.org/x/text v0.3.4 // indirect
 	golang.org/x/time v0.0.0-20191024005414-555d28b269f0
 	google.golang.org/api v0.32.0
 	google.golang.org/genproto v0.0.0-20200923140941-5646d36feee1
diff --git a/go.sum b/go.sum
index ee83ac5..4ea8016 100644
--- a/go.sum
+++ b/go.sum
@@ -270,8 +270,8 @@
 github.com/google/pprof v0.0.0-20200905233945-acf8798be1f7 h1:k+KkMRk8mGOu1xG38StS7dQ+Z6oW1i9n3dgrAVU9Q/E=
 github.com/google/pprof v0.0.0-20200905233945-acf8798be1f7/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
 github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
-github.com/google/safehtml v0.0.1 h1:w2QjiCjg5S0Ca7JPd4H+fbuB0eLTK9qR3vJz3xLnhWE=
-github.com/google/safehtml v0.0.1/go.mod h1:L4KWwDsUJdECRAEpZoBn3O64bQaywRscowZjJAzjHnU=
+github.com/google/safehtml v0.0.2 h1:ZOt2VXg4x24bW0m2jtzAOkhoXV0iM8vNKc0paByCZqM=
+github.com/google/safehtml v0.0.2/go.mod h1:L4KWwDsUJdECRAEpZoBn3O64bQaywRscowZjJAzjHnU=
 github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
 github.com/googleapis/gax-go/v2 v2.0.5 h1:sjZBwGj9Jlw33ImPtvFviGYvseOtDM7hkSKB7+Tv3SM=
 github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
@@ -347,6 +347,10 @@
 github.com/jackc/puddle v1.1.0/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
 github.com/jackc/puddle v1.1.1/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
 github.com/jackc/puddle v1.1.2/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
+github.com/jba/templatecheck v0.1.0 h1:Rawj2Z1sx5nW3CtaZ+d973VKENKkXSIlscBCXMFAUjs=
+github.com/jba/templatecheck v0.1.0/go.mod h1:HzXVhxZv+uArJx7Reareec/jWUvKoHpKDvs6I3wdsRw=
+github.com/jba/templatecheck v0.2.0 h1:OHHJOumS3D6HiHiRp7FuRF17icl7AenH2cvufaBw5Ss=
+github.com/jba/templatecheck v0.2.0/go.mod h1:HzXVhxZv+uArJx7Reareec/jWUvKoHpKDvs6I3wdsRw=
 github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
 github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
 github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
@@ -700,6 +704,8 @@
 golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
 golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k=
 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.4 h1:0YWbFKbhXG/wIiuHDSKpS0Iy7FSA+u45VtBMfQcFTTc=
+golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 golang.org/x/time v0.0.0-20190308202827-9d24e82272b4 h1:SvFZT6jyqRaOeXpc5h/JSfZenJ2O330aBsf7JfSUXmQ=
 golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
diff --git a/internal/frontend/server.go b/internal/frontend/server.go
index 339ff32..219371c 100644
--- a/internal/frontend/server.go
+++ b/internal/frontend/server.go
@@ -481,6 +481,19 @@
 	return buf.Bytes(), nil
 }
 
+var templateFuncs = template.FuncMap{
+	"add": func(i, j int) int { return i + j },
+	"pluralize": func(i int, s string) string {
+		if i == 1 {
+			return s
+		}
+		return s + "s"
+	},
+	"commaseparate": func(s []string) string {
+		return strings.Join(s, ", ")
+	},
+}
+
 // parsePageTemplates parses html templates contained in the given base
 // directory in order to generate a map of Name->*template.Template.
 //
@@ -507,18 +520,7 @@
 
 	templates := make(map[string]*template.Template)
 	for _, set := range htmlSets {
-		t, err := template.New("base.tmpl").Funcs(template.FuncMap{
-			"add": func(i, j int) int { return i + j },
-			"pluralize": func(i int, s string) string {
-				if i == 1 {
-					return s
-				}
-				return s + "s"
-			},
-			"commaseparate": func(s []string) string {
-				return strings.Join(s, ", ")
-			},
-		}).ParseFilesFromTrustedSources(join(base, tsc("base.tmpl")))
+		t, err := template.New("base.tmpl").Funcs(templateFuncs).ParseFilesFromTrustedSources(join(base, tsc("base.tmpl")))
 		if err != nil {
 			return nil, fmt.Errorf("ParseFiles: %v", err)
 		}
diff --git a/internal/frontend/server_test.go b/internal/frontend/server_test.go
index 7b8bb06..da9899f 100644
--- a/internal/frontend/server_test.go
+++ b/internal/frontend/server_test.go
@@ -16,6 +16,7 @@
 	"time"
 
 	"github.com/google/safehtml/template"
+	"github.com/jba/templatecheck"
 	"golang.org/x/net/html"
 	"golang.org/x/pkgsite/internal"
 	"golang.org/x/pkgsite/internal/experiment"
@@ -1106,3 +1107,63 @@
 		postgres.ResetTestDB(testDB, t)
 	}
 }
+
+func TestCheckTemplates(t *testing.T) {
+	// Perform additional checks on parsed templates.
+	staticPath := template.TrustedSourceFromConstant("../../content/static")
+	templateDir := template.TrustedSourceJoin(staticPath, template.TrustedSourceFromConstant("html"))
+	templates, err := parsePageTemplates(templateDir)
+	if err != nil {
+		t.Fatal(err)
+	}
+	for _, c := range []struct {
+		name    string
+		subs    []string
+		typeval interface{}
+	}{
+		{"badge", nil, badgePage{}},
+		// error.tmpl omitted because relies on an associated "message" template
+		// that's parsed on demand; see renderErrorPage above.
+		{"fetch", nil, errorPage{}},
+		{"index", nil, basePage{}},
+		{"license_policy", nil, licensePolicyPage{}},
+		{"search", nil, SearchPage{}},
+		{"search_help", nil, basePage{}},
+		{"unit_details", nil, UnitPage{}},
+		{
+			"unit_details",
+			[]string{"unit_outline", "legacy_unit_outline", "unit_readme", "unit_doc", "unit_files", "unit_directories"},
+			MainDetails{},
+		},
+		{"unit_importedby", nil, UnitPage{}},
+		{"unit_importedby", []string{"importedby"}, ImportedByDetails{}},
+		{"unit_imports", nil, UnitPage{}},
+		{"unit_imports", []string{"imports"}, ImportsDetails{}},
+		{"unit_licenses", nil, UnitPage{}},
+		{"unit_licenses", []string{"licenses"}, LicensesDetails{}},
+		{"unit_versions", nil, UnitPage{}},
+		{"unit_versions", []string{"versions"}, VersionsDetails{}},
+	} {
+		t.Run(c.name, func(t *testing.T) {
+			tm := templates[c.name+".tmpl"]
+			if tm == nil {
+				t.Fatalf("no template %q", c.name)
+			}
+			if c.subs == nil {
+				if err := templatecheck.CheckSafe(tm, c.typeval, templateFuncs); err != nil {
+					t.Fatal(err)
+				}
+			} else {
+				for _, n := range c.subs {
+					s := tm.Lookup(n)
+					if s == nil {
+						t.Fatalf("no sub-template %q of %q", n, c.name)
+					}
+					if err := templatecheck.CheckSafe(s, c.typeval, templateFuncs); err != nil {
+						t.Fatalf("%s: %v", n, err)
+					}
+				}
+			}
+		})
+	}
+}