ryde_html/
lib.rs

1use std::{borrow::Cow, collections::HashSet, fmt::Display, io::Write};
2
3extern crate self as html;
4
5fn escape<'a, S: Into<Cow<'a, str>>>(input: S) -> Cow<'a, str> {
6    let input = input.into();
7    fn needs_escaping(c: char) -> bool {
8        c == '<' || c == '>' || c == '&' || c == '"' || c == '\''
9    }
10
11    if let Some(first) = input.find(needs_escaping) {
12        let mut output = String::from(&input[0..first]);
13        output.reserve(input.len() - first);
14        let rest = input[first..].chars();
15        for c in rest {
16            match c {
17                '<' => output.push_str("&lt;"),
18                '>' => output.push_str("&gt;"),
19                '&' => output.push_str("&amp;"),
20                '"' => output.push_str("&quot;"),
21                '\'' => output.push_str("&#39;"),
22                _ => output.push(c),
23            }
24        }
25        Cow::Owned(output)
26    } else {
27        input
28    }
29}
30
31pub struct Element {
32    name: &'static str,
33    attrs: Vec<u8>,
34    children: Option<Box<dyn Render>>,
35    class: String,
36    css: Vec<String>,
37}
38
39macro_rules! impl_attr {
40    ($ident:ident) => {
41        pub fn $ident(self, value: impl Display) -> Self {
42            self.attr(stringify!($ident), value)
43        }
44    };
45
46    ($ident:ident, $name:expr) => {
47        pub fn $ident(self, value: impl Display) -> Self {
48            self.attr($name, value)
49        }
50    };
51}
52
53macro_rules! impl_bool_attr {
54    ($ident:ident) => {
55        pub fn $ident(self) -> Self {
56            self.bool_attr(stringify!($ident))
57        }
58    };
59}
60
61impl Element {
62    fn new(name: &'static str, children: Option<Box<dyn Render>>) -> Element {
63        Element {
64            name,
65            attrs: vec![],
66            children,
67            class: "".into(),
68            css: vec![],
69        }
70    }
71
72    pub fn attr(mut self, name: &'static str, value: impl Display) -> Self {
73        if !self.attrs.is_empty() {
74            self.attrs
75                .write(b" ")
76                .expect("attr failed to write to buffer");
77        }
78        self.attrs
79            .write_fmt(format_args!("{}", name))
80            .expect("attr failed to write to buffer");
81        self.attrs
82            .write(b"=\"")
83            .expect("attr failed to write to buffer");
84        self.attrs
85            .write_fmt(format_args!("{}", escape(value.to_string())))
86            .expect("attr failed to write to buffer");
87        self.attrs
88            .write(b"\"")
89            .expect("attr failed to write to buffer");
90
91        self
92    }
93
94    pub fn bool_attr(mut self, name: &'static str) -> Self {
95        if !self.attrs.is_empty() {
96            self.attrs
97                .write(b" ")
98                .expect("bool_attr failed to write to buffer");
99        }
100        self.attrs
101            .write_fmt(format_args!("{}", name))
102            .expect("bool_attr failed to write to buffer");
103
104        self
105    }
106
107    #[deprecated(since = "0.1.1", note = "Please use type_ instead")]
108    pub fn r#type(self, value: impl Display) -> Self {
109        self.attr("type", value)
110    }
111
112    #[deprecated(since = "0.1.1", note = "Please use for_ instead")]
113    pub fn r#for(self, value: impl Display) -> Self {
114        self.attr("for", value)
115    }
116
117    pub fn css(mut self, value: (impl Display, Vec<&str>)) -> Self {
118        self.css.extend(value.1.into_iter().map(|x| x.to_string()));
119        self.class(value.0)
120    }
121
122    pub fn class(mut self, value: impl Display) -> Self {
123        if self.class.is_empty() {
124            self.class = value.to_string();
125        } else {
126            self.class.push(' ');
127            self.class.push_str(&value.to_string());
128        }
129        self
130    }
131
132    pub fn replace(self, value: impl Display) -> Self {
133        self.attr("x-replace", value)
134    }
135
136    impl_attr!(id);
137    impl_attr!(charset);
138    impl_attr!(content);
139    impl_attr!(name);
140    impl_attr!(href);
141    impl_attr!(rel);
142    impl_attr!(target);
143    impl_attr!(src);
144    impl_attr!(integrity);
145    impl_attr!(crossorigin);
146    impl_attr!(role);
147    impl_attr!(method);
148    impl_attr!(action);
149    impl_attr!(placeholder);
150    impl_attr!(value);
151    impl_attr!(rows);
152    impl_attr!(alt);
153    impl_attr!(style);
154    impl_attr!(onclick);
155    impl_attr!(placement);
156    impl_attr!(toggle);
157    impl_attr!(scope);
158    impl_attr!(title);
159    impl_attr!(lang);
160    impl_attr!(type_, "type");
161    impl_attr!(for_, "for");
162    impl_attr!(aria_controls, "aria-controls");
163    impl_attr!(aria_expanded, "aria-expanded");
164    impl_attr!(aria_label, "aria-label");
165    impl_attr!(aria_haspopup, "aria-haspopup");
166    impl_attr!(aria_labelledby, "aria-labelledby");
167    impl_attr!(aria_current, "aria-current");
168    impl_bool_attr!(defer);
169    impl_bool_attr!(checked);
170    impl_bool_attr!(enabled);
171    impl_bool_attr!(disabled);
172}
173
174pub trait Render {
175    fn render(&self, buffer: &mut Vec<u8>) -> std::io::Result<()>;
176    fn styles(&self, _styles: &mut HashSet<String>) -> std::io::Result<()> {
177        Ok(())
178    }
179}
180
181impl Render for Element {
182    fn render(&self, buffer: &mut Vec<u8>) -> std::io::Result<()> {
183        let name_bytes = self.name.as_bytes();
184        buffer.write(b"<")?;
185        buffer.write(name_bytes)?;
186        if !self.attrs.is_empty() {
187            buffer.write(b" ")?;
188            buffer.write(&self.attrs)?;
189        }
190        if !self.class.is_empty() {
191            buffer.write(b" ")?;
192            buffer.write_fmt(format_args!("class=\"{}\"", self.class))?;
193        }
194        buffer.write(b">")?;
195        match &self.children {
196            Some(children) => {
197                children.render(buffer)?;
198                buffer.write(b"</")?;
199                buffer.write(name_bytes)?;
200                buffer.write(b">")?;
201            }
202            None => {}
203        };
204
205        Ok(())
206    }
207
208    fn styles(&self, styles: &mut HashSet<String>) -> std::io::Result<()> {
209        if !self.css.is_empty() {
210            styles.extend(self.css.clone().into_iter().collect::<HashSet<String>>());
211        }
212        match &self.children {
213            Some(children) => {
214                children.styles(styles)?;
215            }
216            None => {}
217        };
218        Ok(())
219    }
220}
221
222pub struct Raw(pub String);
223
224impl Render for Raw {
225    fn render(&self, buffer: &mut Vec<u8>) -> std::io::Result<()> {
226        buffer.write_fmt(format_args!("{}", self.0))?;
227
228        Ok(())
229    }
230}
231
232pub fn danger(html: impl Display) -> Raw {
233    Raw(html.to_string())
234}
235
236impl Render for String {
237    fn render(&self, buffer: &mut Vec<u8>) -> std::io::Result<()> {
238        buffer.write_fmt(format_args!("{}", escape(self)))?;
239
240        Ok(())
241    }
242}
243
244impl Render for &String {
245    fn render(&self, buffer: &mut Vec<u8>) -> std::io::Result<()> {
246        buffer.write_fmt(format_args!("{}", escape(*self)))?;
247
248        Ok(())
249    }
250}
251
252impl<'a> Render for &'a str {
253    fn render(&self, buffer: &mut Vec<u8>) -> std::io::Result<()> {
254        buffer.write_fmt(format_args!("{}", escape(*self)))?;
255
256        Ok(())
257    }
258}
259
260impl Render for () {
261    fn render(&self, _buffer: &mut Vec<u8>) -> std::io::Result<()> {
262        Ok(())
263    }
264}
265
266impl<T> Render for Vec<T>
267where
268    T: Render,
269{
270    fn render(&self, buffer: &mut Vec<u8>) -> std::io::Result<()> {
271        for t in self {
272            t.render(buffer)?;
273        }
274
275        Ok(())
276    }
277
278    fn styles(&self, buffer: &mut HashSet<String>) -> std::io::Result<()> {
279        for t in self {
280            t.styles(buffer)?;
281        }
282
283        Ok(())
284    }
285}
286
287macro_rules! impl_render_tuple {
288    ($max:expr) => {
289        seq_macro::seq!(N in 0..=$max {
290            impl<#(T~N,)*> Render for (#(T~N,)*)
291            where
292                #(T~N: Render,)*
293            {
294                fn render(&self, buffer: &mut Vec<u8>) -> std::io::Result<()> {
295                    #(self.N.render(buffer)?;)*
296
297                    Ok(())
298                }
299
300                fn styles(&self, buffer: &mut HashSet<String>) -> std::io::Result<()> {
301                    #(self.N.styles(buffer)?;)*
302
303                    Ok(())
304                }
305            }
306        });
307    };
308}
309
310seq_macro::seq!(N in 0..=31 {
311    impl_render_tuple!(N);
312});
313
314pub fn doctype() -> Element {
315    Element::new("!DOCTYPE html", None)
316}
317
318pub fn render(renderable: impl Render + 'static) -> String {
319    let mut v: Vec<u8> = vec![];
320    renderable.render(&mut v).expect("Failed to render html");
321    String::from_utf8_lossy(&v).into()
322}
323
324pub fn styles(renderable: &impl Render) -> String {
325    let mut styles: HashSet<String> = HashSet::new();
326    renderable
327        .styles(&mut styles)
328        .expect("Failed to style html");
329    let mut other_styles = styles
330        .iter()
331        .filter(|s| !s.starts_with("@media"))
332        .collect::<Vec<_>>();
333    let media_styles = styles
334        .iter()
335        .filter(|s| s.starts_with("@media"))
336        .collect::<Vec<_>>();
337    other_styles.extend(media_styles);
338    other_styles
339        .into_iter()
340        .map(|s| s.clone())
341        .collect::<Vec<_>>()
342        .join("")
343}
344
345macro_rules! impl_render_num {
346    ($t:ty) => {
347        impl Render for $t {
348            fn render(&self, buffer: &mut Vec<u8>) -> std::io::Result<()> {
349                buffer.write_fmt(format_args!("{}", &self))?;
350                Ok(())
351            }
352        }
353    };
354}
355
356impl_render_num!(u8);
357impl_render_num!(u16);
358impl_render_num!(f64);
359impl_render_num!(f32);
360impl_render_num!(i64);
361impl_render_num!(u64);
362impl_render_num!(i32);
363impl_render_num!(u32);
364impl_render_num!(usize);
365impl_render_num!(isize);
366
367pub fn element(name: &'static str, children: impl Render + 'static) -> Element {
368    Element::new(name, Some(Box::new(children)))
369}
370
371pub fn self_closing_element(name: &'static str) -> Element {
372    Element::new(name, None)
373}
374
375pub fn anon_element(children: impl Render + 'static) -> Element {
376    Element::new("", Some(Box::new(children)))
377}
378
379macro_rules! impl_element {
380    ($ident:ident) => {
381        pub fn $ident(child: impl Render + 'static) -> Element {
382            Element::new(stringify!($ident), Some(Box::new(child)))
383        }
384    };
385}
386
387macro_rules! impl_void_element {
388    ($ident:ident) => {
389        pub fn $ident() -> Element {
390            Element::new(stringify!($ident), None)
391        }
392    };
393}
394
395impl_element!(html);
396impl_element!(head);
397impl_element!(title);
398impl_element!(body);
399impl_element!(div);
400impl_element!(section);
401impl_element!(style);
402impl_element!(h1);
403impl_element!(h2);
404impl_element!(h3);
405impl_element!(h4);
406impl_element!(h5);
407impl_element!(li);
408impl_element!(ul);
409impl_element!(ol);
410impl_element!(p);
411impl_element!(span);
412impl_element!(b);
413impl_element!(i);
414impl_element!(u);
415impl_element!(tt);
416impl_element!(string);
417impl_element!(pre);
418impl_element!(script);
419impl_element!(main);
420impl_element!(nav);
421impl_element!(a);
422impl_element!(form);
423impl_element!(button);
424impl_element!(blockquote);
425impl_element!(footer);
426impl_element!(wrapper);
427impl_element!(label);
428impl_element!(table);
429impl_element!(thead);
430impl_element!(th);
431impl_element!(tr);
432impl_element!(td);
433impl_element!(tbody);
434impl_element!(textarea);
435impl_element!(datalist);
436impl_element!(option);
437
438impl_void_element!(area);
439impl_void_element!(base);
440impl_void_element!(br);
441impl_void_element!(col);
442impl_void_element!(embed);
443impl_void_element!(hr);
444impl_void_element!(img);
445impl_void_element!(input);
446impl_void_element!(link);
447impl_void_element!(meta);
448impl_void_element!(param);
449impl_void_element!(source);
450impl_void_element!(track);
451impl_void_element!(wbr);
452
453#[cfg(test)]
454mod tests {
455    use html::*;
456
457    #[test]
458    fn it_works() {
459        let html = render((doctype(), html((head(()), body(())))));
460        assert_eq!(
461            "<!DOCTYPE html><html><head></head><body></body></html>",
462            html
463        );
464    }
465
466    #[test]
467    fn it_works_with_numbers() {
468        let html = render((doctype(), html((head(()), body(0)))));
469        assert_eq!(
470            "<!DOCTYPE html><html><head></head><body>0</body></html>",
471            html
472        );
473    }
474
475    #[test]
476    fn it_escapes_correctly() {
477        let html = render((doctype(), html((head(()), body("<div />")))));
478        assert_eq!(
479            html,
480            "<!DOCTYPE html><html><head></head><body>&lt;div /&gt;</body></html>",
481        );
482    }
483
484    #[test]
485    fn it_escapes_more() {
486        let html = render((
487            doctype(),
488            html((head(()), body("<script>alert('hello')</script>"))),
489        ));
490        assert_eq!(
491            html,
492            "<!DOCTYPE html><html><head></head><body>&lt;script&gt;alert(&#39;hello&#39;)&lt;/script&gt;</body></html>",
493        );
494    }
495
496    #[test]
497    fn it_renders_attributes() {
498        let html = render((doctype(), html((head(()), body(div("hello").id("hello"))))));
499        assert_eq!(
500            "<!DOCTYPE html><html><head></head><body><div id=\"hello\">hello</div></body></html>",
501            html
502        );
503    }
504
505    #[test]
506    fn it_renders_custom_self_closing_elements() {
507        fn hx_close() -> Element {
508            self_closing_element("hx-close")
509        }
510        let html = render(hx_close().id("id"));
511        assert_eq!("<hx-close id=\"id\">", html);
512    }
513
514    #[test]
515    fn readme_works() {
516        use html::*;
517
518        fn render_to_string(element: Element) -> String {
519            render((
520                doctype(),
521                html((
522                    head((title("title"), meta().charset("utf-8"))),
523                    body(element),
524                )),
525            ))
526        }
527
528        assert_eq!(
529        render_to_string(div("html")),
530        "<!DOCTYPE html><html><head><title>title</title><meta charset=\"utf-8\"></head><body><div>html</div></body></html>"
531      )
532    }
533
534    #[test]
535    fn max_tuples_works() {
536        let elements = seq_macro::seq!(N in 0..=31 {
537            (#(br().id(N),)*)
538        });
539
540        assert_eq!(render(elements),
541            "<br id=\"0\"><br id=\"1\"><br id=\"2\"><br id=\"3\"><br id=\"4\"><br id=\"5\"><br id=\"6\"><br id=\"7\"><br id=\"8\"><br id=\"9\"><br id=\"10\"><br id=\"11\"><br id=\"12\"><br id=\"13\"><br id=\"14\"><br id=\"15\"><br id=\"16\"><br id=\"17\"><br id=\"18\"><br id=\"19\"><br id=\"20\"><br id=\"21\"><br id=\"22\"><br id=\"23\"><br id=\"24\"><br id=\"25\"><br id=\"26\"><br id=\"27\"><br id=\"28\"><br id=\"29\"><br id=\"30\"><br id=\"31\">"
542        )
543    }
544
545    #[test]
546    fn bool_attr_works() {
547        let html = render(input().type_("checkbox").checked());
548
549        assert_eq!(html, r#"<input type="checkbox" checked>"#)
550    }
551
552    #[test]
553    fn multiple_attrs_spaced_correctly() {
554        let html = render(input().type_("checkbox").checked().aria_label("label"));
555
556        assert_eq!(
557            html,
558            r#"<input type="checkbox" checked aria-label="label">"#
559        )
560    }
561
562    #[test]
563    fn readme1_works() {
564        let element = input()
565            .attr("hx-post", "/")
566            .attr("hx-target", ".target")
567            .attr("hx-swap", "outerHTML")
568            .attr("hx-push-url", "false");
569        let html = render(element);
570
571        assert_eq!(
572            html,
573            r#"<input hx-post="/" hx-target=".target" hx-swap="outerHTML" hx-push-url="false">"#
574        )
575    }
576
577    #[test]
578    fn readme2_works() {
579        fn turbo_frame(children: Element) -> Element {
580            element("turbo-frame", children)
581        }
582        let html = render(turbo_frame(div("inside turbo frame")).id("id"));
583
584        assert_eq!(
585            "<turbo-frame id=\"id\"><div>inside turbo frame</div></turbo-frame>",
586            html
587        );
588    }
589
590    #[test]
591    fn styles_dedup() {
592        let p1 = p("").css((
593            "color-red background-green",
594            vec![
595                ".color-red{color:red;}".into(),
596                ".background-green{background:green;}".into(),
597            ],
598        ));
599        let p2 = p("").css((
600            "color-red background-blue",
601            vec![
602                ".color-red{color:red;}".into(),
603                ".background-blue{background:blue;}".into(),
604            ],
605        ));
606        let div1 = div((p1, p2)).css(("color-red", vec![".color-red{color:red;}".into()]));
607        let styles = styles(&div1);
608        let mut styles = styles
609            .split(".")
610            .into_iter()
611            .filter(|x| !x.is_empty())
612            .map(|x| format!(".{}", x))
613            .collect::<Vec<String>>();
614
615        styles.sort();
616
617        let mut expected: Vec<String> = vec![
618            ".color-red{color:red;}".into(),
619            ".background-blue{background:blue;}".into(),
620            ".background-green{background:green;}".into(),
621        ];
622
623        expected.sort();
624
625        assert_eq!(expected, styles);
626    }
627}