rastrillo / fiesta Public

Clone
git clone https://amadan.net/rastrillo/fiesta

Plain git — no account needed to clone.

Download

Download this file

1// Package rowrule is the Row Rule as a check on markup. A dropdown
2// never sits on a line of its own. It sits in a row beside what it acts
3// on. A menu alone on a line reads as a control for nothing in
4// particular, and one above a page's title pushes the title down and
5// reads as the page's first action. DESIGN.md states the rule. The
6// check package holds every gallery example to it; the site's tests
7// hold the landing page to it in a browser, at a phone's widths too.
8// One check serves both, so the page that sells Fiesta cannot keep a
9// looser rule than the gallery teaches.
10package rowrule
11
12import (
13 "regexp"
14 "slices"
15 "strings"
16
17 xhtml "golang.org/x/net/html"
18
19 "amadan.net/rastrillo/fiesta/internal/markup"
20)
21
22// Rows are Fiesta's rows a dropdown may sit in, each with why it is a
23// row: a dropdown's nearest ancestor that lays something out must carry
24// one of these, or be one of the page's own rows that Breaches is given.
25var Rows = map[string]string{
26 "rst-page-header": "the page header: a title and its actions",
27 "rst-box-head": "a section head: a heading and its actions",
28 "rst-lbar": "a list's toolbar",
29 "rst-bulkbar": "the bulk bar",
30 "rst-shell-tail": "the shell's bar",
31 "rst-shell-rail-foot": "the rail's foot",
32 "rst-page-foot": "the column's footer row",
33 "rst-dropdown-menu": "a dropdown inside a menu is one of that menu's rows",
34}
35
36// shellChrome is the shell's own chrome. A dropdown there is laid out
37// beside the page, not above its title, so it may come before the page
38// header in document order; anything else that does is above the title.
39var shellChrome = []string{"rst-shell-tail", "rst-shell-rail-foot"}
40
41// Breaches reads one parsed document and returns one message per
42// breach of the Row Rule, in document order. It is a pure function so
43// its own test can hold each rule to an exact message.
44//
45// pageRows names the page's own rows by class, each with why it is a
46// row, for a page that lays out its own bars, as the site's landing
47// page does. A gallery example passes none: there, a row is a Fiesta
48// row. Naming a row here says only where the menu sits; that the row
49// keeps the menu on a line with something else is a question of layout,
50// which a browser has to answer at each width.
51//
52// The caller respells the markup with markup.Respell before parsing
53// it, so a class-spelled dropdown is visited and a class-spelled row
54// counts as its attribute twin; a class list the respeller left alone
55// is reported here, so a caller that skips the respell is told. The
56// respeller's notes are not read: most are about escaped markup in a
57// code sample, which is text and holds no dropdown.
58//
59// Parsing is the caller's because every caller is a test. x/net's
60// html.Parse has a known quadratic case (GO-2026-6860), and govulncheck
61// fails the gate for any package of the module that calls it; tests are
62// outside its scan, and they parse only this repository's markup.
63func Breaches(doc *xhtml.Node, pageRows map[string]string) []string {
64 var out []string
65 var order []*xhtml.Node
66 var walk func(*xhtml.Node)
67 walk = func(n *xhtml.Node) {
68 if n.Type == xhtml.ElementNode {
69 order = append(order, n)
70 }
71 for c := n.FirstChild; c != nil; c = c.NextSibling {
72 walk(c)
73 }
74 }
75 walk(doc)
76 header := slices.IndexFunc(order, func(n *xhtml.Node) bool { return nodeCarries(n, "rst-page-header") })
77 for at, n := range order {
78 if c, ok := unrespelled(n); ok {
79 out = append(out, `a class list could not be respelled, so the check cannot read it: <`+n.Data+` class="`+c+`">`)
80 }
81 if !isDropdown(n) {
82 continue
83 }
84 row := n.Parent
85 for row != nil && groupsControls(row) {
86 row = row.Parent
87 }
88 if row == nil || !isRow(row, pageRows) {
89 out = append(out, "a dropdown sits in "+describe(row)+", not in a row")
90 }
91 // Every header and section head around the menu needs its
92 // heading, not only the nearest row: a toolbar inside a titleless
93 // page header is still a menu with nothing beside it to act on.
94 for p := n.Parent; p != nil; p = p.Parent {
95 if nodeCarries(p, "rst-page-header") && !holds(p, "h1") {
96 out = append(out, "a page header holds a dropdown and no h1")
97 }
98 if nodeCarries(p, "rst-box-head") && !holds(p, "h2", "h3", "h4", "h5", "h6") {
99 out = append(out, "a section head holds a dropdown and no heading")
100 }
101 }
102 if header >= 0 && at < header && !inShellChrome(n) {
103 out = append(out, "a dropdown comes before the page header")
104 }
105 }
106 return out
107}
108
109// unrespelled returns the class list of an element the respeller left
110// as it was while it still names a component class: a dropdown or a row
111// written that way is one this check would never see. Utility classes
112// such as rst-sr-only have no attribute twin and stay classes by design.
113func unrespelled(n *xhtml.Node) (string, bool) {
114 for _, a := range n.Attr {
115 if a.Key != "class" {
116 continue
117 }
118 for _, name := range rstClassName.FindAllString(a.Val, -1) {
119 if _, _, ok := markup.Attribute(name); ok {
120 return a.Val, true
121 }
122 }
123 }
124 return "", false
125}
126
127var rstClassName = regexp.MustCompile(`rst-[A-Za-z0-9_-]+`)
128
129func isDropdown(n *xhtml.Node) bool { return n.Data == "details" && nodeCarries(n, "rst-dropdown") }
130
131// nodeCarries reports whether n has the attribute name, whatever its value.
132func nodeCarries(n *xhtml.Node, name string) bool {
133 return slices.ContainsFunc(n.Attr, func(a xhtml.Attribute) bool { return a.Key == name })
134}
135
136// groupsControls is a <form> or <search> with no rst- attribute: it
137// groups a row's controls and lays nothing out, so the row is above it.
138// One that carries an rst- attribute is styled, and is what the menu
139// sits in.
140func groupsControls(n *xhtml.Node) bool {
141 if n.Type != xhtml.ElementNode || (n.Data != "form" && n.Data != "search") {
142 return false
143 }
144 return !slices.ContainsFunc(n.Attr, func(a xhtml.Attribute) bool { return strings.HasPrefix(a.Key, "rst-") })
145}
146
147func isRow(n *xhtml.Node, pageRows map[string]string) bool {
148 return slices.ContainsFunc(n.Attr, func(a xhtml.Attribute) bool {
149 if _, ok := Rows[a.Key]; ok {
150 return true
151 }
152 if a.Key != "class" {
153 return false
154 }
155 return slices.ContainsFunc(strings.Fields(a.Val), func(c string) bool { _, ok := pageRows[c]; return ok })
156 })
157}
158
159// describe names an element as a failure shows it: its tag and its rst-
160// attributes, main[rst-page], so the reader can find it in the markup.
161func describe(n *xhtml.Node) string {
162 if n == nil || n.Type != xhtml.ElementNode {
163 return "the document"
164 }
165 s := n.Data
166 for _, a := range n.Attr {
167 if strings.HasPrefix(a.Key, "rst-") {
168 s += "[" + a.Key + "]"
169 }
170 }
171 return s
172}
173
174// holds reports whether row has one of the tags among its descendants,
175// outside any dropdown: a heading inside a menu's panel titles nothing
176// in the row, so a header holding only that is still a menu on its own.
177func holds(row *xhtml.Node, tags ...string) bool {
178 for c := row.FirstChild; c != nil; c = c.NextSibling {
179 if c.Type != xhtml.ElementNode || isDropdown(c) {
180 continue
181 }
182 if slices.Contains(tags, c.Data) || holds(c, tags...) {
183 return true
184 }
185 }
186 return false
187}
188
189func inShellChrome(n *xhtml.Node) bool {
190 for p := n.Parent; p != nil; p = p.Parent {
191 if slices.ContainsFunc(shellChrome, func(name string) bool { return nodeCarries(p, name) }) {
192 return true
193 }
194 }
195 return false
196}
197