use crate::i18n::{Lang, t};
use crate::routes::ui::{ASSET_VERSION, icon};
#[derive(Debug, Clone, Copy)]
pub struct PageContext<'a> {
pub theme: &'a str,
pub lang: Lang,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Nav {
Home,
Csaf,
Import,
Export,
Settings,
Info,
}
impl Nav {
const fn title_icon(self) -> &'static str {
match self {
Self::Home => "speedometer2",
Self::Csaf => "file-earmark-text",
Self::Import => "box-arrow-in-down",
Self::Export => "box-arrow-up",
Self::Settings => "sliders",
Self::Info => "info-circle",
}
}
const fn as_str(self) -> &'static str {
match self {
Self::Home => "home",
Self::Csaf => "csaf",
Self::Import => "import",
Self::Export => "export",
Self::Settings => "settings",
Self::Info => "info",
}
}
}
pub const THEME_COOKIE: &str = "csaf_theme";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ThemeChoice {
System,
Light,
Dark,
}
impl ThemeChoice {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::System => "system",
Self::Light => "light",
Self::Dark => "dark",
}
}
#[must_use]
pub fn from_value(value: &str) -> Option<Self> {
match value {
"system" => Some(Self::System),
"light" => Some(Self::Light),
"dark" => Some(Self::Dark),
_ => None,
}
}
}
fn theme_from_cookie(header: &str) -> Option<ThemeChoice> {
header.split(';').find_map(|pair| {
let (name, value) = pair.split_once('=')?;
(name.trim() == THEME_COOKIE).then(|| ThemeChoice::from_value(value.trim()))?
})
}
#[must_use]
pub fn theme_for(parts: &http::request::Parts, default_theme: &str) -> &'static str {
let from_cookie = parts
.headers
.get_all(http::header::COOKIE)
.iter()
.filter_map(|value| value.to_str().ok())
.find_map(theme_from_cookie);
from_cookie
.or_else(|| ThemeChoice::from_value(default_theme))
.unwrap_or(ThemeChoice::Light)
.as_str()
}
fn language_switcher(lang: Lang) -> String {
use std::fmt::Write as _;
let items = Lang::all()
.into_iter()
.fold(String::new(), |mut acc, choice| {
let current = if choice == lang {
r#" aria-current="true""#
} else {
""
};
let _ = writeln!(
acc,
r#" <a href="/lang/{code}" lang="{code}" hreflang="{code}" class="menu-link"{current}>{endonym}</a>"#,
code = choice.code(),
endonym = choice.endonym(),
);
acc
});
format!(
r#" <details class="nav-dropdown">
<summary class="menu-summary">{globe}<span>{label}</span>{caret}</summary>
<div class="dropdown-menu dropdown-menu-end lang-menu">
{items} </div>
</details>
"#,
globe = icon("translate"),
caret = icon("chevron-down"),
label = t(lang, "nav.language"),
)
}
struct NavEntry {
key: &'static str,
href: &'static str,
label_key: &'static str,
icon: &'static str,
}
const PAGE_LINKS: [NavEntry; 4] = [
NavEntry {
key: "home",
href: "/",
label_key: "nav.dashboard",
icon: "house",
},
NavEntry {
key: "csaf",
href: "/csaf",
label_key: "nav.csaf",
icon: "file-earmark-text",
},
NavEntry {
key: "import",
href: "/admin/import",
label_key: "nav.import",
icon: "box-arrow-in-down",
},
NavEntry {
key: "export",
href: "/admin/export",
label_key: "nav.export",
icon: "box-arrow-up",
},
];
const SETTINGS_LINK: NavEntry = NavEntry {
key: "settings",
href: "/settings",
label_key: "nav.settings",
icon: "gear",
};
fn nav_link(lang: Lang, active_key: &str, entry: &NavEntry) -> String {
let current = if active_key == entry.key {
r#" aria-current="page""#
} else {
""
};
format!(
" <a href=\"{href}\" class=\"nav-link\"{current}>{icon}<span>{label}</span></a>\n",
href = entry.href,
icon = icon(entry.icon),
label = t(lang, entry.label_key),
)
}
fn nav_links(lang: Lang, active_key: &str) -> String {
PAGE_LINKS
.iter()
.map(|entry| nav_link(lang, active_key, entry))
.collect()
}
fn theme_menu(lang: Lang, theme: &str) -> String {
let radio = |value: &str, label_key: &'static str, icon_name: &'static str| {
let checked = if theme == value { " checked" } else { "" };
format!(
r#" <label class="check-row" for="theme-{value}"><input type="radio" id="theme-{value}" name="theme" value="{value}"{checked}> {icon}<span>{label}</span></label>
"#,
icon = icon(icon_name),
label = t(lang, label_key),
)
};
format!(
r#" <details class="nav-dropdown">
<summary class="menu-summary">{theme_icon}<span>{label}</span>{caret}</summary>
<div class="dropdown-menu dropdown-menu-end">
<form method="post" action="/preferences/theme" class="theme-form">
<fieldset class="theme-fieldset">
<legend class="theme-legend">{label}</legend>
{system}{light}{dark} </fieldset>
<button type="submit" class="btn theme-apply">{apply}</button>
</form>
</div>
</details>
"#,
theme_icon = icon("circle-half"),
caret = icon("chevron-down"),
label = t(lang, "nav.theme"),
system = radio("system", "nav.theme_system", "circle-half"),
light = radio("light", "nav.theme_light", "sun"),
dark = radio("dark", "nav.theme_dark", "moon"),
apply = t(lang, "nav.theme_apply"),
)
}
fn info_menu(lang: Lang, active_key: &str) -> String {
let current = if active_key == "info" {
r#" aria-current="page""#
} else {
""
};
format!(
r#" <details class="nav-dropdown">
<summary class="menu-summary"{current}>{info_icon}<span>{nav_info}</span>{caret}</summary>
<div class="dropdown-menu dropdown-menu-end">
<a href="/info/about" class="menu-link">{info_about}</a>
<a href="/info/license" class="menu-link">{info_license}</a>
<a href="/info/system" class="menu-link">{info_system}</a>
<a href="/info/privacy" class="menu-link">{info_privacy}</a>
<a href="/info/security" class="menu-link">{info_security}</a>
<hr class="dropdown-divider">
<a href="/changelog" class="menu-link">{info_changelog}</a>
<a href="/readme" class="menu-link">{info_readme}</a>
<a href="/administrator" class="menu-link">{info_administrator}</a>
<a href="/user" class="menu-link">{info_user}</a>
</div>
</details>
"#,
info_icon = icon("info-circle"),
caret = icon("chevron-down"),
nav_info = t(lang, "nav.info"),
info_about = t(lang, "nav.info_about"),
info_license = t(lang, "nav.info_license"),
info_system = t(lang, "nav.info_system"),
info_privacy = t(lang, "nav.info_privacy"),
info_security = t(lang, "nav.info_security"),
info_changelog = t(lang, "nav.info_changelog"),
info_readme = t(lang, "nav.info_readme"),
info_administrator = t(lang, "nav.info_administrator"),
info_user = t(lang, "nav.info_user"),
)
}
fn confirm_dialog(lang: Lang) -> String {
format!(
r#"<dialog id="confirm-dialog" class="confirm-dialog" aria-labelledby="confirm-dialog-message">
<form method="dialog" class="confirm-form">
<p id="confirm-dialog-message" class="confirm-message">{message}</p>
<div class="confirm-actions">
<button type="submit" value="cancel" class="btn secondary">{cancel}</button>
<button type="submit" value="confirm" class="btn danger confirm-ok">{ok}</button>
</div>
</form>
</dialog>
"#,
message = t(lang, "ui.confirm_message"),
cancel = t(lang, "csaf.form_cancel"),
ok = t(lang, "ui.confirm_ok"),
)
}
fn asset_tags() -> String {
let link = |path: &str| {
format!(
r#"<link rel="stylesheet" href="/static/css/{path}?v={ASSET_VERSION}">
"#
)
};
format!(
"{}{}{}{}",
link("tokens/ndaal-tokens.css"),
link("tokens/csaf-tokens.css"),
link("base/base.css"),
link("csaf-ui.css"),
)
}
fn decorate_page_title(content: &str, icon_name: &'static str) -> String {
let Some(start) = content.find("<h1") else {
return content.to_owned();
};
let Some(open_len) = content[start..].find('>') else {
return content.to_owned();
};
let open_end = start + open_len;
let open_tag = &content[start..open_end];
if open_tag.contains("class=") || open_tag[3..].chars().next().is_some_and(|c| c != ' ') {
return content.to_owned();
}
format!(
"{before}{open_tag} class=\"page-title\">{icon}{after}",
before = &content[..start],
icon = icon(icon_name),
after = &content[open_end + 1..],
)
}
fn header_html(lang: Lang, theme: ThemeChoice, active_key: &str) -> String {
format!(
r#"<header class="site-header">
<nav class="primary" aria-label="{main_label}">
<a href="/" class="brand" aria-label="{brand_label}">
<img src="/static/img/logo.png" alt="{logo_alt}" class="brand-logo" width="640" height="259">
<span class="brand-text">CSAF</span>
</a>
<details class="nav-collapse">
<summary class="nav-toggle" aria-label="{nav_toggle}" title="{nav_toggle}">{menu_icon}</summary>
<div class="nav-group">
{nav_links} <span class="spacer"></span>
{settings_link}{theme_menu}{lang_switcher}{info_menu} </div>
</details>
</nav>
</header>
"#,
main_label = t(lang, "nav.main_label"),
brand_label = t(lang, "nav.brand_label"),
logo_alt = html_escape(t(lang, "nav.logo_alt")),
nav_toggle = t(lang, "nav.toggle"),
menu_icon = icon("list"),
nav_links = nav_links(lang, active_key),
settings_link = nav_link(lang, active_key, &SETTINGS_LINK),
theme_menu = theme_menu(lang, theme.as_str()),
lang_switcher = language_switcher(lang),
info_menu = info_menu(lang, active_key),
)
}
#[must_use]
pub fn wrap_page(title: &str, ctx: PageContext<'_>, active: Nav, content: &str) -> String {
let lang = ctx.lang;
let theme = ThemeChoice::from_value(ctx.theme).unwrap_or(ThemeChoice::System);
let theme_attr = match theme {
ThemeChoice::System => String::new(),
explicit => format!(r#" data-theme="{}""#, explicit.as_str()),
};
format!(
r##"<!DOCTYPE html>
<html lang="{lang_code}" dir="{lang_dir}"{theme_attr}>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{title} — CSAF</title>
{assets}</head>
<body>
<a href="#main-content" class="skip-link">{skip_main}</a>
{header}<main id="main-content" class="page">
{content}
</main>
<footer class="site-footer">
CSAF · Pierre Gronau — ndaal Gesellschaft für Sicherheit in der Informationstechnik mbH & Co KG, Cologne · Apache-2.0
</footer>
<div id="live-status" class="sr-only" role="status"></div>
{dialog}<script src="/static/js/csaf-ui.js?v={ASSET_VERSION}" defer></script>
</body>
</html>"##,
title = html_escape(title),
lang_code = lang.code(),
lang_dir = lang.dir(),
assets = asset_tags(),
skip_main = t(lang, "nav.skip_main"),
header = header_html(lang, theme, active.as_str()),
dialog = confirm_dialog(lang),
content = decorate_page_title(content, active.title_icon()),
)
}
#[must_use]
pub fn tlp_color_class(label: &str) -> &'static str {
match label {
"CLEAR" => "tlp-clear",
"GREEN" => "tlp-green",
"AMBER" => "tlp-amber",
"AMBER+STRICT" => "tlp-amber-strict",
"RED" => "tlp-red",
_ => "tlp-unknown",
}
}
#[must_use]
pub fn html_escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}
#[cfg(test)]
mod tests {
use super::*;
const SKILL_TOKENS: &str = include_str!("../../static/css/tokens/ndaal-tokens.css");
const CSAF_TOKENS: &str = include_str!("../../static/css/tokens/csaf-tokens.css");
const CSAF_UI_CSS: &str = include_str!("../../static/css/csaf-ui.css");
const CSAF_UI_JS: &str = include_str!("../../static/js/csaf-ui.js");
fn ctx(theme: &str) -> PageContext<'_> {
PageContext {
theme,
lang: Lang::En,
}
}
fn parts_with_cookie(cookie: Option<&str>) -> http::request::Parts {
let mut builder = http::Request::builder().uri("/");
if let Some(cookie) = cookie {
builder = builder.header("cookie", cookie);
}
builder.body(()).expect("request builds").into_parts().0
}
#[test]
fn test_wrap_page_light_theme_attribute() {
let html = wrap_page("Test", ctx("light"), Nav::Home, "<p>body</p>");
assert!(html.contains(r#"<html lang="en" dir="ltr" data-theme="light">"#));
assert!(html.contains(r#"value="light" checked"#));
}
#[test]
fn test_wrap_page_dark_theme_attribute() {
let html = wrap_page("Test", ctx("dark"), Nav::Home, "<p>body</p>");
assert!(html.contains(r#"data-theme="dark""#));
assert!(html.contains(r#"value="dark" checked"#));
}
#[test]
fn test_wrap_page_system_theme_leaves_the_attribute_off() {
let html = wrap_page("Test", ctx("system"), Nav::Home, "");
assert!(!html.contains("data-theme="), "{html}");
assert!(html.contains(r#"value="system" checked"#));
}
#[test]
fn test_wrap_page_unknown_theme_falls_back_to_system() {
let html = wrap_page("Test", ctx(r#"x" onload="evil"#), Nav::Home, "");
assert!(!html.contains("data-theme="), "{html}");
assert!(!html.contains("evil"), "{html}");
}
#[test]
fn test_theme_for_prefers_the_cookie_over_the_settings_default() {
let parts = parts_with_cookie(Some("csaf_lang=de; csaf_theme=dark; other=1"));
assert_eq!(theme_for(&parts, "light"), "dark");
let parts = parts_with_cookie(Some("csaf_theme=system"));
assert_eq!(theme_for(&parts, "dark"), "system");
}
#[test]
fn test_theme_for_uses_the_settings_default_without_a_valid_cookie() {
assert_eq!(theme_for(&parts_with_cookie(None), "dark"), "dark");
assert_eq!(theme_for(&parts_with_cookie(None), "system"), "system");
let parts = parts_with_cookie(Some("csaf_theme=purple"));
assert_eq!(theme_for(&parts, "dark"), "dark");
assert_eq!(theme_for(&parts_with_cookie(None), "chartreuse"), "light");
}
#[test]
fn test_theme_choice_round_trips_and_rejects_unknown_values() {
for choice in [ThemeChoice::System, ThemeChoice::Light, ThemeChoice::Dark] {
assert_eq!(ThemeChoice::from_value(choice.as_str()), Some(choice));
}
for bad in ["", "DARK", " dark", "dark;", "auto"] {
assert_eq!(ThemeChoice::from_value(bad), None, "{bad:?}");
}
}
#[test]
fn test_wrap_page_marks_the_active_page_for_assistive_tech() {
let html = wrap_page("Settings", ctx("light"), Nav::Settings, "");
assert!(html.contains(r#"href="/settings" class="nav-link" aria-current="page""#));
assert!(html.contains(r#"href="/" class="nav-link">"#));
assert_eq!(html.matches(r#"aria-current="page""#).count(), 1);
}
#[test]
fn test_wrap_page_uses_csaf_branding_not_csaf_crud() {
let html = wrap_page("Test", ctx("light"), Nav::Home, "");
assert!(html.contains(r#"class="brand-text">CSAF</span>"#));
assert!(!html.contains("CSAF CRUD"));
assert!(html.contains("Test — CSAF</title>"));
}
#[test]
fn test_wrap_page_escapes_the_ampersand_in_the_logo_alt_text() {
let html = wrap_page("Test", ctx("light"), Nav::Home, "");
assert!(html.contains(r#"alt="ndaal — information security & compliance""#));
}
#[test]
fn test_theme_cookie_in_a_later_cookie_field_is_found() {
let (mut parts, ()) = http::Request::builder()
.uri("/")
.body(())
.expect("request builds")
.into_parts();
parts.headers.append(
http::header::COOKIE,
http::HeaderValue::from_static("other=1"),
);
parts.headers.append(
http::header::COOKIE,
http::HeaderValue::from_static("csaf_theme=dark"),
);
assert_eq!(theme_for(&parts, "light"), "dark");
}
#[test]
fn test_wrap_page_escapes_the_title() {
let html = wrap_page("a & b </title><b>", ctx("light"), Nav::Home, "");
assert!(html.contains("<title>a & b </title><b> — CSAF</title>"));
}
#[test]
fn test_wrap_page_logo_declares_its_intrinsic_size() {
let html = wrap_page("Test", ctx("light"), Nav::Home, "");
assert!(html.contains(r#"width="640" height="259""#));
}
#[test]
fn test_wrap_page_loads_the_script_after_the_content() {
let html = wrap_page("Test", ctx("light"), Nav::Home, "");
let script = html.find("<script src").expect("script tag present");
assert!(
script > html.find("</main>").expect("main closes"),
"{script}"
);
assert!(script > html.find("</head>").expect("head closes"));
}
#[test]
fn test_theme_radios_are_bound_to_their_labels_by_id() {
let html = wrap_page("Test", ctx("light"), Nav::Home, "");
for value in ["system", "light", "dark"] {
assert!(html.contains(&format!(r#"id="theme-{value}""#)), "{value}");
assert!(
html.contains(&format!(r#"<label class="check-row" for="theme-{value}">"#)),
"{value}"
);
}
}
#[test]
fn test_confirmation_dialog_has_text_before_the_script_fills_it() {
let html = wrap_page("Test", ctx("light"), Nav::Home, "");
assert!(!html.contains(r#"class="confirm-message"></p>"#));
assert!(!html.contains(r#"confirm-ok"></button>"#));
}
#[test]
fn test_wrap_page_includes_logo_image() {
let html = wrap_page("Test", ctx("light"), Nav::Home, "");
assert!(html.contains(r#"<img src="/static/img/logo.png""#));
assert!(html.contains("alt=\"ndaal"));
}
#[test]
fn test_primary_is_the_skills_measured_blue_with_no_local_override() {
assert!(
SKILL_TOKENS.contains("--ndaal-primary: #0A4DA6;"),
"the vendored skill tokens define the blue primary"
);
assert!(
!CSAF_TOKENS.contains("--ndaal-primary"),
"no primary override"
);
assert!(
!CSAF_TOKENS.contains("#8B1A1A"),
"the old burgundy theme must not return"
);
assert!(
!CSAF_TOKENS.contains("#1a237e"),
"the pre-ndaal blue must not return"
);
}
#[test]
fn test_wrap_page_loads_the_four_stylesheets_in_cascade_order() {
let html = wrap_page("Test", ctx("light"), Nav::Home, "");
let order = [
"/static/css/tokens/ndaal-tokens.css",
"/static/css/tokens/csaf-tokens.css",
"/static/css/base/base.css",
"/static/css/csaf-ui.css",
];
let positions: Vec<usize> = order
.iter()
.map(|path| {
html.find(path)
.unwrap_or_else(|| panic!("{path} not linked"))
})
.collect();
assert!(
positions.windows(2).all(|pair| pair[0] < pair[1]),
"tokens must load before the layers that override them: {positions:?}"
);
assert!(html.contains(&format!("csaf-ui.css?v={}", env!("CARGO_PKG_VERSION"))));
assert!(html.contains(r#"<script src="/static/js/csaf-ui.js?v="#));
}
#[test]
fn test_wrap_page_is_compatible_with_a_strict_content_security_policy() {
let html = wrap_page("Test", ctx("dark"), Nav::Csaf, "<p>x</p>");
assert!(!html.contains("<style"), "inline <style> found");
assert!(!html.contains(" style=\""), "style attribute found");
assert!(!html.contains("javascript:"), "javascript: URL found");
for attr in ["onclick=", "onsubmit=", "onload=", "onchange=", "onerror="] {
assert!(!html.contains(attr), "inline handler {attr} found");
}
let scripts = html.matches("<script").count();
let external = html.matches("<script src=").count();
assert_eq!(scripts, external, "every <script> must have a src");
}
#[test]
fn test_html_escape_special_chars() {
assert_eq!(html_escape("<script>"), "<script>");
assert_eq!(html_escape("a & b"), "a & b");
assert_eq!(html_escape(r#"'"<>&"#), "'"<>&");
}
#[test]
fn test_tlp_color_class_whitelist() {
assert_eq!(tlp_color_class("CLEAR"), "tlp-clear");
assert_eq!(tlp_color_class("GREEN"), "tlp-green");
assert_eq!(tlp_color_class("AMBER"), "tlp-amber");
assert_eq!(tlp_color_class("AMBER+STRICT"), "tlp-amber-strict");
assert_eq!(tlp_color_class("RED"), "tlp-red");
}
#[test]
fn test_tlp_color_class_rejects_untrusted() {
for bad in [
"",
"clear",
"red\"><script>alert(1)</script>",
" CLEAR ",
"WHITE",
" ",
] {
let class = tlp_color_class(bad);
assert_eq!(class, "tlp-unknown", "bad={bad:?} -> {class}");
assert!(
class
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-')
);
}
}
#[test]
fn test_tlp_colours_and_script_stay_in_step_with_the_server_whitelist() {
for class in ["tlp-red", "tlp-amber", "tlp-green", "tlp-clear"] {
assert!(
CSAF_UI_CSS.contains(&format!(".tlp-select.{class}")),
"{class} missing from csaf-ui.css"
);
}
for label in ["CLEAR", "GREEN", "AMBER", "AMBER+STRICT", "RED"] {
let want = format!(r#""{label}": "{}""#, tlp_color_class(label));
assert!(CSAF_UI_JS.contains(&want), "{want} missing from csaf-ui.js");
}
assert!(CSAF_UI_JS.contains(
r#"select.classList.add(known ? TLP_CLASSES[select.value] : "tlp-unknown")"#
));
}
#[test]
fn test_wrap_page_sets_html_lang_from_context() {
let html = wrap_page(
"Test",
PageContext {
theme: "light",
lang: Lang::De,
},
Nav::Home,
"",
);
assert!(html.contains(r#"<html lang="de""#));
assert!(html.contains(r#"<html lang="de" dir="ltr""#));
assert!(html.contains("Einstellungen")); }
#[test]
fn test_wrap_page_sets_html_dir_rtl_for_urdu() {
let html = wrap_page(
"Test",
PageContext {
theme: "light",
lang: Lang::Ur,
},
Nav::Home,
"",
);
assert!(html.contains(r#"<html lang="ur" dir="rtl""#));
}
#[test]
fn test_wrap_page_header_cluster_order_is_settings_theme_language_info() {
let html = wrap_page("Test", ctx("light"), Nav::Home, "");
let at = |needle: &str| {
html.find(needle)
.unwrap_or_else(|| panic!("{needle} missing"))
};
let settings_pos = at(r#"href="/settings""#);
let theme_pos = at(r#"action="/preferences/theme""#);
let lang_pos = at(r#"href="/lang/en""#);
let info_pos = at(r#"href="/info/about""#);
assert!(settings_pos < theme_pos, "settings must precede theme");
assert!(theme_pos < lang_pos, "theme must precede language");
assert!(lang_pos < info_pos, "language must precede info");
}
#[test]
fn test_theme_menu_is_a_real_form_with_three_radios_and_a_fallback_button() {
let html = wrap_page("Test", ctx("light"), Nav::Home, "");
assert!(
html.contains(r#"<form method="post" action="/preferences/theme" class="theme-form">"#)
);
for value in ["system", "light", "dark"] {
assert!(
html.contains(&format!(
r#"<input type="radio" id="theme-{value}" name="theme" value="{value}""#
)),
"radio {value} missing"
);
}
assert!(html.contains(r#"class="btn theme-apply""#));
}
#[test]
fn test_wrap_page_has_a_live_region_and_a_confirmation_dialog() {
let html = wrap_page("Test", ctx("light"), Nav::Home, "");
assert!(html.contains(r#"<div id="live-status" class="sr-only" role="status"></div>"#));
assert!(html.contains(r#"<dialog id="confirm-dialog""#));
assert!(html.contains(r#"aria-labelledby="confirm-dialog-message""#));
}
#[test]
fn test_wrap_page_landmarks_are_labelled() {
let html = wrap_page("Test", ctx("light"), Nav::Home, "");
assert!(html.contains(r#"<nav class="primary" aria-label="Main navigation">"#));
assert!(html.contains("<header class=\"site-header\">"));
assert!(html.contains("<footer class=\"site-footer\">"));
}
#[test]
fn test_wrap_page_language_switcher_lists_all_49_with_current_marked() {
let html = wrap_page(
"Test",
PageContext {
theme: "light",
lang: Lang::Fr,
},
Nav::Home,
"",
);
for choice in Lang::all() {
let code = choice.code();
assert!(
html.contains(&format!(
r#"href="/lang/{code}" lang="{code}" hreflang="{code}""#
)),
"missing switcher link (with lang/hreflang) for {code}"
);
assert!(
html.contains(choice.endonym()),
"missing endonym for {code}"
);
}
assert!(html.contains(
r#"href="/lang/fr" lang="fr" hreflang="fr" class="menu-link" aria-current="true""#
));
assert!(!html.contains(r#"hreflang="en" class="menu-link" aria-current"#));
}
#[test]
fn test_language_switcher_html_matches_snapshot() {
let html =
language_switcher(Lang::En).replace(&format!("?v={ASSET_VERSION}#"), "?v=VERSION#");
insta::assert_snapshot!(html);
}
#[test]
fn test_language_switcher_orders_the_pinned_five_before_the_tail() {
let html = language_switcher(Lang::En);
let pos = |code: &str| {
html.find(&format!(r#"href="/lang/{code}""#))
.unwrap_or_else(|| panic!("no link for {code}"))
};
let order = ["en", "de", "fr", "es", "it", "sq"];
for pair in order.windows(2) {
assert!(
pos(pair[0]) < pos(pair[1]),
"{} must precede {}",
pair[0],
pair[1]
);
}
assert!(pos("sq") < pos("ur"), "tail runs Albanian..Urdu");
}
#[test]
fn test_wrap_page_includes_skip_link_targeting_main() {
let html = wrap_page("Test", ctx("light"), Nav::Home, "");
assert!(html.contains(r##"<a href="#main-content" class="skip-link">"##));
assert!(html.contains(r#"<main id="main-content" class="page">"#));
let skip = html.find("skip-link").expect("skip link");
let first_link = html.find("<a href=\"/\"").expect("brand link");
assert!(skip < first_link, "skip link must come first");
}
#[test]
fn test_decorate_page_title_adds_class_and_leading_icon() {
let out = decorate_page_title("<h1>Dashboard</h1><p>x</p>", "house");
assert!(
out.starts_with(r#"<h1 class="page-title"><svg class="icon""#),
"{out}"
);
assert!(out.contains("#house"), "{out}");
assert!(out.ends_with("Dashboard</h1><p>x</p>"), "{out}");
}
#[test]
fn test_decorate_page_title_keeps_an_existing_id() {
let out = decorate_page_title(r#"<h1 id="documents-heading">Docs</h1>"#, "gear");
assert!(
out.starts_with(r#"<h1 id="documents-heading" class="page-title"><svg"#),
"{out}"
);
}
#[test]
fn test_decorate_page_title_leaves_a_classed_or_missing_heading_alone() {
let classed = r#"<h1 class="custom">Own</h1>"#;
assert_eq!(decorate_page_title(classed, "gear"), classed);
let none = "<h2>Section</h2><p>no top heading</p>";
assert_eq!(decorate_page_title(none, "gear"), none);
assert_eq!(decorate_page_title("", "gear"), "");
}
#[test]
fn test_decorate_page_title_only_touches_the_first_h1_and_real_h1_tags() {
let two = "<h1>One</h1><h1>Two</h1>";
assert_eq!(
decorate_page_title(two, "gear")
.matches("page-title")
.count(),
1
);
let lookalike = "<h10>x</h10><h1x>y</h1x>";
assert_eq!(decorate_page_title(lookalike, "gear"), lookalike);
}
#[test]
fn test_wrap_page_gives_each_section_its_own_title_icon() {
for (nav, symbol) in [
(Nav::Home, "#speedometer2"),
(Nav::Csaf, "#file-earmark-text"),
(Nav::Import, "#box-arrow-in-down"),
(Nav::Export, "#box-arrow-up"),
(Nav::Settings, "#sliders"),
(Nav::Info, "#info-circle"),
] {
let html = wrap_page("T", ctx("light"), nav, "<h1>Title</h1>");
let main = html
.split(r#"<main id="main-content""#)
.nth(1)
.expect("main present");
assert!(main.contains(r#"<h1 class="page-title">"#), "{nav:?}");
assert!(main.contains(symbol), "{nav:?} should use {symbol}");
}
}
use proptest::prelude::*;
proptest! {
#[test]
fn prop_html_escape_never_leaves_unescaped_markup_chars(s in ".*") {
let escaped = html_escape(&s);
prop_assert!(!escaped.contains('<'));
prop_assert!(!escaped.contains('>'));
prop_assert!(!escaped.contains('"'));
prop_assert!(!escaped.contains('\''));
}
#[test]
fn prop_theme_for_only_ever_returns_a_fixed_value(cookie in ".*", default in ".*") {
let parts = parts_with_cookie(
http::HeaderValue::from_str(&cookie).ok().map(|_| cookie.as_str()),
);
let theme = theme_for(&parts, &default);
prop_assert!(["system", "light", "dark"].contains(&theme));
}
}
fn page_like() -> impl Strategy<Value = String> {
proptest::collection::vec(
prop_oneof![
Just("<h1>".to_owned()),
Just("<h1 id=\"x\">".to_owned()),
Just("<h1 class=\"own\">".to_owned()),
Just("<h10>".to_owned()),
Just("<h1".to_owned()),
Just("</h1>".to_owned()),
"[ -~]{0,8}",
"[äöü߀日本語<>&\"]{0,4}",
],
0..8,
)
.prop_map(|parts| parts.concat())
}
proptest! {
#[test]
fn prop_decorate_page_title_is_total_and_leaves_h1_less_pages_alone(
content in prop_oneof![page_like(), ".*"],
) {
let out = decorate_page_title(&content, "house");
if !content.contains("<h1") {
prop_assert_eq!(out, content);
}
}
#[test]
fn prop_decorate_page_title_is_idempotent(content in page_like()) {
let once = decorate_page_title(&content, "house");
prop_assert_eq!(decorate_page_title(&once, "house"), once);
}
#[test]
fn prop_decorate_page_title_only_inserts(content in page_like()) {
let out = decorate_page_title(&content, "house");
if out != content {
let inserted = format!(" class=\"page-title\">{}", icon("house"));
prop_assert_eq!(out.replacen(&inserted, ">", 1), content);
}
}
}
}