internal/gaby: check and fix templates

Perform simple validation on the HTML generated by the templates.
This CL just checks that tags match, but that caught a few problems.

Change-Id: Idd4f3227b20fb351d9616f962db7c1c4c1821962
Reviewed-on: https://go-review.googlesource.com/c/oscar/+/617415
Reviewed-by: Zvonimir Pavlinovic <zpavlinovic@google.com>
Reviewed-by: Tatiana Bradley <tatianabradley@google.com>
LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
diff --git a/internal/gaby/templates_test.go b/internal/gaby/templates_test.go
new file mode 100644
index 0000000..9d9ea7f
--- /dev/null
+++ b/internal/gaby/templates_test.go
@@ -0,0 +1,124 @@
+// Copyright 2024 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package main
+
+import (
+	"bytes"
+	"errors"
+	"fmt"
+	"io"
+	"slices"
+	"strings"
+	"testing"
+
+	"github.com/google/safehtml/template"
+	"golang.org/x/net/html"
+	"golang.org/x/net/html/atom"
+	"golang.org/x/oscar/internal/actions"
+	"golang.org/x/oscar/internal/search"
+)
+
+func TestTemplates(t *testing.T) {
+	for _, test := range []struct {
+		name  string
+		tmpl  *template.Template
+		value any
+	}{
+		{"search", searchPageTmpl, searchPage{Results: []search.Result{{Kind: "k", Title: "t"}}}},
+		{"actionlog", actionLogPageTmpl, actionLogPage{
+			StartTime: "t",
+			Entries:   []*actions.Entry{{Kind: "k"}},
+		}},
+	} {
+		t.Run(test.name, func(t *testing.T) {
+			var buf bytes.Buffer
+			if err := test.tmpl.Execute(&buf, test.value); err != nil {
+				t.Fatal(err)
+			}
+			html := buf.String()
+			if err := validateHTML(html); err != nil {
+				printNumbered(html)
+				t.Fatalf("\n%s", err)
+			}
+		})
+	}
+}
+
+func printNumbered(s string) {
+	for i, line := range strings.Split(s, "\n") {
+		fmt.Printf("%3d %s\n", i+1, line)
+	}
+}
+
+// validateHTML performs basic HTML validation.
+// It checks that every start tag has a matching end tag.
+func validateHTML(s string) error {
+	type tag struct {
+		line int
+		a    atom.Atom
+	}
+
+	var errs []error
+	var stack []tag
+
+	r := newLineReader(strings.NewReader(s))
+	tizer := html.NewTokenizer(r)
+	for tizer.Err() == nil {
+		tt := tizer.Next()
+		switch tt {
+		case html.ErrorToken:
+			if tizer.Err() != io.EOF {
+				errs = append(errs, tizer.Err())
+			}
+		case html.StartTagToken:
+			stack = append(stack, tag{r.line, tizer.Token().DataAtom})
+		case html.EndTagToken:
+			end := tizer.Token().DataAtom
+			n := len(stack)
+			if n == 0 {
+				errs = append(errs, fmt.Errorf("no start tag matching end tag </%s> on line %d", end, r.line))
+			} else {
+				top := stack[n-1]
+				if top.a != end {
+					errs = append(errs, fmt.Errorf("end tag </%s> on line %d does not match start tag <%s> on line %d",
+						end, r.line, top.a, top.line))
+					// don't pop the stack
+				} else {
+					stack = stack[:n-1]
+				}
+			}
+		default:
+			// ignore
+		}
+	}
+	return errors.Join(errs...)
+}
+
+// A lineReader is an io.Reader that tracks line numbers.
+type lineReader struct {
+	line int
+	rest []byte
+	r    io.Reader
+}
+
+func newLineReader(r io.Reader) *lineReader {
+	return &lineReader{line: 1, r: r}
+}
+
+func (r *lineReader) Read(buf []byte) (n int, err error) {
+	if len(r.rest) == 0 {
+		n, err = r.r.Read(buf)
+		r.rest = slices.Clone(buf[:n])
+	}
+	i := bytes.IndexByte(r.rest, '\n')
+	if i < 0 {
+		i = len(r.rest) - 1
+	} else {
+		r.line++
+	}
+	n = copy(buf, r.rest[:i+1])
+	r.rest = r.rest[i+1:]
+	return n, err
+}
diff --git a/internal/gaby/tmpl/actionlog.tmpl b/internal/gaby/tmpl/actionlog.tmpl
index 6e402c1..e22ca25 100644
--- a/internal/gaby/tmpl/actionlog.tmpl
+++ b/internal/gaby/tmpl/actionlog.tmpl
@@ -7,8 +7,8 @@
 <html>
   <head>
     <title>Oscar Action Log</title>
-    <link rel="stylesheet" href="static/style.css">
-    <link rel="stylesheet" href="static/actionlog.css">
+    <link rel="stylesheet" href="static/style.css"/>
+    <link rel="stylesheet" href="static/actionlog.css"/>
     <script>
       // Toggle the hidden state of a row containing the action.
       // Also change the button label accordingly..
@@ -119,10 +119,11 @@
             <td>{{$e.Result | fmtval}}</td>
             <td>{{$e.Error}}</td>
           </tr>
-          <tr id="{{(print "action-" $i) | safeid}}" hidden="true"><td colspan="7">
-            <td>{{$e.Action | fmtval}}</td>
-          <tr>
+          <tr id="{{(print "action-" $i) | safeid}}" hidden="true">
+            <td colspan="7">{{$e.Action | fmtval}}</td>
+          </tr>
         {{end}}
+        </table>
       {{else}}
         No entries.
       {{end}}
diff --git a/internal/gaby/tmpl/searchpage.tmpl b/internal/gaby/tmpl/searchpage.tmpl
index 6a80b97..c00d6c2 100644
--- a/internal/gaby/tmpl/searchpage.tmpl
+++ b/internal/gaby/tmpl/searchpage.tmpl
@@ -7,8 +7,8 @@
 <html>
   <head>
     <title>Oscar Search</title>
-	<link rel="stylesheet" href="static/style.css">
-	<link rel="stylesheet" href="static/search.css">
+	<link rel="stylesheet" href="static/style.css"/>
+	<link rel="stylesheet" href="static/search.css"/>
   </head>
   <body>
     <div class="section" class="header">
@@ -42,7 +42,7 @@
 		 <input id="allow_kind" type="text" name="allow_kind" value="{{.Allow}}" optional autofocus />
 		</span>
 		<span>
-		 <label for="deny_kind">exclude types</code></label>
+		 <label for="deny_kind">exclude types</label>
 		 <input id="deny_kind" type="text" name="deny_kind" value="{{.Deny}}" optional autofocus />
 		</span>
 		<span class="submit">
@@ -85,7 +85,7 @@
 		  {{end -}}
 	    {{end -}}
 	    <span class="kind">type: {{.Kind}}</span>
-	    <span class="score">similarity: <b>{{.Score}}</b><span>
+	    <span class="score">similarity: <b>{{.Score}}</b></span>
 		</div>
 	  {{end}}
 	{{- else -}}
@@ -93,4 +93,4 @@
   	{{- end}}
    </div>
   </body>
-</html>
\ No newline at end of file
+</html>