auditmysite 1.7.3

Resource-efficient WCAG 2.2 AA Accessibility Checker in Rust
Documentation
//! Module detail renderers (performance, SEO, security, mobile, dark mode, AI visibility).

use renderreport::components::advanced::{
    ChecklistPanel, ChecklistRow, Grid, KeyValueList, List, MetricStrip, MetricStripItem, PageBreak,
};
use renderreport::components::charts::{Gauge, GaugeThreshold};
use renderreport::components::text::{Label, TextBlock};
use renderreport::components::{AuditTable, Finding, ScoreCard, TableColumn};
use renderreport::prelude::*;

use crate::i18n::I18n;
use crate::output::localized::{is_english, pick};
use crate::output::report_model::*;

use super::helpers::{
    manual_recheck_instruction, map_severity, score_quality_color, score_quality_label,
    truncate_with_ellipsis,
};

mod accessibility;
mod ai_transparency;
mod commerce;
mod dark_mode;
mod design_quality;
mod experience;
mod html_conform;
mod indicators;
mod network_dns;
mod overview;
mod performance;
mod platform;
mod seo;

pub(super) use accessibility::{render_a11y_journey_findings, render_screen_reader_section};
pub(super) use ai_transparency::render_ai_transparency;
pub(super) use commerce::render_commerce;
pub(super) use dark_mode::render_dark_mode;
pub(super) use design_quality::render_design_quality;
pub(super) use experience::{render_journey, render_ux};
pub(super) use html_conform::render_html_conform;
pub(super) use indicators::{
    render_ai_visibility, render_best_practices, render_content_visibility, render_source_quality,
    render_tech_stack,
};
pub(super) use network_dns::render_network_dns;
pub(super) use overview::{render_budget_violations, render_search_experience};
pub(super) use performance::render_performance;
pub(super) use platform::{mobile_category_label, render_mobile, render_security};
pub(super) use seo::render_seo;

/// A neutral per-section line shown when a module produced no findings, so a
/// reader can distinguish "checked and clean" from "not checked" instead of the
/// section silently collapsing to nothing (#446).
fn clean_section_note(i18n: &I18n) -> Label {
    Label::new(i18n.t("pdf-section-clean"))
        .with_size("10.5pt")
        .with_color(crate::output::pdf::design::tokens::NEUTRAL)
}

