auditmysite 1.5.1

Resource-efficient WCAG 2.1 AA (+ select 2.2 criteria) Accessibility Checker in Rust
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
//! PDF Report Generator using renderreport/Typst
//!
//! Pure layout layer — receives pre-computed ViewModel blocks and maps them
//! directly to renderreport components. Zero data transformation here.

mod appendix;
mod batch;
mod batch_report;
mod bik_guide;
mod cover;
pub(crate) mod design;
mod detail_modules;
mod diagnosis;
mod en301549;
mod findings;
mod helpers;
mod problem_profile;
mod sanitize;
mod single_report;
mod wcag_coverage;

pub use self::batch_report::{generate_batch_pdf, generate_batch_typ};

use renderreport::components::advanced::{
    ChecklistPanel, ChecklistRow, PageBreak, TableOfContents,
};
use renderreport::components::text::Label;
use renderreport::components::{CoverModuleGauge, CoverPage};
use renderreport::prelude::*;

// Re-export helpers used by sibling sub-modules via `super::`.
use self::appendix::{cover_logo_asset, register_cover_logo_asset};
use self::diagnosis::{business_relevance, format_word_count, output_scope_callout};
use self::helpers::{create_engine, extract_domain};
use self::single_report::{
    render_appendix_full, render_management_page, render_module_sections, render_part_divider,
    render_root_cause_analysis, render_tech_details, render_timeframe_roadmap,
};
use crate::audit::{normalize, AuditReport};
use crate::cli::ReportLevel;
use crate::i18n::I18n;
use crate::output::builder::build_view_model;
use crate::output::report_model::*;

const WORDMARK_ASSET: &str = "/auditmysite-wordmark.svg";
const CUSTOM_COVER_LOGO_ASSET: &str = "/cover-logo-custom";
const PAGE_DESKTOP_SCREENSHOT_ASSET: &str = "/auditmysite-desktop-preview.png";
const PAGE_MOBILE_SCREENSHOT_ASSET: &str = "/auditmysite-mobile-preview.png";

// ─── Single Report ──────────────────────────────────────────────────────────
//
// 6-page structure:
//   Page 1 — Hero / Entry (pitch: status, scores, impact, consequences)
//   Page 2 — Dominant Issue (focus: biggest problem, top fixes, leverage)
//   Page 3 — Key Findings (bridge: Problem/Impact/Ursache/Fix cards)
//   Page 4 — Action Plan (decide: quick wins, action table, execution note)
//   Page 5 — Tech Entry (transition: intro + severity overview)
//   Page 6+ — Tech Details (implement: WCAG details, code examples, modules)

pub fn generate_pdf(report: &AuditReport, config: &ReportConfig) -> anyhow::Result<Vec<u8>> {
    let pdf_bytes: anyhow::Result<Vec<u8>> = (|| {
        let (engine, mut built_report) = build_single_report(report, config)?;
        self::sanitize::render_pdf_sanitized(&engine, &mut built_report)
    })();
    cleanup_screenshot_temps(report);
    pdf_bytes
}

/// Render the intermediate Typst source for a single-page report.
///
/// Useful for the hidden `--debug-typ` mode and for snapshot/template-regression
/// checks (issue #239) — lets reviewers inspect completeness and wording without
/// going through the heavy PDF + pdftotext pipeline.
pub fn generate_typ(report: &AuditReport, config: &ReportConfig) -> anyhow::Result<String> {
    let typ: anyhow::Result<String> = (|| {
        let (engine, mut built_report) = build_single_report(report, config)?;
        self::sanitize::sanitize_request(&mut built_report, &Default::default());
        Ok(engine.render_typ(&built_report)?)
    })();
    cleanup_screenshot_temps(report);
    typ
}

