From cfd83df713c7d65377c804b9342010d0a70390a7 Mon Sep 17 00:00:00 2001 From: Joscha Date: Mon, 2 Dec 2024 17:27:42 +0100 Subject: [PATCH] Document library --- README.md | 30 ++++++++ src/element.rs | 195 ++++++++++++++++++++++++++++++++++++++++++++++--- src/lib.rs | 68 ++++++++++++++++- src/render.rs | 59 +++++++++++---- 4 files changed, 325 insertions(+), 27 deletions(-) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..407f90f --- /dev/null +++ b/README.md @@ -0,0 +1,30 @@ +# el + +`el` is a Rust library for writing, modifying, and safely rendering HTML +elements as simple data structures. It is inspired by [hiccup] and named after a +small helper function I once wrote in JS. + +[hiccup]: https://github.com/weavejester/hiccup + +## Usage example + +```rs +use el::{Attr, Render, html::*}; + +let page: String = html(( + head(( + meta(Attr::new("charset", "utf-8")), + meta(( + Attr::new("name", "viewport"), + Attr::new("content", "width=device-width, initial-scale=1"), + )), + title("Example page"), + )), + body(( + h1((Attr::id("heading"), "Example page")), + p(("This is an example for a ", em("simple"), " web page.")), + )), +)) +.render_to_string() +.unwrap(); +``` diff --git a/src/element.rs b/src/element.rs index b77f566..eff677f 100644 --- a/src/element.rs +++ b/src/element.rs @@ -1,7 +1,11 @@ use std::collections::{btree_map::Entry, BTreeMap, HashMap}; -/// -#[derive(Clone, Copy, PartialEq, Eq)] +/// The kind of an element. +/// +/// Follows the [definitions from the HTML standard][spec]. +/// +/// [spec]: https://html.spec.whatwg.org/multipage/syntax.html#elements-2 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ElementKind { Void, Template, @@ -11,27 +15,71 @@ pub enum ElementKind { Normal, } -#[derive(Clone)] +/// A single bit of [`Element`] content. +#[derive(Debug, Clone, PartialEq, Eq)] pub enum Content { + /// A raw string to be rendered without any checks. + /// + /// Can also be constructed using [`Self::raw`]. + /// + /// # Warning + /// + /// This is an escape hatch for including arbitrary text. Using it + /// incorrectly may result in security vulnerabilities in the rendered HTML. Raw(String), + /// Plain text. + /// + /// Can also be constructed using [`Self::text`]. Text(String), + /// An HTML comment (``). + /// + /// Can also be constructed using [`Self::comment`]. Comment(String), + /// A child [`Element`]. + /// + /// Can also be constructed using [`Self::element`]. Element(Element), } impl Content { + /// Construct [`Content::Raw`], a raw string to be rendered without any + /// checks. + /// + /// # Warning + /// + /// This is an escape hatch for including arbitrary text. Using it + /// incorrectly may result in security vulnerabilities in the rendered HTML. pub fn raw(str: impl ToString) -> Self { Self::Raw(str.to_string()) } + /// Construct [`Content::Text`], plain text. pub fn text(str: impl ToString) -> Self { Self::Text(str.to_string()) } + /// Construct [`Content::Comment`], an HTML comment (``). pub fn comment(str: impl ToString) -> Self { Self::Comment(str.to_string()) } + /// Construct [`Content::Element`], a child [`Element`]. + /// + /// Instead of calling `Content::element(foo)`, you can also use + /// `foo.into()`. + pub fn element(e: impl Into) -> Self { + Self::Element(e.into()) + } + + /// Construct a doctype of the form ``. + /// + /// # Example + /// + /// ``` + /// use el::Content; + /// let doctype = Content::doctype(); + /// assert_eq!(doctype, Content::raw("")); + /// ``` pub fn doctype() -> Self { Self::raw("") } @@ -55,15 +103,61 @@ impl From for Content { } } -#[derive(Clone)] +/// An HTML element. +/// +/// SVG and MathML elements are also modelled using this type. +/// +/// Errors (e.g. illegal characters or an element of [`ElementKind::Void`] +/// having children) are deferred until rendering and are not checked during +/// element construction. See also [`crate::Render`] and [`crate::Error`]. +#[derive(Debug, Clone, PartialEq, Eq)] pub struct Element { + /// The tag name of the element. pub name: String, + /// What kind of element this is. + /// + /// # Warning + /// + /// The element kind affects the correctness of the rendered output. + /// Choosing an incorrect kind may result in security vulnerabilities in the + /// rendered HTML. See [`ElementKind`] for more details. pub kind: ElementKind, + /// The attributes (e.g. `id` or `class`) of the element. + /// + /// This map does not take into account case insensitivity of attributes. + /// Any attributes contained in the map will appear in the rendered output. pub attributes: BTreeMap, + /// The children of the element. pub children: Vec, } impl Element { + /// Create a new element of a specific [`ElementKind`]. + /// + /// See also [`Self::normal`] to create elements of kind + /// [`ElementKind::Normal`]. + /// + /// # Warning + /// + /// The element kind affects the correctness of the rendered output. + /// Choosing an incorrect kind may result in security vulnerabilities in the + /// rendered HTML. See [`ElementKind`] for more details. + /// + /// # Example + /// + /// ``` + /// use el::{Element, ElementKind, html, svg}; + /// + /// let p = Element::new("p", ElementKind::Normal); + /// let link = Element::new("link", ElementKind::Void); + /// let script = Element::new("script", ElementKind::RawText); + /// let svg = Element::new("svg", ElementKind::Foreign); + /// + /// assert_eq!(p, html::p(())); + /// assert_eq!(link, html::link(())); + /// assert_eq!(script, html::script(())); + /// assert_eq!(svg, svg::svg(())); + /// ``` pub fn new(name: impl ToString, kind: ElementKind) -> Self { let mut name = name.to_string(); if kind != ElementKind::Foreign { @@ -78,26 +172,109 @@ impl Element { } } + /// Create a new element of the kind [`ElementKind::Normal`]. + /// + /// `Element::normal(foo)` is equivalent to calling `Element::new(foo, + /// ElementKind::Normal)`. + /// + /// # Warning + /// + /// The element kind affects the correctness of the rendered output. + /// Choosing an incorrect kind may result in security vulnerabilities in the + /// rendered HTML. See [`ElementKind`] for more details. + /// + /// # Example + /// + /// ``` + /// use el::{Element, ElementKind}; + /// let element = Element::normal("custom"); + /// assert_eq!(element.kind, ElementKind::Normal); + /// ``` pub fn normal(name: impl ToString) -> Self { Self::new(name, ElementKind::Normal) } + /// Add components to the element in-place. + /// + /// To add multiple components, either call this function repeatedly or use + /// a type like tuples, arrays, [`Vec`], [`Option`], [`Result`] to combine + /// multiple components. See [`ElementComponent`] for more info. + /// + /// # Example + /// + /// ``` + /// use el::{Attr, html::*}; + /// let mut element = p(()); + /// + /// // Adding single components + /// element.add("some text"); + /// element.add(Attr::class("foo")); + /// + /// // Adding multiple components + /// element.add((Attr::id("bar"), " ", em("and"), " some more text")); + /// ``` pub fn add(&mut self, c: impl ElementComponent) { c.add_to_element(self); } + /// A more builder-pattern-like version of [`Self::add`]. + /// + /// Instead of a mutable reference, this function takes ownership of the + /// element before returning it again. This can be more ergonomic in some + /// cases. + /// + /// # Example + /// + /// ``` + /// use el::{Attr, html::*}; + /// + /// let element = p(()) + /// // Adding single components + /// .with("some text") + /// .with(Attr::class("foo")) + /// // Adding multiple components + /// .with((Attr::id("bar"), " ", em("and"), " some more text")); + /// ``` pub fn with(mut self, c: impl ElementComponent) -> Self { self.add(c); self } } +/// A component can add itself to an [`Element`] by modifying it. +/// +/// A component usually represents either a bit of content or an attribute for +/// the element it is being added to. Some components (e.g. tuples, arrays, +/// [`Vec`], [`Option`], [`Result`]) consist of further components. This creates +/// a flexible API for building [`Element`]s: +/// +/// ``` +/// use el::{Attr, Render, html::*}; +/// let p = p(( +/// Attr::id("foo"), +/// Attr::class("bar"), +/// Attr::class("baz"), +/// "Hello ", em("world"), "!", +/// )); +/// assert_eq!( +/// p.render_to_string().unwrap(), +/// r#"

