use renderreport::components::advanced::{
ChecklistPanel, ChecklistRow, KeyValueList, List, PageBreak, SectionHeaderSplit,
TableOfContents,
};
use renderreport::components::text::{Label, TextBlock};
use renderreport::components::{AuditTable, BenchmarkRow, BenchmarkTable, TableColumn};
use renderreport::prelude::Image;
use renderreport::prelude::*;
use crate::audit::BatchReport;
use crate::cli::ReportLevel;
use crate::i18n::I18n;
use crate::output::builder::build_batch_presentation;
use crate::output::report_model::*;
use crate::util::truncate_url;
use super::batch::build_batch_overview_grid;
use super::cover::{batch_certificate_label, build_batch_cover_score_row, certificate_badge_path};
use super::findings::first_sentence;
use super::helpers::{priority_label_i18n, role_label_i18n, severity_label_i18n};
pub(super) fn build_batch_assessment(
summary: &crate::output::report_model::PortfolioSummary,
dist: &SeverityDistribution,
i18n: &I18n,
) -> String {
let en = i18n.locale() == "en";
let score = summary.average_score.round() as u32;
if dist.critical > 0 && score < 50 {
if en {
"Critical barriers — not WCAG conformant".to_string()
} else {
"Kritische Barrieren — nicht WCAG-konform".to_string()
}
} else if dist.critical > 0 {
if en {
"Technically solid, but legally risky".to_string()
} else {
"Technisch solide, aber rechtlich riskant".to_string()
}
} else if dist.high > 0 {
if en {
"Good foundation, but not accessible".to_string()
} else {
"Gute Basis, aber nicht barrierefrei".to_string()
}
} else if score >= 85 {
if en {
"Largely accessible — polish".to_string()
} else {
"Weitgehend barrierefrei — Feinschliff".to_string()
}
} else if en {
"Solid foundation with room to optimize".to_string()
} else {
"Solide Grundlage mit Optimierungspotenzial".to_string()
}
}
pub(super) fn build_batch_key_points(
pres: &BatchPresentation,
dist: &SeverityDistribution,
i18n: &I18n,
) -> Vec<String> {
let en = i18n.locale() == "en";
let mut points = Vec::with_capacity(3);
let ch = dist.critical + dist.high;
if ch > 0 {
if en {
points.push(format!(
"{} critical/high violations across {} URLs",
ch, pres.portfolio_summary.total_urls
));
} else {
points.push(format!(
"{} kritische/hohe Verstöße über {} URLs hinweg",
ch, pres.portfolio_summary.total_urls
));
}
}
if let Some(top) = pres.top_issues.first() {
if en {
points.push(format!(
"Main issue: {} ({} occurrences on {} URLs)",
top.title,
top.occurrence_count,
top.affected_urls.len()
));
} else {
points.push(format!(
"Hauptproblem: {} ({} Vorkommen auf {} URLs)",
top.title,
top.occurrence_count,
top.affected_urls.len()
));
}
}
if dist.critical > 0 {
if en {
points.push(
"WCAG Level A violations detected automatically — manual review needed for a defensible BFSG classification".to_string(),
);
} else {
points.push(
"WCAG-Level-A-Verstöße automatisiert erkannt — manuelle Prüfung für belastbare BFSG-Einordnung nötig".to_string(),
);
}
} else if dist.high > 0 {
if en {
points.push(
"No Level A violations, but structural weaknesses on multiple pages".to_string(),
);
} else {
points.push(
"Keine Level-A-Verstöße, aber strukturelle Schwächen auf mehreren Seiten"
.to_string(),
);
}
} else if en {
points.push(
"No automatically detectable critical barriers — manual review recommended."
.to_string(),
);
} else {
points.push(
"Keine automatisiert erkennbaren kritischen Barrieren — manuelle Prüfung empfohlen."
.to_string(),
);
}
points
}
pub(super) fn build_batch_quick_actions(pres: &BatchPresentation, _i18n: &I18n) -> Vec<String> {
let mut actions: Vec<String> = Vec::new();
for item in &pres.action_plan.quick_wins {
if actions.len() >= 3 {
break;
}
let action_lower = item.action.to_lowercase();
let scope = if action_lower.contains("alle") || action_lower.contains("global") {
" (global)"
} else {
""
};
actions.push(format!("{}{}", item.action, scope));
}
if actions.is_empty() {
for group in pres.top_issues.iter().take(3) {
let rec = first_sentence(&group.recommendation);
if !rec.is_empty() {
actions.push(rec.to_string());
}
}
}
actions
}
pub(super) fn render_batch_action_plan_enhanced(
mut builder: renderreport::engine::ReportBuilder,
plan: &ActionPlan,
i18n: &I18n,
) -> renderreport::engine::ReportBuilder {
let en = i18n.locale() == "en";
let effort_col = if en { "Effort" } else { "Aufwand" };
let role_col = if en { "Role" } else { "Rolle" };
let scope_global = "global";
let scope_content = "Content";
let scope_component = if en { "Component" } else { "Komponente" };
let render_section = |mut b: renderreport::engine::ReportBuilder,
title: String,
items: &[crate::output::report_model::ActionItem],
i18n: &I18n|
-> renderreport::engine::ReportBuilder {
if items.is_empty() {
return b;
}
b = b.add_component(Section::new(title).with_level(2));
let mut table = AuditTable::new(vec![
TableColumn::new(i18n.t("column-action")),
TableColumn::new(effort_col),
TableColumn::new(if en { "Scope" } else { "Reichweite" }),
TableColumn::new(role_col),
TableColumn::new(i18n.t("column-priority")),
]);
for item in items {
let action_lower = item.action.to_lowercase();
let scope = if action_lower.contains("alle")
|| action_lower.contains("global")
|| action_lower.contains("designsystem")
|| action_lower.contains("design system")
|| action_lower.contains("seitenübergreifend")
|| action_lower.contains("site-wide")
{
scope_global
} else if action_lower.contains("content")
|| action_lower.contains("text")
|| action_lower.contains("bild")
|| action_lower.contains("image")
{
scope_content
} else {
scope_component
};
table = table.add_row(vec![
item.action.clone(),
item.effort.label().to_string(),
scope.to_string(),
role_label_i18n(item.role, i18n),
priority_label_i18n(item.priority, i18n),
]);
}
b.add_component(table)
};
builder = render_section(
builder,
i18n.t("section-quick-wins"),
&plan.quick_wins,
i18n,
);
builder = render_section(
builder,
i18n.t("section-medium-actions"),
&plan.medium_term,
i18n,
);
builder = render_section(
builder,
i18n.t("section-structural-actions"),
&plan.structural,
i18n,
);
builder
}
pub(super) fn render_batch_consistency(
mut builder: renderreport::engine::ReportBuilder,
consistency: &crate::audit::batch_consistency::BatchConsistencyAnalysis,
i18n: &I18n,
) -> renderreport::engine::ReportBuilder {
let en = i18n.locale() == "en";
let title = if en {
"Cross-page consistency"
} else {
"Seitenübergreifende Konsistenz"
};
let intro = if en {
"Checks that shared structural elements (navigation, headings, canonical URLs) are consistent across all audited pages. WCAG 3.2.3 / 3.2.4."
} else {
"Prüft, ob geteilte strukturelle Elemente (Navigation, Überschriften, Canonical-URLs) auf allen geprüften Seiten konsistent sind. WCAG 3.2.3 / 3.2.4."
};
builder = builder.add_component(SectionHeaderSplit::new(title, intro).with_level(2));
let nav = &consistency.navigation;
let nav_title = if en {
format!(
"Navigation ({}/{} with main nav, {}/{} with skip link)",
nav.pages_with_main_nav, nav.total_pages, nav.pages_with_skip_link, nav.total_pages
)
} else {
format!(
"Navigation ({}/{} mit Hauptnav, {}/{} mit Skip-Link)",
nav.pages_with_main_nav, nav.total_pages, nav.pages_with_skip_link, nav.total_pages
)
};
let nav_rows: Vec<ChecklistRow> = if nav.findings.is_empty() {
vec![ChecklistRow::new(
if en { "Consistent" } else { "Konsistent" },
if en {
"No navigation inconsistencies detected."
} else {
"Keine Navigation-Inkonsistenzen erkannt."
},
)
.with_status("good")]
} else {
nav.findings
.iter()
.map(|f| {
ChecklistRow::new(if en { "Issue" } else { "Befund" }, f.as_str())
.with_status("warn")
})
.collect()
};
builder = builder.add_component(ChecklistPanel::new(nav_rows).with_title(&nav_title));
let h = &consistency.headings;
let head_title = if en {
format!(
"Headings ({}/{} with single H1, {} missing, {} multiple)",
h.pages_with_single_h1, h.total_pages, h.pages_with_no_h1, h.pages_with_multiple_h1
)
} else {
format!(
"Überschriften ({}/{} mit einem H1, {} ohne H1, {} mit mehreren)",
h.pages_with_single_h1, h.total_pages, h.pages_with_no_h1, h.pages_with_multiple_h1
)
};
let head_rows: Vec<ChecklistRow> = if h.findings.is_empty() {
vec![ChecklistRow::new(
if en { "Consistent" } else { "Konsistent" },
if en {
"Every page starts with exactly one H1."
} else {
"Jede Seite beginnt mit genau einem H1."
},
)
.with_status("good")]
} else {
h.findings
.iter()
.map(|f| {
ChecklistRow::new(if en { "Issue" } else { "Befund" }, f.as_str())
.with_status("warn")
})
.collect()
};
builder = builder.add_component(ChecklistPanel::new(head_rows).with_title(&head_title));
let c = &consistency.canonical;
let canon_title = if en {
format!(
"Canonical URLs (www: {}, non-www: {}, missing: {} of {})",
c.www_count, c.non_www_count, c.missing_count, c.total_pages
)
} else {
format!(
"Canonical-URLs (www: {}, ohne www: {}, fehlend: {} von {})",
c.www_count, c.non_www_count, c.missing_count, c.total_pages
)
};
let canon_rows: Vec<ChecklistRow> = if c.findings.is_empty() {
vec![ChecklistRow::new(
if en { "Consistent" } else { "Konsistent" },
if en {
"All pages canonicalize to the same domain variant."
} else {
"Alle Seiten kanonisieren auf dieselbe Domain-Variante."
},
)
.with_status("good")]
} else {
c.findings
.iter()
.map(|f| {
ChecklistRow::new(if en { "Issue" } else { "Befund" }, f.as_str())
.with_status("warn")
})
.collect()
};
builder.add_component(ChecklistPanel::new(canon_rows).with_title(&canon_title))
}
pub(super) fn render_next_steps_batch(
mut builder: renderreport::engine::ReportBuilder,
pres: &BatchPresentation,
i18n: &I18n,
) -> renderreport::engine::ReportBuilder {
let en = i18n.locale() == "en";
let intro = if en {
"Concrete recommendation for implementation."
} else {
"Konkrete Handlungsempfehlung für die Umsetzung."
};
builder = builder.add_component(
SectionHeaderSplit::new(i18n.t("section-next-steps-recommended"), intro).with_level(1),
);
let mut steps: Vec<(String, &str)> = Vec::new();
let scope_global = "global";
let scope_component = if en {
"component-based"
} else {
"komponentenbasiert"
};
for item in &pres.action_plan.quick_wins {
if steps.len() >= 3 {
break;
}
let action_lower = item.action.to_lowercase();
let scope = if action_lower.contains("alle")
|| action_lower.contains("designsystem")
|| action_lower.contains("design system")
|| action_lower.contains("global")
{
scope_global
} else {
scope_component
};
steps.push((item.action.clone(), scope));
}
if steps.len() < 3 {
for item in &pres.action_plan.medium_term {
if steps.len() >= 3 {
break;
}
steps.push((item.action.clone(), scope_component));
}
}
if !steps.is_empty() {
let mut table = AuditTable::new(vec![
TableColumn::new(i18n.t("column-priority")),
TableColumn::new(i18n.t("column-action")),
TableColumn::new(if en { "Scope" } else { "Reichweite" }),
]);
for (i, (action, scope)) in steps.iter().enumerate() {
table = table.add_row(vec![
format!("{}", i + 1),
action.clone(),
scope.to_string(),
]);
}
builder = builder.add_component(table);
}
let callout_body = if en {
"For a complete WCAG conformance check we additionally recommend a manual audit with assistive technologies (screen reader, keyboard navigation). This automated audit covers about 30–40% of WCAG criteria."
} else {
"Für eine vollständige WCAG-Konformitätsprüfung empfehlen wir ergänzend einen manuellen Audit mit assistiven Technologien (Screenreader, Tastaturnavigation). Dieser automatisierte Audit deckt ca. 30–40% der WCAG-Kriterien ab."
};
builder = builder
.add_component(Callout::info(callout_body).with_title(i18n.t("section-next-steps-block")));
builder
}
pub fn generate_batch_pdf(batch: &BatchReport, config: &ReportConfig) -> anyhow::Result<Vec<u8>> {
let (engine, built_report) = build_batch_report(batch, config)?;
Ok(engine.render_pdf(&built_report)?)
}
pub fn generate_batch_typ(batch: &BatchReport, config: &ReportConfig) -> anyhow::Result<String> {
let (engine, built_report) = build_batch_report(batch, config)?;
Ok(engine.render_typ(&built_report)?)
}
fn build_batch_report(
batch: &BatchReport,
config: &ReportConfig,
) -> anyhow::Result<(renderreport::Engine, renderreport::RenderRequest)> {
let engine = super::helpers::create_engine()?;
let i18n = I18n::new(&config.locale)?;
let pres = build_batch_presentation(batch);
let domain = &pres.portfolio_summary.domain;
let score = pres.portfolio_summary.average_score.round() as u32;
let mut builder = engine
.report("wcag-batch-audit")
.title(&pres.cover.title)
.subtitle(&pres.cover.url)
.metadata("date", &pres.cover.date)
.metadata("version", &pres.cover.version)
.metadata("author", domain)
.metadata("footer_prefix", "Audit:")
.metadata("footer_link_url", "")
.metadata(
"footer_tagline",
"A technical auditing platform by casoon.de",
);
let cover_logo_asset = super::cover_logo_asset(config);
builder = super::register_cover_logo_asset(builder, config, cover_logo_asset);
builder = builder
.add_component(Image::new(cover_logo_asset).with_width("120pt"))
.add_component(
Label::new(i18n.t("batch-cover-eyebrow"))
.with_size("11pt")
.bold()
.with_color("#0f766e"),
)
.add_component(
Label::new(i18n.t("batch-cover-title"))
.with_size("28pt")
.bold(),
)
.add_component(
Label::new(i18n.t("batch-cover-kicker"))
.with_size("12pt")
.with_color("#475569"),
);
{
let modules_str = pres.portfolio_summary.active_modules.join(", ");
let mut cover_meta = KeyValueList::new().with_title(i18n.t("batch-cover-frame-title"));
cover_meta = cover_meta
.add(i18n.t("batch-cover-frame-domain"), domain)
.add(i18n.t("batch-cover-frame-date"), &pres.cover.date)
.add(
i18n.t("batch-cover-frame-urls"),
format!("{}", pres.portfolio_summary.total_urls),
)
.add(
i18n.t("batch-cover-frame-certificate"),
&pres.portfolio_summary.certificate,
)
.add(i18n.t("batch-cover-frame-modules"), &modules_str)
.add(
i18n.t("batch-cover-frame-version"),
format!("auditmysite v{}", pres.cover.version),
);
builder = builder.add_component(cover_meta);
}
let batch_badge_asset = "/certificate-badge-batch.svg";
let batch_badge_enabled =
if let Ok(path) = certificate_badge_path(batch_certificate_label(score)) {
builder = builder.asset(batch_badge_asset, path);
true
} else {
false
};
builder = builder
.add_component(build_batch_cover_score_row(
score,
pres.portfolio_summary.total_urls as u32,
pres.portfolio_summary.total_violations as u32,
batch_badge_enabled.then_some(batch_badge_asset),
&i18n,
)?)
.add_component(
TextBlock::new(&pres.portfolio_summary.verdict_text)
.with_size("11pt")
.with_line_height("1.4em")
.with_max_width("100%"),
)
.add_component(super::output_scope_callout(&i18n))
.add_component(PageBreak::new());
if config.level != ReportLevel::Executive {
builder = builder.add_component(TableOfContents::new().with_depth(1));
}
let dist = &pres.portfolio_summary.severity_distribution;
{
let assessment = build_batch_assessment(&pres.portfolio_summary, dist, &i18n);
let risk_title = assessment;
let callout = match pres.portfolio_summary.risk_level.as_str() {
"Kritisch" | "Hoch" | "Critical" | "High" => {
Callout::warning(&pres.portfolio_summary.risk_summary).with_title(&risk_title)
}
"Mittel" | "Medium" => {
Callout::info(&pres.portfolio_summary.risk_summary).with_title(&risk_title)
}
_ => Callout::success(&pres.portfolio_summary.risk_summary).with_title(&risk_title),
};
builder = builder
.add_component(Section::new(i18n.t("batch-section-status")).with_level(1))
.add_component(callout);
}
builder = builder.add_component(build_batch_overview_grid(
pres.portfolio_summary.total_urls as u32,
score,
pres.portfolio_summary.total_violations as u32,
(dist.critical + dist.high) as u32,
pres.portfolio_summary.crawl_links.as_ref().map(|links| {
(links.broken_internal_links.len() + links.broken_external_links.len()) as u32
}),
));
{
let key_points = build_batch_key_points(&pres, dist, &i18n);
let mut kp_list = List::new().with_title(i18n.t("narrative-key-points-title"));
for point in &key_points {
kp_list = kp_list.add_item(point);
}
builder = builder.add_component(kp_list);
}
{
let en = i18n.locale() == "en";
let a11y_avg = pres.portfolio_summary.average_score.round() as u32;
let user_impact = match (a11y_avg, en) {
(s, true) if s < 50 => "Critical — core functions are unreachable for users with disabilities",
(s, false) if s < 50 => "Kritisch — zentrale Funktionen sind für Nutzer mit Einschränkungen nicht erreichbar",
(s, true) if s < 70 => "Limited — structural issues impede users with assistive technologies",
(s, false) if s < 70 => "Eingeschränkt — strukturelle Probleme behindern Nutzer mit Hilfstechnologien",
(s, true) if s < 85 => "Good — individual barriers for assistive technologies on several pages",
(s, false) if s < 85 => "Gut — einzelne Barrieren für Hilfstechnologien auf mehreren Seiten",
(_, true) => "Very good — assistive technologies are largely supported",
(_, false) => "Sehr gut — Hilfstechnologien werden weitgehend unterstützt",
};
let business_impact = if dist.critical > 0 {
if en {
"Large parts of the website are unusable or barely usable for certain user groups."
} else {
"Weite Teile der Website sind für bestimmte Nutzergruppen nicht oder kaum nutzbar."
}
} else if dist.high > 0 {
if en {
"Individual functional areas are problematic for users with disabilities."
} else {
"Einzelne Funktionsbereiche sind für Nutzer mit Einschränkungen problematisch."
}
} else if en {
"Low impact — users can fundamentally use the website."
} else {
"Geringe Auswirkung — Nutzer können die Website grundsätzlich verwenden."
};
let legal_impact = if dist.critical > 0 {
if en {
"WCAG Level A violations detected automatically — manual review required for a defensible BFSG classification."
} else {
"WCAG-Level-A-Verstöße automatisiert erkannt — für belastbare BFSG-Einordnung ist manuelle Prüfung nötig."
}
} else if en {
"No critical violations detected automatically — manual review recommended for full classification."
} else {
"Automatisiert keine kritischen Verstöße erkannt — manuelle Prüfung für vollständige Einordnung empfohlen."
};
let (user_label, business_label, risk_label) = if en {
("User", "Business", "Risk")
} else {
("Nutzer", "Business", "Risiko")
};
let mut impact_kv = KeyValueList::new().with_title(i18n.t("narrative-impact-title"));
impact_kv = impact_kv
.add(user_label, user_impact)
.add(business_label, business_impact)
.add(risk_label, legal_impact);
builder = builder.add_component(impact_kv);
}
{
let actions = build_batch_quick_actions(&pres, &i18n);
if !actions.is_empty() {
let rows: Vec<ChecklistRow> = actions
.iter()
.map(|action| ChecklistRow::new(action, "").with_status("warn"))
.collect();
builder = builder.add_component(
ChecklistPanel::new(rows).with_title(i18n.t("narrative-quick-actions-title")),
);
}
}
if !pres.portfolio_summary.module_averages.is_empty() {
let mut module_kv = KeyValueList::new().with_title(i18n.t("batch-panel-module-averages"));
for (name, score) in &pres.portfolio_summary.module_averages {
module_kv = module_kv.add(name, format!("{}/100", score));
}
builder = builder.add_component(module_kv);
}
let rows: Vec<BenchmarkRow> = pres
.url_ranking
.iter()
.enumerate()
.map(|(i, u)| {
let mut row = BenchmarkRow::new(
(i + 1) as u32,
&truncate_url(&u.url, 35),
u.overall_score,
u.score as u32,
u.critical_violations as u32,
);
if let Some(detail) = pres.url_details.iter().find(|detail| detail.url == u.url) {
if let Some((_, score)) = detail
.module_scores
.iter()
.find(|(module, _)| module == "SEO")
{
row = row.with_seo(*score);
}
if let Some((_, score)) = detail
.module_scores
.iter()
.find(|(module, _)| module == "Performance")
{
row = row.with_performance(*score);
}
if let Some((_, score)) = detail
.module_scores
.iter()
.find(|(module, _)| module == "Security")
{
row = row.with_security(*score);
}
}
row
})
.collect();
let (ranking_title, ranking_intro) = if i18n.locale() == "en" {
(
"URL ranking",
"All audited URLs, sorted by score. Lower-scoring URLs need attention first.",
)
} else {
(
"URL-Ranking",
"Übersicht aller geprüften URLs, sortiert nach Score. \
URLs mit niedrigerem Score haben höheren Handlungsbedarf.",
)
};
builder = builder
.add_component(SectionHeaderSplit::new(ranking_title, ranking_intro).with_level(1))
.add_component(BenchmarkTable::new(rows));
let en_top = i18n.locale() == "en";
let top_intro = if en_top {
"The following problem groups occur across multiple URLs. \
Fixing them yields the largest improvement because they affect many pages simultaneously."
} else {
"Die folgenden Problemgruppen treten über mehrere URLs hinweg auf. \
Durch Behebung dieser Probleme wird die größte Verbesserung erzielt, \
da sie viele Seiten gleichzeitig betreffen."
};
builder = builder.add_component(
SectionHeaderSplit::new(i18n.t("batch-section-most-frequent"), top_intro).with_level(1),
);
if !pres.issue_frequency.is_empty() {
let affected_col = if en_top {
"Affected URLs"
} else {
"Betr. URLs"
};
let mut freq_table = AuditTable::new(vec![
TableColumn::new(i18n.t("batch-col-problem")),
TableColumn::new("WCAG"),
TableColumn::new(i18n.t("batch-col-occurrences")),
TableColumn::new(affected_col),
TableColumn::new(i18n.t("batch-col-priority")),
])
.with_title(i18n.t("batch-section-most-frequent-violations"));
for issue in &pres.issue_frequency {
freq_table = freq_table.add_row(vec![
issue.problem.clone(),
issue.wcag.clone(),
issue.occurrences.to_string(),
issue.affected_urls.to_string(),
super::helpers::priority_label_i18n(issue.priority, &i18n),
]);
}
builder = builder.add_component(freq_table);
}
let scope_global_word = if en_top {
"Global — affects all pages"
} else {
"Global — betrifft alle Seiten"
};
let scope_individual = if en_top {
"Specific pages — isolated content"
} else {
"Einzelne Seiten — isolierter Inhalt"
};
let occurrences_word_top = if en_top { "occurrences" } else { "Vorkommen" };
let affected_urls_word = if en_top {
"affected URLs"
} else {
"betroffene URLs"
};
let effort_word = if en_top { "Effort" } else { "Aufwand" };
let scope_word = if en_top { "Scope" } else { "Reichweite" };
let impact_user_label = if en_top {
"Impact (user)"
} else {
"Auswirkung (Nutzer)"
};
let impact_business_label = if en_top {
"Impact (business)"
} else {
"Auswirkung (Business)"
};
let fix_label = if en_top { "Fix" } else { "Empfehlung" };
let meta_label = if en_top { "Meta" } else { "Einordnung" };
for group in pres.top_issues.iter().take(5) {
let scope = if group.affected_urls.len() >= pres.portfolio_summary.total_urls {
scope_global_word
} else {
scope_individual
};
let effort_label = group.effort.label();
let meta_line = format!(
"{} {} · {} {} · {}: {} · {}: {}",
group.occurrence_count,
occurrences_word_top,
group.affected_urls.len(),
affected_urls_word,
effort_word,
effort_label,
scope_word,
scope
);
let mut kv = KeyValueList::new()
.with_title(&group.title)
.add(
i18n.t("findings-card-key-problem"),
&group.customer_description,
)
.add(impact_user_label, &group.user_impact)
.add(impact_business_label, &group.business_impact);
if !group.typical_cause.is_empty() {
kv = kv.add(i18n.t("findings-card-key-cause"), &group.typical_cause);
}
if !group.recommendation.is_empty() {
kv = kv.add(fix_label, &group.recommendation);
}
kv = kv.add(meta_label, meta_line);
builder = builder.add_component(kv);
}
let action_intro = if en_top {
"The following actions are prioritized by effort and impact. \
Actions that improve many pages simultaneously take precedence."
} else {
"Die folgenden Maßnahmen sind nach Aufwand und Wirkung priorisiert. \
Maßnahmen, die viele Seiten gleichzeitig verbessern, haben Vorrang."
};
builder = builder.add_component(
SectionHeaderSplit::new(i18n.t("batch-action-plan-title"), action_intro).with_level(1),
);
builder = render_batch_action_plan_enhanced(builder, &pres.action_plan, &i18n);
if !pres.portfolio_summary.render_blocking_summary.is_empty() {
let en_rb = i18n.locale() == "en";
let (rb_section, rb_kv_title, rb_intro) = if en_rb {
(
"Render blocking & assets",
"Render-blocking overview (domain-wide)",
"Render-blocking resources and third-party traffic, aggregated across all audited pages.",
)
} else {
(
"Render-Blocking & Assets",
"Render-Blocking-Übersicht (domainweit)",
"Render-blockierende Ressourcen und Third-Party-Traffic, aggregiert über alle geprüften Seiten.",
)
};
let mut kv = KeyValueList::new().with_title(rb_kv_title);
for (label, value) in &pres.portfolio_summary.render_blocking_summary {
kv = kv.add(label, value);
}
builder = builder
.add_component(Section::new(rb_section).with_level(1))
.add_component(TextBlock::new(rb_intro))
.add_component(kv);
}
let en_pdf = i18n.locale() == "en";
if !pres.portfolio_summary.budget_summary.is_empty() {
let pages_col = if en_pdf {
"Affected pages"
} else {
"Betr. Seiten"
};
let budget_table_title = if en_pdf {
"Performance budget violations (domain-wide)"
} else {
"Performance-Budget-Verstöße (domainweit)"
};
let mut table = AuditTable::new(vec![
TableColumn::new(i18n.t("batch-col-metric")),
TableColumn::new(i18n.t("batch-col-budget")),
TableColumn::new(pages_col),
TableColumn::new(i18n.t("batch-col-severity")),
])
.with_title(budget_table_title);
for (metric, budget, count, sev) in &pres.portfolio_summary.budget_summary {
table = table.add_row(vec![
metric.clone(),
budget.clone(),
count.to_string(),
sev.clone(),
]);
}
let budgets_intro = if en_pdf {
"On how many pages each performance budget was exceeded."
} else {
"Auf wie vielen Seiten welche Performance-Budgets überschritten wurden."
};
builder = builder
.add_component(Section::new(i18n.t("batch-section-performance-budgets")).with_level(1))
.add_component(TextBlock::new(budgets_intro))
.add_component(table);
}
builder = builder.add_component(
SectionHeaderSplit::new(
i18n.t("batch-section-tech-url-matrix"),
i18n.t("batch-section-tech-url-matrix-intro"),
)
.with_level(1),
);
if let Some(ref crawl_links) = pres.portfolio_summary.crawl_links {
let target_col = if en_pdf { "Target" } else { "Ziel" };
let type_col = if en_pdf { "Type" } else { "Typ" };
let direct_label = if en_pdf { "direct" } else { "direkt" };
let hops_label = if en_pdf { "hops" } else { "Hops" };
let internal_intro = if en_pdf {
format!(
"For the crawl starting at {} we checked {} internal link targets. {} broken internal links detected.",
crawl_links.seed_url,
crawl_links.checked_internal_links,
crawl_links.broken_internal_links.len()
)
} else {
format!(
"Für den Crawl ab {} wurden {} interne Linkziele geprüft. {} kaputte interne Verlinkungen wurden erkannt.",
crawl_links.seed_url,
crawl_links.checked_internal_links,
crawl_links.broken_internal_links.len()
)
};
builder = builder
.add_component(
Section::new(i18n.t("batch-section-broken-links-internal")).with_level(1),
)
.add_component(TextBlock::new(internal_intro));
if crawl_links.broken_internal_links.is_empty() {
builder = builder.add_component(Callout::info(if en_pdf {
"No broken internal links detected in the audited crawl set."
} else {
"Keine kaputten internen Links im geprüften Crawl-Set erkannt."
}));
} else {
let mut table = AuditTable::new(vec![
TableColumn::new(i18n.t("batch-col-source")),
TableColumn::new(target_col),
TableColumn::new(i18n.t("batch-col-status-code")),
TableColumn::new(type_col),
])
.with_title(i18n.t("batch-table-broken-internal"));
for row in &crawl_links.broken_internal_links {
let severity_color = match row.severity.as_str() {
"high" => "#dc2626",
"medium" => "#ea580c",
_ => "#ca8a04",
};
let typ_label = if row.redirect_hops > 0 {
format!("→{} {}", row.redirect_hops, hops_label)
} else {
direct_label.to_string()
};
table = table.add_row(vec![
truncate_url(&row.source_url, 30),
truncate_url(&row.target_url, 38),
format!("\x1b[{}m{}\x1b[0m", severity_color, row.status),
typ_label,
]);
}
builder = builder.add_component(table);
}
if !crawl_links.broken_external_links.is_empty() {
let ext_intro = if en_pdf {
format!(
"{} external link targets checked. {} broken external links detected.",
crawl_links.checked_external_links,
crawl_links.broken_external_links.len()
)
} else {
format!(
"{} externe Linkziele geprüft. {} kaputte externe Verlinkungen erkannt.",
crawl_links.checked_external_links,
crawl_links.broken_external_links.len()
)
};
builder = builder
.add_component(
Section::new(i18n.t("batch-section-broken-links-external")).with_level(2),
)
.add_component(TextBlock::new(ext_intro));
let mut ext_table = AuditTable::new(vec![
TableColumn::new(i18n.t("batch-col-source")),
TableColumn::new(target_col),
TableColumn::new(i18n.t("batch-col-status-code")),
TableColumn::new(type_col),
])
.with_title(i18n.t("batch-table-broken-external"));
for row in &crawl_links.broken_external_links {
let typ_label = if row.redirect_hops > 0 {
format!("→{} {}", row.redirect_hops, hops_label)
} else {
direct_label.to_string()
};
ext_table = ext_table.add_row(vec![
truncate_url(&row.source_url, 30),
truncate_url(&row.target_url, 38),
row.status.clone(),
typ_label,
]);
}
builder = builder.add_component(ext_table);
} else if crawl_links.checked_external_links > 0 {
let ext_clean_msg = if en_pdf {
format!(
"{} external link targets checked — no broken external links detected.",
crawl_links.checked_external_links
)
} else {
format!(
"{} externe Linkziele geprüft — keine kaputten externen Links erkannt.",
crawl_links.checked_external_links
)
};
builder = builder
.add_component(Section::new(i18n.t("batch-section-external-links")).with_level(2))
.add_component(Callout::info(ext_clean_msg));
}
if !crawl_links.redirect_chains.is_empty() {
let chain_intro = if en_pdf {
format!(
"{} links with more than one redirect hop detected.",
crawl_links.redirect_chains.len()
)
} else {
format!(
"{} Links mit mehr als einem Redirect-Hop erkannt.",
crawl_links.redirect_chains.len()
)
};
builder = builder
.add_component(Section::new(i18n.t("batch-section-redirect-chains")).with_level(2))
.add_component(TextBlock::new(chain_intro));
let mut chain_table = AuditTable::new(vec![
TableColumn::new(i18n.t("batch-col-source")),
TableColumn::new(if en_pdf { "Target" } else { "Ziel" }),
TableColumn::new("Hops"),
TableColumn::new(if en_pdf { "Final URL" } else { "Final-URL" }),
])
.with_title(if en_pdf {
"Redirect chains (> 1 hop)"
} else {
"Redirect-Ketten (> 1 Hop)"
});
for chain in &crawl_links.redirect_chains {
chain_table = chain_table.add_row(vec![
truncate_url(&chain.source_url, 28),
truncate_url(&chain.target_url, 28),
chain.hops.to_string(),
truncate_url(&chain.final_url, 32),
]);
}
builder = builder.add_component(chain_table);
}
}
let page_col = if en_pdf { "Page" } else { "Seite" };
let title_col = if en_pdf { "Title" } else { "Titel" };
let mut matrix = AuditTable::new(vec![
TableColumn::new("#").with_width("4%"),
TableColumn::new(page_col).with_width("26%"),
TableColumn::new(title_col).with_width("28%"),
TableColumn::new(i18n.t("batch-col-links-to")).with_width("10%"),
TableColumn::new(i18n.t("batch-col-links-from")).with_width("10%"),
TableColumn::new(i18n.t("batch-col-words")).with_width("10%"),
TableColumn::new("Score").with_width("12%"),
])
.with_title(i18n.t("batch-table-pages-overview"));
for row in &pres.url_matrix {
let score_str = pres
.url_details
.iter()
.find(|d| d.url == row.url)
.map(|d| format!("{}/100", d.score.round() as u32))
.unwrap_or_else(|| "—".to_string());
matrix = matrix.add_row(vec![
row.rank.to_string(),
truncate_url(&row.url, 34),
row.title
.as_deref()
.map(|t| truncate_url(t, 36))
.unwrap_or_else(|| "—".to_string()),
row.inbound_links.to_string(),
row.outbound_links.to_string(),
super::format_word_count(row.word_count),
score_str,
]);
}
builder = builder.add_component(matrix);
if config.level != ReportLevel::Executive {
let mut focus_table = AuditTable::new(vec![
TableColumn::new("URL"),
TableColumn::new(i18n.t("batch-col-page-type")),
TableColumn::new(i18n.t("batch-col-attributes")),
TableColumn::new(i18n.t("batch-col-top-issues")),
])
.with_title(i18n.t("batch-table-focus-pages"));
for detail in pres.url_details.iter().take(10) {
focus_table = focus_table.add_row(vec![
truncate_url(&detail.url, 38),
detail.page_type.clone().unwrap_or_else(|| "—".to_string()),
if detail.page_attributes.is_empty() {
"—".to_string()
} else {
truncate_url(&detail.page_attributes.join(", "), 40)
},
if detail.top_issues.is_empty() {
"—".to_string()
} else {
truncate_url(&detail.top_issues.join(", "), 52)
},
]);
}
builder = builder.add_component(focus_table);
}
let en_batch = i18n.locale() == "en";
let (content_seo_title, content_seo_intro) = if en_batch {
(
"Content & SEO potential",
"Content strengths and weaknesses with direct relevance to rankings, visibility and conversion. \
Each finding is tied to a concrete action.",
)
} else {
(
"Content & SEO-Potenzial",
"Content-Stärken und -Schwächen mit direktem Bezug zu Rankings, Sichtbarkeit und Conversion. \
Jede Auffälligkeit ist an eine konkrete Handlung geknüpft.",
)
};
builder = builder
.add_component(SectionHeaderSplit::new(content_seo_title, content_seo_intro).with_level(1));
if !pres.portfolio_summary.weakest_content_pages.is_empty() {
let issues_title = if en_batch {
"Content issues needing action"
} else {
"Content-Probleme mit Handlungsbedarf"
};
let high_marker = if en_batch { "high" } else { "hoch" };
let mut issues_kv = KeyValueList::new().with_title(issues_title);
for (url, page_type, score) in &pres.portfolio_summary.weakest_content_pages {
let relevance = super::business_relevance(Some(page_type.as_str()), url, i18n.locale());
let impact = if relevance == high_marker {
if en_batch {
"Likely ranking loss + lower conversion"
} else {
"Rankingverlust + geringere Conversion wahrscheinlich"
}
} else if *score < 30 {
if en_batch {
"Weak organic visibility"
} else {
"Schwache organische Sichtbarkeit"
}
} else if en_batch {
"Optimization potential for SEO"
} else {
"Optimierungspotenzial für SEO"
};
let value = if en_batch {
format!(
"{} — {} → +300–800 words of structured content recommended",
page_type, impact
)
} else {
format!(
"{} — {} → +300–800 Wörter strukturierter Inhalt empfohlen",
page_type, impact
)
};
let key_str = if en_batch {
format!("{} (profile: {}/100)", truncate_url(url, 35), score)
} else {
format!("{} (Profil: {}/100)", truncate_url(url, 35), score)
};
issues_kv = issues_kv.add(key_str, value);
}
builder = builder.add_component(issues_kv);
}
if !pres.portfolio_summary.distribution_insights.is_empty() {
let action_label = if en_batch {
"Action needed"
} else {
"Handlungsbedarf"
};
let panel_title = if en_batch {
"Content patterns → business impact"
} else {
"Content-Auffälligkeiten → Business-Impact"
};
let rows: Vec<ChecklistRow> = pres
.portfolio_summary
.distribution_insights
.iter()
.map(|insight| {
let impact = if insight.contains("Thin") || insight.contains("dünn") {
if en_batch {
format!("{} → weaker rankings, lower dwell time", insight)
} else {
format!("{} → schwächere Rankings, geringere Verweildauer", insight)
}
} else if insight.contains("Duplikat") || insight.contains("duplicate") {
if en_batch {
format!(
"{} → keyword cannibalization, split ranking signals",
insight
)
} else {
format!(
"{} → Keyword-Kannibalisierung, Split der Ranking-Signale",
insight
)
}
} else {
insight.clone()
};
ChecklistRow::new(action_label, &impact).with_status("warn")
})
.collect();
builder = builder.add_component(ChecklistPanel::new(rows).with_title(panel_title));
}
if !pres.portfolio_summary.near_duplicates.is_empty() {
let near_dup_title = if en_batch {
"Near-duplicate content → keyword cannibalization"
} else {
"Near-Duplicate-Content → Keyword-Kannibalisierung"
};
let mut table = AuditTable::new(vec![
TableColumn::new(i18n.t("batch-col-page-a")),
TableColumn::new(i18n.t("batch-col-page-b")),
TableColumn::new(i18n.t("batch-col-similarity")),
TableColumn::new(i18n.t("batch-col-risk")),
])
.with_title(near_dup_title);
for (url_a, url_b, sim) in &pres.portfolio_summary.near_duplicates {
let risk = if *sim >= 95 {
if en_batch {
"High — consolidate"
} else {
"Hoch — konsolidieren"
}
} else if en_batch {
"Medium — differentiate"
} else {
"Mittel — differenzieren"
};
table = table.add_row(vec![
truncate_url(url_a, 35),
truncate_url(url_b, 35),
format!("{sim}%"),
risk.to_string(),
]);
}
builder = builder.add_component(table);
}
if !pres.portfolio_summary.page_type_distribution.is_empty() {
let high_label = if en_batch { "high" } else { "hoch" };
let medium_label = if en_batch { "medium" } else { "mittel" };
let low_label = if en_batch { "low" } else { "niedrig" };
let mut type_table = AuditTable::new(vec![
TableColumn::new(i18n.t("batch-col-page-type")),
TableColumn::new(i18n.t("batch-col-pages-list")),
TableColumn::new(i18n.t("batch-col-share")),
TableColumn::new(i18n.t("batch-col-relevance")),
])
.with_title(i18n.t("batch-table-page-type-distribution"));
for (label, count, pct) in &pres.portfolio_summary.page_type_distribution {
let relevance = match label.as_str() {
"Marketing / Landing Page"
| "Transaktional / Utility"
| "Transactional / Utility" => high_label,
"Editorial / Artikel"
| "Editorial / Article"
| "Strukturierter Wissensinhalt"
| "Structured knowledge content" => medium_label,
"Thin / Minimal Content" => low_label,
_ => medium_label,
};
type_table = type_table.add_row(vec![
label.clone(),
count.to_string(),
format!("{pct}%"),
relevance.to_string(),
]);
}
builder = builder.add_component(type_table);
}
if !pres.portfolio_summary.schema_distribution.is_empty() {
let total = pres.portfolio_summary.total_urls;
let without = pres.portfolio_summary.pages_without_schema;
let summary = if without == 0 {
if en_batch {
format!("All {} pages have structured data.", total)
} else {
format!("Alle {} Seiten haben strukturierte Daten.", total)
}
} else if en_batch {
format!("{} of {} pages without structured data.", without, total)
} else {
format!("{} von {} Seiten ohne strukturierte Daten.", without, total)
};
let schema_callout_title = if en_batch {
"Structured data (Schema.org)"
} else {
"Strukturierte Daten (Schema.org)"
};
builder = builder.add_component(Callout::info(&summary).with_title(schema_callout_title));
let mut schema_table = AuditTable::new(vec![
TableColumn::new(i18n.t("batch-col-schema-type")).with_width("55%"),
TableColumn::new(i18n.t("batch-col-pages-list")).with_width("20%"),
TableColumn::new(i18n.t("batch-col-share")).with_width("25%"),
])
.with_title(i18n.t("batch-table-schema-distribution"));
for (schema_type, count) in &pres.portfolio_summary.schema_distribution {
let pct = (*count * 100).checked_div(total).unwrap_or(0);
schema_table = schema_table.add_row(vec![
schema_type.clone(),
count.to_string(),
format!("{pct}%"),
]);
}
builder = builder.add_component(schema_table);
}
if !pres.portfolio_summary.strongest_content_pages.is_empty() {
let mut strengths = AuditTable::new(vec![
TableColumn::new("URL"),
TableColumn::new(i18n.t("batch-col-page-type")),
TableColumn::new(i18n.t("batch-col-profile")),
])
.with_title(i18n.t("batch-table-top-pages"));
for (url, page_type, score) in &pres.portfolio_summary.strongest_content_pages {
strengths = strengths.add_row(vec![
truncate_url(url, 42),
page_type.clone(),
format!("{score}/100"),
]);
}
builder = builder.add_component(strengths);
}
if let Some(ref consistency) = batch.consistency {
builder = render_batch_consistency(builder, consistency, &i18n);
}
builder = render_next_steps_batch(builder, &pres, &i18n);
if config.level == ReportLevel::Technical && !pres.appendix.per_url.is_empty() {
let appendix_intro = if en_pdf {
"Complete listing of all detected violations per URL with technical details for implementation."
} else {
"Vollständige Auflistung aller erkannten Verstöße pro URL \
mit technischen Details für die Umsetzung."
};
builder = builder.add_component(
SectionHeaderSplit::new(i18n.t("section-appendix"), appendix_intro).with_level(1),
);
let rule_col = if en_pdf { "Rule" } else { "Regel" };
let elements_col = if en_pdf {
"Affected elements"
} else {
"Betr. Elemente"
};
for url_appendix in &pres.appendix.per_url {
if url_appendix.violations.is_empty() {
continue;
}
builder = builder
.add_component(Section::new(truncate_url(&url_appendix.url, 70)).with_level(2));
let mut table = AuditTable::new(vec![
TableColumn::new(rule_col),
TableColumn::new(i18n.t("batch-col-severity")),
TableColumn::new(i18n.t("batch-col-description")),
TableColumn::new(elements_col),
]);
for v in &url_appendix.violations {
let elements = v
.affected_elements
.iter()
.map(|e| e.selector.clone())
.collect::<Vec<_>>()
.join("; ");
table = table.add_row(vec![
format!(
"{} — {} ({}×)",
v.rule,
v.rule_name,
v.affected_elements.len()
),
severity_label_i18n(v.severity, &i18n),
v.message.clone(),
elements,
]);
}
builder = builder.add_component(table);
}
}
let built_report = builder.build();
Ok((engine, built_report))
}