Skip to main content

typstify_core/
utils.rs

1//! Shared string utilities for HTML escaping, slugification, and HTML stripping.
2
3/// Escape HTML special characters.
4///
5/// Converts `&`, `<`, `>`, `"`, and `'` to their HTML entity equivalents.
6#[must_use]
7pub fn html_escape(s: &str) -> String {
8    s.replace('&', "&amp;")
9        .replace('<', "&lt;")
10        .replace('>', "&gt;")
11        .replace('"', "&quot;")
12        .replace('\'', "&#x27;")
13}
14
15/// Convert text to a URL-safe slug.
16///
17/// Lowercases the text, replaces whitespace and special characters with hyphens,
18/// and collapses consecutive hyphens.
19#[must_use]
20pub fn slugify(text: &str) -> String {
21    text.to_lowercase()
22        .chars()
23        .map(|c| {
24            if c.is_alphanumeric() {
25                c
26            } else if c.is_whitespace() || c == '-' || c == '_' {
27                '-'
28            } else {
29                '\0'
30            }
31        })
32        .filter(|c| *c != '\0')
33        .collect::<String>()
34        .split('-')
35        .filter(|s| !s.is_empty())
36        .collect::<Vec<_>>()
37        .join("-")
38}
39
40/// Strip HTML tags from content, handling script/style blocks and HTML entities.
41///
42/// This is the most comprehensive version: it skips `<script>` and `<style>` blocks,
43/// decodes common HTML entities, and collapses whitespace.
44#[must_use]
45pub fn strip_html(html: &str) -> String {
46    let mut result = String::with_capacity(html.len());
47    let mut in_tag = false;
48    let mut in_script = false;
49    let mut in_style = false;
50
51    let html_lower = html.to_lowercase();
52    let chars: Vec<char> = html.chars().collect();
53    let chars_lower: Vec<char> = html_lower.chars().collect();
54
55    let mut i = 0;
56    while i < chars.len() {
57        let c = chars[i];
58
59        // Check for script/style start
60        if i + 7 < chars.len() {
61            let next_7: String = chars_lower[i..i + 7].iter().collect();
62            if next_7 == "<script" {
63                in_script = true;
64            } else if next_7 == "</scrip" {
65                in_script = false;
66            }
67        }
68
69        if i + 6 < chars.len() {
70            let next_6: String = chars_lower[i..i + 6].iter().collect();
71            if next_6 == "<style" {
72                in_style = true;
73            } else if next_6 == "</styl" {
74                in_style = false;
75            }
76        }
77
78        if c == '<' {
79            in_tag = true;
80        } else if c == '>' {
81            in_tag = false;
82        } else if !in_tag && !in_script && !in_style {
83            result.push(c);
84        }
85
86        i += 1;
87    }
88
89    // Decode common HTML entities
90    result = result
91        .replace("&nbsp;", " ")
92        .replace("&amp;", "&")
93        .replace("&lt;", "<")
94        .replace("&gt;", ">")
95        .replace("&quot;", "\"")
96        .replace("&#39;", "'");
97
98    // Collapse multiple whitespace
99    let mut collapsed = String::with_capacity(result.len());
100    let mut prev_space = false;
101    for c in result.chars() {
102        if c.is_whitespace() {
103            if !prev_space {
104                collapsed.push(' ');
105                prev_space = true;
106            }
107        } else {
108            collapsed.push(c);
109            prev_space = false;
110        }
111    }
112
113    collapsed.trim().to_string()
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119
120    #[test]
121    fn test_html_escape() {
122        assert_eq!(html_escape("a & b"), "a &amp; b");
123        assert_eq!(html_escape("<div>"), "&lt;div&gt;");
124        assert_eq!(html_escape(r#"a "b" c"#), "a &quot;b&quot; c");
125        assert_eq!(html_escape("it's"), "it&#x27;s");
126    }
127
128    #[test]
129    fn test_slugify() {
130        assert_eq!(slugify("Hello World"), "hello-world");
131        assert_eq!(slugify("foo_bar"), "foo-bar");
132        assert_eq!(slugify("  multiple   spaces  "), "multiple-spaces");
133        assert_eq!(slugify("special!@#chars"), "specialchars");
134    }
135
136    #[test]
137    fn test_strip_html() {
138        assert_eq!(strip_html("<p>Hello</p>"), "Hello");
139        assert_eq!(strip_html("<script>alert(1)</script>"), "");
140        assert_eq!(
141            strip_html("<b>bold</b> and <i>italic</i>"),
142            "bold and italic"
143        );
144        assert_eq!(strip_html("a &amp; b"), "a & b");
145        assert_eq!(
146            strip_html("  multiple   <br>   spaces  "),
147            "multiple spaces"
148        );
149    }
150}