Stage 0, step 2. The guards land before any template is restyled.
Guard tests (guard_*_test.go under internal/web) read their rules from the
accepted mockups instead of copying them - the Bulma subset and WIDTH_OK
from check.py, the shell's classes from build.py, the stylesheet from
theme.css + brand.css, the skeletons from outlines.json:
- TestPanelClassVocabulary, TestTemplatesUseComponents,
TestPanelPageStructure, TestPanelOutlinesMatchMockups,
TestRedesignedPagesLoadOnlyTheKit: hold every page that has left
legacy_pages.txt to its mockup, in source and as rendered.
- TestPanelCSSIsTheMockupStylesheet, TestPanelCSSContract: panel.css is
brand.css + theme.css rule for rule.
- TestOutlinePortMatchesCheckPy: the Go port of outline() agrees with
outlines.json on all 33 screens.
- TestLegacyRatchetMatchesTheEngine, TestVendoredAssetsArePinned,
TestStaticHoldsOnlyTheKnownAssets.
- TestRoutesFollowNavigation: the 2.0 route table, no old path answered,
no template link the router does not serve.
The kit tests are skipped while legacy_pages.txt lists @kit and the route
test while it lists @routes; both were run with the entry removed and fail
where expected (no kit yet, old route table).
.github/scripts/design-first.sh, run per commit in test.yml: a commit that
touches the mockups or a guard touches nothing else under internal/web;
legacy_pages.txt only shrinks; a changed template is not, whitespace aside,
any template from history. Exercised on a scratch clone for each case.
Plan: Enforcement and Against resurrected pages describe the guards as
built (the ratchet is a file beside the guards, not a list inside them).
Roadmap 7/27.
go test ./internal/web/...
ok github.com/mixeme/selfpost/internal/web
ok github.com/mixeme/selfpost/internal/web/auth
ok github.com/mixeme/selfpost/internal/web/handlers
ok github.com/mixeme/selfpost/internal/web/validate
ok github.com/mixeme/selfpost/internal/web/view
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
423 lines
11 KiB
Go
423 lines
11 KiB
Go
package view
|
||
|
||
// GUARD FILE — docs/plans/panel-redesign.md § The contract, rule 7. Not edited
|
||
// together with internal/web, and never to make an implementation pass.
|
||
//
|
||
// The HTML reading the guard tests share: a small tolerant parser and the port
|
||
// of outline() from docs/assets/panel-redesign/panel/check.py. The port is
|
||
// itself tested against the mockups (TestOutlinePortMatchesCheckPy), so the Go
|
||
// tests and the Python lint cannot drift apart unnoticed.
|
||
|
||
import (
|
||
"encoding/json"
|
||
"html"
|
||
"regexp"
|
||
"strings"
|
||
)
|
||
|
||
// gnode is one element of a parsed page.
|
||
type gnode struct {
|
||
tag string
|
||
attrs map[string]string
|
||
parent *gnode
|
||
kids []*gnode
|
||
text string // the text directly inside this element
|
||
}
|
||
|
||
func (n *gnode) classes() []string { return strings.Fields(n.attrs["class"]) }
|
||
|
||
func (n *gnode) has(class string) bool {
|
||
for _, c := range n.classes() {
|
||
if c == class {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// walk visits n and every element under it, parents first.
|
||
func (n *gnode) walk(fn func(*gnode)) {
|
||
fn(n)
|
||
for _, k := range n.kids {
|
||
k.walk(fn)
|
||
}
|
||
}
|
||
|
||
func (n *gnode) find(class string) []*gnode {
|
||
var out []*gnode
|
||
n.walk(func(m *gnode) {
|
||
if m.has(class) {
|
||
out = append(out, m)
|
||
}
|
||
})
|
||
return out
|
||
}
|
||
|
||
func (n *gnode) byTag(tag string) []*gnode {
|
||
var out []*gnode
|
||
n.walk(func(m *gnode) {
|
||
if m.tag == tag {
|
||
out = append(out, m)
|
||
}
|
||
})
|
||
return out
|
||
}
|
||
|
||
// textOf is the element's text with its descendants', whitespace collapsed.
|
||
func textOf(n *gnode) string {
|
||
var b strings.Builder
|
||
n.walk(func(m *gnode) { b.WriteString(m.text) })
|
||
return strings.Join(strings.Fields(b.String()), " ")
|
||
}
|
||
|
||
var (
|
||
voidTags = map[string]bool{"input": true, "img": true, "br": true, "hr": true, "meta": true, "link": true, "wbr": true}
|
||
rawTags = map[string]bool{"script": true, "style": true, "textarea": true, "title": true}
|
||
tagName = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9-]*`)
|
||
)
|
||
|
||
// parseHTML builds the element tree of a document or a fragment. Like the
|
||
// parser check.py uses, it never fails: an end tag closes the nearest open
|
||
// element of that name and is ignored when there is none.
|
||
func parseHTML(src string) *gnode {
|
||
root := &gnode{tag: "root", attrs: map[string]string{}}
|
||
cur := root
|
||
for i := 0; i < len(src); {
|
||
lt := strings.IndexByte(src[i:], '<')
|
||
if lt < 0 {
|
||
cur.text += html.UnescapeString(src[i:])
|
||
break
|
||
}
|
||
cur.text += html.UnescapeString(src[i : i+lt])
|
||
i += lt
|
||
rest := src[i:]
|
||
switch {
|
||
case strings.HasPrefix(rest, "<!--"):
|
||
end := strings.Index(rest, "-->")
|
||
if end < 0 {
|
||
return root
|
||
}
|
||
i += end + 3
|
||
case strings.HasPrefix(rest, "<!"), strings.HasPrefix(rest, "<?"):
|
||
end := strings.IndexByte(rest, '>')
|
||
if end < 0 {
|
||
return root
|
||
}
|
||
i += end + 1
|
||
case strings.HasPrefix(rest, "</"):
|
||
end := strings.IndexByte(rest, '>')
|
||
if end < 0 {
|
||
return root
|
||
}
|
||
name := strings.ToLower(strings.TrimSpace(rest[2:end]))
|
||
for n := cur; n != root; n = n.parent {
|
||
if n.tag == name {
|
||
cur = n.parent
|
||
break
|
||
}
|
||
}
|
||
i += end + 1
|
||
default:
|
||
name := tagName.FindString(rest[1:])
|
||
if name == "" {
|
||
cur.text += "<"
|
||
i++
|
||
continue
|
||
}
|
||
name = strings.ToLower(name)
|
||
attrs, n := parseAttrs(rest[1+len(name):])
|
||
i += 1 + len(name) + n
|
||
node := &gnode{tag: name, attrs: attrs, parent: cur}
|
||
cur.kids = append(cur.kids, node)
|
||
switch {
|
||
case voidTags[name]:
|
||
case rawTags[name]:
|
||
end := strings.Index(strings.ToLower(src[i:]), "</"+name)
|
||
if end < 0 {
|
||
return root
|
||
}
|
||
node.text = html.UnescapeString(src[i : i+end])
|
||
i += end
|
||
if gt := strings.IndexByte(src[i:], '>'); gt >= 0 {
|
||
i += gt + 1
|
||
}
|
||
default:
|
||
cur = node
|
||
}
|
||
}
|
||
}
|
||
return root
|
||
}
|
||
|
||
// parseAttrs reads the attributes of a start tag and reports how many bytes it
|
||
// consumed, the closing '>' included.
|
||
func parseAttrs(s string) (map[string]string, int) {
|
||
attrs := map[string]string{}
|
||
i := 0
|
||
for i < len(s) {
|
||
for i < len(s) && (s[i] == ' ' || s[i] == '\t' || s[i] == '\n' || s[i] == '\r' || s[i] == '/') {
|
||
i++
|
||
}
|
||
if i >= len(s) {
|
||
break
|
||
}
|
||
if s[i] == '>' {
|
||
return attrs, i + 1
|
||
}
|
||
start := i
|
||
for i < len(s) && !strings.ContainsRune(" \t\r\n=>/", rune(s[i])) {
|
||
i++
|
||
}
|
||
name := strings.ToLower(s[start:i])
|
||
for i < len(s) && (s[i] == ' ' || s[i] == '\t' || s[i] == '\n' || s[i] == '\r') {
|
||
i++
|
||
}
|
||
value := ""
|
||
if i < len(s) && s[i] == '=' {
|
||
i++
|
||
for i < len(s) && (s[i] == ' ' || s[i] == '\t' || s[i] == '\n' || s[i] == '\r') {
|
||
i++
|
||
}
|
||
if i < len(s) && (s[i] == '"' || s[i] == '\'') {
|
||
q := s[i]
|
||
end := strings.IndexByte(s[i+1:], q)
|
||
if end < 0 {
|
||
return attrs, len(s)
|
||
}
|
||
value = s[i+1 : i+1+end]
|
||
i += end + 2
|
||
} else {
|
||
start = i
|
||
for i < len(s) && !strings.ContainsRune(" \t\r\n>", rune(s[i])) {
|
||
i++
|
||
}
|
||
value = s[start:i]
|
||
}
|
||
}
|
||
if _, dup := attrs[name]; !dup && name != "" {
|
||
attrs[name] = html.UnescapeString(value)
|
||
}
|
||
}
|
||
return attrs, i
|
||
}
|
||
|
||
var sizeClass = regexp.MustCompile(`^is-\d+$`)
|
||
|
||
// boxKind names what a box holds under its head: check.py box_kind().
|
||
func boxKind(box *gnode) string {
|
||
var kinds []string
|
||
for _, k := range box.kids {
|
||
if k.has("sp-box-head") || k.has("sp-box-foot") {
|
||
continue
|
||
}
|
||
kind := k.tag
|
||
if k.tag == "table" || k.has("table-container") {
|
||
kind = "table"
|
||
}
|
||
for _, m := range [][2]string{{"sp-record", "record"}, {"sp-health", "health"}, {"sp-empty", "empty"},
|
||
{"sp-log", "log"}, {"sp-help-topic", "help"}, {"sp-box-body", "body"}} {
|
||
if k.has(m[0]) {
|
||
kind = m[1]
|
||
break
|
||
}
|
||
}
|
||
kinds = append(kinds, kind)
|
||
}
|
||
var out []string
|
||
for i := 0; i < len(kinds); {
|
||
j := i
|
||
for j < len(kinds) && kinds[j] == kinds[i] {
|
||
j++
|
||
}
|
||
if j-i == 1 {
|
||
out = append(out, kinds[i])
|
||
} else {
|
||
out = append(out, kinds[i]+"×"+itoa(j-i))
|
||
}
|
||
i = j
|
||
}
|
||
return strings.Join(out, "+")
|
||
}
|
||
|
||
func itoa(n int) string {
|
||
b, _ := json.Marshal(n)
|
||
return string(b)
|
||
}
|
||
|
||
// outline is the component skeleton of a page — what a reviewer sees with the
|
||
// text blurred: check.py outline(). The result marshals to the JSON shape of
|
||
// outlines.json.
|
||
func outline(node *gnode) []any {
|
||
out := []any{}
|
||
for _, k := range node.kids {
|
||
switch {
|
||
case k.has("sp-head"):
|
||
parts := []string{"head"}
|
||
if len(k.find("sp-postmark")) > 0 {
|
||
parts = append(parts, "postmark")
|
||
}
|
||
if len(k.find("buttons")) > 0 {
|
||
parts = append(parts, "actions")
|
||
} else {
|
||
for _, c := range k.kids {
|
||
if c.tag == "a" || c.tag == "form" {
|
||
parts = append(parts, "actions")
|
||
break
|
||
}
|
||
}
|
||
}
|
||
out = append(out, strings.Join(parts, "+"))
|
||
case k.has("box"):
|
||
variant := ""
|
||
for _, v := range []string{"sp-danger-zone", "sp-credential"} {
|
||
if k.has(v) {
|
||
variant = "." + v
|
||
break
|
||
}
|
||
}
|
||
title := "?"
|
||
if heads := k.find("sp-box-head"); len(heads) > 0 {
|
||
title = "?!" // a head without the number + title pair is not box_head
|
||
if nos := heads[0].find("sp-no"); len(nos) > 0 && len(nos[0].parent.kids) > 1 {
|
||
title = textOf(nos[0].parent.kids[1])
|
||
}
|
||
}
|
||
out = append(out, "box"+variant+"["+title+"]:"+boxKind(k))
|
||
case k.has("columns"):
|
||
cols := []any{}
|
||
for _, c := range k.kids {
|
||
size := "auto"
|
||
for _, cls := range c.classes() {
|
||
if sizeClass.MatchString(cls) {
|
||
size = cls
|
||
break
|
||
}
|
||
}
|
||
cols = append(cols, map[string]any{size: outline(c)})
|
||
}
|
||
out = append(out, map[string]any{"columns": cols})
|
||
case k.tag == "form":
|
||
out = append(out, map[string]any{"form": outline(k)})
|
||
case k.has("menu") || k.tag == "aside":
|
||
out = append(out, "side_menu")
|
||
case k.has("buttons"):
|
||
out = append(out, "submit_row")
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// outlineJSON renders an outline the way two of them are compared and shown.
|
||
func outlineJSON(v any) string {
|
||
b, err := json.MarshalIndent(v, "", " ")
|
||
if err != nil {
|
||
return "unmarshalable outline: " + err.Error()
|
||
}
|
||
return string(b)
|
||
}
|
||
|
||
// classAttr finds every class="…" in template or HTML source.
|
||
var classAttr = regexp.MustCompile(`\bclass\s*=\s*"([^"]*)"`)
|
||
|
||
// templateAction matches one {{…}} of a Go template.
|
||
var templateAction = regexp.MustCompile(`(?s)\{\{.*?\}\}`)
|
||
|
||
// staticClasses lists the class tokens a template writes literally. A token an
|
||
// action contributes to is dropped here — the rendered page is checked too, and
|
||
// that is where a computed class is seen whole.
|
||
func staticClasses(src string) []string {
|
||
const dyn = "\x00"
|
||
var out []string
|
||
for _, m := range classAttr.FindAllStringSubmatch(templateAction.ReplaceAllString(src, dyn), -1) {
|
||
for _, c := range strings.Fields(m[1]) {
|
||
if !strings.Contains(c, dyn) {
|
||
out = append(out, c)
|
||
}
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// cssRule is one rule of a stylesheet, normalised for comparison.
|
||
type cssRule struct {
|
||
media string // the enclosing @media prelude, "" at the top level
|
||
selector string
|
||
body string
|
||
}
|
||
|
||
func (r cssRule) String() string {
|
||
s := r.selector + " { " + r.body + " }"
|
||
if r.media != "" {
|
||
s = r.media + " { " + s + " }"
|
||
}
|
||
return s
|
||
}
|
||
|
||
var (
|
||
cssComment = regexp.MustCompile(`(?s)/\*.*?\*/`)
|
||
cssURL = regexp.MustCompile(`url\(\s*["']?([^"')]*)["']?\s*\)`)
|
||
cssSpace = regexp.MustCompile(`\s+`)
|
||
)
|
||
|
||
// parseCSS splits a stylesheet into its rules, in order. Selectors and
|
||
// declarations are whitespace-normalised and a url() keeps only its file name,
|
||
// so the same rule reads the same wherever the file it came from lives.
|
||
func parseCSS(css string) []cssRule {
|
||
return parseCSSBlock(cssComment.ReplaceAllString(css, ""), "")
|
||
}
|
||
|
||
func parseCSSBlock(css, media string) []cssRule {
|
||
var rules []cssRule
|
||
for {
|
||
open := strings.IndexByte(css, '{')
|
||
if open < 0 {
|
||
return rules
|
||
}
|
||
prelude := cssSpace.ReplaceAllString(strings.TrimSpace(css[:open]), " ")
|
||
depth, end := 1, -1
|
||
for i := open + 1; i < len(css); i++ {
|
||
if css[i] == '{' {
|
||
depth++
|
||
} else if css[i] == '}' {
|
||
depth--
|
||
if depth == 0 {
|
||
end = i
|
||
break
|
||
}
|
||
}
|
||
}
|
||
if end < 0 {
|
||
return rules
|
||
}
|
||
inner := css[open+1 : end]
|
||
css = css[end+1:]
|
||
if strings.HasPrefix(prelude, "@media") || strings.HasPrefix(prelude, "@supports") {
|
||
rules = append(rules, parseCSSBlock(inner, prelude)...)
|
||
continue
|
||
}
|
||
var decls []string
|
||
for _, d := range strings.Split(inner, ";") {
|
||
d = cssSpace.ReplaceAllString(strings.TrimSpace(d), " ")
|
||
if d == "" {
|
||
continue
|
||
}
|
||
if c := strings.IndexByte(d, ':'); c >= 0 {
|
||
d = strings.TrimSpace(d[:c]) + ": " + strings.TrimSpace(d[c+1:])
|
||
}
|
||
d = cssURL.ReplaceAllStringFunc(d, func(u string) string {
|
||
name := cssURL.FindStringSubmatch(u)[1]
|
||
if s := strings.LastIndexByte(name, '/'); s >= 0 {
|
||
name = name[s+1:]
|
||
}
|
||
return "url(" + name + ")"
|
||
})
|
||
decls = append(decls, d)
|
||
}
|
||
var sels []string
|
||
for _, s := range strings.Split(prelude, ",") {
|
||
sels = append(sels, strings.TrimSpace(s))
|
||
}
|
||
rules = append(rules, cssRule{media: media, selector: strings.Join(sels, ", "), body: strings.Join(decls, "; ")})
|
||
}
|
||
}
|