Skip to main content

max / makeover-build

20.1 KB · 573 lines History Blame Raw
1 //! Checks that a hand-written frontend still agrees with the crate that
2 //! generates its siblings.
3 //!
4 //! The generated files cannot drift: they ask makeover-geometry for the answer.
5 //! The hand-written ones state it, and a stylesheet or a script that disagrees
6 //! with the crate is not an error at any point -- it is a rule that quietly
7 //! stops matching where it used to. Cheaper to read a panic naming the line.
8 //!
9 //! Deliberately assertions and not substitutions. A JS or CSS file that has to
10 //! be generated to be correct stops being readable on its own, and it is worth
11 //! something that you can still open the frontend in a browser and have it
12 //! work.
13
14 use std::path::{Path, PathBuf};
15
16 use makeover_geometry::{Density, SizeClass};
17
18 /// The declaration this check reads. Shared vocabulary, not a parameter: two
19 /// apps and a server naming the same string want the same name for it.
20 const CONST_NAME: &str = "TOUCH_DENSITY";
21
22 /// The capability sniffs the media query replaced, so neither can come back by
23 /// copy-paste.
24 ///
25 /// Both ask the hardware what it has rather than what is pointing at the
26 /// screen, so both say yes to a touchscreen laptop driving a mouse.
27 const SNIFFS: &[&str] = &["ontouchstart", "maxTouchPoints"];
28
29 /// Fail the build if a JS copy of the touch-density query has drifted from
30 /// [`Density::Touch`].
31 ///
32 /// Every `.js` file under `js_dir`, recursively, must state the crate's own
33 /// media condition in a `const TOUCH_DENSITY = '...'`, at least one file must
34 /// declare it, and no file may name a capability sniff.
35 ///
36 /// The string is the crate's and no app gets a say in it, which is why this
37 /// check takes no policy argument. The generated `geometry.css` already keys
38 /// its touch gap overrides on the same condition, so the gestures and the
39 /// spacing agree by construction rather than by two people remembering.
40 ///
41 /// Emits `cargo:rerun-if-changed` for every file it read.
42 ///
43 /// # Panics
44 ///
45 /// If `js_dir` cannot be read, if no declaration is found, or if any file
46 /// disagrees with the crate. A build script has nowhere useful to return an
47 /// error to, and a frontend that disagrees with its own stylesheet is worse
48 /// than a failed build.
49 pub fn check_touch_density(js_dir: impl AsRef<Path>) {
50 let js_dir = js_dir.as_ref();
51 let want = Density::Touch.media_condition();
52 let mut wrong: Vec<String> = Vec::new();
53 let mut found = 0usize;
54
55 let files = js_files(js_dir);
56 for path in &files {
57 let src = std::fs::read_to_string(path).expect("read js file");
58 let name = path
59 .strip_prefix(js_dir)
60 .unwrap_or(path)
61 .display()
62 .to_string();
63
64 for (offset, literal) in touch_density_literals(&src) {
65 found += 1;
66 if literal != want {
67 wrong.push(format!(
68 " {name}:{} {CONST_NAME} = '{literal}'",
69 line_of(&src, offset)
70 ));
71 }
72 }
73
74 for needle in SNIFFS {
75 if let Some(offset) = src.find(needle) {
76 wrong.push(format!(
77 " {name}:{} {needle} -- device sniff, not a density question",
78 line_of(&src, offset)
79 ));
80 }
81 }
82 }
83
84 assert!(
85 found > 0,
86 "no {CONST_NAME} literal found under {}.\n\n\
87 A frontend that asks whether it is being touched states\n\
88 makeover_geometry::Density::Touch's media condition in a const of that\n\
89 name, and this check exists to keep every copy equal to it. If the\n\
90 const was renamed, rename it back rather than dropping the check; if\n\
91 this frontend genuinely asks no density question, drop the call.",
92 js_dir.display()
93 );
94
95 assert!(
96 wrong.is_empty(),
97 "hand-written touch detection disagrees with makeover_geometry::Density.\n\n\
98 Density::Touch.media_condition() is: {want}\n\n\
99 Wrong:\n{}\n\n\
100 Fix the JS to state the crate's string. Never widen it to catch a\n\
101 device the query misses: density is what is pointing at the screen,\n\
102 and a laptop with a touchscreen and a mouse is a pointer device.",
103 wrong.join("\n")
104 );
105
106 for path in &files {
107 println!("cargo:rerun-if-changed={}", path.display());
108 }
109 }
110
111 /// Every `.js` file under `dir`, recursively, sorted.
112 fn js_files(dir: &Path) -> Vec<PathBuf> {
113 files_with_extension(dir, "js")
114 }
115
116 /// Every file under `dir` with extension `ext`, recursively, sorted.
117 ///
118 /// Recursive because a consumer's frontend is not always one flat directory:
119 /// the Tauri apps keep `js/*.js`, the server keeps subdirectories under
120 /// `static/`, and a check that silently skipped the nested half would report
121 /// clean on the files most likely to have been copied.
122 fn files_with_extension(dir: &Path, ext: &str) -> Vec<PathBuf> {
123 let mut out = Vec::new();
124 let mut stack = vec![dir.to_path_buf()];
125 while let Some(d) = stack.pop() {
126 for entry in std::fs::read_dir(&d)
127 .unwrap_or_else(|e| panic!("read {}: {e}", d.display()))
128 .flatten()
129 {
130 let path = entry.path();
131 if path.is_dir() {
132 stack.push(path);
133 } else if path.extension().is_some_and(|x| x == ext) {
134 out.push(path);
135 }
136 }
137 }
138 out.sort();
139 out
140 }
141
142 /// `(byte offset of the declaration, the literal's contents)` for every
143 /// `const TOUCH_DENSITY = '...'` in a JS source.
144 fn touch_density_literals(src: &str) -> Vec<(usize, &str)> {
145 let mut out = Vec::new();
146 let mut at = 0;
147 while let Some(i) = src[at..].find(CONST_NAME) {
148 let start = at + i;
149 at = start + CONST_NAME.len();
150 // Only the declaration states the string; a use site reads the const.
151 let Some(rest) = src[at..].strip_prefix(" = ") else {
152 continue;
153 };
154 let open = at + " = ".len();
155 let Some(quote @ ('\'' | '"')) = rest.chars().next() else {
156 continue;
157 };
158 let body = open + 1;
159 if let Some(j) = src[body..].find(quote) {
160 out.push((start, &src[body..body + j]));
161 at = body + j + 1;
162 }
163 }
164 out
165 }
166
167 fn line_of(src: &str, offset: usize) -> usize {
168 src[..offset].matches('\n').count() + 1
169 }
170
171 /// Fail the build if a hand-written breakpoint has drifted from [`SizeClass`].
172 ///
173 /// Every pixel width named by a media query under `frontend/css` or
174 /// `frontend/js`, recursively, must be a [`SizeClass`] boundary or one of
175 /// `tuning_widths`.
176 ///
177 /// Without this, moving `SizeClass::Medium::min_px` regenerates the emitted
178 /// stylesheets and silently leaves every hand-written query behind, and what
179 /// you get is not an error but a stylesheet that disagrees with itself at the
180 /// old boundary.
181 ///
182 /// `tuning_widths` is the one thing an app gets a say in, which is why this
183 /// takes a parameter where [`check_touch_density`] does not. A shell boundary
184 /// is a [`SizeClass`] edge and belongs to makeover-geometry; a tuning width is
185 /// a point inside a shell where something reflows without the shell changing --
186 /// a dashboard dropping from three columns to two, a pane's width cap ending.
187 /// Nothing switches shells at one, so it should not move when a size class
188 /// does. Pass `&[]` if the app has none, and treat every addition as owing a
189 /// note saying what it tunes: the list is where a genuine boundary goes to hide
190 /// from this check.
191 ///
192 /// The generated stylesheets are scanned too, and pass by construction: they
193 /// ask makeover-geometry for the number rather than stating it. Scanning them
194 /// costs nothing and means a consumer never has to name which files are
195 /// hand-written.
196 ///
197 /// Emits `cargo:rerun-if-changed` for every file it read.
198 ///
199 /// # Panics
200 ///
201 /// If `frontend/css` or `frontend/js` cannot be read, or if any width is
202 /// neither a size-class boundary nor a declared tuning width. A build script
203 /// has nowhere useful to return an error to.
204 pub fn check_breakpoints(frontend: impl AsRef<Path>, tuning_widths: &[u16]) {
205 let frontend = frontend.as_ref();
206 let allowed = allowed_widths(tuning_widths);
207 let mut stale: Vec<String> = Vec::new();
208
209 let css_files = files_with_extension(&frontend.join("css"), "css");
210 for path in &css_files {
211 let raw = std::fs::read_to_string(path).expect("read css file");
212 // Comments first: a note about a breakpoint that used to be here is
213 // prose, not a rule, and should not fail a build.
214 let src = strip_block_comments(&raw);
215 let name = display_name(frontend, path);
216 for (offset, condition) in media_conditions(&src) {
217 for px in media_widths(condition) {
218 if !allowed.contains(&px) {
219 stale.push(format!(
220 " {name}:{} @media{condition} ({px}px)",
221 line_of(&src, offset)
222 ));
223 }
224 }
225 }
226 }
227
228 let js_files = js_files(&frontend.join("js"));
229 for path in &js_files {
230 let src = std::fs::read_to_string(path).expect("read js file");
231 let name = display_name(frontend, path);
232 // No declarations in JS, so any width condition is a media query.
233 for (offset, px) in js_widths(&src) {
234 if !allowed.contains(&px) {
235 stale.push(format!(" {name}:{} ({px}px)", line_of(&src, offset)));
236 }
237 }
238 }
239
240 assert!(
241 stale.is_empty(),
242 "hand-written breakpoints disagree with makeover_geometry::SizeClass.\n\n\
243 Allowed: {allowed:?}\n\
244 ({:?} come from SizeClass; {tuning_widths:?} were passed as tuning widths.)\n\n\
245 Stale:\n{}\n\n\
246 If a size class moved, update these to match. If one of these is a new\n\
247 tuning width inside the wide shell rather than a shell boundary, add it\n\
248 to the caller's tuning list with a note saying what it tunes.",
249 allowed
250 .iter()
251 .filter(|px| !tuning_widths.contains(px))
252 .collect::<Vec<_>>(),
253 stale.join("\n")
254 );
255
256 for path in css_files.iter().chain(&js_files) {
257 println!("cargo:rerun-if-changed={}", path.display());
258 }
259 }
260
261 /// A path as the frontend sees it, for an error a reader can act on.
262 fn display_name(frontend: &Path, path: &Path) -> String {
263 path.strip_prefix(frontend)
264 .unwrap_or(path)
265 .display()
266 .to_string()
267 }
268
269 /// Every width a hand-written media query is allowed to name.
270 ///
271 /// Read out of [`SizeClass::media_condition`] rather than typed, which is the
272 /// whole point: that is the one place the numbers come from, and a bump in
273 /// makeover-geometry has to reach the stylesheet through here.
274 fn allowed_widths(tuning_widths: &[u16]) -> Vec<u16> {
275 let mut widths: Vec<u16> = SizeClass::all()
276 .iter()
277 .flat_map(|c| media_widths(&c.media_condition()))
278 .collect();
279 widths.extend_from_slice(tuning_widths);
280 widths.sort_unstable();
281 widths.dedup();
282 widths
283 }
284
285 /// The pixel values in a media condition, in the order they appear.
286 fn media_widths(condition: &str) -> Vec<u16> {
287 let mut out = Vec::new();
288 let mut rest = condition;
289 while let Some(i) = rest.find("-width:") {
290 rest = &rest[i + "-width:".len()..];
291 let digits: String = rest
292 .trim_start()
293 .chars()
294 .take_while(char::is_ascii_digit)
295 .collect();
296 if let Ok(px) = digits.parse() {
297 out.push(px);
298 }
299 }
300 out
301 }
302
303 /// `(byte offset of the `@media`, the condition text before the `{`)`.
304 fn media_conditions(css: &str) -> Vec<(usize, &str)> {
305 let mut out = Vec::new();
306 let mut at = 0;
307 while let Some(i) = css[at..].find("@media") {
308 let start = at + i;
309 let after = start + "@media".len();
310 match css[after..].find('{') {
311 Some(j) => {
312 out.push((start, &css[after..after + j]));
313 at = after + j;
314 }
315 None => break,
316 }
317 }
318 out
319 }
320
321 /// `(byte offset, pixel value)` for every `(max-width: Npx)` in a JS source.
322 ///
323 /// The parentheses are the whole test, and they have to be: a media condition
324 /// is always parenthesized and a CSS declaration never is, so `'max-width:
325 /// 320px'` in an inline-style string is not a breakpoint and must not read as
326 /// one. goingson's shared-updater.js builds exactly that, and the first version
327 /// of this check failed the build on it.
328 fn js_widths(src: &str) -> Vec<(usize, u16)> {
329 let mut out = Vec::new();
330 for pat in ["(max-width:", "(min-width:"] {
331 let mut at = 0;
332 while let Some(i) = src[at..].find(pat) {
333 let start = at + i;
334 let rest = src[start + pat.len()..].trim_start();
335 let digits: String = rest.chars().take_while(char::is_ascii_digit).collect();
336 if let Ok(px) = digits.parse()
337 && rest[digits.len()..].starts_with("px)")
338 {
339 out.push((start, px));
340 }
341 at = start + pat.len();
342 }
343 }
344 out
345 }
346
347 /// Replace every `/* ... */` with spaces, so byte offsets still line up.
348 fn strip_block_comments(css: &str) -> String {
349 let bytes = css.as_bytes();
350 let mut out = String::with_capacity(css.len());
351 let mut i = 0;
352 while i < bytes.len() {
353 if bytes[i..].starts_with(b"/*") {
354 let end = css[i..].find("*/").map_or(bytes.len(), |j| i + j + 2);
355 for c in css[i..end].chars() {
356 out.push(if c == '\n' { '\n' } else { ' ' });
357 }
358 i = end;
359 } else {
360 let c = css[i..].chars().next().unwrap();
361 out.push(c);
362 i += c.len_utf8();
363 }
364 }
365 out
366 }
367
368 #[cfg(test)]
369 mod tests {
370 use super::*;
371
372 fn scratch(name: &str) -> PathBuf {
373 let dir =
374 std::env::temp_dir().join(format!("makeover-drift-{}-{name}", std::process::id()));
375 let _ = std::fs::remove_dir_all(&dir);
376 std::fs::create_dir_all(&dir).expect("create scratch");
377 dir
378 }
379
380 fn write(dir: &Path, name: &str, src: &str) {
381 if let Some(parent) = dir.join(name).parent() {
382 std::fs::create_dir_all(parent).unwrap();
383 }
384 std::fs::write(dir.join(name), src).unwrap();
385 }
386
387 fn declaring() -> String {
388 format!(
389 "const {CONST_NAME} = '{}';\n",
390 Density::Touch.media_condition()
391 )
392 }
393
394 #[test]
395 fn the_crates_own_string_passes() {
396 let dir = scratch("ok");
397 write(&dir, "touch.js", &declaring());
398 check_touch_density(&dir);
399 }
400
401 #[test]
402 #[should_panic(expected = "disagrees with makeover_geometry::Density")]
403 fn a_drifted_literal_fails() {
404 let dir = scratch("drift");
405 write(&dir, "touch.js", &declaring());
406 write(
407 &dir,
408 "haptics.js",
409 &format!("const {CONST_NAME} = '(pointer: coarse)';\n"),
410 );
411 check_touch_density(&dir);
412 }
413
414 #[test]
415 #[should_panic(expected = "device sniff")]
416 fn the_sniff_cannot_come_back() {
417 let dir = scratch("sniff");
418 write(&dir, "touch.js", &declaring());
419 write(&dir, "legacy.js", "if ('ontouchstart' in window) {}\n");
420 check_touch_density(&dir);
421 }
422
423 #[test]
424 #[should_panic(expected = "no TOUCH_DENSITY literal found")]
425 fn a_frontend_that_states_nothing_fails() {
426 let dir = scratch("empty");
427 write(&dir, "app.js", "export const x = 1;\n");
428 check_touch_density(&dir);
429 }
430
431 #[test]
432 fn a_use_site_is_not_a_declaration() {
433 // The const is read far more often than it is declared, and a read
434 // states no string. Counting one as a declaration would make the
435 // `found > 0` assertion pass on a frontend that only imports it.
436 let src =
437 format!("import {{ {CONST_NAME} }} from './touch.js';\nmatchMedia({CONST_NAME});\n");
438 assert!(touch_density_literals(&src).is_empty());
439 }
440
441 #[test]
442 fn nested_files_are_read() {
443 // The server keeps its scripts in subdirectories, and the nested half
444 // is the half most likely to be a copy.
445 let dir = scratch("nested");
446 write(&dir, "touch.js", &declaring());
447 write(&dir, "screens/legacy.js", "navigator.maxTouchPoints > 0;\n");
448 let files = js_files(&dir);
449 assert_eq!(files.len(), 2);
450 }
451
452 #[test]
453 fn a_non_js_file_is_ignored() {
454 let dir = scratch("nonjs");
455 write(&dir, "touch.js", &declaring());
456 write(&dir, "styles.css", "body { }\n");
457 assert_eq!(js_files(&dir).len(), 1);
458 }
459
460 fn frontend(name: &str) -> PathBuf {
461 let dir = scratch(name);
462 std::fs::create_dir_all(dir.join("css")).unwrap();
463 std::fs::create_dir_all(dir.join("js")).unwrap();
464 dir
465 }
466
467 /// A width every size class agrees is a boundary.
468 fn boundary() -> u16 {
469 SizeClass::Medium.min_px()
470 }
471
472 #[test]
473 fn the_crates_own_boundaries_pass() {
474 let dir = frontend("bp-ok");
475 write(
476 &dir,
477 "css/styles.css",
478 &format!("@media (min-width: {}px) {{ body {{ }} }}\n", boundary()),
479 );
480 check_breakpoints(&dir, &[]);
481 }
482
483 #[test]
484 #[should_panic(expected = "disagree with makeover_geometry::SizeClass")]
485 fn a_stale_css_width_fails() {
486 let dir = frontend("bp-css");
487 write(&dir, "css/styles.css", "@media (max-width: 768px) { }\n");
488 check_breakpoints(&dir, &[]);
489 }
490
491 #[test]
492 #[should_panic(expected = "disagree with makeover_geometry::SizeClass")]
493 fn a_stale_js_width_fails() {
494 let dir = frontend("bp-js");
495 write(&dir, "js/shell.js", "matchMedia('(max-width: 768px)');\n");
496 check_breakpoints(&dir, &[]);
497 }
498
499 #[test]
500 fn a_declared_tuning_width_passes() {
501 let dir = frontend("bp-tuning");
502 write(&dir, "css/styles.css", "@media (min-width: 1400px) { }\n");
503 check_breakpoints(&dir, &[1400]);
504 }
505
506 #[test]
507 fn a_width_in_a_comment_is_prose() {
508 // The note explaining which breakpoint used to be here is not a rule,
509 // and failing a build on documentation would teach people to delete it.
510 let dir = frontend("bp-comment");
511 write(
512 &dir,
513 "css/styles.css",
514 "/* was @media (max-width: 768px) until the size classes landed */\n",
515 );
516 check_breakpoints(&dir, &[]);
517 }
518
519 #[test]
520 fn an_unparenthesized_width_is_not_a_breakpoint() {
521 // A JS string building an inline style states `max-width: 320px` with
522 // no parentheses. It is a declaration, not a query, and the first
523 // version of this check failed the build on one.
524 let dir = frontend("bp-inline");
525 write(
526 &dir,
527 "js/style.js",
528 "el.style.cssText = 'max-width: 320px; display: block';\n",
529 );
530 check_breakpoints(&dir, &[]);
531 }
532
533 #[test]
534 fn nested_css_is_read() {
535 // Same argument as the touch check: the nested half is the half most
536 // likely to be a copy.
537 let dir = frontend("bp-nested");
538 write(
539 &dir,
540 "css/screens/detail.css",
541 "@media (max-width: 768px) { }\n",
542 );
543 let found = std::panic::catch_unwind(|| check_breakpoints(&dir, &[]));
544 assert!(found.is_err(), "a nested stylesheet must be scanned");
545 }
546
547 #[test]
548 fn the_error_names_the_file_and_line() {
549 let dir = frontend("bp-message");
550 write(
551 &dir,
552 "css/styles.css",
553 "body { }\n@media (max-width: 768px) { }\n",
554 );
555 let err = std::panic::catch_unwind(|| check_breakpoints(&dir, &[])).unwrap_err();
556 let msg = err
557 .downcast_ref::<String>()
558 .expect("panic payload is a String");
559 assert!(msg.contains("css/styles.css:2"), "got: {msg}");
560 }
561
562 #[test]
563 fn both_quote_styles_read() {
564 let want = Density::Touch.media_condition();
565 for q in ['\'', '"'] {
566 let src = format!("const {CONST_NAME} = {q}{want}{q};\n");
567 let found = touch_density_literals(&src);
568 assert_eq!(found.len(), 1);
569 assert_eq!(found[0].1, want);
570 }
571 }
572 }
573