fn build_single_report(
    report: &AuditReport,
    config: &ReportConfig,
) -> anyhow::Result<(
    std::sync::Arc<renderreport::Engine>,
    renderreport::RenderRequest,
)> {
    let engine = create_engine();
    let normalized = normalize(report);
    let vm = build_view_model(&normalized, config);
    let i18n = I18n::new(&config.locale)?;

    let mut builder = engine
        .report("wcag-audit")
        .metadata("date", &vm.meta.date)
        .metadata("version", &vm.meta.version)
        .metadata("author", &vm.meta.author)
        .metadata("score", &vm.meta.score_label)
        .metadata("lang", i18n.locale())
        .metadata("footer_prefix", "Audit:")
        .metadata("footer_link_url", "")
        .metadata(
            "footer_tagline",
            "A technical auditing platform by casoon.de",
        );

    builder = register_page_screenshot_assets(builder, report)?;

    // ── Cover Page (single composed component) ───────────────────────
    let en = i18n.locale() == "en";
    // The headline is the accessibility score — the subject of the report.
    // The weighted overall value across all six modules is a different
    // question and is reported as such, further in (plan 29, D1).
    let headline_score = vm.summary.score;
    let band_phrase = self::cover::cover_band_phrase(
        headline_score,
        vm.summary.ci_verdict,
        vm.summary.run_is_partial,
        en,
    );
    let domain = extract_domain(&vm.cover.domain);
    let module_gauges: Vec<CoverModuleGauge> = vm
        .modules
        .dashboard
        .iter()
        .map(|m| {
            let name = self::helpers::module_name_with_taxonomy_suffix(
                &m.name,
                &m.measurement_type,
                &i18n,
            );
            CoverModuleGauge::new(name, m.score)
        })
        .collect();
    // The gauge strip's label carries the scope qualifier: a bare
    // "Barrierefreiheit 100" on the cover reads as "fully accessible", which
    // no automated run can establish.
    let (score_lbl, find_lbl, mod_lbl, crit_lbl, no_crit) = if en {
        (
            "ACCESSIBILITY · AUTOMATED SCOPE",
            "WCAG OCCURRENCES",
            "MODULES · 0–100 WITHIN THE AUTOMATED SCOPE · HIGHER IS BETTER",
            "critical/high",
            "0 critical/high",
        )
    } else {
        (
            "BARRIEREFREIHEIT · AUTOMATISIERTER PRÜFUMFANG",
            "WCAG-VORKOMMEN",
            "MODULE · 0–100 IM AUTOMATISIERTEN PRÜFUMFANG · HÖHER IST BESSER",
            "kritisch/hoch",
            "0 kritisch/hoch",
        )
    };

    // White-label: only a user-supplied custom logo takes the brand slot on the
    // cover; the default keeps the clean text brand (no auto wordmark image).
    let cover_logo_asset = self::appendix::cover_logo_asset(config);
    let custom_logo = if cover_logo_asset == CUSTOM_COVER_LOGO_ASSET {
        builder = self::appendix::register_cover_logo_asset(builder, config, cover_logo_asset);
        Some(cover_logo_asset)
    } else {
        None
    };

    // What was actually audited belongs on the cover, not only on page 2 —
    // "Website-Qualitätsbericht casoon.de · 92" otherwise reads as a verdict
    // on the whole site when exactly one page was checked.
    //
    // The cover has room for exactly one subtitle line: a second line pushes
    // the lower gauge row's labels off the page (the template gives each label
    // a fixed 22pt box and the page does not grow). `narrative-cover-kicker`
    // is therefore deliberately terse — the areas it used to enumerate are
    // named by the gauge strip right below it anyway.
    let cover_subtitle = format!(
        "{} · {}",
        vm.executive.cover_kicker,
        self::single_report::audit_scope_line(en)
    );
    let mut cover = CoverPage::new(&vm.cover.title, &domain, headline_score, &vm.cover.grade)
        .with_brand(&vm.cover.brand)
        .with_subtitle(&cover_subtitle)
        .with_date(&vm.cover.date)
        .with_band_phrase(&band_phrase)
        .with_issues(vm.cover.total_issues, vm.cover.critical_issues)
        .with_module_gauges(module_gauges)
        .with_labels(score_lbl, find_lbl, mod_lbl, crit_lbl, no_crit);
    if let Some(logo) = custom_logo {
        cover = cover.with_logo(logo);
    }
    builder = builder.add_component(cover);

    // --- Page 2: Management Summary (CoverPage already emits a page break) ---
    builder = render_management_page(builder, &vm, &i18n);

    if vm.meta.report_level != ReportLevel::Executive {
        // --- Page 3: Table of Contents ---
        builder = builder.add_component(PageBreak::new());
        let toc_title = if en { "Contents" } else { "Inhalt" };
        builder = builder.add_component(TableOfContents::new().with_title(toc_title).with_depth(2));

        // --- Part 1: Befunde nach Ursache (Divider) ---
        let (p1_title, p1_intro, p1_audience_title, p1_audience_body) = if en {
            (
                "Findings by Root Cause",
                "Which underlying causes explain the most findings, and the prioritized plan to fix them.",
                "Audience",
                "Site owners, decision-makers, and developers. This part explains what is driving the findings and what to do next.",
            )
        } else {
            (
                "Befunde nach Ursache",
                "Welche Ursachen die meisten Befunde erklären – und der priorisierte Plan, sie zu beheben.",
                "Zielgruppe",
                "Inhaber, Entscheider und Entwickler. Dieser Teil erklärt, was die Befunde verursacht und was als Nächstes zu tun ist.",
            )
        };
        builder = render_part_divider(
            builder,
            1,
            p1_title,
            p1_intro,
            p1_audience_title,
            p1_audience_body,
            &i18n,
        );

        // Ursachenanalyse (Table, components list, leverage)
        builder = render_root_cause_analysis(builder, &vm, &i18n);

        // Maßnahmenplan (Roadmap) & confidence notes
        builder = render_timeframe_roadmap(builder, &vm, &i18n);
        builder = render_audit_confidence_notes(builder, &normalized.normalized.audit_flags, &i18n);
    }

    // Executive level stops here
    if vm.meta.report_level == ReportLevel::Executive {
        builder = builder
            .add_component(
                Callout::info(&vm.methodology.limitations)
                    .with_title(i18n.t("callout-limitations-title")),
            )
            .add_component(
                Callout::warning(&vm.methodology.disclaimer)
                    .with_title(i18n.t("callout-note-title")),
            );
        let built_report = builder.build();
        return Ok((engine, built_report));
    }

    // --- Part 2: Technische Details für Entwickler (Divider) ---
    let (p2_title, p2_intro, p2_audience_title, p2_audience_body) = if en {
        (
            "Technical Details for Developers",
            "Detailed WCAG violations, HTML evidence, and code examples grouped by systemic components vs. local instances.",
            "Audience",
            "Developers and QA teams. Read this part to implement fixes for all detected violations.",
        )
    } else {
        (
            "Technische Details für Entwickler",
            "Detaillierte WCAG-Verstöße, HTML-Evidenzen und Code-Beispiele, gruppiert nach systemischen Komponenten vs. Einzelfällen.",
            "Zielgruppe",
            "Entwickler und QA-Teams. Dieser Teil liefert konkrete Code-Befunde zur Behebung aller Barrieren.",
        )
    };
    builder = render_part_divider(
        builder,
        2,
        p2_title,
        p2_intro,
        p2_audience_title,
        p2_audience_body,
        &i18n,
    );

    // Technical details findings list
    builder = render_tech_details(builder, &vm, report, &i18n);

    // --- Part 3: Qualitäts-Analysen (Divider) ---
    let (p3_title, p3_intro, p3_audience_title, p3_audience_body) = if en {
        (
            "Quality Analyses",
            "Search engine optimization, load speed, mobile layout, and security header metrics.",
            "Audience",
            "Marketing, SEO specialists, and product owners. Read this part to optimize discoverability and performance.",
        )
    } else {
        (
            "Qualitäts-Analysen",
            "Suchmaschinenoptimierung, Ladezeiten, mobiles Layout und Sicherheits-Metriken.",
            "Zielgruppe",
            "Marketing, SEO-Spezialisten und Product Owner. Dieser Teil ergänzt die Barrierefreiheit um Qualitäts-Indikatoren.",
        )
    };
    builder = render_part_divider(
        builder,
        3,
        p3_title,
        p3_intro,
        p3_audience_title,
        p3_audience_body,
        &i18n,
    );

    // SEO, performance, mobile, ux, journey etc.
    builder = render_module_sections(builder, &vm, report, &i18n);

    // --- Appendix: Rohdaten & Methodik ---
    builder = render_appendix_full(
        builder,
        &vm,
        report,
        &normalized.normalized.findings,
        config,
        &i18n,
    );

    let built_report = builder.build();
    Ok((engine, built_report))
}

