oficina / sheets Public

Clone
git clone https://amadan.net/oficina/sheets

Plain git — no account needed to clone.

Download

Download this file

1package guards
2
3import (
4 "io/fs"
5 "os"
6 "path/filepath"
7 "regexp"
8 "strings"
9 "testing"
10)
11
12// classOnlyUtilities are the seven rastrillo names that are classes by
13// design, listed in AGENTS.md's Frontend section: "An attribute says
14// what a thing *is*; a class says what is being applied to it."
15//
16// They have no attribute twin in tokens.css, so writing one as an
17// attribute produces markup that matches nothing — and fails silently.
18// That is exactly what happened here: `rst-sr-only` was written as an
19// attribute on the formula bar's label, and the label rendered visibly
20// in the middle of the toolbar. Nothing errored; it just looked wrong,
21// and only a screenshot caught it. Hence this test.
22var classOnlyUtilities = []string{
23 "rst-grow", "rst-mono", "rst-sr-only", "rst-m-hide",
24 "rst-danger", "rst-nm", "rst-cell-mut",
25}
26
27// TestUtilitiesAreNeverWrittenAsAttributes walks the templates for a
28// class-only name used in attribute position.
29//
30// The match is deliberately narrow: `rst-sr-only` immediately after a
31// `<`-tag's whitespace and followed by `>`, `=`, or more whitespace.
32// `class="rst-sr-only"` does not match, because there the name is
33// inside a quoted value.
34func TestUtilitiesAreNeverWrittenAsAttributes(t *testing.T) {
35 root := repoRoot(t)
36 files := templateFiles(t, root)
37 if len(files) == 0 {
38 t.Fatal("guards: found no templates; this test is not checking anything")
39 }
40
41 for _, name := range classOnlyUtilities {
42 // The name in attribute position: preceded by whitespace, and
43 // followed by whitespace, '>', '/' or '=' — never by a quote,
44 // which is what a class value looks like.
45 re := regexp.MustCompile(`\s` + regexp.QuoteMeta(name) + `[\s>/=]`)
46 for _, rel := range files {
47 body, err := os.ReadFile(filepath.Join(root, rel))
48 if err != nil {
49 t.Fatalf("guards: read %s: %v", rel, err)
50 }
51 for _, line := range strings.Split(string(body), "\n") {
52 // A class list is the correct spelling and must not trip
53 // this: only look at the parts of the line outside quotes.
54 if re.MatchString(stripQuoted(line)) {
55 t.Errorf("%s: %s is written as an attribute; it is one of the seven "+
56 "class-only utilities (AGENTS.md Frontend) and has no attribute "+
57 "twin, so it matches nothing:\n %s",
58 rel, name, strings.TrimSpace(line))
59 }
60 }
61 }
62 }
63}
64
65// stripQuoted blanks the contents of double- and single-quoted runs, so
66// a name inside class="..." is invisible to the attribute check while
67// the same name written bare is not.
68func stripQuoted(line string) string {
69 var b strings.Builder
70 var quote byte
71 for i := 0; i < len(line); i++ {
72 c := line[i]
73 switch {
74 case quote != 0:
75 if c == quote {
76 quote = 0
77 b.WriteByte(c)
78 } else {
79 b.WriteByte(' ')
80 }
81 case c == '"' || c == '\'':
82 quote = c
83 b.WriteByte(c)
84 default:
85 b.WriteByte(c)
86 }
87 }
88 return b.String()
89}
90
91// templateFiles returns every .html file under the repo.
92func templateFiles(t *testing.T, root string) []string {
93 t.Helper()
94 var out []string
95 err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
96 if err != nil {
97 return err
98 }
99 if d.IsDir() {
100 if strings.HasPrefix(d.Name(), ".") || d.Name() == "node_modules" {
101 return filepath.SkipDir
102 }
103 return nil
104 }
105 if strings.HasSuffix(d.Name(), ".html") {
106 out = append(out, relPath(t, root, path))
107 }
108 return nil
109 })
110 if err != nil {
111 t.Fatalf("guards: walk: %v", err)
112 }
113 return out
114}
115
116// badgesThatSwallowProse are rastrillo attributes whose rule gives the
117// element a FIXED width and height. Wrapped around a sentence they keep
118// the box and let the text spill out of it.
119//
120// rst-help is the case that produced this test. It is the little "?"
121// beside a label — tokens.css gives it inline-flex, 28px square, a
122// border and a radius. Given a paragraph it rendered one or two words
123// per line with the badge's border struck through the middle, and ran
124// off the left edge of a phone.
125//
126// It is here rather than in a review checklist because it came back.
127// Fixed on the sign-in screen, it reappeared within days on the share
128// and history screens, written by a different session reaching for the
129// obvious name for help text. The name is a trap and prose is the bait,
130// so the trap is what gets guarded.
131//
132// The prose token is rst-field-help.
133var badgesThatSwallowProse = []string{"rst-help"}
134
135// TestBadgeAttributesNeverWrapProse fails on a badge holding a sentence.
136//
137// The threshold is deliberately generous: a badge legitimately holds an
138// icon, a glyph, or a very short label, and this test must not argue
139// with that. What it catches is prose — anything past a handful of
140// characters, which no 28px box can show.
141func TestBadgeAttributesNeverWrapProse(t *testing.T) {
142 root := repoRoot(t)
143 dir := filepath.Join(root, "internal/sheets/templates")
144 entries, err := os.ReadDir(dir)
145 if err != nil {
146 t.Fatal(err)
147 }
148 for _, name := range badgesThatSwallowProse {
149 re := regexp.MustCompile(`(?s)<(\w+)\s[^>]*\b` +
150 regexp.QuoteMeta(name) + `\b[^>]*>(.*?)</`)
151 for _, e := range entries {
152 if e.IsDir() || !strings.HasSuffix(e.Name(), ".html") {
153 continue
154 }
155 body, err := os.ReadFile(filepath.Join(dir, e.Name()))
156 if err != nil {
157 t.Fatal(err)
158 }
159 // Template comments first: they discuss this attribute by
160 // name, and matching inside one would fail on the very
161 // prose that explains the rule.
162 clean := regexp.MustCompile(`(?s)\{\{/\*.*?\*/\}\}`).
163 ReplaceAllString(string(body), "")
164 for _, m := range re.FindAllStringSubmatch(clean, -1) {
165 inner := strings.TrimSpace(m[2])
166 if len(inner) > 24 {
167 t.Errorf("%s: [%s] wraps %d characters of prose. It is a "+
168 "28px square badge — the box stays and the text "+
169 "spills out of it. Use rst-field-help.\n %.60s",
170 e.Name(), name, len(inner), inner)
171 }
172 }
173 }
174 }
175}
176