workers-rsx 0.1.0

A JSX-like templating engine for Cloudflare Workers
Documentation
use crate::html_escaping::escape_html;
use crate::Render;
use std::borrow::Cow;
use std::collections::HashMap;
use std::fmt::{Result, Write};

/// Attribute value: either a key-value pair or a boolean attribute (no value)
#[derive(Debug)]
pub enum AttrValue<'a> {
    Value(Cow<'a, str>),
    Boolean,
}

type Attributes<'a> = Option<HashMap<&'a str, AttrValue<'a>>>;

/// Simple HTML element tag
#[derive(Debug)]
pub struct SimpleElement<'a, T: Render> {
    /// the HTML tag name, like `html`, `head`, `body`, `link`...
    pub tag_name: &'a str,
    pub attributes: Attributes<'a>,
    pub contents: Option<T>,
}

/// HTML void elements that must not have a closing tag
const VOID_ELEMENTS: &[&str] = &[
    "area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param",
    "source", "track", "wbr",
];

fn is_void_element(tag: &str) -> bool {
    VOID_ELEMENTS.contains(&tag)
}

fn write_attributes<'a, W: Write>(maybe_attributes: Attributes<'a>, writer: &mut W) -> Result {
    match maybe_attributes {
        None => Ok(()),
        Some(mut attributes) => {
            for (key, value) in attributes.drain() {
                match value {
                    AttrValue::Boolean => {
                        write!(writer, " {}", key)?;
                    }
                    AttrValue::Value(v) => {
                        write!(writer, " {}=\"", key)?;
                        escape_html(&v, writer)?;
                        write!(writer, "\"")?;
                    }
                }
            }
            Ok(())
        }
    }
}

impl<T: Render> Render for SimpleElement<'_, T> {
    fn render_into<W: Write>(self, writer: &mut W) -> Result {
        let is_void = is_void_element(self.tag_name);

        match self.contents {
            None => {
                write!(writer, "<{}", self.tag_name)?;
                write_attributes(self.attributes, writer)?;
                if is_void {
                    write!(writer, ">")
                } else {
                    write!(writer, "></{}>", self.tag_name)
                }
            }
            Some(renderable) => {
                write!(writer, "<{}", self.tag_name)?;
                write_attributes(self.attributes, writer)?;
                write!(writer, ">")?;
                renderable.render_into(writer)?;
                if !is_void {
                    write!(writer, "</{}>", self.tag_name)?;
                }
                Ok(())
            }
        }
    }
}

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

    #[test]
    fn script_empty_renders_open_and_close_tag() {
        let el: SimpleElement<'_, ()> = SimpleElement {
            tag_name: "script",
            attributes: {
                let mut attrs = HashMap::new();
                attrs.insert("src", AttrValue::Value(Cow::Borrowed("app.js")));
                Some(attrs)
            },
            contents: None,
        };
        let result = el.render();
        assert!(
            result.contains("></script>"),
            "Expected closing tag, got: {}",
            result
        );
        assert!(
            !result.contains("/>"),
            "Should not self-close script, got: {}",
            result
        );
    }

    #[test]
    fn script_with_content_renders_open_and_close_tag() {
        let el = SimpleElement {
            tag_name: "script",
            attributes: None,
            contents: Some("console.log(1)"),
        };
        let result = el.render();
        assert_eq!(result, "<script>console.log(1)</script>");
    }

    #[test]
    fn div_empty_renders_open_and_close_tag() {
        let el: SimpleElement<'_, ()> = SimpleElement {
            tag_name: "div",
            attributes: None,
            contents: None,
        };
        assert_eq!(el.render(), "<div></div>");
    }

    #[test]
    fn void_element_no_closing_tag() {
        let el: SimpleElement<'_, ()> = SimpleElement {
            tag_name: "br",
            attributes: None,
            contents: None,
        };
        assert_eq!(el.render(), "<br>");
    }

    #[test]
    fn void_element_input_no_closing_tag() {
        let el: SimpleElement<'_, ()> = SimpleElement {
            tag_name: "input",
            attributes: {
                let mut attrs = HashMap::new();
                attrs.insert("type", AttrValue::Value(Cow::Borrowed("text")));
                Some(attrs)
            },
            contents: None,
        };
        let result = el.render();
        assert!(result.starts_with("<input"));
        assert!(result.ends_with(">"));
        assert!(!result.contains("</input>"));
    }
}