fn module_customer_context(
    i18n: &I18n,
    module_key: &str,
    // `None` for a module that carries no 0-100 score at all (plan 29, D4) —
    // the score-derived weakness sentence is then simply omitted.
    score: Option<u32>,
    // Intentionally unused: the technical interpretation / heuristic disclaimer
    // is already shown in the module's "Überblick" and indicator notes. Appending
    // it here duplicated jargon (e.g. "DOM-Komplexität 20804 Knoten") into the
    // plain-language customer passage, defeating its purpose (#446 readability).
    _interpretation: &str,
) -> Label {
    let en = is_english(i18n);
    // html_conform is score-neutral with a known false-positive gap (see
    // detail_modules/html_conform.rs) -- a generic "clear weakness" sentence
    // for its score would contradict the module's own heuristic-reliability
    // disclaimer immediately above it. content_visibility no longer has a
    // score to judge at all.
    let scored = match module_key {
        "content_visibility" | "html_conform" => None,
        _ => score,
    };
    let weakness = match scored {
        Some(s) if s < 50 => Some(pick(
            i18n,
            "Der Score zeigt in diesem Bereich eine deutliche Schwäche.",
            "The score indicates a clear weakness in this area.",
        )),
        Some(s) if s < 75 => Some(pick(
            i18n,
            "Der Score zeigt in diesem Bereich erkennbares Verbesserungspotenzial.",
            "The score indicates visible improvement potential in this area.",
        )),
        _ => None,
    };
    let module_text = match (module_key, en) {
        ("performance", true) => "Visitors may experience delays, unstable rendering or unnecessary data transfer before the page feels usable.",
        ("performance", false) => "Besucher können Verzögerungen, instabiles Rendering oder unnötige Datenmenge erleben, bevor die Seite nutzbar wirkt.",
        ("seo", true) => "Search engines and AI systems need clear titles, headings, structured data and enough readable content to understand the page.",
        ("seo", false) => "Suchmaschinen und KI-Systeme benötigen klare Titel, Überschriften, strukturierte Daten und ausreichend lesbaren Inhalt, um die Seite zu verstehen.",
        ("search_experience", true) => "This score combines technical findability with whether users, search engines and AI systems can actually understand and trust the content.",
        ("search_experience", false) => "Dieser Wert verbindet technische Auffindbarkeit mit der Frage, ob Nutzer, Suchmaschinen und KI-Systeme die Inhalte tatsächlich verstehen und ihnen vertrauen können.",
        ("security", true) => "Security headers and HTTPS signals influence visible trust and reduce avoidable browser-side risk in the checked scope.",
        ("security", false) => "Security Header und HTTPS-Signale beeinflussen sichtbares Vertrauen und reduzieren vermeidbare Browser-Risiken im geprüften Umfang.",
        ("html_conform", true) => "Clean, spec-conformant HTML helps browsers, assistive technologies and search engines parse and render the page reliably.",
        ("html_conform", false) => "Sauberes, spezifikationskonformes HTML hilft Browsern, assistiven Technologien und Suchmaschinen, die Seite zuverlässig zu verarbeiten und darzustellen.",
        ("mobile", true) => "Mobile visitors depend on readable text, fitting content and controls that are easy to tap on small screens.",
        ("mobile", false) => "Mobile Besucher sind auf lesbaren Text, passende Inhaltsbreiten und gut antippbare Bedienelemente angewiesen.",
        ("ux", true) => "This indicator estimates whether the page feels understandable, consistent and low-friction for common visitor tasks.",
        ("ux", false) => "Dieser Indikator schätzt, ob die Seite für typische Besucheraufgaben verständlich, konsistent und reibungsarm wirkt.",
        ("journey", true) => "This indicator estimates whether visitors can move through the page intent without avoidable friction.",
        ("journey", false) => "Dieser Indikator schätzt, ob Besucher ohne vermeidbare Reibung durch den Zweck der Seite kommen.",
        ("source_quality", true) => "Source quality signals show whether content appears substantial, consistent and trustworthy enough to support decisions.",
        ("source_quality", false) => "Quellenqualität zeigt, ob Inhalte substanziell, konsistent und vertrauenswürdig genug wirken, um Entscheidungen zu stützen.",
        ("ai_visibility", true) => "AI visibility indicates whether content can be parsed, chunked, attributed and cited by AI-assisted systems.",
        ("ai_visibility", false) => "KI-Sichtbarkeit zeigt, ob Inhalte von KI-gestützten Systemen gelesen, gegliedert, zugeordnet und zitiert werden können.",
        ("content_visibility", true) => "Content visibility combines discoverability, trust and topical depth signals; it is an indicator, not a guarantee of reach.",
        ("content_visibility", false) => "Content Visibility verbindet Auffindbarkeit, Vertrauen und inhaltliche Tiefe; es ist ein Indikator, keine Reichweiten-Garantie.",
        _ if en => "This module describes customer-facing quality beyond pure accessibility findings.",
        _ => "Dieses Modul beschreibt kundennahe Qualität über reine Accessibility-Befunde hinaus.",
    };
    let mut parts = Vec::new();
    if let Some(w) = weakness {
        parts.push(w);
    }
    parts.push(module_text);
    // No meta-label prefix — the plain-language sentence speaks for itself
    // ("Was das für Kunden bedeutet:" was redundant wording, #446 readability).
    let text = parts.join(" ");
    Label::new(text)
        .with_size("10.5pt")
        .with_color(crate::output::pdf::design::tokens::NEUTRAL)
}

