//! Checks that a hand-written frontend still agrees with the crate that //! generates its siblings. //! //! The generated files cannot drift: they ask makeover-geometry for the answer. //! The hand-written ones state it, and a stylesheet or a script that disagrees //! with the crate is not an error at any point -- it is a rule that quietly //! stops matching where it used to. Cheaper to read a panic naming the line. //! //! Deliberately assertions and not substitutions. A JS or CSS file that has to //! be generated to be correct stops being readable on its own, and it is worth //! something that you can still open the frontend in a browser and have it //! work. use std::path::{Path, PathBuf}; use makeover_geometry::Density; /// The declaration this check reads. Shared vocabulary, not a parameter: two /// apps and a server naming the same string want the same name for it. const CONST_NAME: &str = "TOUCH_DENSITY"; /// The capability sniffs the media query replaced, so neither can come back by /// copy-paste. /// /// Both ask the hardware what it has rather than what is pointing at the /// screen, so both say yes to a touchscreen laptop driving a mouse. const SNIFFS: &[&str] = &["ontouchstart", "maxTouchPoints"]; /// Fail the build if a JS copy of the touch-density query has drifted from /// [`Density::Touch`]. /// /// Every `.js` file under `js_dir`, recursively, must state the crate's own /// media condition in a `const TOUCH_DENSITY = '...'`, at least one file must /// declare it, and no file may name a capability sniff. /// /// The string is the crate's and no app gets a say in it, which is why this /// check takes no policy argument. The generated `geometry.css` already keys /// its touch gap overrides on the same condition, so the gestures and the /// spacing agree by construction rather than by two people remembering. /// /// Emits `cargo:rerun-if-changed` for every file it read. /// /// # Panics /// /// If `js_dir` cannot be read, if no declaration is found, or if any file /// disagrees with the crate. A build script has nowhere useful to return an /// error to, and a frontend that disagrees with its own stylesheet is worse /// than a failed build. pub fn check_touch_density(js_dir: impl AsRef) { let js_dir = js_dir.as_ref(); let want = Density::Touch.media_condition(); let mut wrong: Vec = Vec::new(); let mut found = 0usize; let files = js_files(js_dir); for path in &files { let src = std::fs::read_to_string(path).expect("read js file"); let name = path .strip_prefix(js_dir) .unwrap_or(path) .display() .to_string(); for (offset, literal) in touch_density_literals(&src) { found += 1; if literal != want { wrong.push(format!( " {name}:{} {CONST_NAME} = '{literal}'", line_of(&src, offset) )); } } for needle in SNIFFS { if let Some(offset) = src.find(needle) { wrong.push(format!( " {name}:{} {needle} -- device sniff, not a density question", line_of(&src, offset) )); } } } assert!( found > 0, "no {CONST_NAME} literal found under {}.\n\n\ A frontend that asks whether it is being touched states\n\ makeover_geometry::Density::Touch's media condition in a const of that\n\ name, and this check exists to keep every copy equal to it. If the\n\ const was renamed, rename it back rather than dropping the check; if\n\ this frontend genuinely asks no density question, drop the call.", js_dir.display() ); assert!( wrong.is_empty(), "hand-written touch detection disagrees with makeover_geometry::Density.\n\n\ Density::Touch.media_condition() is: {want}\n\n\ Wrong:\n{}\n\n\ Fix the JS to state the crate's string. Never widen it to catch a\n\ device the query misses: density is what is pointing at the screen,\n\ and a laptop with a touchscreen and a mouse is a pointer device.", wrong.join("\n") ); for path in &files { println!("cargo:rerun-if-changed={}", path.display()); } } /// Every `.js` file under `dir`, recursively, sorted. /// /// Recursive because a consumer's scripts are not always one flat directory: /// the Tauri apps keep `js/*.js`, the server keeps subdirectories under /// `static/`, and a check that silently skipped the nested half would report /// clean on the files most likely to have been copied. fn js_files(dir: &Path) -> Vec { let mut out = Vec::new(); let mut stack = vec![dir.to_path_buf()]; while let Some(d) = stack.pop() { for entry in std::fs::read_dir(&d) .unwrap_or_else(|e| panic!("read {}: {e}", d.display())) .flatten() { let path = entry.path(); if path.is_dir() { stack.push(path); } else if path.extension().is_some_and(|x| x == "js") { out.push(path); } } } out.sort(); out } /// `(byte offset of the declaration, the literal's contents)` for every /// `const TOUCH_DENSITY = '...'` in a JS source. fn touch_density_literals(src: &str) -> Vec<(usize, &str)> { let mut out = Vec::new(); let mut at = 0; while let Some(i) = src[at..].find(CONST_NAME) { let start = at + i; at = start + CONST_NAME.len(); // Only the declaration states the string; a use site reads the const. let Some(rest) = src[at..].strip_prefix(" = ") else { continue; }; let open = at + " = ".len(); let Some(quote @ ('\'' | '"')) = rest.chars().next() else { continue; }; let body = open + 1; if let Some(j) = src[body..].find(quote) { out.push((start, &src[body..body + j])); at = body + j + 1; } } out } fn line_of(src: &str, offset: usize) -> usize { src[..offset].matches('\n').count() + 1 } #[cfg(test)] mod tests { use super::*; fn scratch(name: &str) -> PathBuf { let dir = std::env::temp_dir().join(format!("makeover-drift-{}-{name}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).expect("create scratch"); dir } fn write(dir: &Path, name: &str, src: &str) { if let Some(parent) = dir.join(name).parent() { std::fs::create_dir_all(parent).unwrap(); } std::fs::write(dir.join(name), src).unwrap(); } fn declaring() -> String { format!( "const {CONST_NAME} = '{}';\n", Density::Touch.media_condition() ) } #[test] fn the_crates_own_string_passes() { let dir = scratch("ok"); write(&dir, "touch.js", &declaring()); check_touch_density(&dir); } #[test] #[should_panic(expected = "disagrees with makeover_geometry::Density")] fn a_drifted_literal_fails() { let dir = scratch("drift"); write(&dir, "touch.js", &declaring()); write( &dir, "haptics.js", &format!("const {CONST_NAME} = '(pointer: coarse)';\n"), ); check_touch_density(&dir); } #[test] #[should_panic(expected = "device sniff")] fn the_sniff_cannot_come_back() { let dir = scratch("sniff"); write(&dir, "touch.js", &declaring()); write(&dir, "legacy.js", "if ('ontouchstart' in window) {}\n"); check_touch_density(&dir); } #[test] #[should_panic(expected = "no TOUCH_DENSITY literal found")] fn a_frontend_that_states_nothing_fails() { let dir = scratch("empty"); write(&dir, "app.js", "export const x = 1;\n"); check_touch_density(&dir); } #[test] fn a_use_site_is_not_a_declaration() { // The const is read far more often than it is declared, and a read // states no string. Counting one as a declaration would make the // `found > 0` assertion pass on a frontend that only imports it. let src = format!("import {{ {CONST_NAME} }} from './touch.js';\nmatchMedia({CONST_NAME});\n"); assert!(touch_density_literals(&src).is_empty()); } #[test] fn nested_files_are_read() { // The server keeps its scripts in subdirectories, and the nested half // is the half most likely to be a copy. let dir = scratch("nested"); write(&dir, "touch.js", &declaring()); write(&dir, "screens/legacy.js", "navigator.maxTouchPoints > 0;\n"); let files = js_files(&dir); assert_eq!(files.len(), 2); } #[test] fn a_non_js_file_is_ignored() { let dir = scratch("nonjs"); write(&dir, "touch.js", &declaring()); write(&dir, "styles.css", "body { }\n"); assert_eq!(js_files(&dir).len(), 1); } #[test] fn both_quote_styles_read() { let want = Density::Touch.media_condition(); for q in ['\'', '"'] { let src = format!("const {CONST_NAME} = {q}{want}{q};\n"); let found = touch_density_literals(&src); assert_eq!(found.len(), 1); assert_eq!(found[0].1, want); } } }