fn render_audit_confidence_notes(
    mut builder: renderreport::engine::ReportBuilder,
    audit_flags: &[crate::audit::normalized::AuditFlag],
    i18n: &I18n,
) -> renderreport::engine::ReportBuilder {
    let en = i18n.locale() == "en";
    let base = if en {
        "Automated findings are reliable for detectable patterns, but context-dependent WCAG criteria, semantic content quality and user journeys still require manual verification. Indicator modules such as AI visibility, content visibility and UX are best-effort signals, not guarantees."
    } else {
        "Automatisierte Befunde sind belastbar für erkennbare Muster, aber kontextabhängige WCAG-Kriterien, semantische Inhaltsqualität und Nutzerwege benötigen weiterhin manuelle Prüfung. Indikator-Module wie KI-Sichtbarkeit, Content Visibility und UX sind Hinweiswerte, keine Garantien."
    };
    let title = if en {
        "Notes on report confidence"
    } else {
        "Hinweise zur Aussagekraft"
    };
    let text = format!("{}: {}", title, base);
    builder = builder.add_component(Label::new(&text).with_size("10.5pt").with_color("#475569"));

    if !audit_flags.is_empty() {
        let mut rows = Vec::new();
        for flag in audit_flags {
            rows.push(
                ChecklistRow::new(
                    audit_flag_title(&flag.kind, en),
                    audit_flag_customer_text(flag, en),
                )
                .with_status("warn"),
            );
        }
        builder = builder.add_component(ChecklistPanel::new(rows).with_title(if en {
            "Audit caveats"
        } else {
            "Audit-Hinweise"
        }));
    }
    builder
}