/// States that an indicator re-reads other modules rather than measuring
/// anything new (plan 29, D3). `None` for a module that measures its own
/// subject, so the note only appears where it is true.
///
/// Without it, a reader sees SEO, AI Visibility and Source Quality agree and
/// takes that for three independent assessments. They largely share inputs.
pub(super) fn derived_from_note(i18n: &I18n, module_name: &str) -> Option<Label> {
    let sources = crate::taxonomy::module_derived_from(module_name);
    if sources.is_empty() {
        return None;
    }
    let en = is_english(i18n);
    let names: Vec<String> = sources
        .iter()
        .map(|key| {
            let translated = i18n.t(&format!("module-{key}"));
            if translated == format!("module-{key}") {
                key.to_string()
            } else {
                translated
            }
        })
        .collect();
    let list = names.join(", ");
    let text = if en {
        format!(
            "This indicator re-reads results already reported under {list}. It measures nothing of its own, so its agreement with those modules is not independent confirmation."
        )
    } else {
        format!(
            "Dieser Indikator liest Ergebnisse neu, die bereits unter {list} berichtet werden. Er misst nichts Eigenes — seine Übereinstimmung mit diesen Modulen ist keine unabhängige Bestätigung."
        )
    };
    Some(
        Label::new(text)
            .with_size("10.5pt")
            .with_color(crate::output::pdf::design::tokens::NEUTRAL),
    )
}

/// Names the points the Accessibility result cost this indicator.
///
/// The penalty is defensible — for users with disabilities, accessibility
/// *is* the UX — but it was folded into the score with nothing said, so the
/// number looked like an independent reading of the page (plan 29, D3).
pub(super) fn a11y_penalty_note(
    builder: renderreport::engine::ReportBuilder,
    penalty: u32,
    score_before: u32,
    i18n: &I18n,
) -> renderreport::engine::ReportBuilder {
    if penalty == 0 {
        return builder;
    }
    let text = if is_english(i18n) {
        format!(
            "Includes an accessibility penalty of {penalty} points: {score_before} before it was applied."
        )
    } else {
        format!(
            "Enthält einen Abzug von {penalty} Punkten für die Barrierefreiheits-Befunde: {score_before} vor dem Abzug."
        )
    };
    builder.add_component(
        Label::new(text)
            .with_size("10.5pt")
            .with_color(crate::output::pdf::design::tokens::NEUTRAL),
    )
}

/// The interpretation, but only where it says more than the chapter opener
/// already did.
///
/// The opener's takeaway is `first_sentence(interpretation)`, and a
/// single-sentence interpretation therefore arrived twice: once in the
/// `section-header-split` body, once as a standalone label two components
/// later, with only a score card between them (plan 36 §3).
pub(super) fn interpretation_beyond_takeaway<'a>(
    interpretation: &'a str,
    takeaway: &str,
) -> Option<&'a str> {
    let rest = interpretation
        .trim()
        .strip_prefix(takeaway.trim())
        .unwrap_or(interpretation)
        .trim();
    (!rest.is_empty()).then_some(rest)
}

/// Gauge color bands matching `design::score_color`'s 40/75 thresholds
/// (higher is better). `Gauge`'s own defaults assume the opposite — a
/// value climbing towards `max` reads as *more* severe — so every score
/// gauge needs these explicit thresholds, not the component default.
fn score_gauge_thresholds() -> Vec<GaugeThreshold> {
    vec![
        GaugeThreshold {
            value: 0.0,
            color: crate::output::pdf::design::tokens::DANGER.to_string(),
        },
        GaugeThreshold {
            value: 40.0,
            color: crate::output::pdf::design::tokens::WARN_DEEP.to_string(),
        },
        GaugeThreshold {
            value: 75.0,
            color: crate::output::pdf::design::tokens::SUCCESS.to_string(),
        },
    ]
}

/// Opens a module as a level-2 chapter: a page break (unless it is the first
/// module) plus a level-2 header carrying the module name and a one-line key
/// takeaway, so the module appears in the table of contents as its own chapter
/// and the reader leaves the page with one core message (#15).
pub(super) fn module_chapter_opener(
    mut builder: renderreport::engine::ReportBuilder,
    title: &str,
    takeaway: &str,
    is_first: bool,
) -> renderreport::engine::ReportBuilder {
    use renderreport::components::advanced::{PageBreak, SectionHeaderSplit};
    if !is_first {
        builder = builder.add_component(PageBreak::new());
    }
    builder.add_component(SectionHeaderSplit::new(title, takeaway).with_level(2))
}