Hello world!

"#, +/// ); +/// ``` pub trait ElementComponent { + /// Add a component to an element, consuming the component in the process. fn add_to_element(self, element: &mut Element); } -// Attributes - +/// An element attribute, used during [`Element`] construction. +/// +/// # Example +/// +/// ``` +/// use el::{Attr, html::*}; +/// let p = p(Attr::class("foo")); +/// assert_eq!(p.attributes["class"], "foo"); +/// ``` pub struct Attr { name: String, value: String, @@ -292,11 +469,11 @@ element_component_tuple!(C1, C2, C3, C4, C5, C6, C7); element_component_tuple!(C1, C2, C3, C4, C5, C6, C7, C8); element_component_tuple!(C1, C2, C3, C4, C5, C6, C7, C8, C9); -/// An HTML document. +/// A full HTML document including doctype. /// /// A `Document(el)` is basically the same as `[Content::doctype(), el.into()]` -/// for the purposes of the [`crate::Render`] trait. -#[derive(Clone)] +/// for the purposes of the [`Render`][crate::Render] trait. +#[derive(Debug, Clone)] pub struct Document(pub Element); impl From for Document { diff --git a/src/lib.rs b/src/lib.rs index 652d972..d3d1826 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,7 +1,71 @@ -//! Create HTML by manipulating elements as structured data. Inspired by the -//! clojure library [hiccup][hiccup]. +//! # el +//! +//! Write, modify, and safely render HTML elements as simple data structures. +//! +//! This library is inspired by [hiccup] and named after a small helper function +//! I once wrote in JS. //! //! [hiccup]: https://github.com/weavejester/hiccup +//! +//! ## Library overview +//! +//! The basic data structure is the [`Element`], which can be rendered to a +//! [`String`] using the [`Render`] trait. Custom elements can be constructed +//! using [`Element::normal`] or [`Element::new`]. Once constructed, elements +//! can be modified by accessing their fields or using the [`Element::add`] or +//! [`Element::with`] methods, though this is usually not necessary. +//! +//! Constructor functions for all (non-deprecated) HTML tags can be found in the +//! [`html`] module, SVG tags in [`svg`] and MathML tags in [`mathml`]. These +//! three modules are designed to be wildcard-included for more concise code, +//! either on a per-function or per-file basis. +//! +//! Element construction uses the [`ElementComponent`] trait, which represents +//! not only element contents but also attributes. Tuples, arrays, [`Vec`], +//! [`Option`], and [`Result`] can be used to combine components. The order of +//! content components is preserved. To set attributes, include [`Attr`] values +//! as components. +//! +//! If you want to render an entire web page, wrap an [`html::html`] element in +//! a [`Document`]. When rendered, documents include the `` +//! annotation required by the standard. +//! +//! ## Usage example +//! +//! ``` +//! use el::{Attr, Render, html::*}; +//! +//! let page: String = html(( +//! head(( +//! meta(Attr::new("charset", "utf-8")), +//! meta(( +//! Attr::new("name", "viewport"), +//! Attr::new("content", "width=device-width, initial-scale=1"), +//! )), +//! title("Example page"), +//! )), +//! body(( +//! h1((Attr::id("heading"), "Example page")), +//! p(("This is an example for a ", em("simple"), " web page.")), +//! )), +//! )) +//! .render_to_string() +//! .unwrap(); +//! ``` +//! +//! ## Axum support +//! +//! The [axum] crate is supported via the optional `axum` feature flag. When it +//! is enabled, [`Document`] implements axum's `IntoResponse` trait and can be +//! returned directly from handlers. In order to prevent accidentally returning +//! incomplete HTML documents, [`Element`] does not implement `IntoResponse`. +//! +//! ```toml +//! [dependencies] +//! el = { version = "...", features = ["axum"] } +//! ``` +//! +//! [axum]: https://crates.io/crates/axum #[cfg(feature = "axum")] mod axum; diff --git a/src/render.rs b/src/render.rs index a153427..52770b5 100644 --- a/src/render.rs +++ b/src/render.rs @@ -6,33 +6,42 @@ use crate::{ Document, }; +/// The cause of an [`Error`]. #[derive(Debug)] pub enum ErrorCause { + /// An error occurred while formatting a value. Format(fmt::Error), - InvalidTagName(String), - InvalidAttrName(String), + /// A name is not a valid tag name. + InvalidTagName { name: String }, + /// A name is not a valid attribute name. + InvalidAttrName { name: String }, + /// A child is in a place where it is not allowed (e.g. it is the child of a + /// [`ElementKind::Void`] element). InvalidChild, - InvalidRawText(String), + /// Text inside a [`ElementKind::RawText`] element contains forbidden + /// structures. + InvalidRawText { text: String }, } +/// An error that can occur during element rendering. #[derive(Debug)] pub struct Error { - reverse_path: Vec, + reverse_path: Vec<(usize, Option)>, cause: ErrorCause, } impl Error { - pub fn new(cause: ErrorCause) -> Self { + pub(crate) fn new(cause: ErrorCause) -> Self { Self { reverse_path: vec![], cause, } } - pub fn at(mut self, index: usize, child: &Content) -> Self { + pub(crate) fn at(mut self, index: usize, child: &Content) -> Self { self.reverse_path.push(match child { - Content::Element(el) => format!("{index}[{}]", el.name), - _ => index.to_string(), + Content::Element(el) => (index, Some(el.name.clone())), + _ => (index, None), }); self } @@ -66,6 +75,11 @@ impl Error { }) .collect::() } + + /// The cause of the error. + pub fn cause(&self) -> &ErrorCause { + &self.cause + } } impl fmt::Display for Error { @@ -74,10 +88,10 @@ impl fmt::Display for Error { match &self.cause { ErrorCause::Format(error) => write!(f, "{error}")?, - ErrorCause::InvalidTagName(name) => write!(f, "Invalid tag name {name:?}")?, - ErrorCause::InvalidAttrName(name) => write!(f, "Invalid attribute name {name:?}")?, + ErrorCause::InvalidTagName { name } => write!(f, "Invalid tag name {name:?}")?, + ErrorCause::InvalidAttrName { name } => write!(f, "Invalid attribute name {name:?}")?, ErrorCause::InvalidChild => write!(f, "Invalid child")?, - ErrorCause::InvalidRawText(text) => write!(f, "Invalid raw text {text:?}")?, + ErrorCause::InvalidRawText { text } => write!(f, "Invalid raw text {text:?}")?, } Ok(()) @@ -92,11 +106,20 @@ impl From for Error { } } +/// A wrapper around [`std::result::Result`] with the error [`Error`]. pub type Result = std::result::Result; +/// Render an [`Element`] or a [`Document`] to a [`fmt::Write`]; usually a +/// [`String`]. +/// +/// To implement this trait, only [`Self::render`] needs to be implemented. pub trait Render { + /// Render to a writer. fn render(&self, w: &mut W) -> Result<()>; + /// Render directly to a [`String`]. + /// + /// This method is implemented by default and uses [`Self::render`]. fn render_to_string(&self) -> Result { let mut result = String::new(); self.render(&mut result)?; @@ -137,11 +160,15 @@ impl Render for Element { fn render(&self, w: &mut W) -> Result<()> { // Checks if !check::is_valid_tag_name(&self.name) { - return Err(Error::new(ErrorCause::InvalidTagName(self.name.clone()))); + return Err(Error::new(ErrorCause::InvalidTagName { + name: self.name.clone(), + })); } for name in self.attributes.keys() { if !check::is_valid_attribute_name(name) { - return Err(Error::new(ErrorCause::InvalidAttrName(name.clone()))); + return Err(Error::new(ErrorCause::InvalidAttrName { + name: name.clone(), + })); } } @@ -174,9 +201,9 @@ impl Render for Element { Content::Text(text) if check::is_valid_raw_text(&self.name, text) => { write!(w, "{text}").map_err(|e| e.into()) } - Content::Text(text) => { - Err(Error::new(ErrorCause::InvalidRawText(text.clone()))) - } + Content::Text(text) => Err(Error::new(ErrorCause::InvalidRawText { + text: text.clone(), + })), _ => Err(Error::new(ErrorCause::InvalidChild)), }, ElementKind::EscapableRawText => match child {