fn audit_flag_title(kind: &str, en: bool) -> &'static str {
    match (kind, en) {
        ("consent_banner", true) => "Consent banner",
        ("consent_banner", false) => "Consent-Banner",
        ("bypass_blocks_untested", true) => "Skip link verification",
        ("bypass_blocks_untested", false) => "Skip-Link-Prüfung",
        ("conflicting_signal", true) => "Conflicting signal",
        ("conflicting_signal", false) => "Widersprüchliches Signal",
        ("viewport_gap", true) => "Desktop/mobile difference",
        ("viewport_gap", false) => "Desktop-/Mobile-Unterschied",
        // Aligned with the "Prüfabdeckung"/"Coverage" framing on the
        // management summary page (`SummaryBlock::audit_quality_note`) —
        // this used to say "Unvollständiger Messumfang" ("incomplete
        // measurement scope"), which reintroduced the alarming "audit is
        // broken" tone the summary page deliberately moved away from for a
        // normal stability-budget limitation (feedback 2026-09-07).
        ("incomplete_audit", true) => "Limited test coverage",
        ("incomplete_audit", false) => "Eingeschränkte Prüfabdeckung",
        ("consent_wall_artifact", true) => "Consent wall artifact",
        ("consent_wall_artifact", false) => "Consent-Wall-Artefakt",
        (_, true) => "Audit note",
        (_, false) => "Audit-Hinweis",
    }
}

fn audit_flag_customer_text(flag: &crate::audit::normalized::AuditFlag, en: bool) -> String {
    match (flag.kind.as_str(), en) {
        ("consent_banner", true) => "A consent banner was detected. Parts of the page may have been hidden or not fully measurable without consent.".to_string(),
        ("consent_banner", false) => "Ein Consent-Banner wurde erkannt. Teile der Seite können ohne Zustimmung verborgen oder nicht vollständig messbar gewesen sein.".to_string(),
        ("bypass_blocks_untested", true) => "A skip link exists, but the usage journey indicates that keyboard focus may not reach the intended target.".to_string(),
        ("bypass_blocks_untested", false) => "Ein Skip-Link ist vorhanden, der Nutzungstest deutet aber darauf hin, dass der Tastaturfokus sein Ziel nicht zuverlässig erreicht.".to_string(),
        ("conflicting_signal", true) => "Two checks report different signals. Treat this result as a review point rather than a final conclusion.".to_string(),
        ("conflicting_signal", false) => "Zwei Prüfungen melden unterschiedliche Signale. Dieses Ergebnis sollte als Prüfhilfe statt als endgültige Aussage gelesen werden.".to_string(),
        ("viewport_gap", true) => "Desktop and mobile results differ strongly. The page experience should be checked separately for both viewports.".to_string(),
        ("viewport_gap", false) => "Desktop- und Mobile-Ergebnis unterscheiden sich deutlich. Die Nutzung sollte für beide Ansichten getrennt geprüft werden.".to_string(),
        // Same sentence as the management summary's Partial-run callout
        // (`build_view_model`'s `audit_quality_note`) — one wording for the
        // same underlying limitation, not two that could drift apart.
        ("incomplete_audit", true) => "Individual measurements hit a stability/retry budget and are excluded from the reported scores, which describe only the successfully measured scope.".to_string(),
        ("incomplete_audit", false) => "Einzelne Messungen haben ein Stabilitäts-/Wiederholungsbudget erreicht und sind daher nicht in den ausgewiesenen Scores enthalten; diese beschreiben den erfolgreich gemessenen Umfang.".to_string(),
        _ => flag.message.clone(),
    }
}

