auditmysite 1.2.0

Resource-efficient WCAG 2.1 Accessibility Checker in Rust
Documentation
//! SEO heading structure analysis
//!
//! Analyzes H1-H6 heading hierarchy for SEO best practices.

use chromiumoxide::Page;
use serde::{Deserialize, Serialize};
use tracing::info;

use crate::error::{AuditError, Result};
use crate::taxonomy::Severity;

/// Heading structure analysis
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct HeadingStructure {
    /// Number of H1 elements
    pub h1_count: usize,
    /// H1 text content (first one if multiple)
    pub h1_text: Option<String>,
    /// All headings in order
    pub headings: Vec<HeadingInfo>,
    /// Heading issues found
    pub issues: Vec<HeadingIssue>,
    /// Total heading count
    pub total_count: usize,
}

/// Information about a single heading
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HeadingInfo {
    /// Heading level (1-6)
    pub level: u8,
    /// Heading text content
    pub text: String,
    /// Character count
    pub length: usize,
    /// Whether heading text ends with a question mark
    #[serde(default)]
    pub is_question: bool,
    /// Whether heading sits inside an FAQ context (e.g. details/summary or class/id containing faq)
    #[serde(default)]
    pub in_faq_context: bool,
    /// Word count of the visible text between this heading and the next heading
    /// (or end of the content area if it's the last one)
    #[serde(default)]
    pub word_count_after: u32,
}

/// Heading-related SEO issue
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HeadingIssue {
    /// Issue type
    pub issue_type: String,
    /// Issue description
    pub message: String,
    /// Severity: "error", "warning"
    pub severity: Severity,
}

/// Analyze heading structure of a page
pub async fn analyze_heading_structure(page: &Page) -> Result<HeadingStructure> {
    info!("Analyzing heading structure...");

    let js_code = r#"
    (() => {
        const headingEls = Array.from(document.querySelectorAll('h1, h2, h3, h4, h5, h6'));
        const countWords = (s) => {
            const t = s.trim();
            return t.length ? t.split(/\s+/).length : 0;
        };
        const headings = headingEls.map((h, i) => {
            const level = parseInt(h.tagName.charAt(1));
            const text = h.textContent.trim();
            const is_question = text.endsWith('?');
            const in_faq_context = !!(
                h.closest('[itemtype*="Question"]') ||
                h.closest('[itemtype*="FAQPage"]') ||
                h.closest('details') ||
                h.closest('.faq') ||
                h.closest('[class*="faq"]') ||
                h.closest('[id*="faq"]')
            );

            // Word count of the visible text between this heading and the next
            // heading (or end of the content area for the last heading).
            let word_count_after = 0;
            try {
                const range = document.createRange();
                range.setStartAfter(h);
                const next = headingEls[i + 1];
                if (next) {
                    range.setEndBefore(next);
                } else if (document.body.lastChild) {
                    range.setEndAfter(document.body.lastChild);
                } else {
                    range.setEnd(document.body, 0);
                }
                const frag = range.cloneContents();
                const container = document.createElement('div');
                container.appendChild(frag);
                container.querySelectorAll('script, style, noscript').forEach(e => e.remove());
                word_count_after = countWords(container.textContent || '');
            } catch (e) {
                word_count_after = 0;
            }

            return { level, text, length: text.length, is_question, in_faq_context, word_count_after };
        });
        return JSON.stringify(headings);
    })()
    "#;

    let js_result = page
        .evaluate(js_code)
        .await
        .map_err(|e| AuditError::CdpError(format!("Heading analysis failed: {}", e)))?;

    let json_str = js_result.value().and_then(|v| v.as_str()).unwrap_or("[]");

    let headings: Vec<HeadingInfo> = serde_json::from_str(json_str).unwrap_or_default();

    Ok(check_headings_structure(headings))
}

