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>