#![doc = include_str!("../README.md")]
#![deny(missing_docs)]
mod element;
pub mod renderer;
pub mod visitor;
use std::iter;
use indexmap::IndexMap;
pub use crate::element::*;
#[derive(Debug, Clone)]
pub struct HtmlElement {
pub tag_name: String,
pub attrs: IndexMap<String, String>,
pub children: Vec<Element>,
}
impl HtmlElement {
pub fn new(tag: impl Into<String>) -> Self {
Self {
tag_name: tag.into(),
attrs: IndexMap::new(),
children: Vec::new(),
}
}
pub fn is_void(&self) -> bool {
match self.tag_name.as_str() {
"area" | "base" | "br" | "col" | "embed" | "hr" | "img" | "input" | "link" | "meta"
| "param" | "source" | "track" | "wbr" => true,
_ => false,
}
}
pub fn attr<V>(mut self, name: impl Into<String>, value: impl Into<Option<V>>) -> Self
where
V: Into<String>,
{
let name = name.into();
match value.into() {
Some(id) => {
*self.attrs.entry(name).or_default() = id.into();
}
None => {
self.attrs.remove(&name);
}
}
self
}
}
pub trait With {
#[inline(always)]
fn with(self, f: impl FnOnce(Self) -> Self) -> Self
where
Self: Sized,
{
f(self)
}
}
impl With for HtmlElement {}
pub trait WithChildren {
fn extend(&mut self, children: impl IntoIterator<Item = Element>);
fn child(mut self, child: impl Into<Element>) -> Self
where
Self: Sized,
{
self.extend(iter::once(child.into()));
self
}
fn children(mut self, children: impl IntoIterator<Item = impl Into<Element>>) -> Self
where
Self: Sized,
{
self.extend(children.into_iter().map(Into::into));
self
}
}
impl WithChildren for HtmlElement {
#[inline(always)]
fn extend(&mut self, children: impl IntoIterator<Item = Element>) {
self.children.extend(children)
}
}
#[derive(Debug, Clone)]
pub struct TextElement {
pub text: String,
}
impl TextElement {
pub fn new(text: impl Into<String>) -> Self {
Self { text: text.into() }
}
}
impl<T: Into<String>> From<T> for TextElement {
fn from(value: T) -> Self {
Self::new(value)
}
}
macro_rules! create_attribute_methods {
($($name:ident),*) => {
$(
#[doc = concat!("Sets the `", stringify!($name), "` attribute to the provided value.")]
pub fn $name<V>(self, value: impl Into<Option<V>>) -> Self
where
V: Into<String>,
{
self.attr(stringify!($name), value)
}
)*
}
}
impl HtmlElement {
create_attribute_methods!(
action,
alt,
charset,
checked,
class,
content,
crossorigin,
defer,
href,
id,
integrity,
lang,
loading,
max,
method,
name,
placeholder,
rel,
role,
src,
start,
style,
tabindex,
target,
title,
translate,
value
);
pub fn async_<V>(self, value: impl Into<Option<V>>) -> Self
where
V: Into<String>,
{
self.attr("async", value)
}
pub fn for_<V>(self, value: impl Into<Option<V>>) -> Self
where
V: Into<String>,
{
self.attr("for", value)
}
pub fn http_equiv<V>(self, value: impl Into<Option<V>>) -> Self
where
V: Into<String>,
{
self.attr("http-equiv", value)
}
pub fn type_<V>(self, value: impl Into<Option<V>>) -> Self
where
V: Into<String>,
{
self.attr("type", value)
}
}
macro_rules! html_elements {
($($name:ident),*) => {
$(
#[doc = concat!("[`<", stringify!($name), ">`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/", stringify!($name), ")")]
pub fn $name() -> HtmlElement {
HtmlElement::new(stringify!($name))
}
)*
}
}
html_elements!(
a, abbr, address, area, article, aside, audio, b, base, bdi, bdo, blockquote, body, br, button,
canvas, caption, cite, code, col, colgroup, data, datalist, dd, del, dfn, div, dl, dt, em,
embed, fieldset, figcaption, figure, footer, form, h1, h2, h3, h4, h5, h6, head, header,
hgroup, hr, html, i, iframe, img, input, ins, kbd, label, legend, li, link, main, map, mark,
math, menu, meta, meter, nav, noscript, object, ol, optgroup, option, output, p, picture,
portal, pre, progress, q, rp, rt, ruby, s, samp, script, search, section, select, small,
source, span, strong, style, sub, sup, svg, table, tbody, td, textarea, tfoot, th, thead, time,
title, tr, track, u, ul, var, video, wbr, details, dialog, summary, slot, template
);
#[cfg(test)]
mod tests {
use crate::renderer::HtmlElementRenderer;
use super::*;
fn render_to_string(element: &HtmlElement) -> String {
HtmlElementRenderer::new()
.render_to_string(element)
.unwrap()
}
#[test]
fn test_new_html_element() {
let element = HtmlElement::new("custom");
assert_eq!(element.tag_name, "custom".to_owned());
}
#[test]
fn test_attributes() {
let element = a().attr("foo", "a").attr("bar", "b");
assert_eq!(element.attrs.get("foo"), Some(&"a".to_string()));
assert_eq!(element.attrs.get("bar"), Some(&"b".to_string()));
}
#[test]
fn test_render_to_string() {
let element = div().class("outer").child(
div()
.class("inner")
.child(h1().class("heading").child("Hello, world!")),
);
insta::assert_yaml_snapshot!(render_to_string(&element));
}
#[test]
fn test_doctype_auto_insertion() {
insta::assert_yaml_snapshot!(render_to_string(&html()));
}
#[test]
fn test_raw_text() {
insta::assert_yaml_snapshot!(render_to_string(
&p().child("This is a ")
.child(a().href("https://example.com").child("link"))
.child(" that you should click on.")
))
}
#[test]
fn test_empty_attributes() {
insta::assert_yaml_snapshot!(render_to_string(
&script()
.async_("")
.defer("")
.attr("data-domain", "example.com")
.src("https://plausible.io/js/plausible.js"),
));
}
#[test]
fn test_crossorigin_attr() {
insta::assert_yaml_snapshot!(render_to_string(
&link()
.rel("preconnect")
.href("https://fonts.gstatic.com")
.crossorigin("")
))
}
#[test]
fn test_escape_html_in_body_text() {
insta::assert_yaml_snapshot!(render_to_string(
&p().child("This is an <script>alert('XSS');</script> attempt")
));
}
#[test]
fn test_escape_html_in_attributes() {
insta::assert_yaml_snapshot!(render_to_string(
&input()
.type_("text")
.name("username")
.value("\" onmouseover=\"alert('XSS')\"")
));
}
#[test]
fn test_escape_html_in_hrefs() {
insta::assert_yaml_snapshot!(render_to_string(
&a().href("https://example.com?param=\"><script>alert('XSS');</script>")
.child("Click me")
));
}
}