#![allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing,
clippy::needless_pass_by_value,
clippy::missing_errors_doc,
clippy::missing_panics_doc,
clippy::module_name_repetitions,
clippy::redundant_closure,
clippy::redundant_closure_for_method_calls,
clippy::needless_raw_string_hashes,
clippy::single_char_pattern,
clippy::format_in_format_args,
clippy::needless_late_init,
clippy::if_then_some_else_none,
clippy::must_use_candidate
)]
use std::{
fs,
path::{Path, PathBuf},
process::{Command, Stdio},
sync::{Mutex, OnceLock},
time::Duration,
};
fn example_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
fn workspace_root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).to_path_buf()
}
fn run_example(name: &str, timeout: Duration) {
let root = workspace_root();
let mut child = Command::new("cargo")
.current_dir(&root)
.args(["run", "--quiet", "--example", name])
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.unwrap_or_else(|e| panic!("failed to spawn cargo for {name}: {e}"));
let start = std::time::Instant::now();
loop {
match child.try_wait() {
Ok(Some(_status)) => break,
Ok(None) => {
if start.elapsed() >= timeout {
let _ = child.kill();
let _ = child.wait();
break;
}
std::thread::sleep(Duration::from_millis(150));
}
Err(e) => panic!("error waiting on {name}: {e}"),
}
}
}
fn read_html(path: &Path) -> String {
fs::read_to_string(path)
.unwrap_or_else(|e| panic!("cannot read {}: {e}", path.display()))
}
fn html_files(dir: &Path) -> Vec<PathBuf> {
fn walk(dir: &Path, out: &mut Vec<PathBuf>) {
let Ok(entries) = fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let p = entry.path();
if p.is_dir() {
walk(&p, out);
} else if p.extension().is_some_and(|e| e == "html") {
out.push(p);
}
}
}
let mut out = Vec::new();
walk(dir, &mut out);
out
}
fn validate_no_empty_preload(html: &str, file: &Path) {
for tag in find_tags(html, "link") {
let lower = tag.to_ascii_lowercase();
let is_preload = lower.contains("rel=\"preload\"")
|| lower.contains("rel='preload'")
|| lower.contains("rel=preload");
if !is_preload {
continue;
}
let has_real_href = lower.contains("href=\"")
&& !lower.contains("href=\"\"")
|| lower.contains("href='") && !lower.contains("href=''")
|| lower.contains("href=")
&& lower.split("href=").nth(1).is_some_and(|after| {
let trimmed = after.trim_start();
let next = trimmed.chars().next().unwrap_or('>');
next != '>' && next != ' ' && next != '"' && next != '\''
});
assert!(
has_real_href,
"{}: <link rel=preload> with empty/missing href: {tag}",
file.display()
);
}
}
fn validate_modern_pwa_meta(html: &str, file: &Path) {
let has_apple = html.contains("apple-mobile-web-app-capable");
if !has_apple {
return;
}
let has_modern = html.contains("name=\"mobile-web-app-capable\"")
|| html.contains("name='mobile-web-app-capable'")
|| html.contains("name=mobile-web-app-capable");
assert!(
has_modern,
"{}: emits deprecated apple-mobile-web-app-capable without modern \
mobile-web-app-capable companion",
file.display()
);
}
fn validate_mobile_menu_hidden_on_desktop(html: &str, file: &Path) {
let Some(style_start) = html.find("<style") else {
return;
};
let after_open = &html[style_start..];
let Some(open_end) = after_open.find('>') else {
return;
};
let body = &after_open[open_end + 1..];
let Some(close_idx) = body.find("</style>") else {
return;
};
let css = &body[..close_idx];
let mut base = String::new();
let bytes = css.as_bytes();
let mut i = 0;
while i < bytes.len() {
if css[i..].starts_with("@media") {
let mut depth = 0_i32;
while i < bytes.len() {
match bytes[i] {
b'{' => depth += 1,
b'}' => {
depth -= 1;
if depth == 0 {
i += 1;
break;
}
}
_ => {}
}
i += 1;
}
} else {
base.push(bytes[i] as char);
i += 1;
}
}
assert!(
base.contains(".mobile-menu{display:none}")
|| base.contains(".mobile-menu { display: none }")
|| base.contains(".mobile-menu{display: none}"),
"{}: base CSS missing `.mobile-menu{{display:none}}` — mobile \
menu will render below the fixed nav on desktop",
file.display()
);
}
fn validate_img_alt(html: &str, file: &Path) {
for tag in find_tags(html, "img") {
let lower = tag.to_ascii_lowercase();
let has_alt = lower.contains("alt=\"")
|| lower.contains("alt='")
|| lower.contains(" alt ")
|| lower.contains(" alt>")
|| lower.ends_with(" alt");
let has_empty_alt =
lower.contains("alt=\"\"") || lower.contains("alt=''");
let is_decorative = lower.contains("role=\"presentation\"")
|| lower.contains("role='presentation'")
|| lower.contains("role=presentation")
|| lower.contains("role=\"none\"");
if !has_alt {
panic!("{}: <img> missing alt attribute: {tag}", file.display());
}
if has_empty_alt && !is_decorative {
panic!(
"{}: <img> has empty alt without role=presentation: {tag}",
file.display()
);
}
}
}
fn validate_html_lang(html: &str, file: &Path) {
let lower = html.to_ascii_lowercase();
let Some(start) = lower.find("<html") else {
return;
};
let Some(end) = lower[start..].find('>') else {
return;
};
let tag = &lower[start..start + end + 1];
assert!(
tag.contains("lang="),
"{}: <html> missing lang attribute",
file.display()
);
}
fn validate_title(html: &str, file: &Path) {
let lower = html.to_ascii_lowercase();
let Some(start) = lower.find("<title>") else {
panic!("{}: missing <title>", file.display());
};
let Some(end) = lower[start..].find("</title>") else {
panic!("{}: unclosed <title>", file.display());
};
let inner = html[start + 7..start + end].trim();
assert!(!inner.is_empty(), "{}: <title> is empty", file.display());
}
fn validate_manifest(path: &Path) {
let raw = fs::read_to_string(path)
.unwrap_or_else(|e| panic!("cannot read {}: {e}", path.display()));
let v: serde_json::Value = serde_json::from_str(&raw)
.unwrap_or_else(|e| panic!("{}: invalid JSON: {e}", path.display()));
if let Some(icons) = v.get("icons").and_then(|i| i.as_array()) {
for (idx, icon) in icons.iter().enumerate() {
let src = icon.get("src").and_then(|s| s.as_str()).unwrap_or("");
assert!(
!src.is_empty(),
"{}: icons[{idx}] has empty src",
path.display()
);
}
}
}
fn validate_search_index(path: &Path) {
let raw = fs::read_to_string(path)
.unwrap_or_else(|e| panic!("cannot read {}: {e}", path.display()));
let v: serde_json::Value = serde_json::from_str(&raw)
.unwrap_or_else(|e| panic!("{}: invalid JSON: {e}", path.display()));
let entries = v.get("entries").and_then(|e| e.as_array());
assert!(
entries.is_some_and(|e| !e.is_empty()),
"{}: no entries[] in search index",
path.display()
);
}
fn validate_html_page(file: &Path) {
let html = read_html(file);
validate_title(&html, file);
validate_html_lang(&html, file);
validate_no_empty_preload(&html, file);
validate_modern_pwa_meta(&html, file);
validate_mobile_menu_hidden_on_desktop(&html, file);
validate_img_alt(&html, file);
}
fn validate_all_html(public_dir: &Path) {
let files = html_files(public_dir);
assert!(
!files.is_empty(),
"{}: no HTML output produced",
public_dir.display()
);
for f in &files {
validate_html_page(f);
}
}
fn find_tags(html: &str, tag_name: &str) -> Vec<String> {
let mut out = Vec::new();
let needle_lower = format!("<{}", tag_name.to_ascii_lowercase());
let lower = html.to_ascii_lowercase();
let bytes = html.as_bytes();
let mut cursor = 0;
while let Some(pos) = lower[cursor..].find(&needle_lower) {
let abs = cursor + pos;
let mut j = abs;
let mut quote: Option<u8> = None;
while j < bytes.len() {
let b = bytes[j];
match quote {
Some(q) if b == q => quote = None,
Some(_) => {}
None => match b {
b'"' | b'\'' => quote = Some(b),
b'>' => break,
_ => {}
},
}
j += 1;
}
let end = (j + 1).min(html.len());
out.push(html[abs..end].to_string());
cursor = end;
}
out
}
fn test_example(
name: &str,
public_dir: &str,
must_have: &[&str],
timeout_secs: u64,
) {
let _guard = example_lock().lock().unwrap_or_else(|p| p.into_inner());
let root = workspace_root();
let public = root.join(public_dir);
let _ = fs::remove_dir_all(&public);
run_example(name, Duration::from_secs(timeout_secs));
assert!(
public.exists(),
"{name}: public dir {} not created",
public.display()
);
for rel in must_have {
let path = public.join(rel);
assert!(
path.exists(),
"{name}: missing required artifact {}",
path.display()
);
}
validate_all_html(&public);
let manifest = public.join("manifest.json");
if manifest.exists() {
validate_manifest(&manifest);
}
let search = public.join("search-index.json");
if search.exists() {
validate_search_index(&search);
}
}
#[test]
fn basic_example_clean_output() {
test_example(
"basic",
"examples/basic/public",
&["index.html", "manifest.json", "search-index.json"],
30,
);
}
#[test]
fn blog_example_clean_output() {
test_example(
"blog",
"examples/blog/public",
&[
"index.html",
"tags/index.html",
"posts/index.html",
"accessible-typography/index.html",
"eaa-checklist/index.html",
"wcag-2-1-vs-2-2/index.html",
"rss.xml",
"atom.xml",
"manifest.json",
"search-index.json",
"accessibility-report.json",
],
45,
);
}
#[test]
fn docs_example_clean_output() {
test_example(
"docs",
"examples/docs/public",
&[
"index.html",
"getting-started/index.html",
"configuration/index.html",
"plugin-api/index.html",
"rss.xml",
"atom.xml",
"manifest.json",
"search-index.json",
],
45,
);
}
#[test]
fn landing_example_clean_output() {
test_example(
"landing",
"examples/landing/public",
&[
"index.html",
"manifest.json",
"search-index.json",
"accessibility-report.json",
],
45,
);
}
#[test]
fn portfolio_example_clean_output() {
test_example(
"portfolio",
"examples/portfolio/public",
&[
"index.html",
"field-notes-collective/index.html",
"linden-editions/index.html",
"polaris-maps/index.html",
"atom.xml",
"manifest.json",
"search-index.json",
],
45,
);
}
#[test]
fn quickstart_example_clean_output() {
test_example(
"quickstart",
"examples/quickstart/public",
&[
"index.html",
"why-we-roast-tuesdays/index.html",
"grinder-buying-guide/index.html",
"sidamo-guji-story/index.html",
"rss.xml",
"atom.xml",
"manifest.json",
"search-index.json",
"accessibility-report.json",
],
45,
);
}
#[test]
fn plugins_example_clean_output() {
test_example(
"plugins",
"examples/plugins/public",
&[
"index.html",
"manifest.json",
"search-index.json",
"robots.txt",
],
30,
);
}
#[test]
fn multilingual_example_per_locale_artifacts() {
let _guard = example_lock().lock().unwrap_or_else(|p| p.into_inner());
let root = workspace_root();
let public = root.join("examples/public");
let _ = fs::remove_dir_all(&public);
run_example("multilingual", Duration::from_secs(120));
assert!(public.exists(), "multilingual public dir not created");
assert!(
public.join("index.html").exists(),
"missing root /index.html (EN promoted)"
);
let locales = [
"en", "fr", "ar", "bn", "cs", "de", "es", "ha", "he", "hi", "id", "it",
"ja", "ko", "nl", "pl", "pt", "ro", "ru", "sv", "th", "tl", "tr", "uk",
"vi", "yo", "zh", "zh-tw",
];
for loc in locales {
let idx = public.join(loc).join("index.html");
assert!(idx.exists(), "missing /{loc}/index.html");
let html = read_html(&idx);
let lower = html.to_ascii_lowercase();
let html_open = &lower[lower.find("<html").unwrap()..];
let html_open = &html_open[..html_open.find('>').unwrap()];
assert!(
html_open.contains("lang="),
"/{loc}/index.html: <html> missing lang attribute"
);
}
let root_html = read_html(&public.join("index.html"));
assert!(
root_html.to_ascii_lowercase().contains("hreflang"),
"root /index.html missing hreflang declarations"
);
}
#[test]
fn validator_rejects_empty_preload() {
let html = r#"<head><link as=image fetchpriority=high href rel=preload type=image/webp></head>"#;
let file = Path::new("test://synthetic");
let result = std::panic::catch_unwind(|| {
validate_no_empty_preload(html, file);
});
assert!(
result.is_err(),
"should have panicked on empty preload href"
);
}
#[test]
fn validator_accepts_valid_preload() {
let html =
r#"<head><link rel="preload" href="/banner.webp" as="image"></head>"#;
let file = Path::new("test://synthetic");
validate_no_empty_preload(html, file); }
#[test]
fn validator_rejects_apple_meta_without_modern() {
let html = r#"<head><meta name="apple-mobile-web-app-capable" content="yes"></head>"#;
let file = Path::new("test://synthetic");
let result = std::panic::catch_unwind(|| {
validate_modern_pwa_meta(html, file);
});
assert!(result.is_err());
}
#[test]
fn validator_accepts_apple_with_modern_meta() {
let html = r#"<head>
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
</head>"#;
let file = Path::new("test://synthetic");
validate_modern_pwa_meta(html, file);
}
#[test]
fn validator_rejects_missing_mobile_menu_base_rule() {
let html = r#"<head><style>@media(max-width:768px){.mobile-menu{display:none}}</style></head>"#;
let file = Path::new("test://synthetic");
let result = std::panic::catch_unwind(|| {
validate_mobile_menu_hidden_on_desktop(html, file);
});
assert!(
result.is_err(),
"should have panicked: rule only inside @media"
);
}
#[test]
fn validator_accepts_mobile_menu_with_base_rule() {
let html = r#"<head><style>.mobile-menu{display:none}@media(max-width:768px){.mobile-menu{display:none;position:fixed}}</style></head>"#;
let file = Path::new("test://synthetic");
validate_mobile_menu_hidden_on_desktop(html, file);
}
#[test]
fn validator_rejects_img_without_alt() {
let html = r#"<body><main><img src="photo.jpg"></main></body>"#;
let file = Path::new("test://synthetic");
let result = std::panic::catch_unwind(|| {
validate_img_alt(html, file);
});
assert!(result.is_err());
}
#[test]
fn validator_accepts_img_with_alt() {
let html =
r#"<body><main><img src="photo.jpg" alt="A photograph"></main></body>"#;
let file = Path::new("test://synthetic");
validate_img_alt(html, file);
}
#[test]
fn validator_accepts_decorative_img_without_alt_text() {
let html = r#"<body><main><img src="logo.svg" alt="" role="presentation"></main></body>"#;
let file = Path::new("test://synthetic");
validate_img_alt(html, file);
}
#[test]
fn validator_rejects_manifest_with_empty_icon_src() {
use std::io::Write;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("manifest.json");
let mut f = fs::File::create(&path).unwrap();
f.write_all(br#"{"icons":[{"src":"","sizes":"512x512"}]}"#)
.unwrap();
let result = std::panic::catch_unwind(|| {
validate_manifest(&path);
});
assert!(result.is_err());
}
#[test]
fn validator_accepts_manifest_with_real_icons() {
use std::io::Write;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("manifest.json");
let mut f = fs::File::create(&path).unwrap();
f.write_all(br#"{"icons":[{"src":"/icon.svg","sizes":"512x512"}]}"#)
.unwrap();
validate_manifest(&path); }