/// First sentence of an interpretation text — used as the chapter's one-line
/// key takeaway so the opener stays concise even when the full interpretation
/// runs to several sentences.
pub(super) fn first_sentence(text: &str) -> String {
    let t = text.trim();
    match t.find(". ") {
        Some(idx) => t[..=idx].trim().to_string(),
        None => t.to_string(),
    }
}

/// Generic label for a module's headline score card. The descriptive module
/// name lives in the level-2 chapter heading, so the card only needs a short
/// "overall score" caption (avoids printing the title twice).
pub(super) fn module_score_caption(i18n: &I18n) -> &'static str {
    if is_english(i18n) {
        "Overall score · 0–100"
    } else {
        "Gesamtwertung · 0–100"
    }
}

/// Plain-language grade band for a module score, aligned with the report's
/// bands (Sehr gut ≥ 90 … Kritisch < 40). Replaces the rejected A–F letter
/// grade as the score-card description. Localized de/en.
pub(super) fn score_band_label(score: u32, i18n: &I18n) -> &'static str {
    crate::registry::FIVE_BAND.label(score as f32, is_english(i18n))
}

fn vital_status(rating: &str) -> &'static str {
    match rating {
        "good" => "good",
        "needs-improvement" => "warn",
        "poor" => "bad",
        _ => "info",
    }
}

/// Localizes the canonical English rating token ("good"/"needs-improvement"/
/// "poor") — printing it raw leaked untranslated English into German report
/// tables (#406).
fn vital_rating_label(rating: &str, en: bool) -> &'static str {
    match (rating, en) {
        ("good", true) => "Good",
        ("needs-improvement", true) => "Needs improvement",
        ("poor", true) => "Poor",
        ("good", false) => "Gut",
        ("needs-improvement", false) => "Verbesserungswürdig",
        ("poor", false) => "Schlecht",
        _ => "—",
    }
}

fn vital_color(rating: &str) -> &'static str {
    crate::output::pdf::design::status_color(vital_status(rating))
}

fn journey_category_label(category: &str, i18n: &I18n) -> String {
    let en = is_english(i18n);
    let label = match category {
        "TabOrder" => Some(if en { "Tab order" } else { "Tab-Reihenfolge" }),
        "FocusTrap" => Some(if en { "Focus trap" } else { "Fokus-Falle" }),
        "StateTransition" => Some(if en {
            "State transition"
        } else {
            "Zustandswechsel"
        }),
        "FocusRestoration" => Some(if en {
            "Focus restoration"
        } else {
            "Fokus-Wiederherstellung"
        }),
        "FormError" => Some(if en {
            "Form error announcement"
        } else {
            "Formularfehler-Ansage"
        }),
        "SpaNavigation" => Some(if en {
            "SPA navigation"
        } else {
            "SPA-Navigation"
        }),
        "HiddenFocusable" => Some(if en {
            "Hidden focusable element"
        } else {
            "Verstecktes fokussierbares Element"
        }),
        "SkipLink" => Some(if en { "Skip link" } else { "Skip-Link" }),
        "FocusIndicator" => Some(if en {
            "Focus indicator"
        } else {
            "Fokus-Indikator"
        }),
        "MenuJourney" => Some(if en {
            "Menu navigation"
        } else {
            "Menü-Navigation"
        }),
        "TabsJourney" => Some(if en {
            "Tab navigation"
        } else {
            "Tab-Navigation"
        }),
        _ => None,
    };
    if let Some(label) = label {
        return label.to_string();
    }
    let mut out = String::new();
    for (i, ch) in category.char_indices() {
        if ch == '_' || ch == '-' {
            out.push(' ');
        } else if ch.is_uppercase() && i > 0 && !out.ends_with(' ') {
            out.push(' ');
            out.push(ch);
        } else {
            out.push(ch);
        }
    }
    out
}

#[cfg(test)]
mod tests;