| 13 |
13 |
|
|
| 14 |
14 |
|
use std::path::{Path, PathBuf};
|
| 15 |
15 |
|
|
| 16 |
|
- |
use makeover_geometry::Density;
|
|
16 |
+ |
use makeover_geometry::{Density, SizeClass};
|
| 17 |
17 |
|
|
| 18 |
18 |
|
/// The declaration this check reads. Shared vocabulary, not a parameter: two
|
| 19 |
19 |
|
/// apps and a server naming the same string want the same name for it.
|
| 109 |
109 |
|
}
|
| 110 |
110 |
|
|
| 111 |
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.
|
| 112 |
117 |
|
///
|
| 113 |
|
- |
/// Recursive because a consumer's scripts are not always one flat directory:
|
|
118 |
+ |
/// Recursive because a consumer's frontend is not always one flat directory:
|
| 114 |
119 |
|
/// the Tauri apps keep `js/*.js`, the server keeps subdirectories under
|
| 115 |
120 |
|
/// `static/`, and a check that silently skipped the nested half would report
|
| 116 |
121 |
|
/// clean on the files most likely to have been copied.
|
| 117 |
|
- |
fn js_files(dir: &Path) -> Vec<PathBuf> {
|
|
122 |
+ |
fn files_with_extension(dir: &Path, ext: &str) -> Vec<PathBuf> {
|
| 118 |
123 |
|
let mut out = Vec::new();
|
| 119 |
124 |
|
let mut stack = vec![dir.to_path_buf()];
|
| 120 |
125 |
|
while let Some(d) = stack.pop() {
|
| 125 |
130 |
|
let path = entry.path();
|
| 126 |
131 |
|
if path.is_dir() {
|
| 127 |
132 |
|
stack.push(path);
|
| 128 |
|
- |
} else if path.extension().is_some_and(|x| x == "js") {
|
|
133 |
+ |
} else if path.extension().is_some_and(|x| x == ext) {
|
| 129 |
134 |
|
out.push(path);
|
| 130 |
135 |
|
}
|
| 131 |
136 |
|
}
|
| 163 |
168 |
|
src[..offset].matches('\n').count() + 1
|
| 164 |
169 |
|
}
|
| 165 |
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 |
+ |
|
| 166 |
368 |
|
#[cfg(test)]
|
| 167 |
369 |
|
mod tests {
|
| 168 |
370 |
|
use super::*;
|
| 255 |
457 |
|
assert_eq!(js_files(&dir).len(), 1);
|
| 256 |
458 |
|
}
|
| 257 |
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 |
+ |
|
| 258 |
562 |
|
#[test]
|
| 259 |
563 |
|
fn both_quote_styles_read() {
|
| 260 |
564 |
|
let want = Density::Touch.media_condition();
|