Skip to main content

ssg_a11y/
html.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Hand-rolled HTML tag/attribute helpers.
5//!
6//! No parsing crate is used deliberately — these operate on raw byte/char
7//! slices so the crate stays dependency-free and trivially embeddable in
8//! any build pipeline.
9
10/// Returns `true` if the `<img>` tag has any form of `alt` attribute.
11pub(crate) fn has_valid_alt(tag: &str) -> bool {
12    let has_alt_eq = tag.contains("alt=");
13    let has_alt_bare = !has_alt_eq
14        && (tag.contains(" alt ")
15            || tag.contains(" alt>")
16            || tag.ends_with(" alt"));
17    has_alt_eq || has_alt_bare
18}
19
20/// Returns `true` if the `<img>` tag has an empty or missing-value alt.
21pub(crate) fn has_empty_alt(tag: &str) -> bool {
22    let has_alt_eq = tag.contains("alt=");
23    let has_alt_bare = !has_alt_eq
24        && (tag.contains(" alt ")
25            || tag.contains(" alt>")
26            || tag.ends_with(" alt"));
27    tag.contains("alt=\"\"")
28        || tag.contains("alt=''")
29        || has_alt_bare
30        || (has_alt_eq && !tag.contains("alt=\"") && !tag.contains("alt='"))
31}
32
33/// Returns `true` if the `<img>` tag is marked as decorative via ARIA roles.
34pub(crate) fn is_decorative_img(tag: &str) -> bool {
35    tag.contains("role=\"presentation\"")
36        || tag.contains("role=\"none\"")
37        || tag.contains("role='presentation'")
38        || tag.contains("role='none'")
39        || tag.contains("role=presentation")
40        || tag.contains("role=none")
41}
42
43/// Returns the index one past the closing `>` of the tag at `tag_start`.
44///
45/// Skips `>` characters inside double- or single-quoted attribute values, so
46/// an inline SVG `data:` URL in a `src` attribute cannot truncate the tag
47/// prematurely.
48// `const` because clippy::missing_const_for_fn asks for it under Rust
49// 1.98, where the lint learned to see through this loop. Nothing about
50// the body changed — it was already const-compatible.
51pub const fn find_tag_end(html: &str, tag_start: usize) -> usize {
52    let bytes = html.as_bytes();
53    let mut i = tag_start;
54    let mut quote: Option<u8> = None;
55    while i < bytes.len() {
56        let b = bytes[i];
57        match quote {
58            Some(q) if b == q => quote = None,
59            Some(_) => {}
60            None => match b {
61                b'"' | b'\'' => quote = Some(b),
62                b'>' => return i + 1,
63                _ => {}
64            },
65        }
66        i += 1;
67    }
68    bytes.len()
69}
70
71/// Extracts an attribute value from an HTML tag string.
72pub(crate) fn extract_attr_value(tag: &str, attr: &str) -> Option<String> {
73    let lower = tag.to_ascii_lowercase();
74    let pattern = format!("{attr}=");
75    let start = lower.find(&pattern)?;
76    let after = &tag[start + pattern.len()..];
77    let trimmed = after.trim_start();
78    if let Some(inner) = trimmed.strip_prefix('"') {
79        let end = inner.find('"')?;
80        Some(inner[..end].to_string())
81    } else if let Some(inner) = trimmed.strip_prefix('\'') {
82        let end = inner.find('\'')?;
83        Some(inner[..end].to_string())
84    } else {
85        let end = trimmed
86            .find(|c: char| c.is_whitespace() || c == '>')
87            .unwrap_or(trimmed.len());
88        Some(trimmed[..end].to_string())
89    }
90}
91
92/// Simple tag stripper for checking inner text.
93pub(crate) fn strip_tags_simple(html: &str) -> String {
94    let mut result = String::with_capacity(html.len());
95    let mut in_tag = false;
96    for ch in html.chars() {
97        if ch == '<' {
98            in_tag = true;
99        } else if ch == '>' {
100            in_tag = false;
101        } else if !in_tag {
102            result.push(ch);
103        }
104    }
105    result
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111
112    #[test]
113    fn extract_attr_value_double_quoted() {
114        let result = extract_attr_value(r#"<a href="/foo">"#, "href");
115        assert_eq!(result, Some("/foo".to_string()));
116    }
117
118    #[test]
119    fn extract_attr_value_single_quoted() {
120        // The single-quote branch.
121        let result = extract_attr_value(r"<a href='/bar'>", "href");
122        assert_eq!(result, Some("/bar".to_string()));
123    }
124
125    #[test]
126    fn extract_attr_value_unquoted() {
127        // The no-quote fallback branch, terminated by whitespace or `>`.
128        let result = extract_attr_value(r"<a href=/baz>", "href");
129        assert_eq!(result, Some("/baz".to_string()));
130    }
131
132    #[test]
133    fn extract_attr_value_missing_attribute_returns_none() {
134        let result = extract_attr_value(r"<a>", "href");
135        assert!(result.is_none());
136    }
137
138    #[test]
139    fn strip_tags_simple_removes_html_tags_and_preserves_text() {
140        let result = strip_tags_simple("<p>hello <b>world</b>!</p>");
141        assert_eq!(result, "hello world!");
142    }
143
144    #[test]
145    fn strip_tags_simple_handles_empty_and_text_only() {
146        assert_eq!(strip_tags_simple(""), "");
147        assert_eq!(strip_tags_simple("plain text"), "plain text");
148    }
149
150    #[test]
151    fn has_empty_alt_detects_bare_alt_attribute() {
152        // `<img alt>` — valueless alt is "present" but empty.
153        assert!(has_empty_alt("<img src=x alt>"));
154        assert!(has_empty_alt("<img alt src=x>"));
155        // Truncated tag ending exactly in ` alt`.
156        assert!(has_empty_alt("<img src=x alt"));
157    }
158
159    #[test]
160    fn has_empty_alt_detects_unquoted_missing_value() {
161        // `alt=` followed by neither quote style — treated as empty.
162        assert!(has_empty_alt("<img alt=>"));
163        // A single-quoted non-empty value is NOT empty.
164        assert!(!has_empty_alt("<img alt='photo'>"));
165    }
166
167    #[test]
168    fn is_decorative_img_covers_all_role_spellings() {
169        assert!(is_decorative_img("<img role=\"presentation\">"));
170        assert!(is_decorative_img("<img role=\"none\">"));
171        assert!(is_decorative_img("<img role='presentation'>"));
172        assert!(is_decorative_img("<img role='none'>"));
173        assert!(is_decorative_img("<img role=presentation>"));
174        assert!(is_decorative_img("<img role=none>"));
175        assert!(!is_decorative_img("<img role=\"img\">"));
176    }
177
178    #[test]
179    fn find_tag_end_without_closing_bracket_returns_len() {
180        let html = "<img src=\"unterminated";
181        assert_eq!(find_tag_end(html, 0), html.len());
182    }
183
184    #[test]
185    fn test_extract_attr_value_quoting_styles() {
186        assert_eq!(
187            extract_attr_value("<img alt=\"hello\">", "alt"),
188            Some("hello".to_string())
189        );
190        assert_eq!(
191            extract_attr_value("<img alt='single'>", "alt"),
192            Some("single".to_string())
193        );
194        assert_eq!(
195            extract_attr_value("<img alt=unquoted>", "alt"),
196            Some("unquoted".to_string())
197        );
198        assert_eq!(
199            extract_attr_value("<img alt=unquoted-space class=x>", "alt"),
200            Some("unquoted-space".to_string())
201        );
202        // Missing closing quotes
203        assert_eq!(extract_attr_value("<img alt=\"unclosed>", "alt"), None);
204        assert_eq!(extract_attr_value("<img alt='unclosed>", "alt"), None);
205        // Attribute not found
206        assert_eq!(extract_attr_value("<img alt=\"hello\">", "width"), None);
207    }
208}