package check // markup-spelling: old-spelling begin — this whole file is about the // two spellings and has to be able to write both. It ends at the file's // end; there is no closing marker. // The stage-1 gate for the markup migration (design spec §6-v3). // // tokens.css is moving from a class vocabulary to an attribute one: // `
` where an app writes `class="rst-box"` today. Stage 1 // is the non-breaking half — every rule names BOTH spellings, so an app // can begin writing attributes while every app and every shipped // partial writing classes keeps styling. Stage 2 flips the partials, // the scaffold, the docs and the examples in one commit; stage 3 drops // the class selectors before 1.0. // // The thing that has to be enforced rather than done once is the // pairing. A class added to tokens.css in stage 2's absence would // otherwise arrive with no attribute twin, and nobody would find out // until an app wrote the attribute and got nothing. So this file parses // tokens.css and holds the invariant in both directions: every class // selector has its twin in the same rule, and every attribute selector // is some class selector's twin — no orphans in either column. // // It also checks the part that could go wrong silently. A class and an // attribute selector both weigh (0,1,0), so a pair is specificity- // neutral and no cascade moves. That is a fact about CSS, and this file // computes it per selector rather than trusting it, because the file // has rules wrapped in :where() (weightless) and rules using :not() and // :has() (weighted by their heaviest argument), which is exactly where // a careless twin would shift the cascade and change how an app that // changed nothing renders. // // The string check is necessary and not sufficient: two selectors can // pair perfectly on paper and still paint differently. The browser // drive in markup_v3_browser_test.go is the evidence — it renders the // same fixture in both spellings and requires the computed styles to // match, property by property, pseudo-elements included. import ( "reflect" "slices" "sort" "strings" "testing" "amadan.net/rastrillo/fiesta" "amadan.net/rastrillo/fiesta/internal/markup" ) // ── The grammar ────────────────────────────────────────────────────── // // It lives in internal/markup, not here. The stage-2 codemod // (`rastrillo markup`) translates markup with the same rules this file // checks tokens.css against, and two implementations of one grammar // agree until the day they do not. attributeTwin is that package's // Selector; the exemption list is its Utilities. attributeFor, the // class-to-attribute lookup, and the two regexps that find a class in // a selector and in markup are in markuphelp_test.go. var classKeepsItsSpelling = markup.Utilities func attributeTwin(selector string) string { return markup.Selector(selector) } // ── A small CSS reader ─────────────────────────────────────────────── // cssRule is one rule's selector list, split on top-level commas, and // the line its prelude ends on so a failure can be walked to. type cssRule struct { selectors []string line int } // parseCSSRules reads tokens.css into its rules. It is deliberately // small — comment-aware, string-aware, brace-counting — because // tokens.css is a hand-written file with no preprocessor, and a // full CSS parser would be a dependency this file has no need of. The // preludes it returns include at-rules (@media, @supports); those carry // no rst- selector and fall out of every check below on their own. It // reads comments and strings with cssSpan, the comment reader the // declaration gates use, so a /* a browser does not take for a comment // hides no rule from the spelling gates either, and it refuses what // cssSpan refuses, a backslash outside a string among them. // // It reads css with readStylesheet first and refuses what that refuses. // A walk over braces alone takes a nested rule for one of its own and // a statement before a rule for part of its selector; the declaration // reader refuses both, and on what it reads every { outside a string // opens a rule or an at-rule's block. func parseCSSRules(css string) ([]cssRule, error) { if _, _, err := readStylesheet("the stylesheet", css); err != nil { return nil, err } var rules []cssRule var prelude strings.Builder for i := 0; i < len(css); { end, comment, err := cssSpan(css, i) if err != nil { return nil, err } switch { case comment: case css[i] == '{': rules = append(rules, cssRule{ selectors: splitSelectorList(prelude.String()), line: 1 + strings.Count(css[:i], "\n"), }) prelude.Reset() case css[i] == '}': prelude.Reset() default: prelude.WriteString(css[i:end]) } i = end } return rules, nil } // cssRules is parseCSSRules failing the test on a stylesheet it // refuses. func cssRules(t testing.TB, css string) []cssRule { t.Helper() rules, err := parseCSSRules(css) if err != nil { t.Fatal(err) } return rules } // ruleSelectors is every selector of every rule in css, read with // cssRules: a gate that asks whether a selector is in the file asks // it here, where prose and strings are not selectors. func ruleSelectors(t testing.TB, css string) []string { t.Helper() var out []string for _, rule := range cssRules(t, css) { out = append(out, rule.selectors...) } return out } // splitSelectorList splits on the commas that separate selectors, // leaving alone the ones inside :is(), :where(), :not(), :has() and // attribute values. It reads with cssSpan, so a comma or a bracket // inside a quoted value is text: counting brackets in the raw text, a // ] inside [title="]"] ended the attribute early and a comma after it // split one selector in two. The preludes it is given come from a // reader that has already refused what cssSpan refuses; should one // not, the rest is read as one selector. func splitSelectorList(prelude string) []string { var out []string depth, start := 0, 0 for i := 0; i < len(prelude); { end, _, err := cssSpan(prelude, i) if err != nil { break } switch prelude[i] { case '(', '[': depth++ case ')', ']': depth-- case ',': if depth == 0 { out = append(out, collapseSpace(prelude[start:i])) start = i + 1 } } i = end } if s := collapseSpace(prelude[start:]); s != "" { out = append(out, s) } return out } func collapseSpace(s string) string { return strings.Join(strings.Fields(s), " ") } // ── Specificity ────────────────────────────────────────────────────── // specificity computes (ids, classes, elements) for one selector, the // way the selectors spec does: an attribute selector and a class weigh // the same, :where() weighs nothing at all, and :is()/:not()/:has() // weigh as much as their heaviest argument. It exists so the gate can // say "this pair is specificity-neutral" as a measurement rather than // as a belief. func specificity(selector string) [3]int { var s [3]int i, n := 0, len(selector) for i < n { c := selector[i] switch { case c == '#': s[0]++ i += 1 + identLen(selector[i+1:]) case c == '.': s[1]++ i += 1 + identLen(selector[i+1:]) case c == '[': s[1]++ i = skipTo(selector, i, '[', ']') case c == ':': if i+1 < n && selector[i+1] == ':' { // A pseudo-element. Never functional in this file. i += 2 i += identLen(selector[i:]) s[2]++ continue } i++ name := selector[i : i+identLen(selector[i:])] i += len(name) if i < n && selector[i] == '(' { end := skipTo(selector, i, '(', ')') args := splitSelectorList(selector[i+1 : end-1]) switch strings.ToLower(name) { case "where": // Contributes nothing, by definition. case "is", "not", "has", "matches", "any": var best [3]int for _, a := range args { if v := specificity(a); heavier(v, best) { best = v } } s[0] += best[0] s[1] += best[1] s[2] += best[2] default: // :nth-child(2) and friends: an ordinary pseudo-class. s[1]++ } i = end continue } switch strings.ToLower(name) { case "before", "after", "first-line", "first-letter": s[2]++ // the four legacy one-colon pseudo-elements default: s[1]++ } case c == '*': i++ case isIdentByte(c): i += identLen(selector[i:]) s[2]++ default: // whitespace, > + ~ | and anything else that weighs nothing i++ } } return s } func heavier(a, b [3]int) bool { for k := 0; k < 3; k++ { if a[k] != b[k] { return a[k] > b[k] } } return false } func isIdentByte(c byte) bool { return c == '-' || c == '_' || c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c >= 0x80 } func identLen(s string) int { i := 0 for i < len(s) && isIdentByte(s[i]) { i++ } return i } // skipTo returns the index just past the bracket that closes the one at // i, minding nesting and quoted attribute values. func skipTo(s string, i int, open, close byte) int { depth := 0 for ; i < len(s); i++ { switch c := s[i]; c { case '"', '\'': i++ for i < len(s) && s[i] != c { if s[i] == '\\' { i++ } i++ } case open: depth++ case close: depth-- if depth == 0 { return i + 1 } } } return len(s) } // ── The gates ──────────────────────────────────────────────────────── // TestEveryClassSelectorHasAnAttributeTwin is the stage-1 invariant. // For every selector in tokens.css that names an rst- class, the same // rule must also carry the attribute spelling of that selector — and // the two must weigh the same, or an app that changed nothing would // start rendering differently. func TestEveryClassSelectorHasAnAttributeTwin(t *testing.T) { assertTwins(t, "tokens.css", string(fiesta.TokensCSS()), 300) } // TestNoAttributeSelectorIsAnOrphan is the same invariant from the // other end: every rst- attribute selector in tokens.css must be some // class selector's twin, in its own rule. An attribute rule with no // class beside it would style the new spelling and not the old one, // which is the breakage stage 1 exists to avoid. func TestNoAttributeSelectorIsAnOrphan(t *testing.T) { assertNoOrphans(t, "tokens.css", string(fiesta.TokensCSS())) } // shell.css is written with exactly one pair, the console bar's // view-transition-name, and its floor is that one, so the gate cannot // pass on a shell.css that lost it: the 300 floor tokens.css carries // would make any small file fail. func TestShellCSSPairsBothSpellings(t *testing.T) { assertTwins(t, "shell.css", string(fiesta.ShellCSS()), 1) assertNoOrphans(t, "shell.css", string(fiesta.ShellCSS())) } // assertTwins holds every rule of css to the pairing rule, and floor is // the fewest pairs the file may have, so a bulk deletion cannot pass by // leaving nothing to check. func assertTwins(t *testing.T, name, css string, floor int) { t.Helper() rules := cssRules(t, css) if len(rules) == 0 { t.Fatal(name + " parsed to no rules at all — the reader is broken, not the file") } paired := 0 for _, rule := range rules { present := make(map[string]bool, len(rule.selectors)) for _, sel := range rule.selectors { present[sel] = true } for _, sel := range rule.selectors { twin := attributeTwin(sel) if twin == sel { continue // nothing to translate: a twin, or a utility, or not ours } if !present[twin] { t.Errorf(name+":%d: %q has no attribute twin in its rule.\n"+ "\tadd %q beside it, or the attribute spelling of this rule styles nothing.\n"+ "\tthe rule reads: %s", rule.line, sel, twin, strings.Join(rule.selectors, ", ")) continue } paired++ if a, b := specificity(sel), specificity(twin); a != b { t.Errorf(name+":%d: the pair does not weigh the same — %q is %v but %q is %v.\n"+ "\ta twin that shifts the cascade changes how an app that changed nothing renders.", rule.line, sel, a, twin, b) } } } if paired < floor { t.Errorf("only %d selectors are paired in %s, the floor is %d, so this is a wholesale loss rather than an edit", paired, name, floor) } t.Logf("%d class selectors paired with an attribute twin", paired) } func assertNoOrphans(t *testing.T, name, css string) { t.Helper() // A selector spelt to slip past the literal "[rst-" read below // ([ rst-page], [rst-PAGE], an escape) is refused by name first. assertPlainSpelling(t, name, css) for _, rule := range cssRules(t, css) { twins := make(map[string]bool, len(rule.selectors)) for _, sel := range rule.selectors { if twin := attributeTwin(sel); twin != sel { twins[twin] = true } } for _, sel := range rule.selectors { if !strings.Contains(sel, "[rst-") { continue } if !twins[sel] { t.Errorf(name+":%d: %q is an attribute selector with no class selector it is the twin of.\n"+ "\tuntil stage 3 retires the class spelling, every rule styles both.\n"+ "\tthe rule reads: %s", rule.line, sel, strings.Join(rule.selectors, ", ")) } } } } // TestClassesThatKeepTheirSpellingAreStillReal keeps the exemption list // honest. Every entry must still be a class in tokens.css (so a stale // name cannot sit there excusing something that no longer exists), and // no entry may have quietly grown an attribute form (so "this stays a // class" stays true). func TestClassesThatKeepTheirSpellingAreStillReal(t *testing.T) { css := stripCSSComments(t, string(fiesta.TokensCSS())) // Present means a selector names the class: the text of the file // holds it in prose and strings too. styled := styledClasses(t, css) for class, why := range classKeepsItsSpelling { if !styled[class] { t.Errorf("markup.Utilities names %q (%s) and tokens.css has no such class: delete the entry", class, why) } flat := "rst-" + strings.ReplaceAll(strings.TrimPrefix(class, "rst-"), "__", "-") if strings.Contains(css, "["+flat+"]") { t.Errorf("markup.Utilities names %q (%s) but tokens.css styles [%s]: it is being migrated after all, so drop the exemption", class, why, flat) } } // Stage 1 left one class unpaired for a reason that is not the // utilities': rst-form__foot and rst-form-foot both existed and // wanted one attribute. Stage 2 resolved it — the partial's row // took rst-form-foot, the save bar became rst-form-bar — so the // exemption is gone and neither old spelling may come back. for _, gone := range []string{".rst-form__foot", ".rst-form-foot__note"} { if strings.Contains(css, gone) { t.Errorf("%s is back in tokens.css: the flip retired it, and it collides with the attribute rst-form-foot already carries", gone) } } // Every name the flip deletes outright must really style nothing, // or the codemod is removing markup that was doing a job. for class, why := range markup.Dropped { if strings.Contains(css, "."+class) { t.Errorf("markup.Dropped names %q (%s) but tokens.css styles it: deleting it from markup would change what renders", class, why) } } } // TestNoTwoClassesWantTheSameAttribute is the check that found the // rst-form__foot/rst-form-foot collision in the first place. BEM's __ // flattens to a hyphen, so a __ name and a flat name can land on the // same attribute — and one attribute cannot carry two different rules. // Every new name has to be checked against this, not just the ones that // existed on the day. func TestNoTwoClassesWantTheSameAttribute(t *testing.T) { seen := map[string][]string{} for _, class := range sortedKeys(styledClasses(t, string(fiesta.TokensCSS()))) { name, variant, ok := attributeFor(class) if !ok { continue } key := name if variant != "" { key = name + "~=" + variant } known := false for _, had := range seen[key] { known = known || had == class } if !known { seen[key] = append(seen[key], class) } } for key, classes := range seen { if len(classes) > 1 { sort.Strings(classes) t.Errorf("%v all translate to %q: one attribute cannot style two different rules — rename one, or add it to classKeepsItsSpelling with the reason", classes, key) } } } // The selector reader finds every rule a browser applies. A comment // marker a browser does not take for one, beside a rule, must not hide // that rule from the spelling gates: [ rst-PAGE] is [rst-page] on // every 1.0 page. One inside a string is read past; one an escape // makes is refused with the backslash, since an escape outside a // string is one more spelling the reader would have to decode. func TestTheRuleReaderSeesEveryRuleABrowserApplies(t *testing.T) { const css = `[rst-snippet]::before { content: "/*"; } [ rst-PAGE] { padding: 0; } [rst-snippet]::after { content: "*/"; }` seen := false for _, rule := range cssRules(t, css) { seen = seen || slices.Contains(rule.selectors, "[ rst-PAGE]") } if !seen { t.Errorf("parseCSSRules(%q) does not read the rule [ rst-PAGE], which a browser applies", css) } for _, css := range []string{ `[rst-snippet] { --rst-gate-a: \/*; } [ rst-PAGE] { padding: 0; } [rst-snippet] { --rst-gate-b: */; }`, `[rst-snippet] { --rst-gate-a: \"; } [ rst-PAGE] { padding: 0; } [rst-snippet] { --rst-gate-b: "\""; }`, `[rst-snippet] { --rst-gate-a: \url(/*); } [ rst-PAGE] { padding: 0; } [rst-snippet] { --rst-gate-b: \url(*/ ); }`, `[rst-snippet] { --rst-gate-a: u\72 l(/*); } [ rst-PAGE] { padding: 0; } [rst-snippet] { --rst-gate-b: u\72 l(*/ ); }`, } { if _, err := parseCSSRules(css); err == nil || !strings.Contains(err.Error(), "backslash") { t.Errorf("parseCSSRules(%q): %v, want an error naming the backslash", css, err) } } // A nested rule is read under the rule around it, and a statement // before a rule is not part of its selector. Reading braces alone // took [rst-d] for a rule of its own and glued the @import to // [ rst-PAGE]; both are refused, as declarationsOf refuses them. for _, css := range []string{ `[rst-c] { color: red; [rst-d] { color: blue } }`, `@import "x.css"; [ rst-PAGE] { padding: 0; }`, } { if rules, err := parseCSSRules(css); err == nil { t.Errorf("parseCSSRules(%q) = %+v, want an error", css, rules) } } } // The selector readers read a quoted value as text, as a browser does. // Read as raw text, each of these let a gate pass: a ] or ( inside a // string left the bracket count off zero, so [b] stayed glued to the // selector before it; the last compound was lost, so a rule styling an // a escaped the tap inventory; and a class or attribute selector // quoted in a value, or a heading quoted in a string, counted as real. func TestTheSelectorReadersReadAStringAsText(t *testing.T) { for _, c := range []struct { prelude string want []string }{ {`[a="]"], [b]`, []string{`[a="]"]`, "[b]"}}, {`[a="("], [b]`, []string{`[a="("]`, "[b]"}}, {`:is(a, b), [c=","]`, []string{":is(a, b)", `[c=","]`}}, } { if got := splitSelectorList(c.prelude); !reflect.DeepEqual(got, c.want) { t.Errorf("splitSelectorList(%q) = %q, want %q", c.prelude, got, c.want) } } if !endsOnControlElement(`[title="]"] a`) { t.Errorf("endsOnControlElement does not see the a in %q", `[title="]"] a`) } const css = `[rst-x] { content: ".rst-y [rst-z]"; } [title=".rst-w [rst-v]"] { color: red; } .rst-u { --rst-a: "/* Utilities */"; }` classes, attributes := styledClasses(t, css), styledAttributes(t, css) for _, name := range []string{"rst-y", "rst-w"} { if classes[name] { t.Errorf("styledClasses reads %s, which only a string names, as styled", name) } } if !classes["rst-u"] { t.Error("styledClasses misses .rst-u") } for _, name := range []string{"rst-z", "rst-v"} { if tokensStyle(attributes, name) { t.Errorf("tokensStyle reads [%s], which only a string names, as styled", name) } } if !tokensStyle(attributes, "rst-x") { t.Error("tokensStyle misses [rst-x]") } if at := commentAt(t, css+" /* Utilities */", "/* Utilities"); at != len(css)+1 { t.Errorf("commentAt finds the heading at %d, want %d, the comment rather than the string", at, len(css)+1) } } // TestSpecificityReadsSelectorsTheWayTheSpecDoes is the gate's own // gate. The pairing check leans on specificity() being right about // :where() (weightless), :not()/:has() (their heaviest argument) and // attributes (a class's weight); if it quietly returned zero for // everything, every pair would look neutral and the check would be // worthless. func TestSpecificityReadsSelectorsTheWayTheSpecDoes(t *testing.T) { for _, c := range []struct { sel string want [3]int }{ {".rst-btn", [3]int{0, 1, 0}}, {"[rst-btn]", [3]int{0, 1, 0}}, {`[rst-btn~="primary"]`, [3]int{0, 1, 0}}, {".rst-btn:hover", [3]int{0, 2, 0}}, {"dialog.rst-modal-panel", [3]int{0, 1, 1}}, {"dialog[rst-modal-panel]", [3]int{0, 1, 1}}, {".rst-page-header::after", [3]int{0, 1, 1}}, {"[rst-page-header]::after", [3]int{0, 1, 1}}, {":where(.rst-list) > :where(:first-child)", [3]int{0, 0, 0}}, {":where([rst-list]) > :where(:first-child)", [3]int{0, 0, 0}}, {":where(.rst-page, .rst-page-header) :focus-visible", [3]int{0, 1, 0}}, {".rst-lrow:not(.rst-lrow--head):has(> .rst-nm, > .rst-person):hover", [3]int{0, 4, 0}}, {`[rst-lrow]:not([rst-lrow~="head"]):has(> .rst-nm, > [rst-person]):hover`, [3]int{0, 4, 0}}, {".rst-form > *:not(.rst-form__foot, .rst-form-foot)", [3]int{0, 2, 0}}, {"[rst-form] > *:not(.rst-form__foot, [rst-form-foot])", [3]int{0, 2, 0}}, {".rst-search input[type=\"search\"]::-webkit-search-cancel-button", [3]int{0, 2, 2}}, {"body:has(.rst-backdrop)", [3]int{0, 1, 1}}, {"#id .c e", [3]int{1, 1, 1}}, } { if got := specificity(c.sel); got != c.want { t.Errorf("specificity(%q) = %v, want %v", c.sel, got, c.want) } } } // The translation itself — both halves of the table, selectors and // markup — is pinned in internal/markup's own tests, where the grammar // now lives. It used to be duplicated here, and a duplicated table is // two things that agree until one of them is edited.