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    // An unquoted value (`alt=photo`, as minifiers write it) is alt
28    // text; only `alt=` with nothing after it is empty.
29    tag.contains("alt=\"\"")
30        || tag.contains("alt=''")
31        || has_alt_bare
32        || (has_alt_eq
33            && extract_attr_value(tag, "alt").is_some_and(|v| v.is_empty()))
34}
35
36/// Returns `true` if the `<img>` tag is marked as decorative: an ARIA
37/// role of `presentation` or `none`, or `aria-hidden="true"`, which
38/// removes the image from the accessibility tree. Any quoting style.
39pub(crate) fn is_decorative_img(tag: &str) -> bool {
40    let role = extract_attr_value(tag, "role");
41    matches!(role.as_deref(), Some("presentation" | "none"))
42        || extract_attr_value(tag, "aria-hidden").as_deref() == Some("true")
43}
44
45/// Returns `true` if an `<img>` tag has no text alternative: no `alt`
46/// attribute at all, or an empty one on an image that is not marked
47/// decorative (see [WCAG 1.1.1]).
48///
49/// Expects the tag with ASCII-lowercased attribute names, from `<img` to
50/// its closing `>` (see [`find_tag_end`]).
51///
52/// [WCAG 1.1.1]: https://www.w3.org/WAI/WCAG22/Understanding/non-text-content
53#[must_use]
54pub fn img_missing_alt(tag: &str) -> bool {
55    !has_valid_alt(tag) || (has_empty_alt(tag) && !is_decorative_img(tag))
56}
57
58/// Returns the index one past the closing `>` of the tag at `tag_start`.
59///
60/// Skips `>` characters inside double- or single-quoted attribute values, so
61/// an inline SVG `data:` URL in a `src` attribute cannot truncate the tag
62/// prematurely.
63// `const` because clippy::missing_const_for_fn asks for it under Rust
64// 1.98, where the lint learned to see through this loop. Nothing about
65// the body changed — it was already const-compatible.
66pub const fn find_tag_end(html: &str, tag_start: usize) -> usize {
67    let bytes = html.as_bytes();
68    let mut i = tag_start;
69    let mut quote: Option<u8> = None;
70    while i < bytes.len() {
71        let b = bytes[i];
72        match quote {
73            Some(q) if b == q => quote = None,
74            Some(_) => {}
75            None => match b {
76                b'"' | b'\'' => quote = Some(b),
77                b'>' => return i + 1,
78                _ => {}
79            },
80        }
81        i += 1;
82    }
83    bytes.len()
84}
85
86/// Extracts an attribute value from an HTML tag string.
87pub(crate) fn extract_attr_value(tag: &str, attr: &str) -> Option<String> {
88    let lower = tag.to_ascii_lowercase();
89    let pattern = format!("{attr}=");
90    let start = lower.find(&pattern)?;
91    let after = &tag[start + pattern.len()..];
92    let trimmed = after.trim_start();
93    if let Some(inner) = trimmed.strip_prefix('"') {
94        let end = inner.find('"')?;
95        Some(inner[..end].to_string())
96    } else if let Some(inner) = trimmed.strip_prefix('\'') {
97        let end = inner.find('\'')?;
98        Some(inner[..end].to_string())
99    } else {
100        let end = trimmed
101            .find(|c: char| c.is_whitespace() || c == '>')
102            .unwrap_or(trimmed.len());
103        Some(trimmed[..end].to_string())
104    }
105}
106
107/// Simple tag stripper for checking inner text.
108pub(crate) fn strip_tags_simple(html: &str) -> String {
109    let mut result = String::with_capacity(html.len());
110    let mut in_tag = false;
111    for ch in html.chars() {
112        if ch == '<' {
113            in_tag = true;
114        } else if ch == '>' {
115            in_tag = false;
116        } else if !in_tag {
117            result.push(ch);
118        }
119    }
120    result
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126
127    #[test]
128    fn extract_attr_value_double_quoted() {
129        let result = extract_attr_value(r#"<a href="/foo">"#, "href");
130        assert_eq!(result, Some("/foo".to_string()));
131    }
132
133    #[test]
134    fn extract_attr_value_single_quoted() {
135        // The single-quote branch.
136        let result = extract_attr_value(r"<a href='/bar'>", "href");
137        assert_eq!(result, Some("/bar".to_string()));
138    }
139
140    #[test]
141    fn extract_attr_value_unquoted() {
142        // The no-quote fallback branch, terminated by whitespace or `>`.
143        let result = extract_attr_value(r"<a href=/baz>", "href");
144        assert_eq!(result, Some("/baz".to_string()));
145    }
146
147    #[test]
148    fn extract_attr_value_missing_attribute_returns_none() {
149        let result = extract_attr_value(r"<a>", "href");
150        assert!(result.is_none());
151    }
152
153    #[test]
154    fn strip_tags_simple_removes_html_tags_and_preserves_text() {
155        let result = strip_tags_simple("<p>hello <b>world</b>!</p>");
156        assert_eq!(result, "hello world!");
157    }
158
159    #[test]
160    fn strip_tags_simple_handles_empty_and_text_only() {
161        assert_eq!(strip_tags_simple(""), "");
162        assert_eq!(strip_tags_simple("plain text"), "plain text");
163    }
164
165    #[test]
166    fn has_empty_alt_detects_bare_alt_attribute() {
167        // `<img alt>` — valueless alt is "present" but empty.
168        assert!(has_empty_alt("<img src=x alt>"));
169        assert!(has_empty_alt("<img alt src=x>"));
170        // Truncated tag ending exactly in ` alt`.
171        assert!(has_empty_alt("<img src=x alt"));
172    }
173
174    #[test]
175    fn has_empty_alt_detects_unquoted_missing_value() {
176        // `alt=` followed by neither quote style — treated as empty.
177        assert!(has_empty_alt("<img alt=>"));
178        // A single-quoted non-empty value is NOT empty.
179        assert!(!has_empty_alt("<img alt='photo'>"));
180    }
181
182    #[test]
183    fn is_decorative_img_covers_all_role_spellings() {
184        assert!(is_decorative_img("<img role=\"presentation\">"));
185        assert!(is_decorative_img("<img role=\"none\">"));
186        assert!(is_decorative_img("<img role='presentation'>"));
187        assert!(is_decorative_img("<img role='none'>"));
188        assert!(is_decorative_img("<img role=presentation>"));
189        assert!(is_decorative_img("<img role=none>"));
190        assert!(!is_decorative_img("<img role=\"img\">"));
191    }
192
193    #[test]
194    fn is_decorative_img_accepts_aria_hidden_true() {
195        // aria-hidden="true" removes the image from the accessibility
196        // tree, so alt="" on it marks a decorative image.
197        assert!(is_decorative_img("<img aria-hidden=\"true\">"));
198        assert!(is_decorative_img("<img aria-hidden='true'>"));
199        assert!(is_decorative_img("<img aria-hidden=true>"));
200        assert!(!is_decorative_img("<img aria-hidden=\"false\">"));
201        assert!(!is_decorative_img("<img src=\"a.png\">"));
202    }
203
204    #[test]
205    fn has_empty_alt_accepts_unquoted_value() {
206        // Minifiers drop the quotes around a one-word alt.
207        assert!(!has_empty_alt("<img alt=photo src=x>"));
208        assert!(!has_empty_alt("<img src=x alt=photo>"));
209    }
210
211    #[test]
212    fn find_tag_end_without_closing_bracket_returns_len() {
213        let html = "<img src=\"unterminated";
214        assert_eq!(find_tag_end(html, 0), html.len());
215    }
216
217    #[test]
218    fn test_extract_attr_value_quoting_styles() {
219        assert_eq!(
220            extract_attr_value("<img alt=\"hello\">", "alt"),
221            Some("hello".to_string())
222        );
223        assert_eq!(
224            extract_attr_value("<img alt='single'>", "alt"),
225            Some("single".to_string())
226        );
227        assert_eq!(
228            extract_attr_value("<img alt=unquoted>", "alt"),
229            Some("unquoted".to_string())
230        );
231        assert_eq!(
232            extract_attr_value("<img alt=unquoted-space class=x>", "alt"),
233            Some("unquoted-space".to_string())
234        );
235        // Missing closing quotes
236        assert_eq!(extract_attr_value("<img alt=\"unclosed>", "alt"), None);
237        assert_eq!(extract_attr_value("<img alt='unclosed>", "alt"), None);
238        // Attribute not found
239        assert_eq!(extract_attr_value("<img alt=\"hello\">", "width"), None);
240    }
241}