| 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. |
| 43 | package markup |
| 44 | |
| 45 | import ( |
| 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. |
| 55 | var 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. |
| 77 | var 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. |
| 86 | var 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. |
| 97 | func 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. |
| 119 | func 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 | |
| 130 | var ( |
| 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". |
| 146 | func 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 | |