fn cleanup_screenshot_temps(report: &AuditReport) {
    let ts = report.timestamp.timestamp_nanos_opt().unwrap_or(0);
    if let Some(ref _shots) = report.page_screenshots {
        let _ = std::fs::remove_file(std::env::temp_dir().join(format!("ams-desktop-{}.png", ts)));
        let _ = std::fs::remove_file(std::env::temp_dir().join(format!("ams-mobile-{}.png", ts)));
    }
    // Element-evidence crops (evidence-grade findings): named
    // `ams-evidence-{ts}-{n}.png` by `render_finding_technical`, n in
    // 0..MAX_ELEMENT_CROPS. Removing a non-existent path is a harmless no-op,
    // so this runs unconditionally rather than tracking whether captures
    // actually happened.
    for n in 0..crate::accessibility::MAX_ELEMENT_CROPS {
        let _ = std::fs::remove_file(
            std::env::temp_dir().join(format!("ams-evidence-{}-{}.png", ts, n)),
        );
    }
}

/// Target width:height ratio for the mobile device-preview crop, derived
/// from the `DevicePreview` component's own layout (70/30 desktop/mobile
/// column split at a shared box height — see `device_preview.typ`): the
/// mobile column is `(1-0.7)/0.7 ≈ 0.43` as wide as the desktop column, and
/// desktop's 1280:800 screenshot already fits its column without cropping,
/// so mobile's target ratio is `0.43 * (1280/800)`.
const MOBILE_PREVIEW_CROP_RATIO: f64 = 0.686;

/// The raw mobile screenshot is a full-viewport capture (e.g. 390×844 CSS px
/// at 2x DPR), several times taller than it is wide. Typst's box+clip
/// overflow handling for an image this much taller than its display box was
/// observed to lose the *top* of the image — exactly the page header a
/// reader most needs to see — instead of reliably keeping it in frame (see
/// `device_preview.typ`). Crop to a display-appropriate aspect ratio here,
/// in Rust, so the asset handed to Typst rarely needs any further clipping.
fn crop_mobile_preview(png_bytes: &[u8]) -> Vec<u8> {
    let Ok(img) = image::load_from_memory(png_bytes) else {
        return png_bytes.to_vec();
    };
    let (width, height) = (img.width(), img.height());
    let target_height = ((width as f64) / MOBILE_PREVIEW_CROP_RATIO).round() as u32;
    if target_height >= height {
        return png_bytes.to_vec();
    }
    let cropped = img.crop_imm(0, 0, width, target_height);
    let mut out = Vec::new();
    if cropped
        .write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png)
        .is_err()
    {
        return png_bytes.to_vec();
    }
    out
}

fn register_page_screenshot_assets(
    mut builder: renderreport::engine::ReportBuilder,
    report: &AuditReport,
) -> anyhow::Result<renderreport::engine::ReportBuilder> {
    let Some(ref shots) = report.page_screenshots else {
        return Ok(builder);
    };

    let ts = report.timestamp.timestamp_nanos_opt().unwrap_or(0);
    let desktop_path = std::env::temp_dir().join(format!("ams-desktop-{}.png", ts));
    let mobile_path = std::env::temp_dir().join(format!("ams-mobile-{}.png", ts));

    std::fs::write(&desktop_path, &shots.desktop)?;
    std::fs::write(&mobile_path, crop_mobile_preview(&shots.mobile))?;

    builder = builder
        .asset(PAGE_DESKTOP_SCREENSHOT_ASSET, desktop_path)
        .asset(PAGE_MOBILE_SCREENSHOT_ASSET, mobile_path);
    Ok(builder)
}

#[cfg(all(test, feature = "pdf_test"))]
#[path = "tests.rs"]
mod tests;