// Package rowrule is the Row Rule as a check on markup. A dropdown // never sits on a line of its own. It sits in a row beside what it acts // on. A menu alone on a line reads as a control for nothing in // particular, and one above a page's title pushes the title down and // reads as the page's first action. DESIGN.md states the rule. The // check package holds every gallery example to it; the site's tests // hold the landing page to it in a browser, at a phone's widths too. // One check serves both, so the page that sells Fiesta cannot keep a // looser rule than the gallery teaches. package rowrule import ( "regexp" "slices" "strings" xhtml "golang.org/x/net/html" "amadan.net/rastrillo/fiesta/internal/markup" ) // Rows are Fiesta's rows a dropdown may sit in, each with why it is a // row: a dropdown's nearest ancestor that lays something out must carry // one of these, or be one of the page's own rows that Breaches is given. var Rows = map[string]string{ "rst-page-header": "the page header: a title and its actions", "rst-box-head": "a section head: a heading and its actions", "rst-lbar": "a list's toolbar", "rst-bulkbar": "the bulk bar", "rst-shell-tail": "the shell's bar", "rst-shell-rail-foot": "the rail's foot", "rst-page-foot": "the column's footer row", "rst-dropdown-menu": "a dropdown inside a menu is one of that menu's rows", } // shellChrome is the shell's own chrome. A dropdown there is laid out // beside the page, not above its title, so it may come before the page // header in document order; anything else that does is above the title. var shellChrome = []string{"rst-shell-tail", "rst-shell-rail-foot"} // Breaches reads one parsed document and returns one message per // breach of the Row Rule, in document order. It is a pure function so // its own test can hold each rule to an exact message. // // pageRows names the page's own rows by class, each with why it is a // row, for a page that lays out its own bars, as the site's landing // page does. A gallery example passes none: there, a row is a Fiesta // row. Naming a row here says only where the menu sits; that the row // keeps the menu on a line with something else is a question of layout, // which a browser has to answer at each width. // // The caller respells the markup with markup.Respell before parsing // it, so a class-spelled dropdown is visited and a class-spelled row // counts as its attribute twin; a class list the respeller left alone // is reported here, so a caller that skips the respell is told. The // respeller's notes are not read: most are about escaped markup in a // code sample, which is text and holds no dropdown. // // Parsing is the caller's because every caller is a test. x/net's // html.Parse has a known quadratic case (GO-2026-6860), and govulncheck // fails the gate for any package of the module that calls it; tests are // outside its scan, and they parse only this repository's markup. func Breaches(doc *xhtml.Node, pageRows map[string]string) []string { var out []string var order []*xhtml.Node var walk func(*xhtml.Node) walk = func(n *xhtml.Node) { if n.Type == xhtml.ElementNode { order = append(order, n) } for c := n.FirstChild; c != nil; c = c.NextSibling { walk(c) } } walk(doc) header := slices.IndexFunc(order, func(n *xhtml.Node) bool { return nodeCarries(n, "rst-page-header") }) for at, n := range order { if c, ok := unrespelled(n); ok { out = append(out, `a class list could not be respelled, so the check cannot read it: <`+n.Data+` class="`+c+`">`) } if !isDropdown(n) { continue } row := n.Parent for row != nil && groupsControls(row) { row = row.Parent } if row == nil || !isRow(row, pageRows) { out = append(out, "a dropdown sits in "+describe(row)+", not in a row") } // Every header and section head around the menu needs its // heading, not only the nearest row: a toolbar inside a titleless // page header is still a menu with nothing beside it to act on. for p := n.Parent; p != nil; p = p.Parent { if nodeCarries(p, "rst-page-header") && !holds(p, "h1") { out = append(out, "a page header holds a dropdown and no h1") } if nodeCarries(p, "rst-box-head") && !holds(p, "h2", "h3", "h4", "h5", "h6") { out = append(out, "a section head holds a dropdown and no heading") } } if header >= 0 && at < header && !inShellChrome(n) { out = append(out, "a dropdown comes before the page header") } } return out } // unrespelled returns the class list of an element the respeller left // as it was while it still names a component class: a dropdown or a row // written that way is one this check would never see. Utility classes // such as rst-sr-only have no attribute twin and stay classes by design. func unrespelled(n *xhtml.Node) (string, bool) { for _, a := range n.Attr { if a.Key != "class" { continue } for _, name := range rstClassName.FindAllString(a.Val, -1) { if _, _, ok := markup.Attribute(name); ok { return a.Val, true } } } return "", false } var rstClassName = regexp.MustCompile(`rst-[A-Za-z0-9_-]+`) func isDropdown(n *xhtml.Node) bool { return n.Data == "details" && nodeCarries(n, "rst-dropdown") } // nodeCarries reports whether n has the attribute name, whatever its value. func nodeCarries(n *xhtml.Node, name string) bool { return slices.ContainsFunc(n.Attr, func(a xhtml.Attribute) bool { return a.Key == name }) } // groupsControls is a