fn check_headings_structure(headings: Vec<HeadingInfo>) -> HeadingStructure {
    // Analyze structure
    let h1_headings: Vec<_> = headings.iter().filter(|h| h.level == 1).collect();
    let h1_count = h1_headings.len();
    let h1_text = h1_headings.first().map(|h| h.text.clone());

    let mut issues = Vec::new();

    // Check for missing H1
    if h1_count == 0 {
        issues.push(HeadingIssue {
            issue_type: "missing_h1".to_string(),
            message: "Page is missing an H1 heading".to_string(),
            severity: Severity::High,
        });
    }

    // Check for multiple H1s
    if h1_count > 1 {
        issues.push(HeadingIssue {
            issue_type: "multiple_h1".to_string(),
            message: format!("Page has {} H1 headings (should have exactly 1)", h1_count),
            severity: Severity::Medium,
        });
    }

    // Check for skipped heading levels
    let mut prev_level = 0u8;
    for heading in &headings {
        if prev_level > 0 && heading.level > prev_level + 1 {
            issues.push(HeadingIssue {
                issue_type: "skipped_level".to_string(),
                message: format!(
                    "Heading level skipped: H{} to H{} (\"{}\")",
                    prev_level,
                    heading.level,
                    truncate(&heading.text, 40)
                ),
                severity: Severity::Medium,
            });
        }
        prev_level = heading.level;
    }

    // Check for empty headings
    for heading in &headings {
        if heading.text.is_empty() {
            issues.push(HeadingIssue {
                issue_type: "empty_heading".to_string(),
                message: format!("Empty H{} heading found", heading.level),
                severity: Severity::High,
            });
        }
    }

    // Check for very long headings
    for heading in &headings {
        let max_len = if heading.is_question || heading.in_faq_context {
            100
        } else if heading.level <= 2 {
            70
        } else {
            90
        };

        if heading.length > max_len {
            issues.push(HeadingIssue {
                issue_type: "long_heading".to_string(),
                message: format!(
                    "H{} is too long ({} chars): \"{}...\"",
                    heading.level,
                    heading.length,
                    truncate(&heading.text, 30)
                ),
                severity: Severity::Medium,
            });
        }
    }

    info!(
        "Heading structure: {} total, {} H1s, {} issues",
        headings.len(),
        h1_count,
        issues.len()
    );

    HeadingStructure {
        h1_count,
        h1_text,
        total_count: headings.len(),
        headings,
        issues,
    }
}

fn truncate(s: &str, max: usize) -> String {
    if s.len() <= max {
        return s.to_string();
    }
    let target = max.saturating_sub(3);
    let boundary = s
        .char_indices()
        .take_while(|(i, _)| *i <= target)
        .last()
        .map(|(i, _)| i)
        .unwrap_or(0);
    format!("{}...", &s[..boundary])
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_heading_info() {
        let heading = HeadingInfo {
            level: 1,
            text: "Test Heading".to_string(),
            length: 12,
            is_question: false,
            in_faq_context: false,
            word_count_after: 0,
        };

        assert_eq!(heading.level, 1);
        assert_eq!(heading.length, 12);
    }

    #[test]
    fn test_faq_question_heading_length() {
        let headings = vec![
            HeadingInfo {
                level: 2,
                text: "Wie läuft eine hyperbare Sauerstofftherapie in unserer Praxis in München ab?".to_string(),
                length: 76,
                is_question: true,
                in_faq_context: false,
                word_count_after: 0,
            },
            HeadingInfo {
                level: 2,
                text: "Normaler Heading mit mehr als 70 Zeichen der eigentlich einen Fehler werfen sollte".to_string(),
                length: 82,
                is_question: false,
                in_faq_context: false,
                word_count_after: 0,
            }
        ];
        let res = check_headings_structure(headings);
        let long_heading_issues: Vec<_> = res
            .issues
            .iter()
            .filter(|i| i.issue_type == "long_heading")
            .collect();
        assert_eq!(long_heading_issues.len(), 1);
        assert!(long_heading_issues[0].message.contains("H2 is too long"));
    }
}