| 14 |
14 |
|
use std::path::{Path, PathBuf};
|
| 15 |
15 |
|
|
| 16 |
16 |
|
use makeover_geometry::{Density, SizeClass};
|
|
17 |
+ |
use makeover_webview::Emit;
|
| 17 |
18 |
|
|
| 18 |
19 |
|
/// The declaration this check reads. Shared vocabulary, not a parameter: two
|
| 19 |
20 |
|
/// apps and a server naming the same string want the same name for it.
|
| 401 |
402 |
|
out
|
| 402 |
403 |
|
}
|
| 403 |
404 |
|
|
|
405 |
+ |
/// Fail the build if a hand-written stylesheet re-specifies a class the
|
|
406 |
+ |
/// generated one already defines.
|
|
407 |
+ |
///
|
|
408 |
+ |
/// The generated sheet sits in `@layer makeover`. Unlayered app CSS beats a
|
|
409 |
+ |
/// layer by construction, whatever the specificity, so an app rule naming a
|
|
410 |
+ |
/// generated class does not merge with it: it wins, silently, and the design
|
|
411 |
+ |
/// system's version of that component stops applying to the one app most likely
|
|
412 |
+ |
/// to be treated as the reference. Both sort-caret defects found on 2026-08-11
|
|
413 |
+ |
/// were this, and both were live for months because nothing looked.
|
|
414 |
+ |
///
|
|
415 |
+ |
/// `frontend` is the directory holding `css/`. `generated` names the sheets
|
|
416 |
+ |
/// this crate writes, relative to `frontend/css`, which are skipped: the
|
|
417 |
+ |
/// generated file naming a generated class is the point.
|
|
418 |
+ |
///
|
|
419 |
+ |
/// Emits `cargo:rerun-if-changed` for every file it read.
|
|
420 |
+ |
///
|
|
421 |
+ |
/// # Panics
|
|
422 |
+ |
///
|
|
423 |
+ |
/// If `frontend/css` cannot be read, or if any hand-written sheet defines a
|
|
424 |
+ |
/// rule for a generated class. A build script has nowhere useful to return an
|
|
425 |
+ |
/// error to, and an app quietly overriding its own design system is worse than
|
|
426 |
+ |
/// a failed build.
|
|
427 |
+ |
pub fn check_vocabulary(frontend: impl AsRef<Path>, opts: &Emit, generated: &[&str]) {
|
|
428 |
+ |
let frontend = frontend.as_ref();
|
|
429 |
+ |
let css = frontend.join("css");
|
|
430 |
+ |
let files: Vec<PathBuf> = files_with_extension(&css, "css")
|
|
431 |
+ |
.into_iter()
|
|
432 |
+ |
.filter(|p| {
|
|
433 |
+ |
let name = p.strip_prefix(&css).unwrap_or(p).display().to_string();
|
|
434 |
+ |
!generated.contains(&name.as_str())
|
|
435 |
+ |
})
|
|
436 |
+ |
.collect();
|
|
437 |
+ |
check_vocabulary_paths(&files, opts, Some(frontend));
|
|
438 |
+ |
}
|
|
439 |
+ |
|
|
440 |
+ |
/// [`check_vocabulary`] against a named list of files rather than a tree.
|
|
441 |
+ |
///
|
|
442 |
+ |
/// For a frontend whose generated and hand-written sheets share a directory, so
|
|
443 |
+ |
/// a directory scan has nothing to point at. Same trade as
|
|
444 |
+ |
/// [`check_breakpoints_files`]: the list is hand-maintained, and a stylesheet
|
|
445 |
+ |
/// nobody adds to it is unchecked rather than failing.
|
|
446 |
+ |
///
|
|
447 |
+ |
/// # Panics
|
|
448 |
+ |
///
|
|
449 |
+ |
/// If a listed file cannot be read, or if any of them re-specifies a generated
|
|
450 |
+ |
/// class.
|
|
451 |
+ |
pub fn check_vocabulary_files<P: AsRef<Path>>(paths: &[P], opts: &Emit) {
|
|
452 |
+ |
let paths: Vec<PathBuf> = paths.iter().map(|p| p.as_ref().to_path_buf()).collect();
|
|
453 |
+ |
check_vocabulary_paths(&paths, opts, None);
|
|
454 |
+ |
}
|
|
455 |
+ |
|
|
456 |
+ |
/// The check itself. `root`, when given, is stripped from reported paths.
|
|
457 |
+ |
fn check_vocabulary_paths(paths: &[PathBuf], opts: &Emit, root: Option<&Path>) {
|
|
458 |
+ |
let generated = makeover_webview::vocabulary::vocabulary(opts);
|
|
459 |
+ |
let mut clashes: Vec<String> = Vec::new();
|
|
460 |
+ |
|
|
461 |
+ |
for path in paths {
|
|
462 |
+ |
println!("cargo::rerun-if-changed={}", path.display());
|
|
463 |
+ |
let raw = std::fs::read_to_string(path)
|
|
464 |
+ |
.unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
|
|
465 |
+ |
let name = match root {
|
|
466 |
+ |
Some(root) => display_name(root, path),
|
|
467 |
+ |
None => path.display().to_string(),
|
|
468 |
+ |
};
|
|
469 |
+ |
// Read the app's sheet the same way the crate reads its own, or the two
|
|
470 |
+ |
// sides are not comparable.
|
|
471 |
+ |
let local = makeover_webview::vocabulary::classes_in_css(&raw);
|
|
472 |
+ |
for class in local.intersection(&generated) {
|
|
473 |
+ |
clashes.push(format!(" {name} .{class}"));
|
|
474 |
+ |
}
|
|
475 |
+ |
}
|
|
476 |
+ |
|
|
477 |
+ |
assert!(
|
|
478 |
+ |
clashes.is_empty(),
|
|
479 |
+ |
"{} hand-written rule(s) re-specify a class the generated stylesheet already \
|
|
480 |
+ |
defines. App CSS is unlayered and beats @layer makeover, so each of these wins \
|
|
481 |
+ |
over the design system silently:\n{}\n\nDelete the local rule, or, if it adds \
|
|
482 |
+ |
something makeover does not answer for, move the addition onto a class of the \
|
|
483 |
+ |
app's own. Count the consumers before deciding it is a divergence worth keeping.",
|
|
484 |
+ |
clashes.len(),
|
|
485 |
+ |
clashes.join("\n")
|
|
486 |
+ |
);
|
|
487 |
+ |
}
|
|
488 |
+ |
|
|
489 |
+ |
/// Warn when the generated vocabulary has grown dead, and fail when it grows
|
|
490 |
+ |
/// deader than the recorded high-water mark.
|
|
491 |
+ |
///
|
|
492 |
+ |
/// A generated class no markup emits is a rule shipped to every user for
|
|
493 |
+ |
/// nothing, and the proportion was large when it was first measured: 42% of the
|
|
494 |
+ |
/// vocabulary unused in goingson, 67% in the MNW server, 84% in Balanced
|
|
495 |
+ |
/// Breakfast. Those are not failures on their own or no app would build. What
|
|
496 |
+ |
/// this converts is the direction: dead vocabulary becoming a number in a build
|
|
497 |
+ |
/// script means a change that worsens it stops being something somebody
|
|
498 |
+ |
/// notices.
|
|
499 |
+ |
///
|
|
500 |
+ |
/// One-sided, the same shape as the MNW server's `frontend_globals` seal:
|
|
501 |
+ |
/// exceeding `high_water` fails, coming in under it warns and asks for the seal
|
|
502 |
+ |
/// to be lowered. A build that fails because dead CSS was deleted would teach
|
|
503 |
+ |
/// the wrong lesson.
|
|
504 |
+ |
///
|
|
505 |
+ |
/// `markup` is every file that can carry a class: templates, `.js`, `.html`,
|
|
506 |
+ |
/// and any Rust that writes markup. A class is counted as used if its name
|
|
507 |
+ |
/// appears in any of them, which is deliberately generous. A stricter reading
|
|
508 |
+ |
/// would need to know how each app builds its class strings, and a check that
|
|
509 |
+ |
/// guesses wrong fails a correct build.
|
|
510 |
+ |
///
|
|
511 |
+ |
/// # Panics
|
|
512 |
+ |
///
|
|
513 |
+ |
/// If a listed file cannot be read, or if more classes are unused than
|
|
514 |
+ |
/// `high_water`.
|
|
515 |
+ |
pub fn check_vocabulary_use<P: AsRef<Path>>(markup: &[P], opts: &Emit, high_water: usize) {
|
|
516 |
+ |
let generated = makeover_webview::vocabulary::names(opts);
|
|
517 |
+ |
let mut haystack = String::new();
|
|
518 |
+ |
for path in markup {
|
|
519 |
+ |
let path = path.as_ref();
|
|
520 |
+ |
println!("cargo::rerun-if-changed={}", path.display());
|
|
521 |
+ |
haystack.push_str(
|
|
522 |
+ |
&std::fs::read_to_string(path)
|
|
523 |
+ |
.unwrap_or_else(|e| panic!("read {}: {e}", path.display())),
|
|
524 |
+ |
);
|
|
525 |
+ |
haystack.push('\n');
|
|
526 |
+ |
}
|
|
527 |
+ |
|
|
528 |
+ |
let unused: Vec<&String> = generated
|
|
529 |
+ |
.iter()
|
|
530 |
+ |
.filter(|class| !haystack.contains(class.as_str()))
|
|
531 |
+ |
.collect();
|
|
532 |
+ |
|
|
533 |
+ |
assert!(
|
|
534 |
+ |
unused.len() <= high_water,
|
|
535 |
+ |
"{} of {} generated classes are emitted by no markup, above the recorded {}. \
|
|
536 |
+ |
The vocabulary grew or the markup stopped using it:\n{}",
|
|
537 |
+ |
unused.len(),
|
|
538 |
+ |
generated.len(),
|
|
539 |
+ |
high_water,
|
|
540 |
+ |
unused
|
|
541 |
+ |
.iter()
|
|
542 |
+ |
.map(|c| format!(" .{c}"))
|
|
543 |
+ |
.collect::<Vec<_>>()
|
|
544 |
+ |
.join("\n")
|
|
545 |
+ |
);
|
|
546 |
+ |
|
|
547 |
+ |
if unused.len() < high_water {
|
|
548 |
+ |
println!(
|
|
549 |
+ |
"cargo::warning=dead makeover vocabulary is down to {} from a sealed {}; \
|
|
550 |
+ |
lower the seal so it cannot grow back",
|
|
551 |
+ |
unused.len(),
|
|
552 |
+ |
high_water
|
|
553 |
+ |
);
|
|
554 |
+ |
}
|
|
555 |
+ |
}
|
|
556 |
+ |
|
| 404 |
557 |
|
#[cfg(test)]
|
| 405 |
558 |
|
mod tests {
|
| 406 |
559 |
|
use super::*;
|
| 628 |
781 |
|
assert!(msg.contains("css/styles.css:2"), "got: {msg}");
|
| 629 |
782 |
|
}
|
| 630 |
783 |
|
|
|
784 |
+ |
#[test]
|
|
785 |
+ |
fn a_rule_restating_a_generated_class_fails_and_names_it() {
|
|
786 |
+ |
let dir = scratch("vocab-clash");
|
|
787 |
+ |
// `.card` is makeover's. An app rule for it beats the generated one,
|
|
788 |
+ |
// because app CSS is unlayered and the generated sheet is not.
|
|
789 |
+ |
write(
|
|
790 |
+ |
&dir,
|
|
791 |
+ |
"css/styles.css",
|
|
792 |
+ |
"body { color: red; }\n.card { box-shadow: none; }\n",
|
|
793 |
+ |
);
|
|
794 |
+ |
let err =
|
|
795 |
+ |
std::panic::catch_unwind(|| check_vocabulary(&dir, &Emit::default(), &[])).unwrap_err();
|
|
796 |
+ |
let msg = err
|
|
797 |
+ |
.downcast_ref::<String>()
|
|
798 |
+ |
.expect("panic payload is a String");
|
|
799 |
+ |
assert!(msg.contains(".card"), "got: {msg}");
|
|
800 |
+ |
assert!(msg.contains("css/styles.css"), "got: {msg}");
|
|
801 |
+ |
}
|
|
802 |
+ |
|
|
803 |
+ |
#[test]
|
|
804 |
+ |
fn an_app_class_of_its_own_is_left_alone() {
|
|
805 |
+ |
let dir = scratch("vocab-clean");
|
|
806 |
+ |
write(
|
|
807 |
+ |
&dir,
|
|
808 |
+ |
"css/styles.css",
|
|
809 |
+ |
".task-list-container { overflow: auto; }\n.day-plan-slot { height: 1rem; }\n",
|
|
810 |
+ |
);
|
|
811 |
+ |
check_vocabulary(&dir, &Emit::default(), &[]);
|
|
812 |
+ |
}
|
|
813 |
+ |
|
|
814 |
+ |
#[test]
|
|
815 |
+ |
fn the_generated_sheet_is_skipped_rather_than_reported_against_itself() {
|
|
816 |
+ |
let dir = scratch("vocab-generated");
|
|
817 |
+ |
let opts = Emit::default();
|
|
818 |
+ |
write(&dir, "css/layout.css", &makeover_webview::stylesheet(&opts));
|
|
819 |
+ |
// Without the skip this is the loudest failure possible: every class in
|
|
820 |
+ |
// the vocabulary, reported as a clash with the vocabulary.
|
|
821 |
+ |
check_vocabulary(&dir, &opts, &["layout.css"]);
|
|
822 |
+ |
}
|
|
823 |
+ |
|
|
824 |
+ |
#[test]
|
|
825 |
+ |
fn a_prefixed_app_is_checked_against_its_own_prefix() {
|
|
826 |
+ |
let dir = scratch("vocab-prefix");
|
|
827 |
+ |
let opts = Emit {
|
|
828 |
+ |
class_prefix: "mo-",
|
|
829 |
+ |
..Emit::default()
|
|
830 |
+ |
};
|
|
831 |
+ |
// Bare `.card` is the app's own class once the generated sheet writes
|
|
832 |
+ |
// `.mo-card`, so this has to pass.
|
|
833 |
+ |
write(&dir, "css/styles.css", ".card { box-shadow: none; }\n");
|
|
834 |
+ |
check_vocabulary(&dir, &opts, &[]);
|
|
835 |
+ |
|
|
836 |
+ |
let dir = scratch("vocab-prefix-clash");
|
|
837 |
+ |
write(&dir, "css/styles.css", ".mo-card { box-shadow: none; }\n");
|
|
838 |
+ |
assert!(std::panic::catch_unwind(|| check_vocabulary(&dir, &opts, &[])).is_err());
|
|
839 |
+ |
}
|
|
840 |
+ |
|
|
841 |
+ |
#[test]
|
|
842 |
+ |
fn dead_vocabulary_above_the_seal_fails_and_below_it_passes() {
|
|
843 |
+ |
let dir = scratch("vocab-seal");
|
|
844 |
+ |
let opts = Emit::default();
|
|
845 |
+ |
let all = makeover_webview::vocabulary::names(&opts).len();
|
|
846 |
+ |
// Markup naming nothing: every class is unused.
|
|
847 |
+ |
write(&dir, "index.html", "<div></div>\n");
|
|
848 |
+ |
let markup = [dir.join("index.html")];
|
|
849 |
+ |
|
|
850 |
+ |
check_vocabulary_use(&markup, &opts, all);
|
|
851 |
+ |
assert!(
|
|
852 |
+ |
std::panic::catch_unwind(|| check_vocabulary_use(&markup, &opts, all - 1)).is_err(),
|
|
853 |
+ |
"a vocabulary deader than the seal has to fail"
|
|
854 |
+ |
);
|
|
855 |
+ |
}
|
|
856 |
+ |
|
| 631 |
857 |
|
#[test]
|
| 632 |
858 |
|
fn both_quote_styles_read() {
|
| 633 |
859 |
|
let want = Density::Touch.media_condition();
|