rastrillo / fiesta Public

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

Plain git — no account needed to clone.

Download

Download this file

1// markup-spelling: old-spelling begin — this package IS the translation,
2// so its doc comments and its table hold both spellings by definition.
3
4// Package markup is the class→attribute codemod for the ratified markup
5// grammar (design spec §6-v3), and the one place that grammar is
6// written down as code.
7//
8// Rastrillo's UI vocabulary is moving from classes to attributes:
9//
10// <div class="rst-box"> -> <div rst-box>
11// <div class="rst-callout__body"> -> <div rst-callout-body>
12// <a class="rst-btn rst-btn--primary"> -> <a rst-btn="primary">
13// <span class="rst-status" data-tone="ok"> -> <span rst-status rst-tone="ok">
14// <td class="rst-m-hide rst-cell-mut"> -> unchanged: utilities keep class
15//
16// The rules, in full:
17//
18// - A kind becomes a bare attribute.
19// - A BEM part (name__part) becomes a flat attribute (name-part). The
20// value slot never names a part, so the flattening is what keeps
21// `rst-btn="primary"` meaning "variant" on every attribute.
22// - A BEM modifier (name--variant) becomes a token in its kind's
23// value, matched with ~= — the same space-separated matching a class
24// list gets, so two variants compose with no new mechanism.
25// - data-tone becomes rst-tone. Every other data-* attribute carries
26// runtime state (data-busy, data-poll, data-theme) and is untouched.
27// - Seven utilities keep class, because class is what cross-cutting
28// styling is for. An element with a kind, a variant and a utility
29// ends up with an attribute and a class, and that is correct.
30//
31// # Why this is a program
32//
33// The framework had ~700 call sites; an app has its own. Hand edits at
34// that scale contain hand errors, and the same translation has to run
35// over repositories this one cannot see. So the transformation is a
36// tool, `rastrillo markup`, and this package is what the tool, the
37// stage-1 tokens.css gate and the browser drive all call — one grammar,
38// not three implementations that agree until they do not.
39//
40// Rewriting is idempotent: the output carries no class= attribute the
41// input side of the grammar can match, so a second pass changes
42// nothing.
43package markup
44
45import (
46 "fmt"
47 "regexp"
48 "strings"
49)
50
51// Utilities are the seven classes that keep the class spelling, with
52// the reason. They are not kinds — they are cross-cutting styling,
53// which is what class is for — so they stay class past stage 3 rather
54// than being migrated and un-migrated.
55var Utilities = map[string]string{
56 "rst-sr-only": "utility: visually-hidden text",
57 "rst-mono": "utility: monospaced value",
58 "rst-m-hide": "utility: hidden on a narrow screen",
59 "rst-grow": "utility: this flex child takes the slack",
60 "rst-nm": "utility: the name cell's type",
61 "rst-danger": "utility: a destructive item's colour",
62 "rst-cell-mut": "utility: a muted, truncating cell",
63}
64
65// Renamed are the classes the flip renames outright rather than
66// translating, because translating them would collide.
67//
68// rst-form__foot and rst-form-foot both existed and both were live:
69// the plain action row the form-foot partial emits, and the sticky save
70// bar an app writes by hand. BEM's __ flattens to a hyphen, so the two
71// wanted one attribute. The partial's row takes the name the partial is
72// called; the save bar, which no partial emits, becomes rst-form-bar.
73//
74// Order does not matter here: a class token is looked up once, so
75// rst-form-foot -> rst-form-bar and rst-form__foot -> rst-form-foot do
76// not chain.
77var Renamed = map[string]string{
78 "rst-form__foot": "rst-form-foot",
79 "rst-form-foot": "rst-form-bar",
80 "rst-form-foot__note": "rst-form-bar__note",
81}
82
83// Dropped are classes that are deleted rather than translated, because
84// they style nothing: an attribute spelling of them would be a name the
85// stylesheet has never heard of, sitting in markup an LLM copies.
86var Dropped = map[string]string{
87 "rst-dropdown__summary": "no rule: the summary is styled structurally, by [rst-dropdown] > summary",
88 "rst-btn__spin": "no rule: the busy spinner is styled structurally, by [rst-btn] > [rst-spin]",
89 "rst-combo__input": "no rule: select.js wrote it beside rst-input and nothing ever styled it",
90}
91
92// Attribute translates one rst- class name into the attribute that
93// styles the same thing. ok is false for a class that keeps its
94// spelling. It is the pure grammar — no renames, no deletions — so it
95// is also what translates a tokens.css selector, where the renames have
96// already happened in the file itself.
97func Attribute(class string) (name, variant string, ok bool) {
98 if _, exempt := Utilities[class]; exempt {
99 return "", "", false
100 }
101 body := strings.TrimPrefix(class, "rst-")
102 if k := strings.Index(body, "--"); k >= 0 {
103 variant = body[k+2:]
104 body = body[:k]
105 }
106 return "rst-" + strings.ReplaceAll(body, "__", "-"), variant, true
107}
108
109// classFor is Attribute plus the two things only a markup rewrite does:
110// the deletions, and — for a migration rather than a respelling — the
111// renames. drop is true for a class that leaves the markup entirely.
112//
113// The renames belong to the migration alone. They translate markup
114// written before the flip, where rst-form-foot meant the sticky save
115// bar; markup already written in today's class vocabulary means the
116// action row by that name, and running the rename over it would move it
117// to rst-form-bar and change what renders. That distinction is why
118// Respell exists beside Rewrite.
119func classFor(class string, migrate bool) (name, variant string, ok, drop bool) {
120 if _, gone := Dropped[class]; gone {
121 return "", "", false, true
122 }
123 if to, renamed := Renamed[class]; migrate && renamed {
124 class = to
125 }
126 name, variant, ok = Attribute(class)
127 return name, variant, ok, false
128}
129
130var (
131 classInSelector = regexp.MustCompile(`\.(rst-[A-Za-z0-9_-]+)`)
132 toneInSelector = regexp.MustCompile(`\[data-tone="([a-z]+)"\]`)
133 // A data-tone that is a real attribute rather than a selector. A
134 // selector's follows a [; an attribute's follows whitespace, or the
135 // opening quote of the Go literal a test writes it in.
136 toneInMarkup = regexp.MustCompile("([\\s`'\"])data-tone=(\\\\?\")")
137)
138
139// Selector is the attribute twin of one tokens.css selector: every
140// translatable rst- class swapped for its attribute, and data-tone for
141// rst-tone. Everything else — type selectors, combinators,
142// pseudo-classes, .icon, an app's own class, and the data-* attributes
143// that carry runtime state — is left exactly as it is. A selector with
144// nothing to translate comes back unchanged, which is how the stage-1
145// gate tells "needs a twin" from "is one".
146func Selector(selector string) string {
147 out := classInSelector.ReplaceAllStringFunc(selector, func(m string) string {
148 name, variant, ok := Attribute(m[1:])
149 if !ok {
150 return m
151 }
152 if variant == "" {
153 return "[" + name + "]"
154 }
155 return fmt.Sprintf("[%s~=%q]", name, variant)
156 })
157 return toneInSelector.ReplaceAllString(out, `[rst-tone~="$1"]`)
158}
159