use crate::error::{Result, RuitlError};
use html_escape::{encode_quoted_attribute, encode_text};
use std::fmt::{self, Display, Write};
#[derive(Debug, Clone, PartialEq)]
pub struct HtmlElement {
pub tag: String,
pub attributes: Vec<(String, HtmlAttribute)>,
pub children: Vec<Html>,
pub self_closing: bool,
}
#[derive(Debug, Clone, PartialEq)]
pub enum HtmlAttribute {
Value(String),
Boolean,
List(Vec<String>),
}
#[derive(Debug, Clone, PartialEq)]
pub enum Html {
Text(String),
Raw(String),
Element(HtmlElement),
Fragment(Vec<Html>),
Empty,
}
impl HtmlElement {
pub fn new<S: Into<String>>(tag: S) -> Self {
Self {
tag: tag.into(),
attributes: Vec::new(),
children: Vec::new(),
self_closing: false,
}
}
pub fn self_closing<S: Into<String>>(tag: S) -> Self {
Self {
tag: tag.into(),
attributes: Vec::new(),
children: Vec::new(),
self_closing: true,
}
}
pub fn attr<K: Into<String>, V: Into<String>>(mut self, key: K, value: V) -> Self {
self.attributes
.push((key.into(), HtmlAttribute::Value(value.into())));
self
}
pub fn bool_attr<K: Into<String>>(mut self, key: K) -> Self {
self.attributes.push((key.into(), HtmlAttribute::Boolean));
self
}
pub fn class<S: Into<String>>(mut self, class: S) -> Self {
let class_name = class.into();
let existing = self
.attributes
.iter_mut()
.find(|(k, _)| k == "class")
.map(|(_, v)| v);
match existing {
Some(HtmlAttribute::Value(existing)) => {
*existing = format!("{} {}", existing, class_name);
}
Some(HtmlAttribute::List(list)) => {
list.push(class_name);
}
_ => {
self.attributes
.push(("class".to_string(), HtmlAttribute::Value(class_name)));
}
}
self
}
pub fn classes<I, S>(mut self, classes: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
let class_list: Vec<String> = classes.into_iter().map(|s| s.into()).collect();
if !class_list.is_empty() {
self.attributes.retain(|(k, _)| k != "class");
self.attributes
.push(("class".to_string(), HtmlAttribute::List(class_list)));
}
self
}
pub fn id<S: Into<String>>(mut self, id: S) -> Self {
self.attributes.retain(|(k, _)| k != "id");
self.attributes
.push(("id".to_string(), HtmlAttribute::Value(id.into())));
self
}
pub fn child(mut self, child: Html) -> Self {
self.children.push(child);
self
}
pub fn attr_if<K: Into<String>, V: Into<String>>(
mut self,
key: K,
condition: bool,
value: V,
) -> Self {
if condition {
self.attributes
.push((key.into(), HtmlAttribute::Value(value.into())));
}
self
}
pub fn attr_optional<K: Into<String>>(mut self, key: K, value: &Option<String>) -> Self {
if let Some(ref val) = value {
self.attributes
.push((key.into(), HtmlAttribute::Value(val.clone())));
}
self
}
pub fn children<I>(mut self, children: I) -> Self
where
I: IntoIterator<Item = Html>,
{
self.children.extend(children);
self
}
pub fn text<S: Into<String>>(mut self, text: S) -> Self {
self.children.push(Html::Text(text.into()));
self
}
pub fn raw<S: Into<String>>(mut self, html: S) -> Self {
self.children.push(Html::Raw(html.into()));
self
}
pub fn has_children(&self) -> bool {
!self.children.is_empty()
}
pub fn is_self_closing(&self) -> bool {
self.self_closing || is_void_element(&self.tag)
}
}
#[cfg(feature = "minify")]
fn maybe_minify(html: String) -> String {
let cfg = minify_html::Cfg {
keep_closing_tags: true,
keep_comments: false,
..Default::default()
};
let bytes = minify_html::minify(html.as_bytes(), &cfg);
String::from_utf8(bytes).unwrap_or(html)
}
#[cfg(not(feature = "minify"))]
fn maybe_minify(html: String) -> String {
html
}
impl HtmlAttribute {
pub fn render(&self) -> String {
match self {
HtmlAttribute::Value(value) => format!("\"{}\"", encode_quoted_attribute(value)),
HtmlAttribute::Boolean => String::new(),
HtmlAttribute::List(list) => {
let joined = list.join(" ");
format!("\"{}\"", encode_quoted_attribute(&joined))
}
}
}
pub fn is_boolean(&self) -> bool {
matches!(self, HtmlAttribute::Boolean)
}
}
impl Html {
pub fn text<S: Into<String>>(content: S) -> Self {
Html::Text(content.into())
}
pub fn raw<S: Into<String>>(content: S) -> Self {
Html::Raw(content.into())
}
pub fn element<S: Into<String>>(tag: S) -> HtmlElement {
HtmlElement::new(tag)
}
pub fn fragment<I>(children: I) -> Self
where
I: IntoIterator<Item = Html>,
{
Html::Fragment(children.into_iter().collect())
}
pub fn empty() -> Self {
Html::Empty
}
pub fn render(&self) -> String {
let mut output = String::new();
self.render_to(&mut output).unwrap_or_default();
maybe_minify(output)
}
pub fn render_into(&self, buf: &mut String) -> Result<()> {
let pre_len = buf.len();
self.render_to(buf)?;
#[cfg(feature = "minify")]
{
let written = buf.split_off(pre_len);
buf.push_str(&maybe_minify(written));
}
#[cfg(not(feature = "minify"))]
{
let _ = pre_len;
}
Ok(())
}
pub fn render_with_capacity(&self, capacity: usize) -> String {
let mut s = String::with_capacity(capacity);
let _ = self.render_to(&mut s);
maybe_minify(s)
}
pub fn len_hint(&self) -> usize {
match self {
Html::Text(s) | Html::Raw(s) => s.len(),
Html::Element(e) => {
let attrs: usize = e
.attributes
.iter()
.map(|(k, v)| {
k.len()
+ match v {
HtmlAttribute::Value(s) => s.len() + 4, HtmlAttribute::Boolean => 1,
HtmlAttribute::List(items) => {
items.iter().map(|s| s.len() + 1).sum::<usize>() + 3
}
}
})
.sum();
let children: usize = e.children.iter().map(|c| c.len_hint()).sum();
e.tag.len() * 2 + 5 + attrs + children
}
Html::Fragment(children) => children.iter().map(|c| c.len_hint()).sum(),
Html::Empty => 0,
}
}
pub fn to_chunks(&self) -> Vec<Vec<u8>> {
match self {
Html::Fragment(children) if !children.is_empty() => children
.iter()
.map(|c| {
let mut s = String::with_capacity(c.len_hint());
let _ = c.render_to(&mut s);
s.into_bytes()
})
.collect(),
_ => {
let mut s = String::with_capacity(self.len_hint());
let _ = self.render_to(&mut s);
vec![s.into_bytes()]
}
}
}
pub fn render_to<W: Write>(&self, writer: &mut W) -> Result<()> {
match self {
Html::Text(text) => {
write!(writer, "{}", encode_text(text))
.map_err(|e| RuitlError::render(format!("Failed to write text: {}", e)))?;
}
Html::Raw(html) => {
write!(writer, "{}", html)
.map_err(|e| RuitlError::render(format!("Failed to write raw HTML: {}", e)))?;
}
Html::Element(element) => {
element.render_to(writer)?;
}
Html::Fragment(children) => {
for child in children {
child.render_to(writer)?;
}
}
Html::Empty => {
}
}
Ok(())
}
pub fn is_empty(&self) -> bool {
match self {
Html::Empty => true,
Html::Text(text) => text.is_empty(),
Html::Raw(html) => html.is_empty(),
Html::Fragment(children) => {
children.is_empty() || children.iter().all(|c| c.is_empty())
}
Html::Element(_) => false, }
}
pub fn text_content(&self) -> String {
match self {
Html::Text(text) => text.clone(),
Html::Raw(_) => String::new(), Html::Element(element) => element
.children
.iter()
.map(|c| c.text_content())
.collect::<Vec<_>>()
.join(""),
Html::Fragment(children) => children
.iter()
.map(|c| c.text_content())
.collect::<Vec<_>>()
.join(""),
Html::Empty => String::new(),
}
}
}
impl HtmlElement {
pub fn render(&self) -> String {
let mut output = String::new();
self.render_to(&mut output).unwrap_or_default();
output
}
pub fn render_to<W: Write>(&self, writer: &mut W) -> Result<()> {
write!(writer, "<{}", self.tag)
.map_err(|e| RuitlError::render(format!("Failed to write opening tag: {}", e)))?;
for (key, value) in &self.attributes {
match value {
HtmlAttribute::Boolean => {
write!(writer, " {}", key).map_err(|e| {
RuitlError::render(format!("Failed to write boolean attribute: {}", e))
})?;
}
_ => {
write!(writer, " {}={}", key, value.render()).map_err(|e| {
RuitlError::render(format!("Failed to write attribute: {}", e))
})?;
}
}
}
if self.is_self_closing() {
write!(writer, " />").map_err(|e| {
RuitlError::render(format!("Failed to write self-closing tag: {}", e))
})?;
} else {
write!(writer, ">")
.map_err(|e| RuitlError::render(format!("Failed to write tag close: {}", e)))?;
for child in &self.children {
child.render_to(writer)?;
}
write!(writer, "</{}>", self.tag)
.map_err(|e| RuitlError::render(format!("Failed to write closing tag: {}", e)))?;
}
Ok(())
}
}
impl Display for Html {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.render())
}
}
impl Display for HtmlElement {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", Html::Element(self.clone()).render())
}
}
impl From<HtmlElement> for Html {
fn from(element: HtmlElement) -> Self {
Html::Element(element)
}
}
impl From<String> for Html {
fn from(text: String) -> Self {
Html::Text(text)
}
}
impl From<&str> for Html {
fn from(text: &str) -> Self {
Html::Text(text.to_string())
}
}
fn is_void_element(tag: &str) -> bool {
matches!(
tag.to_lowercase().as_str(),
"area"
| "base"
| "br"
| "col"
| "embed"
| "hr"
| "img"
| "input"
| "link"
| "meta"
| "param"
| "source"
| "track"
| "wbr"
)
}
pub fn html() -> HtmlElement {
HtmlElement::new("html")
}
pub fn head() -> HtmlElement {
HtmlElement::new("head")
}
pub fn body() -> HtmlElement {
HtmlElement::new("body")
}
pub fn div() -> HtmlElement {
HtmlElement::new("div")
}
pub fn p() -> HtmlElement {
HtmlElement::new("p")
}
pub fn h1() -> HtmlElement {
HtmlElement::new("h1")
}
pub fn h2() -> HtmlElement {
HtmlElement::new("h2")
}
pub fn h3() -> HtmlElement {
HtmlElement::new("h3")
}
pub fn h4() -> HtmlElement {
HtmlElement::new("h4")
}
pub fn h5() -> HtmlElement {
HtmlElement::new("h5")
}
pub fn h6() -> HtmlElement {
HtmlElement::new("h6")
}
pub fn span() -> HtmlElement {
HtmlElement::new("span")
}
pub fn a() -> HtmlElement {
HtmlElement::new("a")
}
pub fn img() -> HtmlElement {
HtmlElement::self_closing("img")
}
pub fn br() -> HtmlElement {
HtmlElement::self_closing("br")
}
pub fn hr() -> HtmlElement {
HtmlElement::self_closing("hr")
}
pub fn input() -> HtmlElement {
HtmlElement::self_closing("input")
}
pub fn button() -> HtmlElement {
HtmlElement::new("button")
}
pub fn form() -> HtmlElement {
HtmlElement::new("form")
}
pub fn ul() -> HtmlElement {
HtmlElement::new("ul")
}
pub fn ol() -> HtmlElement {
HtmlElement::new("ol")
}
pub fn li() -> HtmlElement {
HtmlElement::new("li")
}
pub fn table() -> HtmlElement {
HtmlElement::new("table")
}
pub fn tr() -> HtmlElement {
HtmlElement::new("tr")
}
pub fn td() -> HtmlElement {
HtmlElement::new("td")
}
pub fn th() -> HtmlElement {
HtmlElement::new("th")
}
pub fn thead() -> HtmlElement {
HtmlElement::new("thead")
}
pub fn tbody() -> HtmlElement {
HtmlElement::new("tbody")
}
pub fn section() -> HtmlElement {
HtmlElement::new("section")
}
pub fn article() -> HtmlElement {
HtmlElement::new("article")
}
pub fn nav() -> HtmlElement {
HtmlElement::new("nav")
}
pub fn header() -> HtmlElement {
HtmlElement::new("header")
}
pub fn footer() -> HtmlElement {
HtmlElement::new("footer")
}
pub fn main() -> HtmlElement {
HtmlElement::new("main")
}
pub fn aside() -> HtmlElement {
HtmlElement::new("aside")
}
pub fn title() -> HtmlElement {
HtmlElement::new("title")
}
pub fn style() -> HtmlElement {
HtmlElement::new("style")
}
pub fn text<S: Into<String>>(content: S) -> Html {
Html::text(content)
}
pub fn raw<S: Into<String>>(content: S) -> Html {
Html::raw(content)
}
pub fn fragment<I>(children: I) -> Html
where
I: IntoIterator<Item = Html>,
{
Html::fragment(children)
}
pub trait HtmlElementExt {
fn attr_if(self, name: &str, condition: bool, value: &str) -> Self;
fn attr_optional<K: Into<String>>(self, name: K, value: &Option<String>) -> Self;
}
impl HtmlElementExt for HtmlElement {
fn attr_if(self, name: &str, condition: bool, value: &str) -> Self {
if condition {
self.attr(name, value)
} else {
self
}
}
fn attr_optional<K: Into<String>>(self, name: K, value: &Option<String>) -> Self {
if let Some(ref val) = value {
self.attr(name, val)
} else {
self
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_basic_element() {
let element = div().class("test").text("Hello, world!");
let html = element.render();
assert_eq!(html, r#"<div class="test">Hello, world!</div>"#);
}
#[test]
fn test_render_into_reuses_buffer() {
let elem = Html::Element(div().text("one"));
let mut buf = String::from("prefix:");
elem.render_into(&mut buf).unwrap();
assert_eq!(buf, "prefix:<div>one</div>");
buf.clear();
let elem2 = Html::Element(div().text("two"));
elem2.render_into(&mut buf).unwrap();
assert_eq!(buf, "<div>two</div>");
}
#[test]
fn test_render_with_capacity_matches_render() {
let elem = Html::Element(div().class("x").child(Html::text("hi")));
let a = elem.render();
let b = elem.render_with_capacity(elem.len_hint());
assert_eq!(a, b);
}
#[test]
fn test_len_hint_non_zero_for_non_empty() {
let elem = Html::Element(div().child(Html::text("hello")));
assert!(elem.len_hint() > 0);
assert_eq!(Html::Empty.len_hint(), 0);
}
#[test]
fn test_to_chunks_splits_top_level_fragment() {
let f = Html::Fragment(vec![
Html::Element(div().text("one")),
Html::Element(div().text("two")),
Html::Element(div().text("three")),
]);
let chunks = f.to_chunks();
assert_eq!(chunks.len(), 3);
let joined = String::from_utf8(chunks.concat()).unwrap();
assert_eq!(joined, f.render());
}
#[test]
fn test_to_chunks_single_element_one_chunk() {
let e = Html::Element(div().text("solo"));
let chunks = e.to_chunks();
assert_eq!(chunks.len(), 1);
assert_eq!(String::from_utf8(chunks[0].clone()).unwrap(), e.render());
}
#[test]
fn test_self_closing_element() {
let element = img().attr("src", "test.jpg").attr("alt", "Test");
let html = element.render();
assert_eq!(html, r#"<img src="test.jpg" alt="Test" />"#);
}
#[test]
fn test_boolean_attribute() {
let element = input().attr("type", "checkbox").bool_attr("checked");
let html = element.render();
assert_eq!(html, r#"<input type="checkbox" checked />"#);
}
#[test]
fn test_nested_elements() {
let element = div()
.class("container")
.child(Html::Element(h1().text("Title")))
.child(Html::Element(p().text("Content")));
let html = Html::Element(element).render();
assert!(html.contains(r#"<div class="container">"#));
assert!(html.contains("<h1>Title</h1>"));
assert!(html.contains("<p>Content</p>"));
assert!(html.contains("</div>"));
}
#[test]
fn test_text_escaping() {
let element = div().text("<script>alert('xss')</script>");
let html = element.render();
assert!(html.contains("<script>"));
assert!(!html.contains("<script>"));
}
#[test]
fn test_raw_html() {
let element = div().raw("<em>emphasized</em>");
let html = element.render();
assert!(html.contains("<em>emphasized</em>"));
}
#[test]
fn test_fragment() {
let frag = fragment(vec![
text("Hello "),
Html::Element(span().text("world")),
text("!"),
]);
let html = frag.render();
assert_eq!(html, "Hello <span>world</span>!");
}
#[test]
fn test_multiple_classes() {
let element = div().classes(vec!["one", "two", "three"]);
let html = element.render();
assert!(html.contains(r#"class="one two three""#));
}
#[test]
fn test_void_elements() {
assert!(is_void_element("br"));
assert!(is_void_element("img"));
assert!(is_void_element("input"));
assert!(!is_void_element("div"));
assert!(!is_void_element("span"));
}
#[test]
fn test_text_content() {
let element = div()
.child(text("Hello "))
.child(Html::Element(span().text("world")))
.child(text("!"));
let html = Html::Element(element);
assert_eq!(html.text_content(), "Hello world!");
}
#[test]
fn test_empty_html() {
assert!(Html::empty().is_empty());
assert!(Html::text("").is_empty());
assert!(Html::fragment(vec![]).is_empty());
assert!(!Html::text("content").is_empty());
}
}