Commit b7251fa5a1
Verified · cmc
internal/web/classes_test.go added +106
| @@ -0,0 +1,106 @@ | ||
| 1 | package web | |
| 2 | ||
| 3 | import ( | |
| 4 | "io/fs" | |
| 5 | "regexp" | |
| 6 | "sort" | |
| 7 | "strings" | |
| 8 | "testing" | |
| 9 | ) | |
| 10 | ||
| 11 | // TestEveryTemplateClassHasARule fails on a class a template uses that | |
| 12 | // no selector in style.css mentions. Class tokens containing template | |
| 13 | // actions ({{...}}) are composed at render time and skipped; their | |
| 14 | // prefixes (chip-, badge-, check-, lang-) are covered by the rules for | |
| 15 | // the concrete values. | |
| 16 | func TestEveryTemplateClassHasARule(t *testing.T) { | |
| 17 | css := string(StyleCSS) | |
| 18 | // Every ".name" that appears in a selector position: outside braces. | |
| 19 | depth := 0 | |
| 20 | var sel strings.Builder | |
| 21 | for _, r := range css { | |
| 22 | switch r { | |
| 23 | case '{': | |
| 24 | depth++ | |
| 25 | case '}': | |
| 26 | depth-- | |
| 27 | default: | |
| 28 | if depth == 0 { | |
| 29 | sel.WriteRune(r) | |
| 30 | } | |
| 31 | } | |
| 32 | } | |
| 33 | // Media queries wrap rules one level deeper; strip their headers and | |
| 34 | // scan again at depth one. | |
| 35 | depth = 0 | |
| 36 | inMedia := false | |
| 37 | for i := 0; i < len(css); i++ { | |
| 38 | if strings.HasPrefix(css[i:], "@media") { | |
| 39 | inMedia = true | |
| 40 | } | |
| 41 | switch css[i] { | |
| 42 | case '{': | |
| 43 | depth++ | |
| 44 | if depth == 1 && !inMedia { | |
| 45 | // ordinary rule; already scanned above | |
| 46 | } | |
| 47 | case '}': | |
| 48 | depth-- | |
| 49 | if depth == 0 { | |
| 50 | inMedia = false | |
| 51 | } | |
| 52 | default: | |
| 53 | if inMedia && depth == 1 { | |
| 54 | sel.WriteByte(css[i]) | |
| 55 | } | |
| 56 | } | |
| 57 | } | |
| 58 | classRe := regexp.MustCompile(`\.([a-zA-Z_][a-zA-Z0-9_-]*)`) | |
| 59 | styled := map[string]bool{} | |
| 60 | for _, m := range classRe.FindAllStringSubmatch(sel.String(), -1) { | |
| 61 | styled[m[1]] = true | |
| 62 | } | |
| 63 | ||
| 64 | attrRe := regexp.MustCompile(`class="([^"]*)"`) | |
| 65 | missing := map[string][]string{} | |
| 66 | entries, err := fs.ReadDir(templateFS, "templates") | |
| 67 | if err != nil { | |
| 68 | t.Fatal(err) | |
| 69 | } | |
| 70 | for _, e := range entries { | |
| 71 | src, err := templateFS.ReadFile("templates/" + e.Name()) | |
| 72 | if err != nil { | |
| 73 | t.Fatal(err) | |
| 74 | } | |
| 75 | for _, m := range attrRe.FindAllStringSubmatch(string(src), -1) { | |
| 76 | for _, c := range strings.Fields(m[1]) { | |
| 77 | if strings.Contains(c, "{{") || strings.Contains(c, "}}") { | |
| 78 | continue | |
| 79 | } | |
| 80 | if !styled[c] { | |
| 81 | missing[c] = append(missing[c], e.Name()) | |
| 82 | } | |
| 83 | } | |
| 84 | } | |
| 85 | } | |
| 86 | var names []string | |
| 87 | for c := range missing { | |
| 88 | names = append(names, c) | |
| 89 | } | |
| 90 | sort.Strings(names) | |
| 91 | for _, c := range names { | |
| 92 | t.Errorf("class %q in %s has no rule in style.css", c, strings.Join(uniq(missing[c]), ", ")) | |
| 93 | } | |
| 94 | } | |
| 95 | ||
| 96 | func uniq(in []string) []string { | |
| 97 | seen := map[string]bool{} | |
| 98 | var out []string | |
| 99 | for _, s := range in { | |
| 100 | if !seen[s] { | |
| 101 | seen[s] = true | |
| 102 | out = append(out, s) | |
| 103 | } | |
| 104 | } | |
| 105 | return out | |
| 106 | } | |