use crate::audit::normalized::{AuditContext, NormalizedReport};
use crate::audit::performance_interpretation::{
append_performance_qualifiers_text, performance_gap_text,
};
use crate::commerce::{commerce_finding_text, CommercePageKind};
use crate::dark_mode::dark_mode_issue_text;
use crate::i18n::I18n;
use crate::output::report_model::{
AiTransparencyPresentation, AnimationPresentation, CommercePresentation, CommerceProductRow,
CoveragePresentation, CriticalChainPresentation, DarkModePresentation,
DesignQualityFindingPresentation, DesignQualityPresentation, DuplicateAssetRow,
FrictionPointPresentation, HtmlConformPresentation, ImageEfficiencyPresentation,
ImageProvenanceFindingPresentation, JourneyDimensionPresentation, JourneyPresentation,
MinificationPresentation, MobilePresentation, ModuleDetailsBlock, OversizedImageRow,
PerformancePresentation, PerformanceViewport, RobotsPresentation, SecurityPresentation,
SeoPresentation, SeoProfilePresentation, SignalDetails, ThirdPartyImpactRow,
ThirdPartyOriginRow, ThirdPartyPresentation, ThrottledPerfEntry, UxDimensionPresentation,
UxIssuePresentation, UxPresentation, VisionDeficiencyModePresentation,
};
use crate::output::search_experience::build_search_experience;
use crate::taxonomy::SeverityExt;
use super::super::helpers::{
security_header_tier_label, security_issue_kind_label, truncate_list, truncate_url_list, yes_no,
};
use super::super::modules::{
build_tracking_summary_text, build_vitals_list, derive_performance_recommendations,
derive_security_recommendations,
};
use super::serp::{build_page_health_presentation, build_serp_presentation};
use crate::seo::interpretation::{
page_profile_optimization_note_text, page_profile_summary_text, seo_interpretation_text,
};
use crate::util::truncate_url;
fn module_interpretation(normalized: &NormalizedReport, module: &str, locale: &str) -> String {
normalized
.interpretation
.as_ref()
.and_then(|i| i.per_module.get(module))
.map(|t| t.for_locale(locale).to_string())
.unwrap_or_default()
}
fn rate_threshold(value: f64, good_max: f64, poor_min: f64) -> &'static str {
if value <= good_max {
"good"
} else if value < poor_min {
"needs-improvement"
} else {
"poor"
}
}
fn localized_decimal(value: f64, decimals: usize, en: bool) -> String {
let formatted = format!("{value:.decimals$}");
if en {
formatted
} else {
formatted.replace('.', ",")
}
}
fn localized_integer(value: i64, en: bool) -> String {
let separator = if en { ',' } else { '.' };
let digits = value.abs().to_string();
let mut grouped = String::with_capacity(digits.len() + digits.len() / 3);
for (index, digit) in digits.chars().enumerate() {
if index > 0 && (digits.len() - index).is_multiple_of(3) {
grouped.push(separator);
}
grouped.push(digit);
}
if value < 0 {
grouped.insert(0, '-');
}
grouped
}
fn build_performance_details(
normalized: &AuditContext<'_>,
i18n: &I18n,
) -> Option<PerformancePresentation> {
let locale = i18n.locale();
normalized.raw_performance.map(|p| {
let performance_score = normalized_module_score(&normalized.normalized, "Performance")
.unwrap_or(p.score.overall);
let performance_grade = normalized_module_grade(&normalized.normalized, "Performance")
.unwrap_or_else(|| p.score.grade.label().to_string());
let vitals = build_vitals_list(p, i18n);
let desktop_viewport = normalized
.raw_performance_desktop
.map(|d| PerformanceViewport {
score: d.score.overall,
grade: d.score.grade.label().to_string(),
vitals: build_vitals_list(d, i18n),
});
let mobile_viewport = Some(PerformanceViewport {
score: p.score.overall,
grade: p.score.grade.label().to_string(),
vitals: vitals.clone(),
});
let en = i18n.locale() == "en";
let mut additional = Vec::new();
if let Some(heap) = p.vitals.js_heap_size {
additional.push((
"JS Heap".to_string(),
format!("{:.1} MB", heap as f64 / 1_048_576.0),
));
}
if let Some(ref cw) = p.content_weight {
additional.push((
if en { "CO2e per view" } else { "CO2e pro View" }.to_string(),
format!("{} g ({})", cw.carbon.format_grams(), cw.carbon.rating),
));
}
let mut resource_ratings = Vec::new();
if let Some(nodes) = p.vitals.dom_nodes {
resource_ratings.push((
if en { "DOM nodes" } else { "DOM-Knoten" }.to_string(),
localized_integer(nodes, en),
rate_threshold(nodes as f64, 800.0, 1500.0).to_string(),
if en {
"Guideline: max. 800"
} else {
"Richtwert: max. 800"
}
.to_string(),
));
}
if let Some(load) = p.vitals.load_time {
resource_ratings.push((
if en { "Load time" } else { "Ladezeit" }.to_string(),
format!("{} s", localized_decimal(load / 1000.0, 1, en)),
rate_threshold(load, 3000.0, 6000.0).to_string(),
if en {
"Guideline: max. 3.0 s"
} else {
"Richtwert: max. 3,0 s"
}
.to_string(),
));
}
if let Some(dcl) = p.vitals.dom_content_loaded {
resource_ratings.push((
if en {
"DOM Content Loaded"
} else {
"DOM-Inhalt geladen"
}
.to_string(),
format!("{} s", localized_decimal(dcl / 1000.0, 1, en)),
rate_threshold(dcl, 1800.0, 3600.0).to_string(),
if en {
"Guideline: max. 1.8 s"
} else {
"Richtwert: max. 1,8 s"
}
.to_string(),
));
}
let recommendations = derive_performance_recommendations(i18n, p);
let mut render_blocking_metrics = Vec::new();
let mut render_blocking_suggestions = Vec::new();
let mut has_render_blocking = false;
if let Some(ref rb) = p.render_blocking {
if rb.has_blocking() || rb.third_party_bytes > 100_000 {
has_render_blocking = true;
render_blocking_metrics.push((
"Blocking Scripts".to_string(),
rb.blocking_scripts.len().to_string(),
));
render_blocking_metrics.push((
"Blocking CSS".to_string(),
rb.blocking_css.len().to_string(),
));
if rb.blocking_transfer_bytes > 0 {
render_blocking_metrics.push((
"Blocking Transfer".to_string(),
format!("{:.1} KB", rb.blocking_transfer_bytes as f64 / 1024.0),
));
}
if rb.third_party_bytes > 0 {
render_blocking_metrics.push((
"Third-Party".to_string(),
format!(
"{:.1} KB ({} Domains)",
rb.third_party_bytes as f64 / 1024.0,
rb.third_party_origin_count
),
));
}
if rb.first_party_bytes > 0 {
render_blocking_metrics.push((
"First-Party".to_string(),
format!("{:.1} KB", rb.first_party_bytes as f64 / 1024.0),
));
}
render_blocking_suggestions = crate::performance::render_blocking_suggestions(
rb.blocking_scripts.len(),
rb.blocking_css.len(),
rb.third_party_bytes,
rb.third_party_origin_count,
locale == "en",
);
}
}
let cwv_all_good = p.vitals.lcp.as_ref().is_none_or(|v| v.rating == "good")
&& p.vitals.fcp.as_ref().is_none_or(|v| v.rating == "good")
&& p.vitals.cls.as_ref().is_none_or(|v| v.rating == "good");
let en = locale == "en";
if has_render_blocking && cwv_all_good && !render_blocking_suggestions.is_empty() {
render_blocking_suggestions.push(if en {
"No measurable impact on the measured vitals — still worth fixing preventively, \
since slow connections or cold caches can be affected more severely."
.to_string()
} else {
"Kein messbarer Einfluss auf die gemessenen Vitals — trotzdem vorbeugend beheben, \
da langsame Verbindungen oder kalte Caches stärker betroffen sein können."
.to_string()
});
}
let base_perf = module_interpretation(&normalized.normalized, "performance", locale);
let score_below_excellent = performance_score < 85;
let perf_interpretation = performance_gap_text(
base_perf,
p,
cwv_all_good,
score_below_excellent,
has_render_blocking,
en,
);
let throttled_lcp_max = normalized
.raw_throttled_performance
.iter()
.filter_map(|t| t.lcp_ms)
.fold(0.0_f64, f64::max);
let mut perf_interpretation =
append_performance_qualifiers_text(perf_interpretation, p, throttled_lcp_max, en);
let mut capping_warnings = Vec::new();
if p.score.is_capped == Some(true) {
let cap = p.score.overall;
let mut size_cap = 100u32;
let mut js_cap = 100u32;
let mut req_cap = 100u32;
let mut dom_cap = 100u32;
if let Some(ref cw) = p.content_weight {
if cw.total_bytes > 10_000_000 {
size_cap = 39;
} else if cw.total_bytes > 5_000_000 {
size_cap = 59;
} else if cw.total_bytes > 3_000_000 {
size_cap = 74;
}
if cw.breakdown.javascript.bytes > 3_000_000 {
js_cap = 59;
} else if cw.breakdown.javascript.bytes > 1_500_000 {
js_cap = 74;
}
if cw.request_count > 120 {
req_cap = 74;
}
}
if let Some(nodes) = p.vitals.dom_nodes {
if nodes > 3000 {
dom_cap = 59;
} else if nodes > 2000 {
dom_cap = 74;
}
}
let min_computed = size_cap.min(js_cap).min(req_cap).min(dom_cap);
if let Some(ref cw) = p.content_weight {
if size_cap == min_computed && size_cap < 100 {
let mb_str = format!("{:.1}", cw.total_bytes as f64 / 1_000_000.0);
capping_warnings.push(i18n.t_args(
"perf-capped-size",
&[("size", mb_str), ("cap", cap.to_string())],
));
}
if js_cap == min_computed && js_cap < 100 {
let mb_str =
format!("{:.1}", cw.breakdown.javascript.bytes as f64 / 1_000_000.0);
capping_warnings.push(i18n.t_args(
"perf-capped-js",
&[("size", mb_str), ("cap", cap.to_string())],
));
}
if req_cap == min_computed && req_cap < 100 {
capping_warnings.push(i18n.t_args(
"perf-capped-requests",
&[
("count", cw.request_count.to_string()),
("cap", cap.to_string()),
],
));
}
}
if let Some(nodes) = p.vitals.dom_nodes {
if dom_cap == min_computed && dom_cap < 100 {
capping_warnings.push(i18n.t_args(
"perf-capped-dom",
&[("nodes", nodes.to_string()), ("cap", cap.to_string())],
));
}
}
}
if !capping_warnings.is_empty() {
if !perf_interpretation.is_empty() {
perf_interpretation.push(' ');
}
perf_interpretation.push_str(&capping_warnings.join(" "));
}
let throttled_profiles: Vec<ThrottledPerfEntry> = normalized
.raw_throttled_performance
.iter()
.map(|t| ThrottledPerfEntry {
profile_name: format!("{:?}", t.profile),
lcp: t
.lcp_ms
.map(|v| format!("{:.0} ms", v))
.unwrap_or_else(|| "\u{2014}".to_string()),
tbt: t
.tbt_ms
.map(|v| format!("{:.0} ms", v))
.unwrap_or_else(|| "\u{2014}".to_string()),
cls: t
.cls
.map(|v| format!("{:.3}", v))
.unwrap_or_else(|| "\u{2014}".to_string()),
score: t.score,
})
.collect();
let cls_attribution = p
.vitals
.cls_attribution
.iter()
.take(5)
.map(|s| {
(
format!("{:.4}", s.value),
format!("{:.0}ms", s.start_time_ms),
s.sources
.first()
.map(|src| src.node.clone())
.unwrap_or_default(),
)
})
.collect();
let third_party = p.third_party.as_ref().map(|tp| {
let page_total = p
.content_weight
.as_ref()
.map(|cw| cw.transfer_bytes)
.unwrap_or(0);
ThirdPartyPresentation {
origins: tp
.origins
.iter()
.take(10)
.map(|o| ThirdPartyOriginRow {
origin: o.origin.clone(),
provider: o.provider.clone(),
category: o.category.clone(),
request_count: o.request_count,
transfer_kb: o.transfer_bytes as f64 / 1024.0,
resource_kinds: o.resource_kinds.join(", "),
})
.collect(),
total_origins: tp.total_origins,
total_kb: tp.total_bytes as f64 / 1024.0,
total_requests: tp.total_requests,
is_significant: tp.is_significant(page_total),
isolated_impact: tp
.isolated_impact
.iter()
.map(|i| ThirdPartyImpactRow {
origin: i.origin.clone(),
baseline_tbt_ms: i.baseline_tbt_ms,
without_script_tbt_ms: i.without_script_tbt_ms,
estimated_impact_ms: i.estimated_impact_ms,
})
.collect(),
}
});
let critical_chain = p
.critical_chain
.as_ref()
.map(|cc| CriticalChainPresentation {
max_depth: cc.max_depth,
critical_path_ms: format!("{:.0}ms", cc.critical_path_ms),
critical_path_kb: format!("{:.1} KB", cc.critical_path_bytes as f64 / 1024.0),
total_requests: cc.total_requests as usize,
});
let minification = p
.minification
.as_ref()
.filter(|m| m.total_unminified_count > 0 || !m.legacy_scripts.is_empty())
.map(|m| {
let top_assets: Vec<(String, String, String)> = m
.unminified_scripts
.iter()
.chain(m.unminified_styles.iter())
.take(5)
.map(|a| {
(
truncate_url(&a.url, 60),
a.kind.clone(),
format!(
"{} KB",
localized_decimal(a.savings_bytes as f64 / 1024.0, 1, en)
),
)
})
.collect();
let legacy_assets: Vec<(String, String, String)> = m
.legacy_scripts
.iter()
.take(5)
.map(|a| {
(
truncate_url(&a.url, 60),
a.signature.clone(),
format!(
"{} KB",
localized_decimal(a.wasted_bytes as f64 / 1024.0, 1, en)
),
)
})
.collect();
MinificationPresentation {
total_count: m.total_unminified_count as usize,
total_savings_kb: m.total_savings_bytes as f64 / 1024.0,
top_assets,
legacy_count: m.legacy_scripts.len(),
legacy_wasted_kb: m.total_legacy_wasted_bytes as f64 / 1024.0,
legacy_assets,
}
});
let coverage = p.coverage.as_ref().map(|cov| CoveragePresentation {
js_used_pct: Some(cov.unused_js.used_pct),
js_unused_kb: Some(cov.unused_js.unused_bytes as f64 / 1024.0),
css_used_pct: cov.unused_css.used_pct,
css_total_rules: Some(cov.unused_css.total_rules),
css_used_rules: Some(cov.unused_css.used_rules),
duplicate_assets: cov
.duplicate_assets
.iter()
.map(|g| DuplicateAssetRow {
kind: g.kind.clone(),
kb: g.bytes as f64 / 1024.0,
urls: g.urls.iter().map(|u| truncate_url(u, 60)).collect(),
})
.collect(),
});
let animations = p
.animations
.as_ref()
.filter(|a| a.total_count > 0)
.map(|a| {
let findings: Vec<(String, String, String)> = a
.findings
.iter()
.take(10)
.map(|f| {
(
f.kind.clone(),
f.property.clone(),
truncate_url(&f.source, 60),
)
})
.collect();
AnimationPresentation {
total_count: a.total_count as usize,
affected_properties: a.affected_properties.clone(),
findings,
}
});
PerformancePresentation {
score: performance_score,
grade: performance_grade,
interpretation: perf_interpretation,
vitals,
desktop: desktop_viewport,
mobile: mobile_viewport,
additional_metrics: additional,
resource_ratings,
recommendations,
render_blocking_metrics,
render_blocking_suggestions,
has_render_blocking,
throttled_profiles,
cls_attribution,
third_party,
critical_chain,
minification,
coverage,
animations,
measurement_warnings: p.measurement_warnings.clone(),
}
})
}
fn build_seo_details(normalized: &AuditContext<'_>, i18n: &I18n) -> Option<SeoPresentation> {
let locale = i18n.locale();
let en = locale == "en";
normalized.raw_seo.map(|s| {
let seo_score = normalized_module_score(&normalized.normalized, "SEO").unwrap_or(s.score);
let mut meta_tags = Vec::new();
if let Some(ref title) = s.meta.title {
meta_tags.push((
if en { "Title" } else { "Titel" }.to_string(),
title.clone(),
));
}
if let Some(ref desc) = s.meta.description {
meta_tags.push((
if en { "Description" } else { "Beschreibung" }.to_string(),
desc.clone(),
));
}
if let Some(ref viewport) = s.meta.viewport {
meta_tags.push(("Viewport".to_string(), viewport.clone()));
}
let meta_issues: Vec<(String, crate::wcag::Severity, String)> = s
.meta_issues
.iter()
.map(|i| (i.field.clone(), i.severity, i.message.clone()))
.collect();
let localized_profile = crate::seo::build_content_profile(s, locale);
let profile = Some(&localized_profile).map(|cp| {
use crate::seo::profile::SchemaExtracted;
let structural_issues: Vec<_> = s
.structured_data
.schema_issues
.iter()
.filter(|issue| issue.issue_type.starts_with("jsonld_"))
.collect();
let valid_schema_nodes = s
.structured_data
.json_ld
.iter()
.filter(|schema| schema.is_valid && !schema.schema_types.is_empty())
.count();
let schema_status_has_json_ld = !s.structured_data.json_ld.is_empty();
let schema_status_summary = if !structural_issues.is_empty() {
i18n.t_args(
"pdf-seo-schema-status-issues",
&[
("nodes", valid_schema_nodes as i64),
("issues", structural_issues.len() as i64),
],
)
} else if schema_status_has_json_ld {
i18n.t_args(
"pdf-seo-schema-status-ok",
&[("nodes", valid_schema_nodes as i64)],
)
} else if s.structured_data.has_structured_data {
i18n.t("pdf-seo-schema-status-non-jsonld")
} else {
i18n.t("pdf-seo-schema-status-none")
};
let mut grouped_structural_issues: std::collections::BTreeMap<
&str,
(&crate::seo::schema::SchemaIssue, usize),
> = std::collections::BTreeMap::new();
for issue in &structural_issues {
grouped_structural_issues
.entry(issue.issue_type.as_str())
.and_modify(|(_, count)| *count += 1)
.or_insert((issue, 1));
}
let schema_status_rows = grouped_structural_issues
.into_values()
.map(|(issue, count)| {
let label = crate::seo::schema::schema_issue_label(issue, en);
(
if count > 1 {
format!("{label} ({count}×)")
} else {
label.to_string()
},
crate::seo::schema::schema_issue_text(issue, en),
)
})
.collect();
let (
schema_fit_summary,
schema_fit_is_success,
schema_fit_is_warning,
schema_fit_facts,
) = if let Some(fit) = &s.structured_data.fit_assessment {
use crate::seo::schema_fit::{SchemaCoverageStatus, SchemaFitStatus};
let expected = if fit.expected_primary_types.is_empty() {
i18n.t("pdf-seo-schema-fit-none")
} else {
fit.expected_primary_types.join(", ")
};
let detected = if fit.detected_primary_types.is_empty() {
i18n.t("pdf-seo-schema-fit-none")
} else {
fit.detected_primary_types.join(", ")
};
(
Some(fit.fit_text(en).to_string()),
fit.fit_status == SchemaFitStatus::Matched,
fit.fit_status == SchemaFitStatus::Mismatch
|| fit.coverage_status == SchemaCoverageStatus::Opportunity,
vec![
(
i18n.t("pdf-seo-schema-fit-page-type"),
fit.page_kind.label(en).to_string(),
),
(
i18n.t("pdf-seo-schema-fit-confidence"),
format!("{}%", fit.confidence),
),
(i18n.t("pdf-seo-schema-fit-expected"), expected),
(i18n.t("pdf-seo-schema-fit-detected"), detected),
],
)
} else {
(None, false, false, Vec::new())
};
let schema_rule_rows = s
.structured_data
.rule_assessments
.iter()
.map(|assessment| {
use crate::seo::schema_rules::SchemaFeatureAvailability;
let status = match assessment.availability {
SchemaFeatureAvailability::Limited => format!(
"{} - {}",
assessment.status_text(en),
i18n.t("pdf-seo-schema-rule-limited")
),
SchemaFeatureAvailability::ContextDependent => format!(
"{} - {}",
assessment.status_text(en),
i18n.t("pdf-seo-schema-rule-context-dependent")
),
SchemaFeatureAvailability::General => {
assessment.status_text(en).to_string()
}
};
let detail = if !assessment.missing_required.is_empty() {
i18n.t_args(
"pdf-seo-schema-rule-missing-required",
&[("fields", assessment.missing_required.join(", "))],
)
} else if !assessment.missing_recommended.is_empty() {
i18n.t_args(
"pdf-seo-schema-rule-missing-recommended",
&[("fields", assessment.missing_recommended.join(", "))],
)
} else if assessment.requirement_status
== crate::seo::schema_rules::SchemaRequirementStatus::NotEvaluated
{
i18n.t("pdf-seo-schema-rule-not-evaluated")
} else {
i18n.t("pdf-seo-schema-rule-complete")
};
(
format!(
"{} - {}",
assessment.schema_type,
assessment.feature.label(en)
),
status,
detail,
)
})
.collect();
let schema_manual_review_rows = s
.structured_data
.rule_assessments
.iter()
.flat_map(|assessment| {
assessment.manual_review.iter().map(|review| {
(
format!(
"{} - {}",
assessment.schema_type,
assessment.feature.label(en)
),
crate::seo::schema_rules::manual_review_text(review, en),
)
})
})
.collect();
let schema_parity_rows = s
.structured_data
.content_parity
.iter()
.map(|assessment| {
(
format!("{} - {}", assessment.schema_type, assessment.property),
assessment.status_text(en).to_string(),
assessment.evidence_text(en),
)
})
.collect();
let schema_rows: Vec<(String, String, String)> = cp
.schema_inventory
.schemas
.iter()
.map(|sd| {
let detail = match &sd.extracted {
SchemaExtracted::Organization { name, .. } => {
name.clone().unwrap_or_default()
}
SchemaExtracted::LocalBusiness { name, address, .. } => format!(
"{}{}",
name.as_deref().unwrap_or(""),
address
.as_ref()
.map(|a| format!(", {}", a))
.unwrap_or_default()
),
SchemaExtracted::Article {
headline, author, ..
} => format!(
"{}{}",
headline.as_deref().unwrap_or(""),
author
.as_ref()
.map(|a| format!(" ({})", a))
.unwrap_or_default()
),
SchemaExtracted::FAQPage { question_count, .. } => {
if en {
format!("{} questions", question_count)
} else {
format!("{} Fragen", question_count)
}
}
SchemaExtracted::Product {
name,
price,
currency,
..
} => format!(
"{}{}",
name.as_deref().unwrap_or(""),
price
.as_ref()
.map(|p| format!(" — {} {}", p, currency.as_deref().unwrap_or("")))
.unwrap_or_default()
),
SchemaExtracted::WebSite {
name,
has_search_action,
..
} => format!(
"{}{}",
name.as_deref().unwrap_or(""),
if *has_search_action {
if en {
" (search)"
} else {
" (Suche)"
}
} else {
""
}
),
SchemaExtracted::WebPage {
name,
author,
in_language,
..
} => format!(
"{}{}{}",
name.as_deref().unwrap_or(""),
author
.as_ref()
.map(|a| format!(" ({})", a))
.unwrap_or_default(),
in_language
.as_ref()
.map(|lang| format!(" · {}", lang))
.unwrap_or_default()
),
SchemaExtracted::Service {
name,
address,
area_served_count,
..
} => format!(
"{}{}{}",
name.as_deref().unwrap_or(""),
address
.as_ref()
.map(|a| format!(" — {}", a))
.unwrap_or_default(),
if *area_served_count > 0 {
if en {
format!(" · {} regions", area_served_count)
} else {
format!(" · {} Regionen", area_served_count)
}
} else {
String::new()
}
),
SchemaExtracted::BreadcrumbList { item_count } => {
if en {
format!("{} levels", item_count)
} else {
format!("{} Ebenen", item_count)
}
}
SchemaExtracted::Generic { key_fields } => key_fields
.first()
.map(|(k, v)| format!("{}: {}", k, v))
.unwrap_or_default(),
};
(
sd.schema_type.clone(),
format!("{}%", sd.completeness_pct),
detail,
)
})
.collect();
let signal_rows: Vec<(String, String, String)> = cp
.signal_strength
.categories
.iter()
.map(|cat| {
let rating = match (cat.pct(), en) {
(90..=100, true) => "Excellent",
(67..=89, true) => "Good",
(34..=66, true) => "Partial",
(1..=33, true) => "Minimal",
(_, true) => "Missing",
(90..=100, false) => "Sehr gut",
(67..=89, false) => "Gut",
(34..=66, false) => "Teilweise",
(1..=33, false) => "Minimal",
(_, false) => "Fehlt",
};
(
cat.name.clone(),
format!("{} / {}", cat.passed, cat.total),
rating.to_string(),
)
})
.collect();
let signal_details: SignalDetails = cp
.signal_strength
.categories
.iter()
.map(|cat| {
let checks = cat
.checks
.iter()
.map(|c| {
(
c.label.clone(),
c.passed,
c.detail.clone().unwrap_or_default(),
)
})
.collect();
(cat.name.clone(), checks)
})
.collect();
SeoProfilePresentation {
identity_summary: cp.content_identity.summary.clone(),
site_name: cp
.content_identity
.site_name
.clone()
.unwrap_or_else(|| "—".to_string()),
content_type: cp.content_identity.content_type.clone(),
language: cp
.content_identity
.language
.clone()
.unwrap_or_else(|| "—".to_string()),
category_hints: cp.content_identity.category_hints.clone(),
identity_facts: vec![
(
if en { "Page title" } else { "Seitentitel" }.to_string(),
cp.content_identity
.site_name
.clone()
.unwrap_or_else(|| "—".to_string()),
),
(
if en { "Content type" } else { "Inhaltstyp" }.to_string(),
cp.content_identity.content_type.clone(),
),
(
if en { "Language" } else { "Sprache" }.to_string(),
cp.content_identity
.language
.clone()
.unwrap_or_else(|| "—".to_string()),
),
(
if en { "Topic hints" } else { "Themenhinweise" }.to_string(),
if cp.content_identity.category_hints.is_empty() {
if en {
"No clear topic hints detected".to_string()
} else {
"Keine klaren Themenhinweise erkannt".to_string()
}
} else {
cp.content_identity.category_hints.join(", ")
},
),
],
page_type: cp.page_classification.primary_type.label(en).to_string(),
page_attributes: cp.page_classification.attributes.clone(),
content_depth_score: cp.page_classification.content_depth_score,
structural_richness_score: cp.page_classification.structural_richness_score,
media_text_balance_score: cp.page_classification.media_text_balance_score,
intent_fit_score: cp.page_classification.intent_fit_score,
page_profile_summary: page_profile_summary_text(cp, en),
optimization_note: page_profile_optimization_note_text(cp, en),
page_profile_facts: vec![
(
if en { "Page type" } else { "Seitentyp" }.to_string(),
cp.page_classification.primary_type.label(en).to_string(),
),
(
if en { "Characteristics" } else { "Merkmale" }.to_string(),
if cp.page_classification.attributes.is_empty() {
if en {
"No defining characteristics detected".to_string()
} else {
"Keine prägenden Merkmale erkannt".to_string()
}
} else {
format!("{}.", cp.page_classification.attributes.join(", "))
},
),
(
if en { "Classification" } else { "Einordnung" }.to_string(),
page_profile_summary_text(cp, en),
),
(
if en { "Recommendation" } else { "Empfehlung" }.to_string(),
page_profile_optimization_note_text(cp, en),
),
],
schema_rows,
schema_count: cp.schema_inventory.total_count,
schema_status_summary,
schema_status_has_errors: !structural_issues.is_empty(),
schema_status_has_json_ld,
schema_status_rows,
schema_fit_summary,
schema_fit_is_success,
schema_fit_is_warning,
schema_fit_facts,
schema_rule_rows,
schema_manual_review_rows,
schema_parity_rows,
signal_rows,
signal_passed: cp.signal_strength.totals().0,
signal_total: cp.signal_strength.totals().1,
signal_details,
maturity_level: cp.maturity.label(en).to_string(),
maturity_description: cp.maturity.description(en).to_string(),
maturity_techniques_used: cp.maturity_techniques,
maturity_techniques_total: 13,
}
});
SeoPresentation {
score: seo_score,
interpretation: seo_interpretation_text(s, en),
meta_tags,
meta_issues,
heading_summary: if en {
format!(
"{} H1 heading(s), {} headings total, {} issues",
s.headings.h1_count,
s.headings.total_count,
s.headings.issues.len()
)
} else {
format!(
"{} H1-Überschrift(en), {} Überschriften gesamt, {} Probleme",
s.headings.h1_count,
s.headings.total_count,
s.headings.issues.len()
)
},
social_summary: if en {
format!(
"Open Graph: {}, Twitter Card: {}, Completeness: {}%",
if s.social.open_graph.is_some() {
"present"
} else {
"missing"
},
if s.social.twitter_card.is_some() {
"present"
} else {
"missing"
},
s.social.completeness
)
} else {
format!(
"Open Graph: {}, Twitter Card: {}, Vollständigkeit: {}%",
if s.social.open_graph.is_some() {
"vorhanden"
} else {
"fehlt"
},
if s.social.twitter_card.is_some() {
"vorhanden"
} else {
"fehlt"
},
s.social.completeness
)
},
technical_summary: vec![
("HTTPS".to_string(), yes_no(locale, s.technical.https)),
(
"Canonical".to_string(),
yes_no(locale, s.technical.has_canonical),
),
(
if en { "Language tag" } else { "Sprachangabe" }.to_string(),
yes_no(locale, s.technical.has_lang),
),
(
if en { "Word count" } else { "Wortanzahl" }.to_string(),
s.technical.word_count.to_string(),
),
(
if en {
"Internal links"
} else {
"Interne Links"
}
.to_string(),
s.technical.internal_links.to_string(),
),
(
if en {
"External links"
} else {
"Externe Links"
}
.to_string(),
s.technical.external_links.to_string(),
),
(
if en {
"Dofollow links"
} else {
"Dofollow-Links"
}
.to_string(),
s.technical.dofollow_links.to_string(),
),
(
if en {
"Nofollow links"
} else {
"Nofollow-Links"
}
.to_string(),
s.technical.nofollow_links.to_string(),
),
(
"AMP".to_string(),
if !s.technical.amp.detected {
if en {
"Not detected".to_string()
} else {
"Nicht erkannt".to_string()
}
} else if s.technical.amp.issues.is_empty() {
if en {
"Detected, basic signals complete".to_string()
} else {
"Erkannt, Basis-Signale vollständig".to_string()
}
} else if en {
format!("Detected, {} basic issue(s)", s.technical.amp.issues.len())
} else {
format!("Erkannt, {} Basisproblem(e)", s.technical.amp.issues.len())
},
),
],
tracking_summary: vec![
(
if en {
"Google Fonts (external)"
} else {
"Google Fonts (extern)"
}
.to_string(),
if s.technical.uses_remote_google_fonts {
format!(
"{} ({})",
if en { "Yes" } else { "Ja" },
truncate_url_list(&s.technical.google_fonts_sources, 2, 48)
)
} else if en {
"No".to_string()
} else {
"Nein".to_string()
},
),
(
if en {
"Tracking cookies"
} else {
"Tracking-Cookies"
}
.to_string(),
if s.technical.tracking_cookies.is_empty() {
if en {
"None detected".to_string()
} else {
"Keine erkannt".to_string()
}
} else {
format!(
"{} ({})",
s.technical.tracking_cookies.len(),
s.technical
.tracking_cookies
.iter()
.map(|c| c.name.clone())
.collect::<Vec<_>>()
.join(", ")
)
},
),
(
if en {
"Cookie inventory"
} else {
"Cookie-Inventar"
}
.to_string(),
if s.technical.cookie_inventory.is_empty() {
if en {
"No cookies detected".to_string()
} else {
"Keine Cookies erkannt".to_string()
}
} else {
let risky = s
.technical
.cookie_inventory
.iter()
.filter(|cookie| !cookie.secure || cookie.same_site.is_none())
.count();
let category_suffix = tracking_category_summary(
s.technical
.cookie_inventory
.iter()
.filter_map(|cookie| cookie.category.as_deref()),
)
.map(|summary| {
if en {
format!("; categories: {summary}")
} else {
format!("; Kategorien: {summary}")
}
})
.unwrap_or_default();
if en {
format!(
"{} cookies, {} without Secure/SameSite signal{}",
s.technical.cookie_inventory.len(),
risky,
category_suffix
)
} else {
format!(
"{} Cookies, {} ohne Secure/SameSite-Signal{}",
s.technical.cookie_inventory.len(),
risky,
category_suffix
)
}
},
),
(
"Browser Storage".to_string(),
if s.technical.storage_items.is_empty() {
if en {
"No keys detected".to_string()
} else {
"Keine Keys erkannt".to_string()
}
} else {
format!(
"{} ({}){}",
s.technical.storage_items.len(),
s.technical
.storage_items
.iter()
.map(|item| format!("{}:{}", item.area, item.key))
.collect::<Vec<_>>()
.join(", "),
tracking_category_summary(
s.technical
.storage_items
.iter()
.filter_map(|item| item.category.as_deref()),
)
.map(|summary| {
if en {
format!("; categories: {summary}")
} else {
format!("; Kategorien: {summary}")
}
})
.unwrap_or_default()
)
},
),
(
if en {
"Tracking signals"
} else {
"Tracking-Signale"
}
.to_string(),
if s.technical.tracking_signals.is_empty() {
if en {
"None detected".to_string()
} else {
"Keine erkannt".to_string()
}
} else {
truncate_list(&s.technical.tracking_signals, 3)
},
),
(
if en {
"Consent cookies"
} else {
"Consent-Cookies"
}
.to_string(),
normalized
.normalized
.consent_privacy
.as_ref()
.map(|snapshot| {
let category_suffix = tracking_category_summary(
snapshot
.added_after_interaction
.iter()
.filter_map(|cookie| cookie.category.as_deref()),
)
.map(|summary| {
if en {
format!("; categories: {summary}")
} else {
format!("; Kategorien: {summary}")
}
})
.unwrap_or_default();
if en {
format!(
"{} before interaction, {} after interaction, {} new{}",
snapshot.before_interaction.len(),
snapshot.after_interaction.len(),
snapshot.added_after_interaction.len(),
category_suffix
)
} else {
format!(
"{} vor Interaktion, {} nach Interaktion, {} neu{}",
snapshot.before_interaction.len(),
snapshot.after_interaction.len(),
snapshot.added_after_interaction.len(),
category_suffix
)
}
})
.unwrap_or_else(|| {
if en {
"Not collected".to_string()
} else {
"Nicht erhoben".to_string()
}
}),
),
(
"Zaraz".to_string(),
if s.technical.zaraz.detected {
if en {
format!(
"Detected ({})",
truncate_list(&s.technical.zaraz.signals, 2)
)
} else {
format!("Erkannt ({})", truncate_list(&s.technical.zaraz.signals, 2))
}
} else if en {
"Not detected".to_string()
} else {
"Nicht erkannt".to_string()
},
),
],
tracking_summary_text: build_tracking_summary_text(i18n, &s.technical),
profile,
page_health: s
.page_health
.as_ref()
.map(|p| build_page_health_presentation(locale, p)),
serp: s.serp.as_ref().map(|_| {
let localized =
crate::seo::build_serp_analysis(s, &normalized.normalized.url, locale);
build_serp_presentation(locale, &localized)
}),
robots: s.robots.as_ref().map(|r| {
use crate::seo::BotClass;
let bot_rows: Vec<(String, String, usize, usize, bool)> = r
.groups
.iter()
.map(|g| {
let fully_blocked = g.disallows.iter().any(|d| d == "/");
(
g.user_agent.clone(),
g.bot_class.label(en).to_string(),
g.allows.len(),
g.disallows.len(),
fully_blocked,
)
})
.collect();
let blocked_ai_bots: Vec<String> = r
.groups
.iter()
.filter(|g| {
matches!(
g.bot_class,
BotClass::AiTraining
| BotClass::AiCitation
| BotClass::AiMixed
| BotClass::UnknownAi
) && g.disallows.iter().any(|d| d == "/")
})
.map(|g| g.user_agent.clone())
.collect();
RobotsPresentation {
error: r.error.clone(),
has_wildcard_disallow_all: r.has_wildcard_disallow_all,
blocks_ai_crawlers: r.blocks_ai_crawlers,
blocks_ai_citation: r.blocks_ai_citation,
inferred_policy: crate::seo::infer_robots_policy(r, en),
sitemaps: r.sitemaps.clone(),
crawl_delays: r.crawl_delays.clone(),
bot_rows,
blocked_ai_bots,
noindex_in_sitemap: r.noindex_in_sitemap,
}
}),
image_efficiency: s
.image_efficiency
.as_ref()
.filter(|ie| ie.total_images > 0)
.map(|ie| ImageEfficiencyPresentation {
total_images: ie.total_images,
modern_format_pct: ie.modern_format_pct,
legacy_count: ie.legacy_format_count,
oversized: ie
.oversized_images
.iter()
.take(5)
.map(|o| OversizedImageRow {
src: truncate_url(&o.src, 60),
natural: format!("{}×{}", o.natural_width, o.natural_height),
display: format!("{}×{}", o.display_width, o.display_height),
})
.collect(),
}),
technical_issues: crate::seo::collect_technical_issues(&s.technical, en)
.iter()
.map(|i| {
(
i.issue_type.clone(),
i.message.clone(),
if en {
i.severity.label_en().to_string()
} else {
i.severity.label().to_string()
},
)
})
.collect(),
}
})
}
fn tracking_category_summary<'a>(categories: impl Iterator<Item = &'a str>) -> Option<String> {
let mut counts = std::collections::BTreeMap::new();
for category in categories {
*counts.entry(category).or_insert(0usize) += 1;
}
if counts.is_empty() {
return None;
}
Some(
counts
.into_iter()
.map(|(category, count)| format!("{category} {count}"))
.collect::<Vec<_>>()
.join(", "),
)
}
fn build_security_details(
normalized: &AuditContext<'_>,
i18n: &I18n,
) -> Option<SecurityPresentation> {
let locale = i18n.locale();
normalized.raw_security.map(|sec| {
let security_score =
normalized_module_score(&normalized.normalized, "Security").unwrap_or(sec.score);
let header_checks: Vec<(&str, &Option<String>)> = vec![
(
"Content-Security-Policy",
&sec.headers.content_security_policy,
),
(
"Strict-Transport-Security",
&sec.headers.strict_transport_security,
),
(
"X-Content-Type-Options",
&sec.headers.x_content_type_options,
),
("X-Frame-Options", &sec.headers.x_frame_options),
("Referrer-Policy", &sec.headers.referrer_policy),
("Permissions-Policy", &sec.headers.permissions_policy),
(
"Cross-Origin-Opener-Policy",
&sec.headers.cross_origin_opener_policy,
),
(
"Cross-Origin-Resource-Policy",
&sec.headers.cross_origin_resource_policy,
),
(
"Access-Control-Allow-Origin",
&sec.headers.access_control_allow_origin,
),
(
"Access-Control-Allow-Credentials",
&sec.headers.access_control_allow_credentials,
),
];
let mut ssl_info = vec![
("HTTPS".to_string(), yes_no(locale, sec.ssl.https)),
(
"Gültiges Zertifikat".to_string(),
yes_no(locale, sec.ssl.valid_certificate),
),
("HSTS".to_string(), yes_no(locale, sec.ssl.has_hsts)),
(
"HSTS Max-Age".to_string(),
sec.ssl
.hsts_max_age
.map(|v| format!("{}s", v))
.unwrap_or_else(|| "—".to_string()),
),
(
"Subdomains".to_string(),
yes_no(locale, sec.ssl.hsts_include_subdomains),
),
("Preload".to_string(), yes_no(locale, sec.ssl.hsts_preload)),
];
push_optional_ssl_row(&mut ssl_info, "TLS", sec.ssl.protocol.as_deref());
push_optional_ssl_row(&mut ssl_info, "Cipher", sec.ssl.cipher.as_deref());
push_optional_ssl_row(
&mut ssl_info,
"Zertifikat Subject",
sec.ssl.certificate_subject.as_deref(),
);
push_optional_ssl_row(
&mut ssl_info,
"Zertifikat Issuer",
sec.ssl.certificate_issuer.as_deref(),
);
if let Some(days) = sec.ssl.certificate_expires_in_days {
ssl_info.push(("Läuft ab in".to_string(), format!("{days} Tage")));
}
if let Some(chain_length) = sec.ssl.certificate_chain_length {
ssl_info.push(("Chain-Länge".to_string(), chain_length.to_string()));
}
push_optional_ssl_row(
&mut ssl_info,
"Zertifikatsfehler",
sec.ssl.certificate_error.as_deref(),
);
SecurityPresentation {
score: security_score,
grade: normalized_module_grade(&normalized.normalized, "Security")
.unwrap_or_else(|| sec.grade.clone()),
interpretation: module_interpretation(&normalized.normalized, "security", locale),
band_label: crate::registry::FIVE_BAND
.label(
crate::audit::interpretation::security_text_band(
security_score as f32,
&sec.issues,
)
.representative_score(),
locale == "en",
)
.to_string(),
headers: header_checks
.iter()
.map(|(name, value)| {
let (status, val) = match value {
Some(v) => ("Vorhanden".to_string(), truncate_url(v, 50)),
None => ("Fehlt".to_string(), "—".to_string()),
};
let tier =
security_header_tier_label(locale, crate::security::header_tier(name));
(name.to_string(), status, val, tier)
})
.collect(),
ssl_info,
issues: sec
.issues
.iter()
.map(|i| {
let title = format!(
"{} — {}",
i.header,
security_issue_kind_label(locale, &i.issue_type)
);
(title, i.severity, i.localized_message(locale == "en"))
})
.collect(),
recommendations: derive_security_recommendations(i18n, sec),
protection: sec
.protection
.services
.iter()
.map(|s| (s.name.clone(), s.kind.clone()))
.collect(),
has_waf: sec.protection.has_waf,
has_cdn: sec.protection.has_cdn,
}
})
}
fn push_optional_ssl_row(rows: &mut Vec<(String, String)>, label: &str, value: Option<&str>) {
if let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) {
rows.push((label.to_string(), truncate_url(value, 80)));
}
}
fn distinct_html_conform_rows(
findings: &[crate::html_conform::HtmlConformFinding],
severity_label: &impl Fn(&str) -> String,
) -> Vec<(String, String, String, String, u32)> {
let mut order: Vec<String> = Vec::new();
let mut rows: std::collections::HashMap<String, (String, String, String, String, u32)> =
std::collections::HashMap::new();
for finding in findings {
let key = crate::html_conform::defect_key(finding);
match rows.get_mut(&key) {
Some(row) => row.4 += 1,
None => {
order.push(key.clone());
rows.insert(
key,
(
finding.rule_id.clone(),
severity_label(&finding.severity),
finding
.message
.split(" at ")
.next()
.unwrap_or(&finding.message)
.trim()
.to_string(),
finding.location.clone().unwrap_or_else(|| "—".to_string()),
1,
),
);
}
}
}
order
.into_iter()
.take(20)
.filter_map(|key| rows.remove(&key))
.collect()
}
fn build_html_conform_details(
normalized: &AuditContext<'_>,
i18n: &I18n,
) -> Option<HtmlConformPresentation> {
let locale = i18n.locale();
normalized.raw_html_conform.map(|hc| {
let score =
normalized_module_score(&normalized.normalized, "HTML Conformance").unwrap_or(hc.score);
let severity_label = |severity: &str| match severity {
"error" => i18n.t("severity-error"),
"warning" => i18n.t("severity-warning"),
_ => i18n.t("severity-info"),
};
let rows = distinct_html_conform_rows(&hc.findings, &severity_label);
let error_label = i18n.t("severity-error");
let warning_label = i18n.t("severity-warning");
let severity_rank = |label: &str| -> u8 {
if label == error_label {
0
} else if label == warning_label {
1
} else {
2
}
};
let mut ranked: Vec<&(String, String, String, String, u32)> = rows.iter().collect();
ranked.sort_by(|a, b| {
severity_rank(&a.1)
.cmp(&severity_rank(&b.1))
.then(b.4.cmp(&a.4))
});
let recommendations = ranked.into_iter().take(4).map(|r| r.2.clone()).collect();
HtmlConformPresentation {
score,
checked: hc.checked,
interpretation: module_interpretation(&normalized.normalized, "html_conform", locale),
error_count: hc.error_count,
warning_count: hc.warning_count,
info_count: hc.info_count,
distinct_defect_count: hc.distinct_defect_count,
findings: rows,
recommendations,
}
})
}
fn build_commerce_details(
normalized: &AuditContext<'_>,
i18n: &I18n,
) -> Option<CommercePresentation> {
let c = normalized.raw_commerce?;
let en = i18n.locale() == "en";
let missing_trust_page = !c.trust_pages.impressum
|| !c.trust_pages.agb
|| !c.trust_pages.widerruf
|| !c.trust_pages.versand
|| !c.trust_pages.zahlungsarten
|| !c.trust_pages.kontakt;
if c.product.is_none() && !missing_trust_page {
return None;
}
let page_kind_label = match c.page_kind {
CommercePageKind::ProductDetail => {
if en {
"Product detail page"
} else {
"Produktdetailseite"
}
}
CommercePageKind::Category => {
if en {
"Category page"
} else {
"Kategorieseite"
}
}
CommercePageKind::Other => {
if en {
"Other shop page"
} else {
"Sonstige Shop-Seite"
}
}
}
.to_string();
let product = c.product.as_ref().map(|p| CommerceProductRow {
score: p.score,
price: p.price.as_ref().map(|price| match &price.currency {
Some(currency) => format!("{} {currency}", price.value),
None => price.value.clone(),
}),
availability: p.availability.clone(),
has_shipping_details: p.shipping.has_shipping_details,
has_return_policy: p.returns.has_return_policy,
rating: p.reviews.rating_value.clone(),
});
let trust_page_label = |key: &str| -> &'static str {
match (key, en) {
("impressum", true) => "Imprint",
("impressum", false) => "Impressum",
("agb", true) => "Terms (AGB)",
("agb", false) => "AGB",
("widerruf", true) => "Right of withdrawal / returns",
("widerruf", false) => "Widerruf / Retoure",
("versand", true) => "Shipping info",
("versand", false) => "Versand / Lieferung",
("zahlungsarten", true) => "Payment methods",
("zahlungsarten", false) => "Zahlungsarten",
(_, true) => "Contact",
(_, false) => "Kontakt",
}
};
let trust_pages = vec![
(
trust_page_label("impressum").to_string(),
c.trust_pages.impressum,
),
(trust_page_label("agb").to_string(), c.trust_pages.agb),
(
trust_page_label("widerruf").to_string(),
c.trust_pages.widerruf,
),
(
trust_page_label("versand").to_string(),
c.trust_pages.versand,
),
(
trust_page_label("zahlungsarten").to_string(),
c.trust_pages.zahlungsarten,
),
(
trust_page_label("kontakt").to_string(),
c.trust_pages.kontakt,
),
];
let findings = c
.findings
.iter()
.map(|f| {
let severity_label = if en {
f.severity.label_en()
} else {
f.severity.label()
};
(
severity_label.to_string(),
commerce_finding_text(f.kind, en),
)
})
.collect();
Some(CommercePresentation {
page_kind_label,
product,
trust_pages,
findings,
})
}
fn build_mobile_details(normalized: &AuditContext<'_>, i18n: &I18n) -> Option<MobilePresentation> {
let locale = i18n.locale();
normalized.raw_mobile.map(|m| {
let mobile_score =
normalized_module_score(&normalized.normalized, "Mobile").unwrap_or(m.score);
let small_targets = m.touch_targets.small_targets;
let en = locale == "en";
let context_hint = if !m.touch_targets.small_by_context.is_empty() {
let parts: Vec<String> = m
.touch_targets
.small_by_context
.iter()
.take(3)
.map(|(ctx, count)| {
if en {
format!("{} in {}", count, ctx)
} else {
format!("{} im Bereich {}", count, ctx)
}
})
.collect();
format!(" ({})", parts.join(", "))
} else {
String::new()
};
let base_mobile = module_interpretation(&normalized.normalized, "mobile", locale);
let mobile_interpretation = if small_targets >= 10 {
let scope_note = if !context_hint.is_empty() {
if en {
" — locally scoped"
} else {
" — lokal begrenzt"
}
} else {
""
};
if en {
format!(
"{} {} touch targets smaller than recommended (44×44 px){}{}.",
base_mobile, small_targets, context_hint, scope_note,
)
} else {
format!(
"{} {} Touch-Targets kleiner als empfohlen (44×44 px){}{}.",
base_mobile, small_targets, context_hint, scope_note,
)
}
} else {
base_mobile
};
MobilePresentation {
score: mobile_score,
interpretation: mobile_interpretation,
viewport: vec![
(
"Viewport-Tag".to_string(),
yes_no(locale, m.viewport.has_viewport),
),
(
"device-width".to_string(),
yes_no(locale, m.viewport.uses_device_width),
),
(
"Initial Scale".to_string(),
yes_no(locale, m.viewport.has_initial_scale),
),
(
"Skalierbar".to_string(),
yes_no(locale, m.viewport.is_scalable),
),
(
"Korrekt konfiguriert".to_string(),
yes_no(locale, m.viewport.is_properly_configured),
),
],
touch_targets: vec![
(
"Gesamt".to_string(),
m.touch_targets.total_targets.to_string(),
),
(
"Ausreichend (≥44px)".to_string(),
m.touch_targets.adequate_targets.to_string(),
),
(
"Zu klein".to_string(),
m.touch_targets.small_targets.to_string(),
),
(
"Zu eng beieinander".to_string(),
m.touch_targets.crowded_targets.to_string(),
),
],
font_analysis: vec![
(
"Basis-Schriftgröße".to_string(),
format!("{:.0}px", m.font_sizes.base_font_size),
),
(
"Kleinste Schrift".to_string(),
format!("{:.0}px", m.font_sizes.smallest_font_size),
),
(
"Lesbarer Text".to_string(),
format!("{:.0}%", m.font_sizes.legible_percentage),
),
(
"Relative Einheiten".to_string(),
yes_no(locale, m.font_sizes.uses_relative_units),
),
],
content_sizing: vec![
(
"Passt in Viewport".to_string(),
yes_no(locale, m.content_sizing.fits_viewport),
),
(
"Kein hor. Scrollen".to_string(),
yes_no(locale, !m.content_sizing.has_horizontal_scroll),
),
(
"Responsive Bilder".to_string(),
yes_no(locale, m.content_sizing.uses_responsive_images),
),
(
"Media Queries".to_string(),
yes_no(locale, m.content_sizing.uses_media_queries),
),
],
issues: m
.issues
.iter()
.map(|i| {
(
i.category.clone(),
i.severity,
i.localized_message(locale == "en"),
)
})
.collect(),
}
})
}
fn build_dark_mode_details(
normalized: &AuditContext<'_>,
i18n: &I18n,
) -> Option<DarkModePresentation> {
let en = i18n.locale() == "en";
normalized.raw_dark_mode.map(|dm| DarkModePresentation {
supported: dm.supported,
score: dm.score,
detection_methods: dm.detection_methods.clone(),
color_scheme_css: dm.color_scheme_css,
meta_color_scheme: dm.meta_color_scheme.clone(),
css_custom_properties: dm.css_custom_properties,
dark_contrast_violations: dm.dark_contrast_violations,
dark_only_violations: dm.dark_only_violations,
light_only_violations: dm.light_only_violations,
print_stylesheet_detected: dm.print.stylesheet_detected,
print_interactive_chrome_hidden: dm.print.interactive_chrome_hidden,
print_content_not_clipped: dm.print.content_not_clipped,
print_clipped_elements: dm.print.clipped_elements,
forced_colors_detected: dm.forced_colors.stylesheet_detected,
forced_colors_active_matches: dm.forced_colors.active_matches,
forced_color_adjust_count: dm.forced_colors.forced_color_adjust_count,
forced_colors_focus_visible: dm.forced_colors.focus_indicators_visible,
vision_deficiency_modes: dm
.vision_deficiency
.modes
.iter()
.map(|mode| VisionDeficiencyModePresentation {
mode: mode.mode.clone(),
contrast_violations: mode.contrast_violations,
new_contrast_violations: mode.new_contrast_violations,
use_of_color_violations: mode.use_of_color_violations,
})
.collect(),
issues: dm
.issues
.iter()
.map(|i| {
(
i.severity.clone(),
dark_mode_issue_text(&i.kind, &i.selectors, en),
)
})
.collect(),
})
}
fn build_design_quality_details(
normalized: &AuditContext<'_>,
i18n: &I18n,
) -> Option<DesignQualityPresentation> {
let en = i18n.locale() == "en";
normalized.raw_design_quality.map(|dq| {
let warning_count = dq.warnings().count();
let advisory_count = dq.advisories().count();
DesignQualityPresentation {
warning_count,
advisory_count,
findings: dq
.findings
.iter()
.map(|f| {
let level_label = match f.level {
crate::design_quality::FindingLevel::Warning => {
if en {
"Warning"
} else {
"Warnung"
}
}
crate::design_quality::FindingLevel::Advisory => {
if en {
"Advisory"
} else {
"Hinweis"
}
}
};
let confidence_label = match f.confidence {
crate::design_quality::Confidence::High => {
if en {
"High"
} else {
"Hoch"
}
}
crate::design_quality::Confidence::Medium => {
if en {
"Medium"
} else {
"Mittel"
}
}
crate::design_quality::Confidence::Low => {
if en {
"Low"
} else {
"Niedrig"
}
}
};
DesignQualityFindingPresentation {
rule_id: f.rule_id.clone(),
level_label: level_label.to_string(),
confidence_label: confidence_label.to_string(),
selector: f.selector.clone(),
evidence: f.evidence.clone(),
message: crate::design_quality::finding_message_text(
&f.rule_id, f.level, en,
),
}
})
.collect(),
}
})
}
fn build_ai_transparency_details(
normalized: &AuditContext<'_>,
i18n: &I18n,
) -> Option<AiTransparencyPresentation> {
let en = i18n.locale() == "en";
normalized.raw_ai_transparency.map(|a| AiTransparencyPresentation {
images_checked: a.images_checked,
findings: a
.findings
.iter()
.map(|f| {
let provenance_label = if en {
match f.provenance {
crate::ai_transparency::AiProvenanceKind::TrainedAlgorithmicMedia => {
"AI-trained model (trainedAlgorithmicMedia)"
}
crate::ai_transparency::AiProvenanceKind::CompositeWithTrainedAlgorithmicMedia => {
"AI-assisted composite (compositeWithTrainedAlgorithmicMedia)"
}
crate::ai_transparency::AiProvenanceKind::CompositeSynthetic => {
"Composite with AI content (compositeSynthetic)"
}
crate::ai_transparency::AiProvenanceKind::VirtualRecording => {
"Virtual recording with AI content (virtualRecording)"
}
crate::ai_transparency::AiProvenanceKind::TrainedAlgorithmicData => {
"Algorithmically generated data (trainedAlgorithmicData)"
}
}
} else {
match f.provenance {
crate::ai_transparency::AiProvenanceKind::TrainedAlgorithmicMedia => {
"KI-trainiertes Modell (trainedAlgorithmicMedia)"
}
crate::ai_transparency::AiProvenanceKind::CompositeWithTrainedAlgorithmicMedia => {
"KI-gestützte Komposition (compositeWithTrainedAlgorithmicMedia)"
}
crate::ai_transparency::AiProvenanceKind::CompositeSynthetic => {
"Komposition mit KI-Anteil (compositeSynthetic)"
}
crate::ai_transparency::AiProvenanceKind::VirtualRecording => {
"Virtuelle Aufzeichnung mit KI-Anteil (virtualRecording)"
}
crate::ai_transparency::AiProvenanceKind::TrainedAlgorithmicData => {
"Algorithmisch erzeugte Daten (trainedAlgorithmicData)"
}
}
}
.to_string();
let validation_label = if en {
match f.validation {
crate::ai_transparency::ManifestValidation::Invalid => "not validated",
crate::ai_transparency::ManifestValidation::Valid => "valid",
crate::ai_transparency::ManifestValidation::Trusted => "trusted",
}
} else {
match f.validation {
crate::ai_transparency::ManifestValidation::Invalid => "nicht validiert",
crate::ai_transparency::ManifestValidation::Valid => "gültig",
crate::ai_transparency::ManifestValidation::Trusted => "vertrauenswürdig",
}
}
.to_string();
ImageProvenanceFindingPresentation {
image_url: f.image_url.clone(),
provenance_label,
validation_label,
generator: f.generator.clone(),
message: crate::ai_transparency::finding_message_text(&f.rule_id, en),
}
})
.collect(),
})
}
fn build_ux_details(normalized: &AuditContext<'_>, i18n: &I18n) -> Option<UxPresentation> {
let locale = i18n.locale();
normalized.raw_ux.map(|u| {
let ux_score = normalized_module_score(&normalized.normalized, "UX").unwrap_or(u.score);
UxPresentation {
score: ux_score,
a11y_penalty: u.score.saturating_sub(ux_score),
score_before_a11y_penalty: u.score,
grade: normalized_module_grade(&normalized.normalized, "UX")
.unwrap_or_else(|| u.grade.clone()),
interpretation: module_interpretation(&normalized.normalized, "ux", locale),
dimensions: vec![
UxDimensionPresentation {
kind: u.cta_clarity.kind,
name: u.cta_clarity.name.clone(),
score: u.cta_clarity.score,
summary: u.cta_clarity.summary.clone(),
},
UxDimensionPresentation {
kind: u.visual_hierarchy.kind,
name: u.visual_hierarchy.name.clone(),
score: u.visual_hierarchy.score,
summary: u.visual_hierarchy.summary.clone(),
},
UxDimensionPresentation {
kind: u.content_clarity.kind,
name: u.content_clarity.name.clone(),
score: u.content_clarity.score,
summary: u.content_clarity.summary.clone(),
},
UxDimensionPresentation {
kind: u.trust_signals.kind,
name: u.trust_signals.name.clone(),
score: u.trust_signals.score,
summary: u.trust_signals.summary.clone(),
},
UxDimensionPresentation {
kind: u.cognitive_load.kind,
name: u.cognitive_load.name.clone(),
score: u.cognitive_load.score,
summary: u.cognitive_load.summary.clone(),
},
],
issues: u
.issues
.iter()
.map(|i| UxIssuePresentation {
kind: i.kind,
dimension: i.dimension.clone(),
severity: i.severity.clone(),
problem: i.problem.clone(),
impact: i.impact.clone(),
recommendation: i.recommendation.clone(),
values: i.values.clone(),
})
.collect(),
}
})
}
fn build_journey_details(
normalized: &AuditContext<'_>,
i18n: &I18n,
) -> Option<JourneyPresentation> {
let locale = i18n.locale();
let en = locale == "en";
normalized.raw_journey.map(|j| {
let journey_score =
normalized_module_score(&normalized.normalized, "Journey").unwrap_or(j.score);
let seo_type: Option<String> = normalized
.raw_seo
.and_then(|s| s.content_profile.as_ref())
.map(|cp| cp.page_classification.primary_type.label(en).to_lowercase());
let journey_type = j.page_intent.label(en).to_lowercase();
let type_note = match seo_type {
Some(ref st) if !st.is_empty() && !journey_type.is_empty() && st != &journey_type => {
if locale == "en" {
format!(
" (Primary classification: {}. Secondary signals point to {}.)",
st, journey_type
)
} else {
format!(
" (Primäre Einordnung: {}. Sekundäre Signale deuten auf {} hin.)",
st, journey_type
)
}
}
_ => String::new(),
};
let base_journey = module_interpretation(&normalized.normalized, "journey", locale);
let journey_interpretation = if type_note.is_empty() {
base_journey
} else {
format!("{}{}", base_journey, type_note)
};
JourneyPresentation {
score: journey_score,
a11y_penalty: j.score.saturating_sub(journey_score),
score_before_a11y_penalty: j.score,
grade: normalized_module_grade(&normalized.normalized, "Journey")
.unwrap_or_else(|| j.grade.clone()),
page_intent: j.page_intent.label(en).to_string(),
interpretation: journey_interpretation,
dimensions: vec![
JourneyDimensionPresentation {
kind: j.entry_clarity.kind,
name: j.entry_clarity.name.clone(),
score: j.entry_clarity.score,
weight_pct: (j.entry_clarity.weight * 100.0).round() as u32,
summary: j.entry_clarity.summary.clone(),
},
JourneyDimensionPresentation {
kind: j.orientation.kind,
name: j.orientation.name.clone(),
score: j.orientation.score,
weight_pct: (j.orientation.weight * 100.0).round() as u32,
summary: j.orientation.summary.clone(),
},
JourneyDimensionPresentation {
kind: j.navigation.kind,
name: j.navigation.name.clone(),
score: j.navigation.score,
weight_pct: (j.navigation.weight * 100.0).round() as u32,
summary: j.navigation.summary.clone(),
},
JourneyDimensionPresentation {
kind: j.interaction.kind,
name: j.interaction.name.clone(),
score: j.interaction.score,
weight_pct: (j.interaction.weight * 100.0).round() as u32,
summary: j.interaction.summary.clone(),
},
JourneyDimensionPresentation {
kind: j.conversion.kind,
name: j.conversion.name.clone(),
score: j.conversion.score,
weight_pct: (j.conversion.weight * 100.0).round() as u32,
summary: j.conversion.summary.clone(),
},
],
friction_points: j
.friction_points
.iter()
.map(|fp| FrictionPointPresentation {
kind: fp.kind,
step: fp.step.clone(),
severity: fp.severity.clone(),
problem: fp.problem.clone(),
impact: fp.impact.clone(),
recommendation: fp.recommendation.clone(),
values: fp.values.clone(),
})
.collect(),
}
})
}
pub(super) fn build_module_details_from_normalized(
i18n: &I18n,
normalized: &AuditContext<'_>,
) -> ModuleDetailsBlock {
let performance = build_performance_details(normalized, i18n);
let search_experience = build_search_experience(normalized, i18n);
let seo = build_seo_details(normalized, i18n);
let security = build_security_details(normalized, i18n);
let html_conform = build_html_conform_details(normalized, i18n);
let commerce = build_commerce_details(normalized, i18n);
let mobile = build_mobile_details(normalized, i18n);
let dark_mode = build_dark_mode_details(normalized, i18n);
let design_quality = build_design_quality_details(normalized, i18n);
let ai_transparency = build_ai_transparency_details(normalized, i18n);
let ux = build_ux_details(normalized, i18n);
let journey = build_journey_details(normalized, i18n);
let network_dns = normalized.raw_network_dns.cloned();
let source_quality = normalized.raw_source_quality.cloned();
let ai_visibility = normalized.raw_ai_visibility.cloned();
let tech_stack = normalized.raw_tech_stack.cloned();
let content_visibility = normalized.raw_content_visibility.cloned();
let best_practices = normalized.raw_best_practices.cloned();
let patterns = normalized.raw_patterns.cloned();
let has_any = performance.is_some()
|| search_experience.is_some()
|| seo.is_some()
|| security.is_some()
|| html_conform.is_some()
|| commerce.is_some()
|| mobile.is_some()
|| ux.is_some()
|| journey.is_some()
|| dark_mode.is_some()
|| design_quality.is_some()
|| ai_transparency.is_some()
|| network_dns.is_some()
|| source_quality.is_some()
|| ai_visibility.is_some()
|| tech_stack.is_some()
|| content_visibility.is_some()
|| best_practices.is_some()
|| patterns.is_some();
ModuleDetailsBlock {
search_experience,
performance,
seo,
security,
html_conform,
commerce,
mobile,
ux,
journey,
dark_mode,
design_quality,
ai_transparency,
network_dns,
source_quality,
ai_visibility,
tech_stack,
content_visibility,
best_practices,
patterns,
has_any,
}
}
#[cfg(test)]
pub(super) fn pdf_rendered_modules() -> std::collections::BTreeSet<&'static str> {
[
"performance",
"seo",
"security",
"html_conform",
"commerce",
"mobile",
"ux",
"journey",
"dark_mode",
"design_quality",
"ai_transparency",
"network_dns",
"source_quality",
"ai_visibility",
"tech_stack",
"content_visibility",
"best_practices",
"patterns",
]
.into_iter()
.collect()
}
pub(super) fn normalized_module_score(
normalized: &NormalizedReport,
module_name: &str,
) -> Option<u32> {
normalized
.module_scores
.iter()
.find(|m| m.name == module_name)
.map(|m| m.score)
}
pub(super) fn normalized_module_grade(
normalized: &NormalizedReport,
module_name: &str,
) -> Option<String> {
normalized
.module_scores
.iter()
.find(|m| m.name == module_name)
.and_then(|m| m.grade.clone())
}
#[cfg(test)]
mod html_conform_recommendations_tests {
use super::*;
use crate::cli::WcagLevel;
use crate::html_conform::{HtmlConformAnalysis, HtmlConformFinding};
use crate::wcag::WcagResults;
fn finding(rule_id: &str, severity: &str, message: &str) -> HtmlConformFinding {
HtmlConformFinding {
rule_id: rule_id.to_string(),
severity: severity.to_string(),
message: message.to_string(),
location: None,
byte_offset: None,
}
}
#[test]
fn recommendations_rank_errors_before_warnings_and_by_occurrence() {
let mut report = crate::audit::AuditReport::new(
"https://example.com".to_string(),
WcagLevel::AA,
WcagResults::new(),
100,
);
report.html_conform = Some(HtmlConformAnalysis {
score: 64,
checked: true,
error_count: 3,
warning_count: 1,
info_count: 0,
distinct_defect_count: 3,
findings: vec![
finding(
"assertion.headings.no-top-level",
"warning",
"This document has heading elements but none of them has a computed heading level",
),
finding(
"assertion.elements.script-module-defer",
"error",
"A script element with type module must not have a defer attribute",
),
finding(
"assertion.elements.script-module-defer",
"error",
"A script element with type module must not have a defer attribute",
),
finding(
"assertion.elements.link-as-missing-rel",
"error",
"A link element with an as attribute must have a rel attribute",
),
],
raw_html: None,
});
let ctx = crate::audit::normalized::normalize(&report);
let i18n = crate::i18n::I18n::new("de").unwrap();
let presentation = build_html_conform_details(&ctx, &i18n)
.expect("html_conform data must produce a presentation");
assert_eq!(
presentation.recommendations[0],
"A script element with type module must not have a defer attribute",
"the error with the most occurrences must rank first"
);
assert!(
presentation.recommendations.contains(
&"A link element with an as attribute must have a rel attribute".to_string()
),
"the other distinct error must be included"
);
let warning_pos = presentation
.recommendations
.iter()
.position(|r| r.contains("computed heading level"))
.expect("the warning must still be included when under the cap of 4");
assert!(
warning_pos > 0,
"the warning must rank after the errors, got position {warning_pos}"
);
}
#[test]
fn recommendations_empty_when_no_findings() {
let mut report = crate::audit::AuditReport::new(
"https://example.com".to_string(),
WcagLevel::AA,
WcagResults::new(),
100,
);
report.html_conform = Some(HtmlConformAnalysis {
score: 100,
checked: true,
error_count: 0,
warning_count: 0,
info_count: 0,
distinct_defect_count: 0,
findings: vec![],
raw_html: None,
});
let ctx = crate::audit::normalized::normalize(&report);
let i18n = crate::i18n::I18n::new("de").unwrap();
let presentation = build_html_conform_details(&ctx, &i18n)
.expect("html_conform data must produce a presentation");
assert!(presentation.recommendations.is_empty());
}
}