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 — the before/after table.
2
3package markup
4
5import (
6 "regexp"
7 "strings"
8 "testing"
9)
10
11// TestTheGrammar pins the translation itself: kind, part, variant,
12// part-with-variant, tone, and the things that must NOT move — a
13// utility class, an app's own class, and the data-* attributes that
14// carry runtime state rather than authored vocabulary.
15func TestTheGrammar(t *testing.T) {
16 for _, c := range []struct{ in, want string }{
17 // Kinds, parts, variants.
18 {`<div class="rst-box">`, `<div rst-box>`},
19 {`<div class="rst-callout__body">`, `<div rst-callout-body>`},
20 {`<a class="rst-btn rst-btn--primary" href="/x">`, `<a rst-btn="primary" href="/x">`},
21 {`<span class="rst-badge rst-badge--positive">`, `<span rst-badge="positive">`},
22 {`<div class="rst-dtp__row rst-dtp__row--set">`, `<div rst-dtp-row="set">`},
23 {`<div class="rst-lrow rst-lrow--head">`, `<div rst-lrow="head">`},
24 // Two kinds on one element become two attributes.
25 {`<details class="rst-dropdown rst-locale">`, `<details rst-dropdown rst-locale>`},
26 // A kind, a variant and a utility: an attribute AND a class.
27 {`<span class="rst-m-hide rst-cell-mut">`, `<span class="rst-m-hide rst-cell-mut">`},
28 {`<a class="rst-nm rst-person rst-person--lg">`, `<a class="rst-nm" rst-person="lg">`},
29 // Tone.
30 {`<span class="rst-status" data-tone="warning">`, `<span rst-status rst-tone="warning">`},
31 {`<div class="rst-callout" data-tone="positive">`, `<div rst-callout rst-tone="positive">`},
32 // Runtime state stays data-*.
33 {`<form class="rst-form" data-busy="false">`, `<form rst-form data-busy="false">`},
34 {`<div class="rst-job" data-poll="/x">`, `<div rst-job data-poll="/x">`},
35 // Not ours.
36 {`<svg class="icon">`, `<svg class="icon">`},
37 {`<p class="flash flash-ok">`, `<p class="flash flash-ok">`},
38 // The renames.
39 {`<div class="rst-form__foot">`, `<div rst-form-foot>`},
40 {`<div class="rst-form-foot"><span class="rst-form-foot__note">x</span></div>`,
41 `<div rst-form-bar><span rst-form-bar-note>x</span></div>`},
42 // The deletion, including the space it took with it.
43 {`<summary class="rst-btn rst-dropdown__summary">`, `<summary rst-btn>`},
44 {`<summary class="rst-dropdown__summary">Filter</summary>`, `<summary>Filter</summary>`},
45 // A Go interpreted string literal keeps its own quoting.
46 {`b.WriteString("<a class=\"rst-btn rst-btn--primary\">")`,
47 `b.WriteString("<a rst-btn=\"primary\">")`},
48 {`b.WriteString("<div class=\"rst-dropdown__menu\">")`,
49 `b.WriteString("<div rst-dropdown-menu>")`},
50 // Templated class lists: the conditional moves into the value.
51 {`<span class="rst-badge{{with .Tone}} rst-badge--{{.}}{{end}}">`,
52 `<span rst-badge{{with .Tone}}="{{.}}"{{end}}>`},
53 {`<input class="rst-input{{if .Short}} rst-input--short{{end}}">`,
54 `<input rst-input{{if .Short}}="short"{{end}}>`},
55 {`<button class="rst-btn{{if .Danger}} rst-btn--danger{{else}} rst-btn--primary{{end}}">`,
56 `<button rst-btn="{{if .Danger}}danger{{else}}primary{{end}}">`},
57 {`<span class="rst-person__av{{if not .Initial}} rst-person__av--empty{{end}}">`,
58 `<span rst-person-av{{if not .Initial}}="empty"{{end}}>`},
59 // A whole class attribute inside a conditional: the value has no
60 // action in it, so it is an ordinary literal list.
61 {`<legend{{if .Hidden}} class="rst-sr-only"{{end}}>`, `<legend{{if .Hidden}} class="rst-sr-only"{{end}}>`},
62 {`<dd{{if .Mono}} class="rst-mono"{{end}}>`, `<dd{{if .Mono}} class="rst-mono"{{end}}>`},
63 // A CSS selector is not markup: neither of these moves.
64 {`.rst-box { color: red }`, `.rst-box { color: red }`},
65 {`.rst-status[data-tone="positive"] { color: red }`, `.rst-status[data-tone="positive"] { color: red }`},
66 // A class attribute that opens a Go literal has no whitespace
67 // in front of it, and is still an attribute.
68 {"wantContains(t, body, `class=\"rst-field\"`)", "wantContains(t, body, `rst-field`)"},
69 {"strings.Contains(got, `class=\"rst-status\" data-tone=\"positive\"`)",
70 "strings.Contains(got, `rst-status rst-tone=\"positive\"`)"},
71 // data-class= and superclass= are not the class attribute.
72 {`<div data-class="rst-box">`, `<div data-class="rst-box">`},
73 // A <details name> group is a value, not a class.
74 {`<details class="rst-row-menu" name="rst-menus">`, `<details rst-row-menu name="rst-menus">`},
75 } {
76 got, notes := Rewrite([]byte(c.in))
77 if string(got) != c.want {
78 t.Errorf("Rewrite(%q)\n got %q\nwant %q", c.in, got, c.want)
79 }
80 if len(notes) > 0 {
81 t.Errorf("Rewrite(%q) reported %v, but the shape is one it handles", c.in, notes)
82 }
83 }
84}
85
86// TestRewritingIsIdempotent is the property the release notes lean on:
87// an app can run the tool twice, or run it over a tree half of which
88// has already been done, and the second pass changes nothing.
89func TestRewritingIsIdempotent(t *testing.T) {
90 src := `<div class="rst-card" style="--rst-cols: 2fr 32px">
91 <div class="rst-lrow rst-lrow--head"><span class="rst-m-hide">Status</span></div>
92 <span class="rst-status" data-tone="positive">Paid</span>
93 <span class="rst-badge{{with .Tone}} rst-badge--{{.}}{{end}}">x</span>
94 <summary class="rst-btn rst-dropdown__summary">Filter</summary>
95 <div class="rst-form-foot"><span class="rst-form-foot__note">n</span></div>
96</div>`
97 once, _ := Rewrite([]byte(src))
98 twice, notes := Rewrite(once)
99 if string(once) != string(twice) {
100 t.Errorf("a second pass changed the file:\n%s\n---\n%s", once, twice)
101 }
102 if len(notes) > 0 {
103 t.Errorf("a second pass reported %v", notes)
104 }
105 // The only class= left is the utilities', which is the grammar.
106 for _, attr := range regexp.MustCompile(`class="([^"]*)"`).FindAllStringSubmatch(string(once), -1) {
107 for _, tok := range strings.Fields(attr[1]) {
108 if _, ok := Utilities[tok]; !ok && strings.HasPrefix(tok, "rst-") {
109 t.Errorf("a migrating class survived: %q in\n%s", tok, once)
110 }
111 }
112 }
113}
114
115// TestAnUnreadableShapeIsReportedNotGuessed. A codemod that guesses is
116// worse than one that stops: the wrong guess renders unstyled and looks
117// like markup somebody wrote.
118func TestAnUnreadableShapeIsReportedNotGuessed(t *testing.T) {
119 for _, in := range []string{
120 `<div class="rst-box{{if .X}} rst-card{{end}}">`, // a second KIND, not a modifier
121 `<div class="{{if .X}}rst-btn--primary{{else}}rst-badge--positive{{end}}">`, // two kinds
122 `<div class="rst-box{{if .X}} something rst-box--wide{{end}}">`, // literal text beside it
123 } {
124 got, notes := Rewrite([]byte(in))
125 if string(got) != in {
126 t.Errorf("Rewrite(%q) = %q: an unreadable shape must be left exactly as it was", in, got)
127 }
128 if len(notes) != 1 {
129 t.Errorf("Rewrite(%q) reported %d notes, want 1", in, len(notes))
130 }
131 }
132}
133
134// TestSelectorTranslationIsTheSameGrammar. tokens.css's stage-1 twins
135// and the markup flip have to mean the same thing by construction, so
136// they are one package and this is the selector half of the table.
137func TestSelectorTranslationIsTheSameGrammar(t *testing.T) {
138 for _, c := range []struct{ in, want string }{
139 {".rst-box", "[rst-box]"},
140 {".rst-box-head + .rst-box", "[rst-box-head] + [rst-box]"},
141 {".rst-btn--primary:hover", `[rst-btn~="primary"]:hover`},
142 {".rst-callout__body > p", "[rst-callout-body] > p"},
143 {".rst-dtp__row--set .rst-dtp__label", `[rst-dtp-row~="set"] [rst-dtp-label]`},
144 {".rst-person__av--empty", `[rst-person-av~="empty"]`},
145 {`.rst-status[data-tone="positive"]`, `[rst-status][rst-tone~="positive"]`},
146 {`.rst-btn[aria-busy="true"]`, `[rst-btn][aria-busy="true"]`},
147 {".rst-mono", ".rst-mono"},
148 {".rst-row-menu__panel .rst-danger:hover", "[rst-row-menu-panel] .rst-danger:hover"},
149 {".rst-search .icon", "[rst-search] .icon"},
150 {`.rst-row__lead[data-lead="positive"]`, `[rst-row-lead][data-lead="positive"]`},
151 // Selector translation is the pure grammar: the markup renames
152 // have already happened in tokens.css itself, so a selector is
153 // never renamed under a rewrite's feet.
154 {".rst-form-foot", "[rst-form-foot]"},
155 {".rst-form-bar .rst-form-bar__note", "[rst-form-bar] [rst-form-bar-note]"},
156 } {
157 if got := Selector(c.in); got != c.want {
158 t.Errorf("Selector(%q) = %q, want %q", c.in, got, c.want)
159 }
160 }
161}
162
163// TestEveryQuotingHTMLAllows. The framework writes one shape; an app
164// may write any of them. A tool that reads only the shape its own
165// repository happens to use tells everyone else "nothing to do", and
166// they find out at stage 3, when the class selectors are gone and
167// nothing says why the page is unstyled.
168func TestEveryQuotingHTMLAllows(t *testing.T) {
169 for _, c := range []struct{ in, want string }{
170 {`<div class='rst-box'>`, `<div rst-box>`},
171 {`<div class=rst-box>`, `<div rst-box>`},
172 {`<div class = "rst-box">`, `<div rst-box>`},
173 {`<div CLASS="rst-box">`, `<div rst-box>`},
174 {`<div Class='rst-btn rst-btn--primary'>`, `<div rst-btn='primary'>`},
175 {`<div class=rst-lrow--head>`, `<div rst-lrow="head">`},
176 // A utility keeps class, and keeps the quoting it was written in.
177 {`<td class='rst-m-hide rst-cell-mut'>`, `<td class='rst-m-hide rst-cell-mut'>`},
178 {`<td class=rst-mono>`, `<td class="rst-mono">`},
179 // A JavaScript literal escapes its single quotes the way a Go
180 // one escapes its double quotes.
181 {`el.outerHTML = '<div class=\'rst-box\'>'`, `el.outerHTML = '<div rst-box>'`},
182 } {
183 got, notes := Rewrite([]byte(c.in))
184 if string(got) != c.want {
185 t.Errorf("Rewrite(%q)\n got %q\nwant %q", c.in, got, c.want)
186 }
187 if len(notes) > 0 {
188 t.Errorf("Rewrite(%q) reported %v, but the shape is one it handles", c.in, notes)
189 }
190 }
191}
192
193// TestThingsThatWearTheShapeOfAClassAttributeAndAreNot. Each of these
194// must come out byte-identical and unreported: rewriting them would be
195// the corruption, and reporting them would be noise a reader learns to
196// ignore.
197func TestThingsThatWearTheShapeOfAClassAttributeAndAreNot(t *testing.T) {
198 for _, in := range []string{
199 `<div data-class="rst-box">`,
200 `if class == "rst-box" { }`,
201 `<a href="/x?class=rst-box&sort=new">link</a>`,
202 `<a href="/x?class=rst-box">link</a>`,
203 } {
204 got, notes := Rewrite([]byte(in))
205 if string(got) != in {
206 t.Errorf("Rewrite(%q) = %q: it is not a class attribute and must not move", in, got)
207 }
208 if len(notes) > 0 {
209 t.Errorf("Rewrite(%q) reported %v: it is not markup, so there is nothing to tell anyone", in, notes)
210 }
211 }
212}
213
214// TestMarkupBuiltByConcatenationIsReportedNotCorrupted is the shape
215// that used to come out as source that does not compile. The reader
216// runs from class=\" to the next \" and lands in the middle of a Go
217// expression; a value holding a quote or a backtick is not a class
218// list, and saying so is the whole of the contract.
219func TestMarkupBuiltByConcatenationIsReportedNotCorrupted(t *testing.T) {
220 for _, in := range []string{
221 "return \"<div class=\\\"rst-\" + kind + \"\\\">x</div>\"",
222 "return `<a class=\"rst-btn rst-btn--` + tone + `\">go</a>`",
223 "var s = '<span class=\\'rst-badge rst-badge--' + tone + '\\'>'",
224 } {
225 got, notes := Rewrite([]byte(in))
226 if string(got) != in {
227 t.Errorf("Rewrite(%q) = %q: markup built by concatenation must come back untouched", in, got)
228 }
229 if len(notes) != 1 {
230 t.Errorf("Rewrite(%q) reported %d notes, want 1 — silence here writes source that does not compile", in, len(notes))
231 continue
232 }
233 if !strings.Contains(notes[0].Text, "not a class list") {
234 t.Errorf("the note does not say what is wrong: %s", notes[0])
235 }
236 }
237}
238
239// An unquoted value with a template action in it is markup, and it is
240// unreadable. Skipping it silently is the H3 failure wearing a
241// different hat: a class attribute carrying the vocabulary that would
242// simply never be migrated, with nothing ever saying so.
243func TestAnUnquotedTemplatedValueIsReported(t *testing.T) {
244 const in = `<div class=rst-{{.Kind}}>x</div>`
245 got, notes := Rewrite([]byte(in))
246 if string(got) != in {
247 t.Errorf("Rewrite(%q) = %q: it must not be guessed at", in, got)
248 }
249 if len(notes) != 1 || !strings.Contains(notes[0].Text, "cannot take apart") {
250 t.Errorf("an unquoted templated class value was not reported: %v", notes)
251 }
252}
253
254// TestEscapedMarkupIsReported: a documentation page showing
255// class=&quot;rst-box&quot; as source. Rewriting it would mean deciding
256// what the escaping is for; naming it is the honest half.
257func TestEscapedMarkupIsReported(t *testing.T) {
258 const in = `<p>Write <code>class=&quot;rst-box&quot;</code> no longer.</p>`
259 got, notes := Rewrite([]byte(in))
260 if string(got) != in {
261 t.Errorf("Rewrite(%q) = %q: escaped markup must not be rewritten", in, got)
262 }
263 if len(notes) != 1 || !strings.Contains(notes[0].Text, "escaped markup") {
264 t.Errorf("escaped markup carrying an rst- name was not reported: %v", notes)
265 }
266}
267
268// TestTheFenceIsTheOnlyOptOut. Every repository migrating its own
269// documentation has a paragraph whose subject IS the old spelling, and
270// without a way to say so the only opt-out is "do not run the tool on
271// that file" — which is not one.
272func TestTheFenceIsTheOnlyOptOut(t *testing.T) {
273 const in = "<div class=\"rst-box\">before</div>\n" +
274 "<!-- " + FenceBegin + " -->\n" +
275 "<div class=\"rst-box\" data-tone=\"positive\">inside</div>\n" +
276 "<!-- " + FenceEnd + " -->\n" +
277 "<div class=\"rst-box\">after</div>\n"
278 got, notes := Rewrite([]byte(in))
279 want := "<div rst-box>before</div>\n" +
280 "<!-- " + FenceBegin + " -->\n" +
281 "<div class=\"rst-box\" data-tone=\"positive\">inside</div>\n" +
282 "<!-- " + FenceEnd + " -->\n" +
283 "<div rst-box>after</div>\n"
284 if string(got) != want {
285 t.Errorf("Rewrite\n got %q\nwant %q", got, want)
286 }
287 if len(notes) > 0 {
288 t.Errorf("a fenced region is a deliberate choice, not a finding: %v", notes)
289 }
290 // An unclosed fence runs to the end of the file, which is what a
291 // whole-file opt-out looks like written once at the top.
292 open := "<!-- " + FenceBegin + " -->\n<div class=\"rst-box\">x</div>\n"
293 if got, _ := Rewrite([]byte(open)); string(got) != open {
294 t.Errorf("an unclosed fence must reach the end of the file: %q", got)
295 }
296}
297
298// TestAFencedRegionProducesNoNote is the other half of
299// TestTheFenceIsTheOnlyOptOut, and it was the half missing. The fence
300// stopped the rewrite and did not stop the report, so a file that
301// fences off a paragraph about the old spelling reported work forever
302// and held the exit code at 3 while it did.
303//
304// This repository was the proof: internal/markup/rewrite.go and
305// internal/markup/markup_test.go are fenced from line 1 and reported
306// notes anyway, on every run, so `rastrillo markup .` could never exit
307// 0 here on a clean tree. How many notes is not worth writing down —
308// adding this very test added another one, which is how the count an
309// earlier draft of this comment carried went stale before anyone read
310// it. A report mode that always says there is work is what teaches a
311// person to skip reading it and run --fix, which is the habit that
312// made the Markdown defect expensive.
313func TestAFencedRegionProducesNoNote(t *testing.T) {
314 // The same text TestEscapedMarkupIsReported asserts IS a note when
315 // it stands on its own — shared deliberately, so the two cannot
316 // drift into testing different things and agreeing.
317 const escaped = "<p>Write <code>class=&quot;rst-box&quot;</code> no longer.</p>\n"
318
319 // The data-tone attribute ahead of the fence is not decoration: the
320 // tone pass rewrites outside fences and shortens the text where it
321 // does, which is why the note pass computes the regions again
322 // rather than reusing the ones the tone pass was given.
323 in := "<span class=\"rst-status\" data-tone=\"ok\">live</span>\n" +
324 "<!-- " + FenceBegin + " -->\n" + escaped + "<!-- " + FenceEnd + " -->\n"
325
326 got, notes := Rewrite([]byte(in))
327 if len(notes) != 0 {
328 t.Errorf("a fenced paragraph about the old spelling is a decision already taken, not a finding: %v", notes)
329 }
330 if !strings.Contains(string(got), escaped) {
331 t.Errorf("the fenced paragraph was rewritten:\n%s", got)
332 }
333 // And the fence still ends where it says: markup outside it is
334 // migrated as usual, or "no notes" would just mean "did nothing".
335 if !strings.Contains(string(got), "<span rst-status rst-tone=\"ok\">") {
336 t.Errorf("the fence swallowed the markup outside it:\n%s", got)
337 }
338}
339
340// TestRespellIsTheGrammarWithoutTheMigration. Rewrite translates markup
341// written before the flip, where rst-form-foot meant the sticky save
342// bar. Respell translates markup written in today's class vocabulary,
343// where it means the action row the form-foot partial emits. Running
344// the wrong one over the wrong markup moves an element to a different
345// rule and changes what renders, so the difference is pinned here.
346func TestRespellIsTheGrammarWithoutTheMigration(t *testing.T) {
347 const in = `<div class="rst-form-foot"><span class="rst-form-foot__note">n</span></div>`
348 migrated, _ := Rewrite([]byte(in))
349 if want := `<div rst-form-bar><span rst-form-bar-note>n</span></div>`; string(migrated) != want {
350 t.Errorf("Rewrite(%q) = %q, want %q", in, migrated, want)
351 }
352 respelled, _ := Respell([]byte(in))
353 if want := `<div rst-form-foot><span rst-form-foot-note>n</span></div>`; string(respelled) != want {
354 t.Errorf("Respell(%q) = %q, want %q", in, respelled, want)
355 }
356 // Everything that is not a rename is the same translation in both.
357 const plain = `<a class="rst-btn rst-btn--primary">x</a>`
358 a, _ := Rewrite([]byte(plain))
359 b, _ := Respell([]byte(plain))
360 if string(a) != string(b) {
361 t.Errorf("Rewrite and Respell disagree on markup with no rename in it: %q vs %q", a, b)
362 }
363}
364
365// TestTheExemptionsAreDisjoint. A class cannot be both a utility that
366// keeps its spelling and a name the flip renames or deletes; the three
367// lists are the whole of what the grammar treats specially, so an
368// overlap would make the translation depend on lookup order.
369func TestTheExemptionsAreDisjoint(t *testing.T) {
370 for class := range Utilities {
371 if _, ok := Renamed[class]; ok {
372 t.Errorf("%q is both a utility and a rename", class)
373 }
374 if _, ok := Dropped[class]; ok {
375 t.Errorf("%q is both a utility and a deletion", class)
376 }
377 }
378 for class := range Renamed {
379 if _, ok := Dropped[class]; ok {
380 t.Errorf("%q is both a rename and a deletion", class)
381 }
382 }
383}
384